diff --git a/internal/services/gateway/rest.go b/internal/services/gateway/rest.go index 7c93902..6d016ba 100644 --- a/internal/services/gateway/rest.go +++ b/internal/services/gateway/rest.go @@ -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 { diff --git a/internal/services/gateway/rest_chat_image_test.go b/internal/services/gateway/rest_chat_image_test.go index acad2f0..0dc1063 100644 --- a/internal/services/gateway/rest_chat_image_test.go +++ b/internal/services/gateway/rest_chat_image_test.go @@ -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 图,也不留下荒谬的余量。