refactor(gateway): 图片上限单一真源 —— 以 vision 硬顶为准,配置只能收紧(消除「网关照放、vision 拒」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 原状:网关与 vision 各有一份同值常量,且网关上限可由 CHAT_IMAGE_MAX_BYTES 调大 ⇒ 调大后
  **网关照放、vision 拒绝**(ErrImageTooLarge),base64/URL 路径上「调大配置」失效
- 修法:网关改为**引用 vision 的常量**(`vision.MaxInlineImageBytes` / `InlineImagePrefixAllowance` /
  `MaxInlineImageChars`,import 方向与既有的 voice 同层、不违规);`chatImageLimitBytes` 语义=
  「configured 生效但**钳在 vision 硬顶**」(只收紧不放宽);默认口径直接用 vision 常量(不再自算)
- 复核:gateway 包测试绿(9 条 ChatImage,含默认逐字节同值、配 1 MiB 生效、配 8 MiB 被钳到硬顶且
  同一串在 vision 侧也 ErrImageTooLarge ⇒ 两侧同一答案);**变异两点**(去掉钳位分支 → 断言红;
  再清空解析循环 → 实测复现「配 2× 硬顶时网关 200 放行」)均已还原
- 登记:真要支持 > 5 MiB 需给 vision 可注入上限(并把 /upload 的 5 MiB 一起改);`.env.example`/`config.go`
  注释/`api-reference` 仍写「上限=CHAT_IMAGE_MAX_BYTES」未提钳位,待同步
This commit is contained in:
AskaEth committed 2026-10-04 16:27:35 +08:00
1 parent e64ea0b156
commit f5cf117482
2 files changed
+117 -18

No files matched your search

+31 -15
View File
@@ -12,6 +12,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
)
// tokenTTL 是已签发 JWT 的有效时长。
@@ -104,7 +105,8 @@ type loginResponse struct {
// 为视觉路径附带的可选 base64 data: URI 或 http(s) URL;
// loop 把它经视觉/OCR 工具或主模型路由(design/06 §0)。
// Image 受配置上限(CHAT_IMAGE_MAX_BYTES,默认与
// POST /api/v1/upload 的 5 MiB 同口径)换算出的字符上限限制,
// POST /api/v1/upload 的 5 MiB 同口径,且不超过 vision 解码层硬顶)
// 换算出的字符上限限制,
// 超限一律 400 image_too_large,不被静默吃下。
// DeviceID 是客户端产出的发送浏览器/设备标识
// (design/16 §4.5),例如 "web:chrome:a1b2c3";它可选且按原样持久化。
@@ -139,37 +141,50 @@ type chatResponse struct {
// 每页历史(GET /sessions/{id}/messages)与每次 WS history 回放都要
// 把整段 base64 再传一遍(协同文档 G3)。
//
// 它只是默认值:生效上限由配置 CHAT_IMAGE_MAX_BYTES 经 chatImageLimitBytes 解析。
const maxChatImageBytes = 5 << 20
// 值直接取 vision 的权威常量(解码层 internal/runtime/multimodal/vision 按同一
// 常量硬顶):两处各写一份同值常量时,配置一放宽就会漂移成「网关照放、vision 拒」。
// 它只是默认值:生效上限由配置 CHAT_IMAGE_MAX_BYTES 经 chatImageLimitBytes
// 解析,且**只能收紧、不能放宽**。
const maxChatImageBytes = vision.MaxInlineImageBytes
// chatImagePrefixAllowance 是 data: URI 前缀(如 "data:image/png;base64,")
// 所占的字符余量。按常见图片 MIME 的最长前缀取整,不含 base64 载荷本身。
const chatImagePrefixAllowance = 64
// 所占的字符余量,取 vision 的同一常量(唯一真源),不含 base64 载荷本身。
const chatImagePrefixAllowance = vision.InlineImagePrefixAllowance
// chatImageCharsFor 把「解码后图像字节上限」换算为 image 字符串的字符上限:
// base64 每 3 字节编码为 4 字符(膨胀 4/3),因此恰好 n 字节的整幅图编码后是
// ceil(n/3)*4 = (n+2)/3*4 字符(这就是 (n+2)/3*4 的由来:余 1、2 字节时末组仍需
// 补满 4 字符),再加 data: 前缀余量 —— 恰好 n 字节的合法图片绝不会被误拒。
// 它是**唯一**的换算处:默认上限与配置上限都经它落地,不存在第二份膨胀系数。
// 默认口径(就是 vision 硬顶)直接返回 vision.MaxInlineImageChars,配置出更小的
// 上限时按同一条膨胀系数现算 —— 网关口径与解码层口径因此不可能漂移。
//
// 校验按**字符串长度**做,是因为入口层只有这一步是零分配的:vision 层要先
// base64 解码整个字符串才拿到真实字节数(vision.decodeBase64 →
// base64.StdEncoding.DecodeString),也就是先分配再报错。在入口层按长度拦下,
// 超大字符串根本走不到解码那一步。
func chatImageCharsFor(bytesLimit int) int {
if bytesLimit == maxChatImageBytes {
return vision.MaxInlineImageChars
}
return ((bytesLimit+2)/3)*4 + chatImagePrefixAllowance
}
// chatImageLimitBytes 解析本次生效的**解码后字节**上限:配置给了正数就用它,
// 否则(未配置的零值、0、负数)回落到默认 maxChatImageBytes。语义被钉成「≤0 =
// 用默认」而**不是**「禁用上限」——一张图能有多大只能有一个答案,配置不该把它
// 变成无限;这同时保证「没配 CHAT_IMAGE_MAX_BYTES」的默认行为与引入该配置之前
// 逐字节一致(同一张图此前能过、现在也能过,反之亦然)。
// 但**钳到 vision 的硬顶为止**(配置只能收紧、不能放宽);否则(未配置的零值、
// 0、负数)回落到默认 maxChatImageBytes —— 语义是「≤0 = 用默认」而**不是**
// 「禁用上限」,默认值等于 vision 硬顶,故默认行为与引入该配置之前逐字节一致。
// 钳位是因为解码层按 vision.MaxInlineImageBytes 硬顶、读不到网关配置:这里若放行
// 更大的值,base64/URL 路径就会出现「网关照放、vision 拒(ErrImageTooLarge)」,
// 即「调大配置」在真实消费路径上失效(最大只到 5 MiB);要真正支持更大的内联图,
// 必须同时把上限透传进 vision 解码层(更大改动,不在本次范围)。
func chatImageLimitBytes(configured int) int {
if configured > 0 {
return configured
if configured <= 0 {
return maxChatImageBytes
}
return maxChatImageBytes
if configured > vision.MaxInlineImageBytes {
return vision.MaxInlineImageBytes
}
return configured
}
// chatImageTooLargeCode 是 image 超限时返回给客户端的机器可读错误码,
@@ -247,8 +262,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
// image 上限在**任何**解码/落库之前检查,且对同步与 stream 两条分支
// 都生效(stream 只是把该轮转发给 ws.chat,转发前必须先拒),
// 因此超限的图片既不进模型、也不落库、也不会被历史回放重传。
// 上限来自配置对默认值的解析(CHAT_IMAGE_MAX_BYTES,≤0 = 用默认 5 MiB),
// 换算成字符上限只有 chatImageCharsFor 一处。
// 上限来自配置对默认值的解析(CHAT_IMAGE_MAX_BYTES,≤0 = 用默认 5 MiB,
// 且钳到 vision 解码层硬顶为止 —— 配置只能收紧),换算成字符上限只有
// chatImageCharsFor 一处。
// 日志只带长度与上限,绝不带图片内容。
limit := chatImageCharsFor(chatImageLimitBytes(s.cfg.Server.ChatImageMaxBytes))
if err := checkChatImageSize(req.Image, limit); err != nil {
@@ -15,12 +15,14 @@ import (
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
)
// 本文件锁定协同文档 G3 的入口层口径:POST /api/v1/chat 的 image 有显式
// 上限(默认与 POST /api/v1/upload 的 5 MiB 同口径,可用 CHAT_IMAGE_MAX_BYTES
// 配置覆盖;≤0 = 用默认),超限回 400 + image_too_large 的明确错误,而不是把
// 几 MB 的 base64 原样吃下、落库、再被每页历史重传一遍。
// 上限(默认与 POST /api/v1/upload 的 5 MiB 同口径 —— 也就是 vision 解码层的
// 硬顶,可用 CHAT_IMAGE_MAX_BYTES 配置收紧;≤0 = 用默认),超限回 400 +
// image_too_large 的明确错误,而不是把几 MB 的 base64 原样吃下、落库、再被
// 每页历史重传一遍。
// chatImageRecordingRunner 记录每次 Run 看到的完整消息(不只是 sessionID),
// 因此既能断言「超限的轮次从未被驱动」,也能逐字节比对正常小图的图片载荷。
@@ -58,6 +60,87 @@ func oversizeImage() string {
return strings.Repeat("A", chatImageCharsFor(maxChatImageBytes)+1)
}
// TestChatImageDefaultLimitMatchesVisionHardCap 钉住「默认口径 = vision 解码层硬顶」
// 这条单一真源:默认上限、前缀余量与字符换算结果都必须与
// internal/runtime/multimodal/vision 的权威常量逐字节相等,且未配置(0 / 负数)时
// 落在同一个数上 —— 默认行为与引入该配置之前逐字节一致,同时两处口径不可能漂移。
func TestChatImageDefaultLimitMatchesVisionHardCap(t *testing.T) {
t.Parallel()
if maxChatImageBytes != vision.MaxInlineImageBytes {
t.Fatalf("默认上限 = %d, want vision 硬顶 %d", maxChatImageBytes, vision.MaxInlineImageBytes)
}
if chatImagePrefixAllowance != vision.InlineImagePrefixAllowance {
t.Fatalf("前缀余量 = %d, want %d", chatImagePrefixAllowance, vision.InlineImagePrefixAllowance)
}
if got := chatImageCharsFor(maxChatImageBytes); got != vision.MaxInlineImageChars {
t.Fatalf("默认字符上限 = %d, want vision.MaxInlineImageChars %d", got, vision.MaxInlineImageChars)
}
for _, configured := range []int{0, -1, -1 << 20} {
if got := chatImageLimitBytes(configured); got != vision.MaxInlineImageBytes {
t.Fatalf("chatImageLimitBytes(%d) = %d, want 默认(= vision 硬顶)%d",
configured, got, vision.MaxInlineImageBytes)
}
}
}
// TestChatImageConfiguredLimitClampedToVisionHardCap 钉住「配置只能收紧上限」:
// CHAT_IMAGE_MAX_BYTES 配成硬顶的 2 倍时,生效上限仍是 vision 硬顶(比硬顶只小一格的
// 图照样通过,多一个字符就被拒),且解码层对同一串给出的裁决一致 —— base64/URL 路径上
// 不再出现「网关照放、vision 拒」的自相矛盾。
// 变异反证:去掉 chatImageLimitBytes 的钳位,第二条断言里的超硬顶载荷会被放行(200)
// 并驱动 runner,本用例立刻红。
func TestChatImageConfiguredLimitClampedToVisionHardCap(t *testing.T) {
t.Parallel()
// 解析口径:配得比硬顶大(大 1 字节、2 倍、1 GiB)一律回到硬顶。
for _, configured := range []int{vision.MaxInlineImageBytes + 1, 2 * vision.MaxInlineImageBytes, 1 << 30} {
if got := chatImageLimitBytes(configured); got != vision.MaxInlineImageBytes {
t.Fatalf("chatImageLimitBytes(%d) = %d, want 硬顶 %d(配置不得放宽上限)",
configured, got, vision.MaxInlineImageBytes)
}
}
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
env.srv.cfg.Server.ChatImageMaxBytes = 2 * vision.MaxInlineImageBytes
tok := env.token(t, auth.RoleOwner)
const prefix = "data:image/png;base64,"
// 比硬顶多一个字符:网关必须按硬顶拒掉它,而不是按配置放行。
over := prefix + strings.Repeat("A", vision.MaxInlineImageChars+1-len(prefix))
if len(over) != vision.MaxInlineImageChars+1 {
t.Fatalf("构造长度 = %d, want %d", len(over), vision.MaxInlineImageChars+1)
}
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": over}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("配到 2× 硬顶时仍须按硬顶拒,status = %d (body=%s)", resp.StatusCode, body)
}
if code, _ := chatImageErrorOf(t, body); code != chatImageTooLargeCode {
t.Fatalf("error.code = %q, want %q", code, chatImageTooLargeCode)
}
if len(runner.calls) != 0 {
t.Fatalf("超限请求驱动了 %d 个回合,want 0", len(runner.calls))
}
if _, _, err := vision.Resolve(context.Background(),
vision.ImageRef{Source: vision.SourceBase64, Data: over}, nil); !errors.Is(err, vision.ErrImageTooLarge) {
t.Fatalf("解码层对同一串的裁决 = %v, want ErrImageTooLarge(网关口径必须与解码层一致)", err)
}
// 恰好等于硬顶的图照常通过:钳位不得顺手把上限再收紧一格。
atCap := prefix + strings.Repeat("A", vision.MaxInlineImageChars-len(prefix))
if len(atCap) != vision.MaxInlineImageChars {
t.Fatalf("构造长度 = %d, want %d", len(atCap), vision.MaxInlineImageChars)
}
resp, body = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": atCap}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("恰好等于硬顶的图必须通过,status = %d (body=%s)", resp.StatusCode, body)
}
if got := runner.images(); len(got) != 1 || got[0] != atCap {
t.Fatalf("到达 runner 的 image 与请求逐字节不一致:len=%d", len(got))
}
}
// TestChatImageLimitMatchesUploadBudget 钉住上限的推导口径:**未配置**时生效上限
// 必须与 POST /api/v1/upload 的 maxUploadBytes 同值(两条入口同一个答案),且换算成
// base64 字符串长度后既**装得下**一张满额的 5 MiB 图,也不留下荒谬的余量。