From c9c4f0aaa51e626bb6eab464b18c6dd7ff167dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Thu, 16 Jul 2026 00:17:16 +0800 Subject: [PATCH] feat(dingtalk): enable secure multimodal image QA --- .../assets/dingtalk-vision-acceptance.png | Bin 0 -> 71918 bytes .../generate_dingtalk_vision_acceptance.py | 34 ++ .../dingtalk-image-multimodal-acceptance.md | 170 ++++++++ frontend/src/utils/weknoraCloudModels.ts | 2 +- internal/agent/engine.go | 7 +- internal/agent/engine_test.go | 10 +- internal/agent/finalize.go | 20 +- internal/agent/finalize_image_test.go | 18 + internal/agent/think.go | 16 +- internal/agent/think_stream_error_test.go | 26 ++ .../chat_pipeline/chat_completion_stream.go | 16 +- .../service/chat_pipeline/query_understand.go | 54 ++- .../query_understand_image_test.go | 54 +++ .../service/session_agent_image_route.go | 63 +++ .../service/session_agent_image_test.go | 26 ++ .../application/service/session_agent_qa.go | 38 +- .../session_agent_vlm_fallback_test.go | 75 ++++ .../service/session_knowledge_qa.go | 25 +- .../service/session_knowledge_qa_test.go | 24 +- internal/im/adapter.go | 2 + internal/im/dingtalk/adapter.go | 383 ++++++++++++++++-- .../im/dingtalk/adapter_security_edge_test.go | 45 ++ internal/im/dingtalk/adapter_test.go | 276 +++++++++++++ .../im/dingtalk/finalize_fail_closed_test.go | 43 ++ internal/im/dingtalk/longconn.go | 39 +- internal/im/dingtalk/longconn_test.go | 37 ++ internal/im/image_qa_test.go | 132 ++++++ internal/im/service.go | 194 +++++++-- internal/im/stream_delivery_error_test.go | 42 ++ internal/logger/llm_logger.go | 29 +- internal/logger/logger_test.go | 26 ++ internal/models/chat/anthropic.go | 334 ++++++++++++--- .../chat/anthropic_error_redaction_test.go | 38 ++ .../chat/anthropic_terminal_edge_test.go | 223 ++++++++++ internal/models/chat/anthropic_test.go | 43 +- internal/models/chat/chat.go | 53 ++- internal/models/chat/image_resolve.go | 21 - internal/models/chat/langfuse_image_test.go | 26 ++ internal/models/chat/langfuse_wrapper.go | 56 ++- internal/models/chat/llm_debug.go | 2 +- internal/models/chat/llm_debug_image_test.go | 27 ++ internal/models/chat/llm_debug_wrapper.go | 5 +- .../chat/model_fail_closed_edge_test.go | 29 ++ internal/models/chat/ollama.go | 118 ++++-- internal/models/chat/openai_stream.go | 74 ++-- .../models/chat/plan_a_fail_closed_test.go | 129 ++++++ internal/models/chat/remote_api.go | 66 ++- internal/models/chat/remote_api_test.go | 56 +++ internal/models/chat/stream_raw_dump.go | 27 +- .../models/chat/stream_raw_dump_image_test.go | 51 +++ .../models/chat/weknora_cloud_image_test.go | 80 ++++ internal/types/chat_manage.go | 19 +- internal/types/qa_request.go | 35 +- internal/utils/log_sanitize.go | 135 +++++- internal/utils/log_sanitize_edge_test.go | 29 ++ internal/utils/log_sanitize_test.go | 29 ++ internal/utils/security.go | 50 ++- internal/utils/security_stage2_test.go | 25 ++ internal/utils/security_test.go | 2 +- 59 files changed, 3279 insertions(+), 429 deletions(-) create mode 100644 docs/test-plans/assets/dingtalk-vision-acceptance.png create mode 100644 docs/test-plans/assets/generate_dingtalk_vision_acceptance.py create mode 100644 docs/test-plans/dingtalk-image-multimodal-acceptance.md create mode 100644 internal/agent/finalize_image_test.go create mode 100644 internal/agent/think_stream_error_test.go create mode 100644 internal/application/service/chat_pipeline/query_understand_image_test.go create mode 100644 internal/application/service/session_agent_image_route.go create mode 100644 internal/application/service/session_agent_image_test.go create mode 100644 internal/application/service/session_agent_vlm_fallback_test.go create mode 100644 internal/im/dingtalk/adapter_security_edge_test.go create mode 100644 internal/im/dingtalk/adapter_test.go create mode 100644 internal/im/dingtalk/finalize_fail_closed_test.go create mode 100644 internal/im/dingtalk/longconn_test.go create mode 100644 internal/im/image_qa_test.go create mode 100644 internal/im/stream_delivery_error_test.go create mode 100644 internal/models/chat/anthropic_error_redaction_test.go create mode 100644 internal/models/chat/anthropic_terminal_edge_test.go create mode 100644 internal/models/chat/langfuse_image_test.go create mode 100644 internal/models/chat/llm_debug_image_test.go create mode 100644 internal/models/chat/model_fail_closed_edge_test.go create mode 100644 internal/models/chat/plan_a_fail_closed_test.go create mode 100644 internal/models/chat/stream_raw_dump_image_test.go create mode 100644 internal/models/chat/weknora_cloud_image_test.go create mode 100644 internal/utils/log_sanitize_edge_test.go create mode 100644 internal/utils/log_sanitize_test.go create mode 100644 internal/utils/security_stage2_test.go diff --git a/docs/test-plans/assets/dingtalk-vision-acceptance.png b/docs/test-plans/assets/dingtalk-vision-acceptance.png new file mode 100644 index 0000000000000000000000000000000000000000..2b736287161ad19dbec61000b6f9219b7bc5dd67 GIT binary patch literal 71918 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�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 C&}Om# literal 0 HcmV?d00001 diff --git a/docs/test-plans/assets/generate_dingtalk_vision_acceptance.py b/docs/test-plans/assets/generate_dingtalk_vision_acceptance.py new file mode 100644 index 00000000..acbeb68e --- /dev/null +++ b/docs/test-plans/assets/generate_dingtalk_vision_acceptance.py @@ -0,0 +1,34 @@ +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont + +OUT = Path(__file__).with_name("dingtalk-vision-acceptance.png") +FONT_PATH = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" + +image = Image.new("RGB", (1280, 720), "#F7FAFC") +draw = ImageDraw.Draw(image) +font_title = ImageFont.truetype(FONT_PATH, 46) +font_code = ImageFont.truetype(FONT_PATH, 58) +font_label = ImageFont.truetype(FONT_PATH, 34) +font_question = ImageFont.truetype(FONT_PATH, 38) + +# Header and unique OCR target. +draw.rounded_rectangle((50, 40, 1230, 190), radius=24, fill="#E6F4FF", outline="#1677FF", width=4) +draw.text((85, 65), "WeKnora 钉钉多模态验收图", font=font_title, fill="#102A43") +draw.text((85, 120), "唯一验收码:WK-VISION-7264", font=font_code, fill="#C53030") + +# Three visually distinct objects. +draw.ellipse((120, 280, 340, 500), fill="#1677FF", outline="#0B4F9C", width=8) +draw.text((160, 520), "蓝色圆形", font=font_label, fill="#0B4F9C") + +draw.polygon([(520, 500), (650, 275), (780, 500)], fill="#FFD43B", outline="#9C7600", width=8) +draw.text((548, 520), "黄色三角形", font=font_label, fill="#725500") + +draw.rounded_rectangle((940, 295, 1140, 495), radius=12, fill="#2FB344", outline="#176B2A", width=8) +draw.text((958, 520), "绿色方形", font=font_label, fill="#176B2A") + +question = "问题:蓝色圆形右侧紧邻的图形是什么颜色和形状?" +draw.rounded_rectangle((80, 610, 1200, 690), radius=18, fill="#FFF4E6", outline="#E67700", width=3) +draw.text((110, 625), question, font=font_question, fill="#7C3E00") + +image.save(OUT, format="PNG", optimize=True) +print(OUT) diff --git a/docs/test-plans/dingtalk-image-multimodal-acceptance.md b/docs/test-plans/dingtalk-image-multimodal-acceptance.md new file mode 100644 index 00000000..73dd27a8 --- /dev/null +++ b/docs/test-plans/dingtalk-image-multimodal-acceptance.md @@ -0,0 +1,170 @@ +# 阶段2:钉钉图片传输与多模态识别验收计划 + +## 1. 验收目标 + +验证图片从钉钉用户消息进入 WeKnora,并由视觉模型真实读取图片内容后返回结果;不得把“图片已上传”“图片作为知识文件入库”或“删除图片后按纯文字回答”判定为多模态通过。 + +## 2. 当前自动化验证基线 + +测试分支:`feature/dingtalk-image-only` + +生产基线:`4022040e`(不包含阶段3~5提交) + +阶段2提交:待本轮代码审查通过后创建。 + +已执行的目标测试: + +```bash +GOTOOLCHAIN=local GOPROXY=off GOMAXPROCS=4 \ +/home/zheromiku/.local/toolchains/go1.26.0/bin/go test -p 1 \ + ./internal/im/dingtalk \ + -run 'Test(ParseCallbackMessagePicture|AdapterDownloadFileUsesMessageFilesAPI|ValidateDingTalkDownloadURLRejectsUnsafeTargets|ParseDingTalkStreamPayloadPicture|ParseDingTalkStreamPayloadPictureJSONString)$' \ + -count=1 + +GOTOOLCHAIN=local GOPROXY=off GOMAXPROCS=4 \ +/home/zheromiku/.local/toolchains/go1.26.0/bin/go test -p 1 \ + ./internal/im -run 'Test(PrepareIMImageForQA|IMQAErrorReply|FailClosedIMImageAnswer)' -count=1 + +GOTOOLCHAIN=local GOPROXY=off GOMAXPROCS=4 \ +/home/zheromiku/.local/toolchains/go1.26.0/bin/go test -p 1 \ + ./internal/models/chat \ + -run '^TestRemoteAPIChat_MultimodalUnsupportedDoesNotStripImages$' \ + -count=1 +``` + +结果:钉钉图片解析、两段式下载、Token请求禁重定向、DNS解析后固定IP拨号、保留/Teredo/受限6to4地址拒绝、签名URL错误脱敏、所有data URI及图片调试日志全量脱敏、JPEG/PNG格式/大小/维度校验、Worker延迟下载、`QARequest.ImageURLs`传递,以及视觉失败/流式部分输出时禁止删图或伪成功,全部通过。 + +已执行相关包回归: + +```bash +GOTOOLCHAIN=local GOPROXY=off GOMAXPROCS=4 \ +/home/zheromiku/.local/toolchains/go1.26.0/bin/go test -p 1 \ + ./internal/agent ./internal/im/dingtalk ./internal/im ./internal/models/chat ./internal/utils ./internal/logger \ + ./internal/application/service/chat_pipeline ./internal/application/service -count=1 +``` + +结果:八个相关包全部通过,八包 `go vet` 同步通过。 + +## 3. 真实验收前置条件 + +- 采用已授权的 App-only 可回滚部署:先备份当前 App 镜像标签,仅重建 `app`,不重建 PostgreSQL、Redis、前端、DocReader 或 MinerU。 +- 部署后先检查 `WeKnora-app` 健康状态;失败立即恢复备份镜像并仅重建 `app`。 +- 钉钉应用已开通机器人消息文件下载权限。 +- WeKnora Agent 已开启“图片上传/多模态”。 +- Agent 当前问答模型支持视觉输入,或配置了明确的独立 VLM;VLM不可用或未返回图片描述时必须失败关闭。 +- 不允许在视觉能力不足时静默删除图片并继续生成答案。 + +## 4. 测试数据 + +标准测试图: + +```text +docs/test-plans/assets/dingtalk-vision-acceptance.png +``` + +图片包含: + +- 唯一验收码:`WK-VISION-7264` +- 蓝色圆形 +- 黄色三角形 +- 绿色方形 +- 明确问题:蓝色圆形右侧紧邻的图形是什么颜色和形状? + +标准答案必须同时包含: + +```text +WK-VISION-7264 +黄色三角形 +``` + +## 5. 人工验收用例 + +### DT-IMG-01:PNG图片真实识别 + +操作:在钉钉中把标准测试图发送给测试机器人。 + +通过标准: + +- 机器人成功回复; +- 回复包含 `WK-VISION-7264`; +- 回复包含“黄色三角形”; +- 回复不是“请用文字描述”“当前不支持图片”或无关答案。 + +### DT-IMG-02:普通截图OCR + +操作:发送一张包含中文、英文和数字的实际设备截图。 + +通过标准: + +- 能提取截图里的关键文字; +- 能结合图片回答具体问题; +- 不编造截图中不存在的错误码、型号或参数。 + +### DT-IMG-03:纯文字回归 + +操作:向同一机器人发送普通文字问题。 + +通过标准:正常进入原知识库问答,图片功能不影响文字消息。 + +### DT-IMG-04:伪图片拒绝 + +操作:将非图片内容伪装为 `.png` 后发送。 + +通过标准:拒绝处理,提示图片格式校验失败;伪图片不得进入模型。 + +### DT-IMG-05:超限图片拒绝 + +操作:发送超过10MB的JPG/PNG。 + +通过标准:明确提示10MB限制,不进入模型请求。 + +### DT-IMG-06:模型不支持视觉 + +操作:测试环境临时选择不支持图片的问答模型后发送标准图。 + +通过标准:明确提示视觉模型未配置或不支持;禁止删除图片后继续给出看似正常的答案。 + +### DT-IMG-07:钉钉权限缺失 + +操作:在没有消息文件下载权限的测试应用中发送图片,或使用接口模拟对应403响应。 + +通过标准:日志记录脱敏后的权限错误,用户收到可理解提示;不得记录 access token、downloadCode 或临时下载URL。 + +## 6. 日志观察点 + +发送标准图后,检查测试环境日志: + +```bash +sudo docker logs WeKnora-app --since 5m 2>&1 | \ +grep -E '\[DingTalk\]|Failed to prepare image|message file|multimodal|vision|image' +``` + +日志不得出现: + +- access token明文; +- `downloadCode`明文; +- 完整临时下载URL; +- 完整Base64图片。 + +## 7. 数据检查 + +阶段2只验收图片传输和模型识别。消息审计字段的完整性、Token统计、知识库命中片段和渠道统计属于阶段3,本阶段不提前判定通过。 + +本阶段允许确认: + +- 用户消息以默认视觉问题进入原有会话历史; +- 图片只作为瞬态 `QARequest.ImageURLs` 传给模型; +- 数据库不保存完整Base64、临时下载码和临时签名URL。 + +## 8. 通过门槛 + +必须同时满足: + +1. 图片自动化测试全部通过; +2. 相关IM包回归通过; +3. `DT-IMG-01`和`DT-IMG-02`真实钉钉测试通过; +4. `DT-IMG-03`文字回归通过; +5. `DT-IMG-04`~`DT-IMG-07`至少完成目标验证或提供自动化等价证据; +6. 用户确认验收。 + +在以上条件完成前,阶段2状态保持“进行中”,不得启动阶段3。 diff --git a/frontend/src/utils/weknoraCloudModels.ts b/frontend/src/utils/weknoraCloudModels.ts index 97fd2ae4..2f255491 100644 --- a/frontend/src/utils/weknoraCloudModels.ts +++ b/frontend/src/utils/weknoraCloudModels.ts @@ -57,7 +57,7 @@ export function buildWkcModelConfig( truncate_prompt_tokens: 0, } } - if (kind === 'vllm') { + if (kind === 'chat' || kind === 'vllm') { parameters.supports_vision = true } diff --git a/internal/agent/engine.go b/internal/agent/engine.go index a8710a47..52e82413 100644 --- a/internal/agent/engine.go +++ b/internal/agent/engine.go @@ -269,7 +269,7 @@ func (e *AgentEngine) Execute( "tools": toolListStr, }) - _, err := e.executeLoop(ctx, state, query, messages, tools, sessionID, messageID) + _, err := e.executeLoop(ctx, state, query, messages, tools, sessionID, messageID, imgs) if err != nil { logger.Errorf(ctx, "[Agent] Execution failed: %v", err) e.eventBus.Emit(ctx, event.Event{ @@ -349,6 +349,7 @@ func (e *AgentEngine) executeLoop( tools []chat.Tool, sessionID string, messageID string, + imageURLs []string, ) (*types.AgentState, error) { startTime := time.Now() common.PipelineInfo(ctx, "Agent", "loop_start", map[string]interface{}{ @@ -386,7 +387,7 @@ loop: if totalTC := countTotalToolCalls(state.RoundSteps); totalTC > 0 { logger.Infof(ctx, "[Agent] Synthesizing final answer from %d existing tool results", totalTC) - _ = e.streamFinalAnswerToEventBus(ctx, query, state, sessionID) + _ = e.streamFinalAnswerToEventBus(ctx, query, state, sessionID, imageURLs) state.IsComplete = true } return state, ctx.Err() @@ -419,7 +420,7 @@ loop: // complete answer." message, which then leaks to the UI as the final // answer for a conversation the user deliberately stopped. if !state.IsComplete && ctx.Err() == nil { - e.handleMaxIterations(ctx, query, state, sessionID) + e.handleMaxIterations(ctx, query, state, sessionID, imageURLs) } return state, nil diff --git a/internal/agent/engine_test.go b/internal/agent/engine_test.go index 0b3da547..399ece31 100644 --- a/internal/agent/engine_test.go +++ b/internal/agent/engine_test.go @@ -120,7 +120,7 @@ func TestExecuteLoop_EmptyContentWithStop_ShouldNotCompleteWithEmpty(t *testing. state := &types.AgentState{} ctx := context.Background() - _, err := engine.executeLoop(ctx, state, "test query", emptyMessages(), emptyTools(), "sess-1", "msg-1") + _, err := engine.executeLoop(ctx, state, "test query", emptyMessages(), emptyTools(), "sess-1", "msg-1", nil) assert.NoError(t, err) assert.True(t, state.IsComplete) @@ -146,7 +146,7 @@ func TestExecuteLoop_NonEmptyContentWithStop_ShouldComplete(t *testing.T) { state := &types.AgentState{} ctx := context.Background() - _, err := engine.executeLoop(ctx, state, "test query", emptyMessages(), emptyTools(), "sess-1", "msg-1") + _, err := engine.executeLoop(ctx, state, "test query", emptyMessages(), emptyTools(), "sess-1", "msg-1", nil) assert.NoError(t, err) assert.True(t, state.IsComplete) @@ -173,7 +173,7 @@ func TestExecuteLoop_EmptyThenNonEmpty_ShouldRetryAndComplete(t *testing.T) { state := &types.AgentState{} ctx := context.Background() - _, err := engine.executeLoop(ctx, state, "test query", emptyMessages(), emptyTools(), "sess-1", "msg-1") + _, err := engine.executeLoop(ctx, state, "test query", emptyMessages(), emptyTools(), "sess-1", "msg-1", nil) assert.NoError(t, err) assert.True(t, state.IsComplete) @@ -328,7 +328,7 @@ func TestExecuteLoop_NaturalStop_DoesNotDuplicateAnswer(t *testing.T) { state := &types.AgentState{} _, err := engine.executeLoop(context.Background(), state, "test query", - emptyMessages(), emptyTools(), "sess-1", "msg-1") + emptyMessages(), emptyTools(), "sess-1", "msg-1", nil) require.NoError(t, err) assert.True(t, state.IsComplete) @@ -358,7 +358,7 @@ func TestStreamFinalAnswerToEventBus_EmitsDoneWhenProviderEndsWithEmptyChunk(t * }) state := &types.AgentState{} - err := engine.streamFinalAnswerToEventBus(context.Background(), "test query", state, "sess-1") + err := engine.streamFinalAnswerToEventBus(context.Background(), "test query", state, "sess-1", nil) require.NoError(t, err) require.Len(t, finalAnswerEvents, 2) diff --git a/internal/agent/finalize.go b/internal/agent/finalize.go index c80344b0..464217f5 100644 --- a/internal/agent/finalize.go +++ b/internal/agent/finalize.go @@ -19,6 +19,7 @@ func (e *AgentEngine) streamFinalAnswerToEventBus( query string, state *types.AgentState, sessionID string, + imageURLs []string, ) error { totalToolCalls := countTotalToolCalls(state.RoundSteps) logger.Infof(ctx, "[Agent][FinalAnswer] Synthesizing from %d steps, %d tool calls", @@ -36,7 +37,7 @@ func (e *AgentEngine) streamFinalAnswerToEventBus( messages := []chat.Message{ {Role: "system", Content: systemPrompt}, - {Role: "user", Content: userTurn}, + {Role: "user", Content: userTurn, Images: append([]string(nil), imageURLs...)}, } // Add all tool call results as context @@ -138,10 +139,23 @@ Now generate the final answer:`, query) return nil } +func collectAgentImageURLs(messages []chat.Message) []string { + var images []string + for _, message := range messages { + images = append(images, message.Images...) + for _, part := range message.MultiContent { + if part.Type == "image_url" && part.ImageURL != nil && part.ImageURL.URL != "" { + images = append(images, part.ImageURL.URL) + } + } + } + return images +} + // handleMaxIterations generates a final answer when the agent loop exhausted all iterations // without the LLM producing a natural stop. It marks state.IsComplete = true. func (e *AgentEngine) handleMaxIterations( - ctx context.Context, query string, state *types.AgentState, sessionID string, + ctx context.Context, query string, state *types.AgentState, sessionID string, imageURLs []string, ) { logger.Info(ctx, "Reached max iterations, generating final answer") common.PipelineWarn(ctx, "Agent", "max_iterations_reached", map[string]interface{}{ @@ -150,7 +164,7 @@ func (e *AgentEngine) handleMaxIterations( }) // Stream final answer generation through EventBus - if err := e.streamFinalAnswerToEventBus(ctx, query, state, sessionID); err != nil { + if err := e.streamFinalAnswerToEventBus(ctx, query, state, sessionID, imageURLs); err != nil { logger.Errorf(ctx, "Failed to synthesize final answer: %v", err) common.PipelineError(ctx, "Agent", "final_answer_failed", map[string]interface{}{ "error": err.Error(), diff --git a/internal/agent/finalize_image_test.go b/internal/agent/finalize_image_test.go new file mode 100644 index 00000000..98ab47ee --- /dev/null +++ b/internal/agent/finalize_image_test.go @@ -0,0 +1,18 @@ +package agent + +import ( + "testing" + + "github.com/Tencent/WeKnora/internal/models/chat" +) + +func TestCollectAgentImageURLsForFinalSynthesis(t *testing.T) { + messages := []chat.Message{ + {Role: "system", Content: "system"}, + {Role: "user", Content: "question", Images: []string{"data:image/png;base64,YWJj"}}, + } + got := collectAgentImageURLs(messages) + if len(got) != 1 || got[0] != "data:image/png;base64,YWJj" { + t.Fatalf("collectAgentImageURLs() = %#v", got) + } +} diff --git a/internal/agent/think.go b/internal/agent/think.go index 3eae32cd..c88d2593 100644 --- a/internal/agent/think.go +++ b/internal/agent/think.go @@ -2,6 +2,7 @@ package agent import ( "context" + "errors" "fmt" "strings" "time" @@ -60,7 +61,7 @@ func (e *AgentEngine) streamLLMToEventBus( // as if they were part of the LLM answer. if chunk.ResponseType == types.ResponseTypeError { result.StreamError = chunk.Content - continue + break } if chunk.Content != "" { @@ -89,6 +90,9 @@ func (e *AgentEngine) streamLLMToEventBus( if emitFunc != nil { emitFunc(&chunk, result.Content) } + if chunk.Done && chunk.ResponseType == types.ResponseTypeAnswer { + break + } } // Stream diagnostic summary: helps identify non-streaming patterns @@ -101,10 +105,10 @@ func (e *AgentEngine) streamLLMToEventBus( chunkCount, len(result.Content), len(result.ToolCalls), streamDuration.Milliseconds(), responseTypeCounts) - // If the stream produced an error and no usable content/tool calls, - // surface it as a Go error so the caller can retry or degrade gracefully. - if result.StreamError != "" && result.Content == "" && len(result.ToolCalls) == 0 { - return result, fmt.Errorf("LLM stream error: %s", result.StreamError) + // A provider error invalidates the entire stream, even if partial content or + // tool calls arrived first. The caller must replace/discard that partial data. + if result.StreamError != "" { + return result, errors.New("LLM stream failed") } return result, nil @@ -406,7 +410,7 @@ func (e *AgentEngine) callLLMWithRetry( "steps": len(state.RoundSteps), "tool_calls": totalTC, }) - if synthErr := e.streamFinalAnswerToEventBus(ctx, query, state, sessionID); synthErr != nil { + if synthErr := e.streamFinalAnswerToEventBus(ctx, query, state, sessionID, collectAgentImageURLs(messages)); synthErr != nil { logger.Errorf(ctx, "[Agent] Final answer synthesis also failed: %v", synthErr) return nil, fmt.Errorf("LLM call failed: %w (synthesis also failed: %v)", err, synthErr) } diff --git a/internal/agent/think_stream_error_test.go b/internal/agent/think_stream_error_test.go new file mode 100644 index 00000000..084f2df1 --- /dev/null +++ b/internal/agent/think_stream_error_test.go @@ -0,0 +1,26 @@ +package agent + +import ( + "context" + "testing" + + "github.com/Tencent/WeKnora/internal/models/chat" + "github.com/Tencent/WeKnora/internal/types" +) + +func TestStreamLLMToEventBusRejectsPartialContentAfterStreamError(t *testing.T) { + model := &mockChat{responses: []mockResponse{{chunks: []types.StreamResponse{ + {ResponseType: types.ResponseTypeAnswer, Content: "unverified partial"}, + {ResponseType: types.ResponseTypeError, Content: "provider failed", Done: true}, + {ResponseType: types.ResponseTypeAnswer, Content: "must be ignored", Done: true}, + }}}} + engine := &AgentEngine{config: &types.AgentConfig{}, chatModel: model} + + result, err := engine.streamLLMToEventBus(context.Background(), []chat.Message{{Role: "user", Content: "inspect image"}}, nil, nil) + if err == nil { + t.Fatal("expected stream error after partial content") + } + if result == nil || result.Content != "unverified partial" { + t.Fatalf("result = %#v; caller needs partial content identified for replacement", result) + } +} diff --git a/internal/application/service/chat_pipeline/chat_completion_stream.go b/internal/application/service/chat_pipeline/chat_completion_stream.go index 9f519568..9710668d 100644 --- a/internal/application/service/chat_pipeline/chat_completion_stream.go +++ b/internal/application/service/chat_pipeline/chat_completion_stream.go @@ -79,8 +79,10 @@ func (p *PluginChatCompletionStream) OnEvent(ctx context.Context, pipelineInfo(ctx, "Stream", "model_call", map[string]interface{}{ "chat_model": chatManage.ChatModelID, }) - responseChan, err := chatModel.ChatStream(ctx, chatMessages, opt) + streamCtx, streamCancel := context.WithCancel(ctx) + responseChan, err := chatModel.ChatStream(streamCtx, chatMessages, opt) if err != nil { + streamCancel() pipelineError(ctx, "Stream", "model_call", map[string]interface{}{ "chat_model": chatManage.ChatModelID, "error": err.Error(), @@ -88,6 +90,7 @@ func (p *PluginChatCompletionStream) OnEvent(ctx context.Context, return ErrModelCall.WithError(err) } if responseChan == nil { + streamCancel() pipelineError(ctx, "Stream", "model_call", map[string]interface{}{ "chat_model": chatManage.ChatModelID, "error": "nil_channel", @@ -105,6 +108,7 @@ func (p *PluginChatCompletionStream) OnEvent(ctx context.Context, // The goroutine monitors ctx.Done() to avoid leaking when the context is cancelled // and the upstream channel is not closed promptly. go func() { + defer streamCancel() thinkingID := fmt.Sprintf("%s-thinking", uuid.New().String()[:8]) answerID := fmt.Sprintf("%s-answer", uuid.New().String()[:8]) thinkingOpen := false @@ -145,19 +149,20 @@ func (p *PluginChatCompletionStream) OnEvent(ctx context.Context, if response.ResponseType == types.ResponseTypeError { pipelineError(ctx, "Stream", "stream_error", map[string]interface{}{ "session_id": chatManage.SessionID, - "error": response.Content, + "error": "chat completion stream failed", }) eventBus.Emit(ctx, types.Event{ ID: fmt.Sprintf("%s-error", uuid.New().String()[:8]), Type: types.EventType(event.EventError), SessionID: chatManage.SessionID, Data: event.ErrorData{ - Error: response.Content, + Error: "chat completion stream failed", Stage: "chat_completion_stream", SessionID: chatManage.SessionID, }, }) - continue + closeThinking() + return } if response.ResponseType == types.ResponseTypeThinking { @@ -190,6 +195,9 @@ func (p *PluginChatCompletionStream) OnEvent(ctx context.Context, Done: response.Done, }, }) + if response.Done { + return + } } } } diff --git a/internal/application/service/chat_pipeline/query_understand.go b/internal/application/service/chat_pipeline/query_understand.go index 36c1271e..c791cc15 100644 --- a/internal/application/service/chat_pipeline/query_understand.go +++ b/internal/application/service/chat_pipeline/query_understand.go @@ -91,7 +91,14 @@ func (p *PluginQueryUnderstand) OnEvent(ctx context.Context, } // --- Select the appropriate model --- - rewriteModel, useImages := p.selectModel(ctx, chatManage, hasImages) + rewriteModel, useImages, selectErr := p.selectModel(ctx, chatManage, hasImages) + if selectErr != nil { + pipelineError(ctx, "QueryUnderstand", "get_visual_model", map[string]interface{}{ + "session_id": chatManage.SessionID, + "error": selectErr.Error(), + }) + return &PluginError{Err: selectErr, Description: "Visual model is unavailable", ErrorType: "image_understanding_failed"} + } if rewriteModel == nil { pipelineError(ctx, "QueryUnderstand", "get_model", map[string]interface{}{ "session_id": chatManage.SessionID, @@ -127,11 +134,18 @@ func (p *PluginQueryUnderstand) OnEvent(ctx context.Context, "session_id": chatManage.SessionID, "error": err.Error(), }) + if hasImages { + return &PluginError{Err: err, Description: "Visual model request failed", ErrorType: "image_understanding_failed"} + } return next() } // --- Parse structured output --- p.parseOutput(chatManage, response.Content) + if hasImages && !chatManage.ChatModelSupportsVision && strings.TrimSpace(chatManage.ImageDescription) == "" { + err := fmt.Errorf("visual model returned no image description") + return &PluginError{Err: err, Description: "Visual model returned no usable image description", ErrorType: "image_understanding_failed"} + } // Persist image description asynchronously — this DB write does not affect // the current pipeline result, so it can run in the background. @@ -222,33 +236,43 @@ func (p *PluginQueryUnderstand) loadHistory(ctx context.Context, chatManage *typ } // selectModel picks the model for query understanding. When images are present -// it prefers a vision-capable model. Returns (model, useImages). -func (p *PluginQueryUnderstand) selectModel(ctx context.Context, chatManage *types.ChatManage, hasImages bool) (chat.Chat, bool) { +// it requires a vision-capable model and never falls back to text-only processing. +func (p *PluginQueryUnderstand) selectModel(ctx context.Context, chatManage *types.ChatManage, hasImages bool) (chat.Chat, bool, error) { if hasImages { + var lastErr error if chatManage.ChatModelSupportsVision { m, err := p.modelService.GetChatModel(ctx, chatManage.ChatModelID) - if err == nil { - return m, true + if err == nil && chat.SupportsImageInput(m) { + return m, true, nil } - pipelineWarn(ctx, "QueryUnderstand", "vision_model_fallback", map[string]interface{}{ + if err == nil { + err = fmt.Errorf("configured Chat transport does not support image input") + } + lastErr = err + pipelineWarn(ctx, "QueryUnderstand", "vision_model_unavailable", map[string]interface{}{ "session_id": chatManage.SessionID, "error": err.Error(), }) } if chatManage.VLMModelID != "" { m, err := p.modelService.GetChatModel(ctx, chatManage.VLMModelID) - if err == nil { - return m, true + if err == nil && chat.SupportsImageInput(m) { + return m, true, nil } - pipelineWarn(ctx, "QueryUnderstand", "vlm_model_fallback", map[string]interface{}{ + if err == nil { + err = fmt.Errorf("configured VLM transport does not support image input") + } + lastErr = err + pipelineWarn(ctx, "QueryUnderstand", "vlm_model_unavailable", map[string]interface{}{ "session_id": chatManage.SessionID, "vlm_model_id": chatManage.VLMModelID, "error": err.Error(), }) } - pipelineWarn(ctx, "QueryUnderstand", "no_vision_model", map[string]interface{}{ - "session_id": chatManage.SessionID, - }) + if lastErr != nil { + return nil, false, fmt.Errorf("no usable visual model: %w", lastErr) + } + return nil, false, fmt.Errorf("no visual model is configured") } textModelID := chatManage.ChatModelID @@ -266,7 +290,7 @@ func (p *PluginQueryUnderstand) selectModel(ctx context.Context, chatManage *typ "error": err.Error(), }) if fallback, fbErr := p.modelService.GetChatModel(ctx, chatManage.ChatModelID); fbErr == nil { - return fallback, false + return fallback, false, nil } } pipelineError(ctx, "QueryUnderstand", "get_model", map[string]interface{}{ @@ -274,9 +298,9 @@ func (p *PluginQueryUnderstand) selectModel(ctx context.Context, chatManage *typ "chat_model_id": textModelID, "error": err.Error(), }) - return nil, false + return nil, false, nil } - return m, false + return m, false, nil } // buildPrompts constructs system and user prompts with placeholder replacement. diff --git a/internal/application/service/chat_pipeline/query_understand_image_test.go b/internal/application/service/chat_pipeline/query_understand_image_test.go new file mode 100644 index 00000000..314555d1 --- /dev/null +++ b/internal/application/service/chat_pipeline/query_understand_image_test.go @@ -0,0 +1,54 @@ +package chatpipeline + +import ( + "context" + "testing" + + modelchat "github.com/Tencent/WeKnora/internal/models/chat" + "github.com/Tencent/WeKnora/internal/types" + "github.com/Tencent/WeKnora/internal/types/interfaces" +) + +type visionModelStub struct{} + +func (visionModelStub) Chat(context.Context, []modelchat.Message, *modelchat.ChatOptions) (*types.ChatResponse, error) { + return nil, nil +} +func (visionModelStub) ChatStream(context.Context, []modelchat.Message, *modelchat.ChatOptions) (<-chan types.StreamResponse, error) { + return nil, nil +} +func (visionModelStub) GetModelName() string { return "test-vlm" } +func (visionModelStub) GetModelID() string { return "test-vlm" } +func (visionModelStub) SupportsImageInput() bool { return true } + +type modelServiceStub struct { + interfaces.ModelService + model modelchat.Chat +} + +func (s modelServiceStub) GetChatModel(context.Context, string) (modelchat.Chat, error) { + return s.model, nil +} + +func TestSelectModelFailsClosedWhenImageHasNoVisualModel(t *testing.T) { + plugin := &PluginQueryUnderstand{} + manage := &types.ChatManage{PipelineRequest: types.PipelineRequest{ChatModelSupportsVision: false}} + model, useImages, err := plugin.selectModel(context.Background(), manage, true) + if err == nil || model != nil || useImages { + t.Fatalf("selectModel = model %v useImages %v err %v; want fail closed", model, useImages, err) + } +} + +func TestSelectModelUsesVLMWhenChatTransportIsNotVisual(t *testing.T) { + vlm := visionModelStub{} + plugin := &PluginQueryUnderstand{modelService: modelServiceStub{model: vlm}} + manage := &types.ChatManage{PipelineRequest: types.PipelineRequest{ + ChatModelSupportsVision: false, + VLMModelID: "document-parser-vlm", + }} + + model, useImages, err := plugin.selectModel(context.Background(), manage, true) + if err != nil || model == nil || !useImages { + t.Fatalf("selectModel = model %v useImages %v err %v; want visual VLM fallback", model, useImages, err) + } +} diff --git a/internal/application/service/session_agent_image_route.go b/internal/application/service/session_agent_image_route.go new file mode 100644 index 00000000..a26905d5 --- /dev/null +++ b/internal/application/service/session_agent_image_route.go @@ -0,0 +1,63 @@ +package service + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "strings" + + "github.com/Tencent/WeKnora/internal/types" + "github.com/Tencent/WeKnora/internal/types/interfaces" +) + +const maxAgentFallbackImageBytes = 10 << 20 + +// prepareAgentImageInput routes image input according to the resolved primary +// transport capability. A configured VLM is required only when the actual +// primary Chat transport cannot carry images directly. +func prepareAgentImageInput( + ctx context.Context, + req *types.QARequest, + primarySupportsVision bool, + modelService interfaces.ModelService, +) (string, []string, error) { + if req == nil { + return "", nil, errors.New("QA request is required") + } + if len(req.ImageURLs) == 0 || primarySupportsVision || strings.TrimSpace(req.ImageDescription) != "" { + return routeAgentImageInput(req, primarySupportsVision) + } + if req.CustomAgent == nil || strings.TrimSpace(req.CustomAgent.Config.VLMModelID) == "" { + return "", nil, errors.New("agent model does not support visual input and no VLM is configured") + } + if modelService == nil { + return "", nil, errors.New("agent VLM service is unavailable") + } + + images := make([][]byte, 0, len(req.ImageURLs)) + for _, raw := range req.ImageURLs { + comma := strings.IndexByte(raw, ',') + if comma <= 0 || !strings.HasPrefix(strings.ToLower(raw[:comma]), "data:image/") || + !strings.HasSuffix(strings.ToLower(raw[:comma]), ";base64") { + return "", nil, errors.New("agent image payload is invalid") + } + decoded, err := base64.StdEncoding.Strict().DecodeString(raw[comma+1:]) + if err != nil || len(decoded) == 0 || len(decoded) > maxAgentFallbackImageBytes { + return "", nil, errors.New("agent image payload is invalid") + } + images = append(images, decoded) + } + + model, err := modelService.GetVLMModel(ctx, req.CustomAgent.Config.VLMModelID) + if err != nil || model == nil { + return "", nil, errors.New("agent VLM is unavailable") + } + prompt := fmt.Sprintf("Analyze the uploaded image(s) for the user's question. Return a concise factual description only.\nQuestion: %s", req.Query) + description, err := model.Predict(ctx, images, prompt) + if err != nil || strings.TrimSpace(description) == "" { + return "", nil, errors.New("agent VLM analysis failed") + } + req.ImageDescription = strings.TrimSpace(description) + return routeAgentImageInput(req, false) +} diff --git a/internal/application/service/session_agent_image_test.go b/internal/application/service/session_agent_image_test.go new file mode 100644 index 00000000..2b960ca9 --- /dev/null +++ b/internal/application/service/session_agent_image_test.go @@ -0,0 +1,26 @@ +package service + +import ( + "strings" + "testing" + + "github.com/Tencent/WeKnora/internal/types" +) + +func TestRouteAgentImageInputFailsClosedWithoutVision(t *testing.T) { + req := &types.QARequest{Query: "what is this", ImageURLs: []string{"data:image/png;base64,abc"}} + if _, images, err := routeAgentImageInput(req, false); err == nil || len(images) != 0 { + t.Fatalf("routeAgentImageInput should reject unsupported visual input: images=%v err=%v", images, err) + } + + query, images, err := routeAgentImageInput(req, true) + if err != nil || len(images) != 1 || query != req.Query { + t.Fatalf("vision route = query %q images %d err %v", query, len(images), err) + } + + req.ImageDescription = "a blue robot" + query, images, err = routeAgentImageInput(req, false) + if err != nil || len(images) != 0 || !strings.Contains(query, req.ImageDescription) { + t.Fatalf("description fallback = query %q images %d err %v", query, len(images), err) + } +} diff --git a/internal/application/service/session_agent_qa.go b/internal/application/service/session_agent_qa.go index 9956966b..c57841e8 100644 --- a/internal/application/service/session_agent_qa.go +++ b/internal/application/service/session_agent_qa.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "strings" "github.com/Tencent/WeKnora/internal/agent/tools" "github.com/Tencent/WeKnora/internal/event" @@ -156,21 +157,18 @@ func (s *sessionService) AgentQA( return err } - // Route image data based on agent model's vision capability - var agentModelSupportsVision bool - if effectiveModelID != "" { - if modelInfo, err := s.modelService.GetModelByID(ctx, effectiveModelID); err == nil && modelInfo != nil { - agentModelSupportsVision = modelInfo.Parameters.SupportsVision - } - } + // The resolved concrete transport is authoritative. Stored model metadata can + // be stale (notably older WeKnora Cloud records), while unknown transports + // still fail closed through SupportsImageInput. + agentModelSupportsVision := modelSupportsDirectImages(summaryModel) - agentQuery := req.Query - var agentImageURLs []string - if agentModelSupportsVision && len(req.ImageURLs) > 0 { - agentImageURLs = req.ImageURLs + agentQuery, agentImageURLs, err := prepareAgentImageInput(ctx, req, agentModelSupportsVision, s.modelService) + if err != nil { + return err + } + if len(agentImageURLs) > 0 { logger.Infof(ctx, "Agent model supports vision, passing %d image(s) directly", len(agentImageURLs)) } else if req.ImageDescription != "" { - agentQuery = req.Query + "\n\n[用户上传图片内容]\n" + req.ImageDescription logger.Infof(ctx, "Agent model does not support vision, appending image description (%d chars)", len(req.ImageDescription)) } if req.QuotedContext != "" { @@ -208,6 +206,22 @@ func (s *sessionService) AgentQA( return nil } +func routeAgentImageInput(req *types.QARequest, modelSupportsVision bool) (string, []string, error) { + if req == nil { + return "", nil, errors.New("QA request is required") + } + if len(req.ImageURLs) == 0 { + return req.Query, nil, nil + } + if modelSupportsVision { + return req.Query, req.ImageURLs, nil + } + if strings.TrimSpace(req.ImageDescription) != "" { + return req.Query + "\n\n[用户上传图片内容]\n" + req.ImageDescription, nil, nil + } + return "", nil, errors.New("agent model does not support visual input and no image description is available") +} + // buildAgentConfig creates a runtime AgentConfig from the QARequest's custom agent configuration, // tenant info, and resolved knowledge bases / search targets. func (s *sessionService) buildAgentConfig( diff --git a/internal/application/service/session_agent_vlm_fallback_test.go b/internal/application/service/session_agent_vlm_fallback_test.go new file mode 100644 index 00000000..ea5ee845 --- /dev/null +++ b/internal/application/service/session_agent_vlm_fallback_test.go @@ -0,0 +1,75 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/Tencent/WeKnora/internal/models/vlm" + "github.com/Tencent/WeKnora/internal/types" + "github.com/Tencent/WeKnora/internal/types/interfaces" +) + +type agentImageVLMStub struct { + calls int + images [][]byte + result string + err error +} + +func (s *agentImageVLMStub) Predict(_ context.Context, images [][]byte, _ string) (string, error) { + s.calls++ + s.images = images + return s.result, s.err +} +func (*agentImageVLMStub) GetModelName() string { return "vlm-stub" } +func (*agentImageVLMStub) GetModelID() string { return "vlm-1" } + +type agentImageModelServiceStub struct { + interfaces.ModelService + model vlm.VLM + err error + calls int +} + +func (s *agentImageModelServiceStub) GetVLMModel(context.Context, string) (vlm.VLM, error) { + s.calls++ + return s.model, s.err +} + +func TestPrepareAgentImageInputUsesVLMOnlyForNonVisualPrimary(t *testing.T) { + visual := &agentImageVLMStub{result: "机械臂报警E01"} + models := &agentImageModelServiceStub{model: visual} + req := &types.QARequest{ + Query: "什么故障?", + ImageURLs: []string{"data:image/png;base64,YWJj"}, + CustomAgent: &types.CustomAgent{Config: types.CustomAgentConfig{VLMModelID: "vlm-1"}}, + } + query, images, err := prepareAgentImageInput(context.Background(), req, false, models) + if err != nil { + t.Fatalf("prepareAgentImageInput: %v", err) + } + if len(images) != 0 || query == req.Query || req.ImageDescription != visual.result { + t.Fatalf("query=%q images=%v description=%q", query, images, req.ImageDescription) + } + if models.calls != 1 || visual.calls != 1 || len(visual.images) != 1 { + t.Fatalf("model calls=%d VLM calls=%d images=%d", models.calls, visual.calls, len(visual.images)) + } +} + +func TestPrepareAgentImageInputSkipsBrokenVLMForVisualPrimary(t *testing.T) { + models := &agentImageModelServiceStub{err: errors.New("stale VLM")} + req := &types.QARequest{ + Query: "describe", + ImageURLs: []string{"data:image/png;base64,YWJj"}, + CustomAgent: &types.CustomAgent{Config: types.CustomAgentConfig{VLMModelID: "broken"}}, + } + primary := &captureChatModel{supportsImage: true} + query, images, err := prepareAgentImageInput(context.Background(), req, modelSupportsDirectImages(primary), models) + if err != nil { + t.Fatalf("visual primary should bypass VLM: %v", err) + } + if query != req.Query || len(images) != 1 || models.calls != 0 { + t.Fatalf("query=%q images=%v model_calls=%d", query, images, models.calls) + } +} diff --git a/internal/application/service/session_knowledge_qa.go b/internal/application/service/session_knowledge_qa.go index 9e1ad706..77d56fcf 100644 --- a/internal/application/service/session_knowledge_qa.go +++ b/internal/application/service/session_knowledge_qa.go @@ -16,6 +16,12 @@ import ( "github.com/Tencent/WeKnora/internal/types" ) +// modelSupportsDirectImages asks the resolved concrete Chat transport. Unknown +// transports fail closed because chat.SupportsImageInput returns false. +func modelSupportsDirectImages(model chat.Chat) bool { + return chat.SupportsImageInput(model) +} + // KnowledgeQA performs knowledge base question answering with LLM summarization // Events are emitted through eventBus (references, answer chunks, completion) // customAgent is optional - if provided, uses custom agent configuration for multiTurnEnabled and historyTurns @@ -68,12 +74,17 @@ func (s *sessionService) KnowledgeQA( logger.Infof(ctx, "Fallback strategy not set, using default: %v", fallbackStrategy) } - // Resolve chat model vision capability and VLM model ID for image routing + // Resolve the concrete Chat transport for image routing. Runtime transport + // capability is authoritative; stored metadata may be stale for existing + // WeKnora Cloud records. var chatModelSupportsVision bool var vlmModelID string - if chatModelID != "" { - if chatModelInfo, err := s.modelService.GetModelByID(ctx, chatModelID); err == nil && chatModelInfo != nil { - chatModelSupportsVision = chatModelInfo.Parameters.SupportsVision + if chatModelID != "" && len(req.ImageURLs) > 0 { + chatModel, modelErr := s.modelService.GetChatModel(ctx, chatModelID) + if modelErr != nil { + logger.Warnf(ctx, "Failed to initialize Chat model %s for image routing: %v", chatModelID, modelErr) + } else { + chatModelSupportsVision = modelSupportsDirectImages(chatModel) } } if req.CustomAgent != nil { @@ -136,7 +147,8 @@ func (s *sessionService) KnowledgeQA( VLMModelID: vlmModelID, ChatModelSupportsVision: chatModelSupportsVision, Attachments: req.Attachments, - Language: types.LanguageNameFromContext(ctx), + + Language: types.LanguageNameFromContext(ctx), }, PipelineState: types.PipelineState{ RewriteQuery: req.Query, @@ -158,6 +170,7 @@ func (s *sessionService) KnowledgeQA( hasKB := len(knowledgeBaseIDs) > 0 || len(knowledgeIDs) > 0 needsRAG := hasKB || req.WebSearchEnabled hasHistory := chatManage.MaxRounds > 0 + needsVisualDescription := len(req.ImageURLs) > 0 && !chatModelSupportsVision var pipeline []types.EventType if !needsRAG { @@ -178,6 +191,8 @@ func (s *sessionService) KnowledgeQA( pipeline = types.NewPipelineBuilder(). AddIf(hasHistory, types.LOAD_HISTORY). AddIf(chatManage.EnableMemory, types.MEMORY_RETRIEVAL). + AddIf(needsVisualDescription, types.QUERY_UNDERSTAND). + AddIf(needsVisualDescription, types.INTO_CHAT_MESSAGE). Add(types.CHAT_COMPLETION_STREAM). AddIf(chatManage.EnableMemory, types.MEMORY_STORAGE). Build() diff --git a/internal/application/service/session_knowledge_qa_test.go b/internal/application/service/session_knowledge_qa_test.go index 6800b1ca..c43c78d0 100644 --- a/internal/application/service/session_knowledge_qa_test.go +++ b/internal/application/service/session_knowledge_qa_test.go @@ -16,15 +16,19 @@ import ( ) type captureChatModel struct { - lastMessages []chat.Message + lastMessages []chat.Message + supportsImage bool + chatResponse *types.ChatResponse + chatErr error } func (m *captureChatModel) Chat( - context.Context, - []chat.Message, - *chat.ChatOptions, + _ context.Context, + messages []chat.Message, + _ *chat.ChatOptions, ) (*types.ChatResponse, error) { - return nil, nil + m.lastMessages = append([]chat.Message(nil), messages...) + return m.chatResponse, m.chatErr } func (m *captureChatModel) ChatStream( @@ -44,8 +48,14 @@ func (m *captureChatModel) ChatStream( return ch, nil } -func (m *captureChatModel) GetModelName() string { return "capture" } -func (m *captureChatModel) GetModelID() string { return "capture" } +func (m *captureChatModel) GetModelName() string { return "capture" } +func (m *captureChatModel) GetModelID() string { return "capture" } +func (m *captureChatModel) SupportsImageInput() bool { return m.supportsImage } + +func TestModelSupportsDirectImagesUsesResolvedTransportAuthority(t *testing.T) { + assert.False(t, modelSupportsDirectImages(&captureChatModel{supportsImage: false})) + assert.True(t, modelSupportsDirectImages(&captureChatModel{supportsImage: true})) +} type stubModelService struct { chatModel chat.Chat diff --git a/internal/im/adapter.go b/internal/im/adapter.go index 41ec2234..cef31f68 100644 --- a/internal/im/adapter.go +++ b/internal/im/adapter.go @@ -64,6 +64,8 @@ type IncomingMessage struct { FileName string // FileSize is the file size in bytes (for file messages, optional). FileSize int64 + // ImageURLs carries transient model-ready image inputs (usually data URIs). Never log these values. + ImageURLs []string // ThreadID is the platform-specific thread identifier. // - Slack: thread_ts (top-level message uses its own timestamp) // - Mattermost: root_id, or post_id if top-level diff --git a/internal/im/dingtalk/adapter.go b/internal/im/dingtalk/adapter.go index f978dc52..28e6e34e 100644 --- a/internal/im/dingtalk/adapter.go +++ b/internal/im/dingtalk/adapter.go @@ -9,7 +9,12 @@ import ( "encoding/json" "fmt" "io" + "mime" + "net" "net/http" + "net/netip" + "net/url" + "path/filepath" "strconv" "strings" "sync" @@ -20,6 +25,7 @@ import ( "github.com/Tencent/WeKnora/internal/im" "github.com/Tencent/WeKnora/internal/logger" + secutils "github.com/Tencent/WeKnora/internal/utils" ) // httpClient is a shared HTTP client with a reasonable timeout for DingTalk API calls. @@ -33,15 +39,18 @@ const dingtalkConvTypeGroup = "2" // Compile-time checks. var ( - _ im.Adapter = (*Adapter)(nil) - _ im.StreamSender = (*Adapter)(nil) + _ im.Adapter = (*Adapter)(nil) + _ im.StreamSender = (*Adapter)(nil) + _ im.FileDownloader = (*Adapter)(nil) ) // Adapter implements im.Adapter for DingTalk. type Adapter struct { - clientID string - clientSecret string - cardTemplateID string // optional: enables AI card streaming when set + clientID string + clientSecret string + cardTemplateID string // optional: enables AI card streaming when set + messageFileDownloadURL string + allowInsecureDownload bool // tests only; production requires HTTPS // accessToken cache tokenMu sync.RWMutex @@ -114,19 +123,20 @@ func (a *Adapter) VerifyCallback(c *gin.Context) error { // DingTalk callback message structure. type callbackMessage struct { - ConversationID string `json:"conversationId"` - ConversationType string `json:"conversationType"` - MsgID string `json:"msgId"` - Msgtype string `json:"msgtype"` - Text *textContent `json:"text"` - SenderNick string `json:"senderNick"` - SenderStaffId string `json:"senderStaffId"` - SenderID string `json:"senderId"` - SessionWebhook string `json:"sessionWebhook"` - RobotCode string `json:"robotCode"` - AtUsers []atUser `json:"atUsers"` - IsInAtList bool `json:"isInAtList"` - ChatbotCorpId string `json:"chatbotCorpId"` + ConversationID string `json:"conversationId"` + ConversationType string `json:"conversationType"` + MsgID string `json:"msgId"` + Msgtype string `json:"msgtype"` + Text *textContent `json:"text"` + Content json.RawMessage `json:"content"` + SenderNick string `json:"senderNick"` + SenderStaffId string `json:"senderStaffId"` + SenderID string `json:"senderId"` + SessionWebhook string `json:"sessionWebhook"` + RobotCode string `json:"robotCode"` + AtUsers []atUser `json:"atUsers"` + IsInAtList bool `json:"isInAtList"` + ChatbotCorpId string `json:"chatbotCorpId"` } type textContent struct { @@ -170,6 +180,20 @@ func parseCallbackMessage(msg *callbackMessage) *im.IncomingMessage { if msg.Text != nil { content = strings.TrimSpace(msg.Text.Content) } + messageType := im.MessageTypeText + fileKey := "" + fileName := "" + if strings.EqualFold(msg.Msgtype, "picture") { + messageType = im.MessageTypeImage + fileName = msg.MsgID + ".png" + content = "" + var picture struct { + DownloadCode string `json:"downloadCode"` + } + if json.Unmarshal(msg.Content, &picture) == nil { + fileKey = strings.TrimSpace(picture.DownloadCode) + } + } return &im.IncomingMessage{ Platform: im.PlatformDingtalk, @@ -178,14 +202,278 @@ func parseCallbackMessage(msg *callbackMessage) *im.IncomingMessage { ChatID: chatID, ChatType: chatType, MessageID: msg.MsgID, - MessageType: im.MessageTypeText, + MessageType: messageType, Content: content, + FileKey: fileKey, + FileName: fileName, Extra: map[string]string{ "session_webhook": msg.SessionWebhook, }, } } +const ( + dingtalkMessageFileDownloadURL = "https://api.dingtalk.com/v1.0/robot/messageFiles/download" + maxDingTalkAPIResponseBytes = 1 << 20 +) + +func (a *Adapter) newDingTalkAPIClient(base *http.Client) *http.Client { + client := *base + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + if !a.allowInsecureDownload { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second} + transport.DialContext = newDingTalkSafeDialContext(net.DefaultResolver.LookupIPAddr, dialer.DialContext) + client.Transport = transport + } + return &client +} + +func readBoundedDingTalkAPIResponse(resp *http.Response) ([]byte, error) { + if resp.ContentLength > maxDingTalkAPIResponseBytes { + return nil, fmt.Errorf("dingtalk API response exceeds size limit") + } + payload, err := io.ReadAll(io.LimitReader(resp.Body, maxDingTalkAPIResponseBytes+1)) + if err != nil { + return nil, fmt.Errorf("read dingtalk API response: %w", err) + } + if len(payload) > maxDingTalkAPIResponseBytes { + return nil, fmt.Errorf("dingtalk API response exceeds size limit") + } + return payload, nil +} + +func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) { + if msg == nil || strings.TrimSpace(msg.FileKey) == "" { + return nil, "", fmt.Errorf("download code is required") + } + token, err := a.getAccessToken(ctx) + if err != nil { + return nil, "", fmt.Errorf("get access token: %w", err) + } + body, err := json.Marshal(map[string]string{ + "downloadCode": msg.FileKey, + "robotCode": a.clientID, + }) + if err != nil { + return nil, "", fmt.Errorf("marshal message file request: %w", err) + } + apiURL := a.messageFileDownloadURL + if apiURL == "" { + apiURL = dingtalkMessageFileDownloadURL + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) + if err != nil { + return nil, "", fmt.Errorf("create message file request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-acs-dingtalk-access-token", token) + apiClient := a.newDingTalkAPIClient(httpClient) + resp, err := apiClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("request message file URL: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, "", fmt.Errorf("dingtalk message file API returned %d", resp.StatusCode) + } + payload, err := readBoundedDingTalkAPIResponse(resp) + if err != nil { + return nil, "", err + } + var result struct { + DownloadURL string `json:"downloadUrl"` + } + if err := json.Unmarshal(payload, &result); err != nil { + return nil, "", fmt.Errorf("decode message file response: %w", err) + } + if !a.allowInsecureDownload { + result.DownloadURL, err = normalizeDingTalkInitialDownloadURL(result.DownloadURL) + if err != nil { + return nil, "", err + } + } + if err := validateDingTalkDownloadURL(result.DownloadURL, a.allowInsecureDownload); err != nil { + return nil, "", err + } + + downloadReq, err := http.NewRequestWithContext(ctx, http.MethodGet, result.DownloadURL, nil) + if err != nil { + return nil, "", fmt.Errorf("create image download request: %w", err) + } + downloadClient := *httpClient + if !a.allowInsecureDownload { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second} + transport.DialContext = newDingTalkSafeDialContext(net.DefaultResolver.LookupIPAddr, dialer.DialContext) + downloadClient.Transport = transport + } + downloadClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return validateDingTalkRedirect(req, via, a.allowInsecureDownload) + } + downloadResp, err := downloadClient.Do(downloadReq) + if err != nil { + if ctx.Err() != nil { + return nil, "", ctx.Err() + } + return nil, "", fmt.Errorf("download dingtalk image request failed") + } + if downloadResp.StatusCode < http.StatusOK || downloadResp.StatusCode >= http.StatusMultipleChoices { + downloadResp.Body.Close() + return nil, "", fmt.Errorf("dingtalk image download returned %d", downloadResp.StatusCode) + } + contentType := strings.ToLower(strings.TrimSpace(strings.Split(downloadResp.Header.Get("Content-Type"), ";")[0])) + if msg.MessageType == im.MessageTypeImage && contentType != "" && contentType != "application/octet-stream" && !strings.HasPrefix(contentType, "image/") { + downloadResp.Body.Close() + return nil, "", fmt.Errorf("dingtalk picture response is not an image") + } + return downloadResp.Body, dingtalkDownloadedFileName(downloadResp, msg, contentType), nil +} + +var dingtalkBlockedPublicRanges = []netip.Prefix{ + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("2001:db8::/32"), +} + +func isSafeDingTalkDownloadIP(ip net.IP) bool { + if ip == nil || !secutils.IsPublicIP(ip) { + return false + } + addr, ok := netip.AddrFromSlice(ip) + if !ok { + return false + } + addr = addr.Unmap() + if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsUnspecified() || addr.IsMulticast() { + return false + } + for _, prefix := range dingtalkBlockedPublicRanges { + if prefix.Contains(addr) { + return false + } + } + return true +} + +func newDingTalkSafeDialContext( + lookup func(context.Context, string) ([]net.IPAddr, error), + dial func(context.Context, string, string) (net.Conn, error), +) func(context.Context, string, string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("invalid dingtalk media address") + } + var ips []net.IPAddr + if literal := net.ParseIP(host); literal != nil { + ips = []net.IPAddr{{IP: literal}} + } else { + ips, err = lookup(ctx, host) + if err != nil { + return nil, fmt.Errorf("resolve dingtalk media host failed") + } + } + if len(ips) == 0 { + return nil, fmt.Errorf("dingtalk media host resolved without addresses") + } + for _, resolved := range ips { + if !isSafeDingTalkDownloadIP(resolved.IP) { + return nil, fmt.Errorf("dingtalk media host resolved to a blocked address") + } + } + var lastErr error + for _, resolved := range ips { + conn, dialErr := dial(ctx, network, net.JoinHostPort(resolved.IP.String(), port)) + if dialErr == nil { + return conn, nil + } + lastErr = dialErr + } + return nil, fmt.Errorf("connect to validated dingtalk media address: %w", lastErr) + } +} + +func validateDingTalkRedirect(req *http.Request, via []*http.Request, allowInsecure bool) error { + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 dingtalk image redirects") + } + // Go may synthesize Referer from the credential-bearing source URL and copy + // headers across redirects. Media redirects need no inherited headers. + req.Header = make(http.Header) + return validateDingTalkDownloadURL(req.URL.String(), allowInsecure) +} + +// normalizeDingTalkInitialDownloadURL upgrades the one-time media URL returned +// directly by DingTalk's authenticated message-file API. Some DingTalk media +// backends still return an http scheme even though the same signed resource is +// available over TLS. Only this initial trusted API response is upgraded; +// redirects remain HTTPS-only and are validated independently. +func normalizeDingTalkInitialDownloadURL(rawURL string) (string, error) { + u, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || u.Hostname() == "" || u.User != nil { + return "", fmt.Errorf("invalid dingtalk download URL") + } + switch u.Scheme { + case "http": + u.Scheme = "https" + case "https": + default: + return "", fmt.Errorf("invalid dingtalk download URL scheme") + } + return u.String(), nil +} + +func validateDingTalkDownloadURL(rawURL string, allowInsecure bool) error { + u, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || u.Hostname() == "" || u.User != nil { + return fmt.Errorf("invalid dingtalk download URL") + } + if allowInsecure { + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("invalid dingtalk download URL scheme") + } + return nil + } + if u.Scheme != "https" { + return fmt.Errorf("dingtalk download URL must use HTTPS") + } + if ip := net.ParseIP(u.Hostname()); ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()) { + return fmt.Errorf("dingtalk download URL points to a private address") + } + if err := secutils.ValidateURLForSSRF(u.String()); err != nil { + return fmt.Errorf("dingtalk download URL failed SSRF validation: %w", err) + } + return nil +} + +func dingtalkDownloadedFileName(resp *http.Response, msg *im.IncomingMessage, contentType string) string { + if _, params, err := mime.ParseMediaType(resp.Header.Get("Content-Disposition")); err == nil { + if name := filepath.Base(strings.TrimSpace(params["filename"])); name != "" && name != "." && name != ".." { + return name + } + } + if msg != nil && strings.TrimSpace(msg.FileName) != "" { + return filepath.Base(msg.FileName) + } + ext := ".bin" + if extensions, _ := mime.ExtensionsByType(contentType); len(extensions) > 0 { + ext = extensions[0] + } + if msg != nil && msg.MessageID != "" { + return msg.MessageID + ext + } + return "dingtalk-file" + ext +} + // ── Send reply ── func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error { @@ -203,6 +491,9 @@ func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, r } func (a *Adapter) replyViaSessionWebhook(ctx context.Context, webhookURL, content string) error { + if err := validateDingTalkDownloadURL(webhookURL, a.allowInsecureDownload); err != nil { + return fmt.Errorf("invalid DingTalk session webhook") + } body := map[string]interface{}{ "msgtype": "markdown", "markdown": map[string]string{ @@ -218,19 +509,22 @@ func (a *Adapter) replyViaSessionWebhook(ctx context.Context, webhookURL, conten req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(jsonBody)) if err != nil { - return fmt.Errorf("create request: %w", err) + return fmt.Errorf("create DingTalk session webhook request") } req.Header.Set("Content-Type", "application/json") - resp, err := httpClient.Do(req) + client := a.newDingTalkAPIClient(httpClient) + resp, err := client.Do(req) if err != nil { - return fmt.Errorf("send reply: %w", err) + return fmt.Errorf("send DingTalk session webhook failed") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("dingtalk sessionWebhook returned %d: %s", resp.StatusCode, string(respBody)) + return fmt.Errorf("dingtalk session webhook returned %d", resp.StatusCode) + } + if _, err := readBoundedDingTalkAPIResponse(resp); err != nil { + return err } return nil } @@ -273,15 +567,18 @@ func (a *Adapter) replyViaOpenAPI(ctx context.Context, incoming *im.IncomingMess req.Header.Set("Content-Type", "application/json") req.Header.Set("x-acs-dingtalk-access-token", token) - resp, err := httpClient.Do(req) + apiClient := a.newDingTalkAPIClient(httpClient) + resp, err := apiClient.Do(req) if err != nil { - return fmt.Errorf("send reply: %w", err) + return fmt.Errorf("send reply failed") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("dingtalk OpenAPI returned %d: %s", resp.StatusCode, string(respBody)) + return fmt.Errorf("dingtalk OpenAPI returned %d", resp.StatusCode) + } + if _, err := readBoundedDingTalkAPIResponse(resp); err != nil { + return err } return nil } @@ -317,22 +614,26 @@ func (a *Adapter) getAccessToken(ctx context.Context) (string, error) { } req.Header.Set("Content-Type", "application/json") - resp, err := httpClient.Do(req) + apiClient := a.newDingTalkAPIClient(httpClient) + resp, err := apiClient.Do(req) if err != nil { - return "", err + return "", fmt.Errorf("dingtalk access token request failed") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("dingtalk accessToken returned %d: %s", resp.StatusCode, string(respBody)) + return "", fmt.Errorf("dingtalk access token returned %d", resp.StatusCode) + } + payload, err := readBoundedDingTalkAPIResponse(resp) + if err != nil { + return "", err } var result struct { AccessToken string `json:"accessToken"` ExpireIn int64 `json:"expireIn"` } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + if err := json.Unmarshal(payload, &result); err != nil { return "", fmt.Errorf("decode token response: %w", err) } if result.AccessToken == "" { @@ -366,15 +667,19 @@ func (a *Adapter) dingtalkAPI(ctx context.Context, method, path string, body int req.Header.Set("Content-Type", "application/json") req.Header.Set("x-acs-dingtalk-access-token", token) - resp, err := httpClient.Do(req) + apiClient := a.newDingTalkAPIClient(httpClient) + resp, err := apiClient.Do(req) if err != nil { - return nil, fmt.Errorf("do request: %w", err) + return nil, fmt.Errorf("dingtalk API request failed") } defer resp.Body.Close() - respBody, _ := io.ReadAll(resp.Body) + respBody, readErr := readBoundedDingTalkAPIResponse(resp) + if readErr != nil { + return nil, readErr + } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("dingtalk API %s returned %d: %s", path, resp.StatusCode, string(respBody)) + return nil, fmt.Errorf("dingtalk API %s returned %d", path, resp.StatusCode) } return respBody, nil } @@ -573,6 +878,7 @@ func (a *Adapter) FinalizeStream(ctx context.Context, incoming *im.IncomingMessa if outTrackID != "" { if err := a.streamingUpdateCard(ctx, outTrackID, finalContent, false); err != nil { logger.Warnf(ctx, "[DingTalk] Failed to finalize card stream: %v", err) + return fmt.Errorf("finalize DingTalk card stream: %w", err) } } return nil @@ -601,14 +907,17 @@ func (a *Adapter) EndStream(ctx context.Context, incoming *im.IncomingMessage, s if outTrackID != "" { if err := a.streamingUpdateCard(ctx, outTrackID, fullContent, true); err != nil { logger.Warnf(ctx, "[DingTalk] Failed to finalize card stream: %v", err) + return fmt.Errorf("end DingTalk card stream: %w", err) } } else if sessionWebhook != "" { if err := a.replyViaSessionWebhook(ctx, sessionWebhook, fullContent); err != nil { logger.Warnf(ctx, "[DingTalk] Failed to end stream: %v", err) + return fmt.Errorf("end DingTalk session webhook stream: %w", err) } } else { if err := a.replyViaOpenAPI(ctx, incoming, fullContent); err != nil { logger.Warnf(ctx, "[DingTalk] Failed to end stream via OpenAPI: %v", err) + return fmt.Errorf("end DingTalk OpenAPI stream: %w", err) } } diff --git a/internal/im/dingtalk/adapter_security_edge_test.go b/internal/im/dingtalk/adapter_security_edge_test.go new file mode 100644 index 00000000..0a1351e4 --- /dev/null +++ b/internal/im/dingtalk/adapter_security_edge_test.go @@ -0,0 +1,45 @@ +package dingtalk + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDingTalkRedirectClearsRefererAndInheritedHeaders(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "https://cdn-two.example/image.png", nil) + req.Header.Set("Referer", "https://cdn-one.example/image.png?signature=TOPSECRET") + req.Header.Set("Authorization", "Bearer TOPSECRET") + if err := validateDingTalkRedirect(req, nil, true); err != nil { + t.Fatalf("validateDingTalkRedirect: %v", err) + } + if len(req.Header) != 0 { + t.Fatalf("redirect headers = %#v, want empty", req.Header) + } +} + +func TestReplyViaSessionWebhookRejectsRedirectWithoutLeakingURL(t *testing.T) { + var reached bool + target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })) + defer target.Close() + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", target.URL) + w.WriteHeader(http.StatusTemporaryRedirect) + })) + defer source.Close() + + a := &Adapter{allowInsecureDownload: true} + signedURL := source.URL + "/hook?access_token=TOPSECRET" + err := a.replyViaSessionWebhook(context.Background(), signedURL, "reply") + if err == nil { + t.Fatal("expected webhook redirect rejection") + } + if reached { + t.Fatal("session webhook followed redirect") + } + if strings.Contains(err.Error(), "TOPSECRET") || strings.Contains(err.Error(), signedURL) { + t.Fatalf("credential URL leaked in error: %v", err) + } +} diff --git a/internal/im/dingtalk/adapter_test.go b/internal/im/dingtalk/adapter_test.go new file mode 100644 index 00000000..0ecda467 --- /dev/null +++ b/internal/im/dingtalk/adapter_test.go @@ -0,0 +1,276 @@ +package dingtalk + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Tencent/WeKnora/internal/im" +) + +func TestParseCallbackMessagePicture(t *testing.T) { + msg := &callbackMessage{ + ConversationID: "cid-group", + ConversationType: dingtalkConvTypeGroup, + MsgID: "msg-picture-1", + Msgtype: "picture", + Content: []byte(`{"downloadCode":"download-code-1"}`), + SenderStaffId: "staff-1", + SenderNick: "Alice", + SessionWebhook: "https://example.invalid/session", + } + + got := parseCallbackMessage(msg) + if got.MessageType != im.MessageTypeImage { + t.Fatalf("MessageType = %q, want image", got.MessageType) + } + if got.FileKey != "download-code-1" { + t.Fatalf("FileKey = %q, want download code", got.FileKey) + } + if got.FileName != "msg-picture-1.png" { + t.Fatalf("FileName = %q", got.FileName) + } + if got.Content != "" { + t.Fatalf("Content = %q, want empty", got.Content) + } +} + +func TestAdapterDownloadFileUsesMessageFilesAPI(t *testing.T) { + var sawToken bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1.0/robot/messageFiles/download": + sawToken = r.Header.Get("x-acs-dingtalk-access-token") == "cached-access-token" + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), `"downloadCode":"download-code-1"`) || !strings.Contains(string(body), `"robotCode":"robot-code-1"`) { + t.Errorf("unexpected request body: %s", body) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"downloadUrl":"` + serverURLForRequest(r) + `/media/picture"}`)) + case "/media/picture": + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Content-Disposition", `attachment; filename="question.png"`) + _, _ = w.Write([]byte("PNGDATA")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + adapter := NewAdapter("robot-code-1", "secret", "") + adapter.token = "cached-access-token" + adapter.tokenExpAt = time.Now().Add(time.Hour) + adapter.messageFileDownloadURL = server.URL + "/v1.0/robot/messageFiles/download" + adapter.allowInsecureDownload = true + + reader, fileName, err := adapter.DownloadFile(context.Background(), &im.IncomingMessage{ + MessageType: im.MessageTypeImage, + MessageID: "msg-picture-1", + FileKey: "download-code-1", + }) + if err != nil { + t.Fatalf("DownloadFile: %v", err) + } + defer reader.Close() + body, _ := io.ReadAll(reader) + if string(body) != "PNGDATA" { + t.Fatalf("body = %q", body) + } + if fileName != "question.png" { + t.Fatalf("fileName = %q", fileName) + } + if !sawToken { + t.Fatal("missing DingTalk access token header") + } +} + +func serverURLForRequest(r *http.Request) string { + return "http://" + r.Host +} + +func TestNormalizeDingTalkInitialDownloadURLUpgradesHTTPToHTTPS(t *testing.T) { + rawURL := "http://media.example.com/path/image.png?signature=abc%2F123&name=a+b.png" + got, err := normalizeDingTalkInitialDownloadURL(rawURL) + if err != nil { + t.Fatalf("normalizeDingTalkInitialDownloadURL: %v", err) + } + want := "https://media.example.com/path/image.png?signature=abc%2F123&name=a+b.png" + if got != want { + t.Fatalf("normalized URL = %q, want %q", got, want) + } +} + +func TestValidateDingTalkDownloadURLRejectsUnsafeTargets(t *testing.T) { + for _, rawURL := range []string{ + "http://example.com/image.png", + "https://user:pass@example.com/image.png", + "https://127.0.0.1/image.png", + "https://10.0.0.8/image.png", + } { + t.Run(rawURL, func(t *testing.T) { + if err := validateDingTalkDownloadURL(rawURL, false); err == nil { + t.Fatalf("unsafe URL accepted: %s", rawURL) + } + }) + } +} + +func TestParseCallbackMessagePictureWithoutDownloadCodeStaysImage(t *testing.T) { + for _, content := range [][]byte{[]byte(`{}`), []byte(`not-json`)} { + msg := parseCallbackMessage(&callbackMessage{MsgID: "bad-picture", Msgtype: "picture", Content: content}) + if msg.MessageType != im.MessageTypeImage { + t.Fatalf("MessageType = %q, want image", msg.MessageType) + } + if msg.FileKey != "" { + t.Fatalf("FileKey = %q, want empty", msg.FileKey) + } + } +} + +func TestAdapterDownloadFileDoesNotFollowCredentialedAPIRedirect(t *testing.T) { + var redirectedToken string + target := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + redirectedToken = r.Header.Get("x-acs-dingtalk-access-token") + })) + defer target.Close() + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", target.URL) + w.WriteHeader(http.StatusTemporaryRedirect) + })) + defer api.Close() + + adapter := NewAdapter("robot-code-1", "secret", "") + adapter.token = "cached-access-token" + adapter.tokenExpAt = time.Now().Add(time.Hour) + adapter.messageFileDownloadURL = api.URL + adapter.allowInsecureDownload = true + _, _, err := adapter.DownloadFile(context.Background(), &im.IncomingMessage{MessageType: im.MessageTypeImage, FileKey: "code"}) + if err == nil { + t.Fatal("expected redirect rejection") + } + if redirectedToken != "" { + t.Fatal("access token leaked across redirect") + } +} + +func TestAdapterDownloadFileAcceptsGenericMediaContentType(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"downloadUrl":"` + serverURLForRequest(r) + `/media"}`)) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write([]byte("PNGDATA")) + })) + defer server.Close() + adapter := NewAdapter("robot", "secret", "") + adapter.token = "token" + adapter.tokenExpAt = time.Now().Add(time.Hour) + adapter.messageFileDownloadURL = server.URL + "/api" + adapter.allowInsecureDownload = true + body, _, err := adapter.DownloadFile(context.Background(), &im.IncomingMessage{MessageType: im.MessageTypeImage, FileKey: "code"}) + if err != nil { + t.Fatalf("DownloadFile: %v", err) + } + body.Close() +} + +func TestDingTalkAPIClientDisablesProxyAndRedirects(t *testing.T) { + a := &Adapter{} + client := a.newDingTalkAPIClient(httpClient) + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("transport type = %T, want *http.Transport", client.Transport) + } + if transport.Proxy != nil { + t.Fatal("DingTalk API client must ignore environment proxies") + } + req := httptest.NewRequest(http.MethodPost, "https://api.dingtalk.com/redirect", nil) + if err := client.CheckRedirect(req, nil); !errors.Is(err, http.ErrUseLastResponse) { + t.Fatalf("redirect error = %v, want http.ErrUseLastResponse", err) + } +} + +func TestDingTalkRedirectLimitFailsClosed(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "https://example.com/image.png", nil) + via := make([]*http.Request, 10) + if err := validateDingTalkRedirect(req, via, false); err == nil { + t.Fatal("validateDingTalkRedirect should reject more than 10 requests") + } +} + +func TestDingTalkSafeDialUsesValidatedConcreteIP(t *testing.T) { + stop := errors.New("stop after address capture") + var dialed string + dial := newDingTalkSafeDialContext( + func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("1.1.1.1")}}, nil + }, + func(_ context.Context, _ string, addr string) (net.Conn, error) { + dialed = addr + return nil, stop + }, + ) + _, err := dial(context.Background(), "tcp", "cdn.example.test:443") + if !errors.Is(err, stop) { + t.Fatalf("dial error = %v, want sentinel", err) + } + if dialed != "1.1.1.1:443" { + t.Fatalf("dialed address = %q, want validated concrete IP", dialed) + } +} + +func TestDingTalkSafeDialRejectsEveryRestrictedResolvedIP(t *testing.T) { + for _, rawIP := range []string{ + "0.0.0.1", + "240.0.0.1", + "2001:0000::1", + "2002:7f00:0001::1", + } { + t.Run(rawIP, func(t *testing.T) { + dialCalled := false + dial := newDingTalkSafeDialContext( + func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP(rawIP)}}, nil + }, + func(context.Context, string, string) (net.Conn, error) { + dialCalled = true + return nil, errors.New("must not dial") + }, + ) + if _, err := dial(context.Background(), "tcp", "cdn.example.test:443"); err == nil { + t.Fatal("restricted resolved IP was accepted") + } + if dialCalled { + t.Fatal("restricted resolved IP reached network dial") + } + }) + } +} + +func TestAdapterDownloadFileRedactsSignedURLFromErrors(t *testing.T) { + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"downloadUrl":"http://127.0.0.1:1/media?X-Amz-Signature=TOPSECRET"}`)) + })) + defer api.Close() + adapter := NewAdapter("robot", "secret", "") + adapter.token = "token" + adapter.tokenExpAt = time.Now().Add(time.Hour) + adapter.messageFileDownloadURL = api.URL + adapter.allowInsecureDownload = true + _, _, err := adapter.DownloadFile(context.Background(), &im.IncomingMessage{MessageType: im.MessageTypeImage, FileKey: "code"}) + if err == nil { + t.Fatal("expected download error") + } + if strings.Contains(err.Error(), "TOPSECRET") || strings.Contains(err.Error(), "X-Amz-Signature") { + t.Fatalf("signed URL leaked in error: %v", err) + } +} diff --git a/internal/im/dingtalk/finalize_fail_closed_test.go b/internal/im/dingtalk/finalize_fail_closed_test.go new file mode 100644 index 00000000..dea475c3 --- /dev/null +++ b/internal/im/dingtalk/finalize_fail_closed_test.go @@ -0,0 +1,43 @@ +package dingtalk + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/Tencent/WeKnora/internal/im" +) + +type failingCardRoundTripper struct{} + +func (failingCardRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("simulated delivery failure") +} + +func TestFinalizeStreamPropagatesCardDeliveryFailure(t *testing.T) { + oldClient := httpClient + httpClient = &http.Client{Transport: failingCardRoundTripper{}, Timeout: time.Second} + t.Cleanup(func() { httpClient = oldClient }) + + adapter := &Adapter{ + clientID: "robot", + token: "cached-token", + tokenExpAt: time.Now().Add(time.Hour), + } + streamID := "dt:fail-closed-test" + streamsMu.Lock() + dStreams[streamID] = &streamState{outTrackID: "track-id", createdAt: time.Now()} + streamsMu.Unlock() + t.Cleanup(func() { + streamsMu.Lock() + delete(dStreams, streamID) + streamsMu.Unlock() + }) + + err := adapter.FinalizeStream(context.Background(), &im.IncomingMessage{}, streamID, "image recognition failed") + if err == nil { + t.Fatal("expected final card delivery failure to propagate") + } +} diff --git a/internal/im/dingtalk/longconn.go b/internal/im/dingtalk/longconn.go index 0931b912..c6e5203b 100644 --- a/internal/im/dingtalk/longconn.go +++ b/internal/im/dingtalk/longconn.go @@ -2,6 +2,7 @@ package dingtalk import ( "context" + "encoding/json" "strings" "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" @@ -71,7 +72,11 @@ func (c *LongConnClient) onChatBotMessage(ctx context.Context, data *chatbot.Bot userID = data.SenderId } - content := strings.TrimSpace(data.Text.Content) + messageType, content, fileKey := parseDingTalkStreamPayload(data.Msgtype, data.Content, data.Text.Content) + fileName := "" + if messageType == im.MessageTypeImage { + fileName = data.MsgId + ".png" + } incoming := &im.IncomingMessage{ Platform: im.PlatformDingtalk, @@ -80,8 +85,10 @@ func (c *LongConnClient) onChatBotMessage(ctx context.Context, data *chatbot.Bot ChatID: chatID, ChatType: chatType, MessageID: data.MsgId, - MessageType: im.MessageTypeText, + MessageType: messageType, Content: content, + FileKey: fileKey, + FileName: fileName, Extra: map[string]string{ "session_webhook": data.SessionWebhook, }, @@ -93,3 +100,31 @@ func (c *LongConnClient) onChatBotMessage(ctx context.Context, data *chatbot.Bot return nil, nil } + +func parseDingTalkStreamPayload(msgType string, rawContent interface{}, text string) (im.MessageType, string, string) { + if !strings.EqualFold(strings.TrimSpace(msgType), "picture") { + return im.MessageTypeText, strings.TrimSpace(text), "" + } + var payload []byte + switch value := rawContent.(type) { + case string: + payload = []byte(value) + case []byte: + payload = value + case json.RawMessage: + payload = value + default: + var err error + payload, err = json.Marshal(rawContent) + if err != nil { + return im.MessageTypeImage, "", "" + } + } + var picture struct { + DownloadCode string `json:"downloadCode"` + } + if json.Unmarshal(payload, &picture) != nil { + return im.MessageTypeImage, "", "" + } + return im.MessageTypeImage, "", strings.TrimSpace(picture.DownloadCode) +} diff --git a/internal/im/dingtalk/longconn_test.go b/internal/im/dingtalk/longconn_test.go new file mode 100644 index 00000000..5579c0e6 --- /dev/null +++ b/internal/im/dingtalk/longconn_test.go @@ -0,0 +1,37 @@ +package dingtalk + +import ( + "testing" + + "github.com/Tencent/WeKnora/internal/im" +) + +func TestParseDingTalkStreamPayloadPicture(t *testing.T) { + messageType, content, fileKey := parseDingTalkStreamPayload("picture", map[string]interface{}{ + "downloadCode": "stream-download-code", + }, "ignored text") + + if messageType != im.MessageTypeImage { + t.Fatalf("MessageType = %q, want image", messageType) + } + if content != "" { + t.Fatalf("content = %q, want empty", content) + } + if fileKey != "stream-download-code" { + t.Fatalf("fileKey = %q", fileKey) + } +} + +func TestParseDingTalkStreamPayloadText(t *testing.T) { + messageType, content, fileKey := parseDingTalkStreamPayload("text", nil, " hello ") + if messageType != im.MessageTypeText || content != "hello" || fileKey != "" { + t.Fatalf("got type=%q content=%q fileKey=%q", messageType, content, fileKey) + } +} + +func TestParseDingTalkStreamPayloadPictureJSONString(t *testing.T) { + messageType, _, fileKey := parseDingTalkStreamPayload("picture", `{"downloadCode":"string-code"}`, "") + if messageType != im.MessageTypeImage || fileKey != "string-code" { + t.Fatalf("got type=%q fileKey=%q", messageType, fileKey) + } +} diff --git a/internal/im/image_qa_test.go b/internal/im/image_qa_test.go new file mode 100644 index 00000000..6edbeef6 --- /dev/null +++ b/internal/im/image_qa_test.go @@ -0,0 +1,132 @@ +package im + +import ( + "bytes" + "context" + "errors" + "image" + "image/color" + "image/png" + "io" + "strings" + "testing" + + "github.com/Tencent/WeKnora/internal/types" +) + +type staticImageDownloader struct { + body string + fileName string +} + +func (d staticImageDownloader) DownloadFile(context.Context, *IncomingMessage) (io.ReadCloser, string, error) { + return io.NopCloser(strings.NewReader(d.body)), d.fileName, nil +} + +func testPNG(t *testing.T, width, height int) string { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, width, height)) + img.SetNRGBA(0, 0, color.NRGBA{R: 20, G: 120, B: 240, A: 255}) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + return buf.String() +} + +func TestPrepareIMImageForQAAndBuildRequest(t *testing.T) { + png := testPNG(t, 32, 32) + msg := &IncomingMessage{ + Platform: PlatformDingtalk, + MessageType: MessageTypeImage, + MessageID: "picture-message-1", + FileKey: "temporary-download-code", + } + + if err := prepareIMImageForQA(context.Background(), msg, staticImageDownloader{body: png, fileName: "question.png"}); err != nil { + t.Fatalf("prepareIMImageForQA: %v", err) + } + if len(msg.ImageURLs) != 1 || !strings.HasPrefix(msg.ImageURLs[0], "data:image/png;base64,") { + t.Fatalf("ImageURLs = %#v", msg.ImageURLs) + } + if msg.Content == "" { + t.Fatal("image-only message should receive a default visual question") + } + + req := buildIMQARequest(&types.Session{ID: "session-1"}, msg.Content, "assistant-1", "user-1", nil, nil, nil, msg.ImageURLs) + if len(req.ImageURLs) != 1 || req.ImageURLs[0] != msg.ImageURLs[0] { + t.Fatalf("request ImageURLs = %#v", req.ImageURLs) + } + +} + +func TestPrepareIMImageForQARejectsInvalidOrOversizedPayload(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {name: "not an image", body: strings.Repeat("plain text", 80), want: "unsupported image content type"}, + {name: "oversized", body: strings.Repeat("x", maxIMImageBytes+1), want: "exceeds 10 MiB limit"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg := &IncomingMessage{Platform: PlatformDingtalk, MessageType: MessageTypeImage, MessageID: "image-1"} + err := prepareIMImageForQA(context.Background(), msg, staticImageDownloader{body: tt.body, fileName: "payload.bin"}) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want containing %q", err, tt.want) + } + if len(msg.ImageURLs) != 0 { + t.Fatal("rejected image must not reach the model") + } + }) + } + + t.Run("dimensions", func(t *testing.T) { + msg := &IncomingMessage{MessageType: MessageTypeImage} + err := prepareIMImageForQA(context.Background(), msg, staticImageDownloader{body: testPNG(t, maxIMImageDimension+1, 1), fileName: "wide.png"}) + if err == nil || !strings.Contains(err.Error(), "dimensions") { + t.Fatalf("error = %v, want dimensions rejection", err) + } + }) + + t.Run("animated formats rejected", func(t *testing.T) { + msg := &IncomingMessage{MessageType: MessageTypeImage} + err := prepareIMImageForQA(context.Background(), msg, staticImageDownloader{body: "GIF89a" + strings.Repeat("x", 600), fileName: "animated.gif"}) + if err == nil || !strings.Contains(err.Error(), "only JPEG and PNG") { + t.Fatalf("error = %v, want JPEG/PNG rejection", err) + } + }) +} + +func TestIMQAErrorReplyDistinguishesImageFailures(t *testing.T) { + imageReply := imQAErrorReply([]string{"data:image/png;base64,abc"}) + if !strings.Contains(imageReply, "图片识别失败") || !strings.Contains(imageReply, "视觉模型") { + t.Fatalf("image error reply = %q", imageReply) + } + if got := imQAErrorReply(nil); got != "抱歉,处理您的问题时出现了异常,请稍后再试。" { + t.Fatalf("text error reply = %q", got) + } +} + +func TestImageStreamDoesNotPublishPartialContent(t *testing.T) { + if allowIncrementalIMFlush(&IncomingMessage{ImageURLs: []string{"data:image/png;base64,YWJj"}}) { + t.Fatal("image streams must buffer until successful completion") + } + if !allowIncrementalIMFlush(&IncomingMessage{Content: "hello"}) { + t.Fatal("text streams must preserve incremental updates") + } +} + +func TestFailClosedIMImageAnswerDiscardsPartialOutput(t *testing.T) { + partial := "I think the image might show" + err := errors.New("multimodal request rejected") + got, overridden := failClosedIMImageAnswer(partial, err, []string{"data:image/png;base64,abc"}) + if !overridden || strings.Contains(got, partial) || !strings.Contains(got, "图片识别失败") { + t.Fatalf("image override = %q overridden=%v", got, overridden) + } + got, overridden = failClosedIMImageAnswer(partial, err, nil) + if overridden || got != partial { + t.Fatalf("text answer changed = %q overridden=%v", got, overridden) + } +} diff --git a/internal/im/service.go b/internal/im/service.go index 31b17a07..b6112f36 100644 --- a/internal/im/service.go +++ b/internal/im/service.go @@ -3,10 +3,15 @@ package im import ( "bytes" "context" + "encoding/base64" "errors" "fmt" + "image" + _ "image/jpeg" + _ "image/png" "io" "mime/multipart" + "net/http" "net/textproto" "os" "regexp" @@ -483,12 +488,17 @@ func buildIMQARequest( customAgent *types.CustomAgent, kbIDs []string, quote *QuotedMessage, + imageURLs ...[]string, ) *types.QARequest { // WebSearchEnabled: the web handler passes this per-request from the // frontend toggle; for IM channels the user has no per-message toggle, // so we derive it from the agent config (the single source of truth). webSearchEnabled := customAgent != nil && customAgent.Config.WebSearchEnabled quotedContext := formatQuotedContext(quote) + var images []string + if len(imageURLs) > 0 { + images = append([]string(nil), imageURLs[0]...) + } return &types.QARequest{ Session: session, Query: query, @@ -498,9 +508,78 @@ func buildIMQARequest( UserMessageID: userMessageID, WebSearchEnabled: webSearchEnabled, QuotedContext: quotedContext, + ImageURLs: images, } } +const ( + maxIMImageBytes = 10 << 20 + maxIMImageDimension = 16_384 + maxIMImagePixels = 40_000_000 +) + +func prepareIMImageForQA(ctx context.Context, msg *IncomingMessage, downloader FileDownloader) error { + if msg == nil || downloader == nil { + return fmt.Errorf("image message and downloader are required") + } + reader, fileName, err := downloader.DownloadFile(ctx, msg) + if err != nil { + return fmt.Errorf("download image: %w", err) + } + defer reader.Close() + + data, err := io.ReadAll(io.LimitReader(reader, maxIMImageBytes+1)) + if err != nil { + return fmt.Errorf("read image: %w", err) + } + if len(data) == 0 { + return fmt.Errorf("downloaded image is empty") + } + if len(data) > maxIMImageBytes { + return fmt.Errorf("image exceeds %d MiB limit", maxIMImageBytes>>20) + } + contentType := strings.ToLower(http.DetectContentType(data)) + switch contentType { + case "image/jpeg", "image/png": + case "image/gif", "image/webp": + return fmt.Errorf("only JPEG and PNG images are supported") + default: + return fmt.Errorf("unsupported image content type %q", contentType) + } + config, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("decode image metadata: %w", err) + } + if config.Width <= 0 || config.Height <= 0 || config.Width > maxIMImageDimension || config.Height > maxIMImageDimension || int64(config.Width)*int64(config.Height) > maxIMImagePixels { + return fmt.Errorf("image dimensions %dx%d exceed safety limits", config.Width, config.Height) + } + + msg.FileName = fileName + msg.ImageURLs = []string{"data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(data)} + if strings.TrimSpace(msg.Content) == "" { + msg.Content = "请识别这张图片的内容,并回答图片中的问题。" + } + return nil +} + +func imQAErrorReply(imageURLs []string) string { + if len(imageURLs) > 0 { + return "图片识别失败:当前模型可能不支持视觉输入,请检查智能体的图片上传开关和视觉模型配置。" + } + return "抱歉,处理您的问题时出现了异常,请稍后再试。" +} + +func allowIncrementalIMFlush(msg *IncomingMessage) bool { + return msg == nil || len(msg.ImageURLs) == 0 +} + +func failClosedIMImageAnswer(answer string, err error, imageURLs []string) (string, bool) { + if err != nil && len(imageURLs) > 0 { + return imQAErrorReply(imageURLs), true + } + return answer, false +} + func buildIMLastRequestState(agentID string, customAgent *types.CustomAgent, kbIDs []string) *types.SessionLastRequestState { state := &types.SessionLastRequestState{ AgentID: agentID, @@ -1232,21 +1311,17 @@ func (s *Service) HandleMessage(ctx context.Context, msg *IncomingMessage, chann logger.Infof(ctx, "[IM] HandleMessage: channel=%s platform=%s user=%s chat=%s msgtype=%s content_len=%d", channelID, msg.Platform, msg.UserID, msg.ChatID, msg.MessageType, len(msg.Content)) - logger.Debugf(ctx, "[IM] HandleMessage detail: msgid=%s filekey=%s filename=%s", - msg.MessageID, msg.FileKey, msg.FileName) + logger.Debugf(ctx, "[IM] HandleMessage detail: msgid=%s has_filekey=%t filename=%s", + msg.MessageID, strings.TrimSpace(msg.FileKey) != "", msg.FileName) - // ── File/Image message shortcut ── - // If the message is a file or image and the channel has a knowledge_base_id configured, - // handle it separately without entering the QA pipeline. - if (msg.MessageType == MessageTypeFile || msg.MessageType == MessageTypeImage) && channel.KnowledgeBaseID != "" { + // Files keep the legacy knowledge-base ingestion path. Images are routed + // below either to multimodal QA or, when vision is disabled, to this path. + if msg.MessageType == MessageTypeFile && channel.KnowledgeBaseID != "" { return s.handleFileMessage(ctx, msg, adapter, channel) } - // ── Non-text message without text content ── - // If the message is an image/file/video but has no text content, the QA pipeline - // cannot do anything useful (no vision support in IM yet). Sending an empty query - // to KB retrieval would return irrelevant results and cause hallucination. - if msg.Content == "" && (msg.MessageType == MessageTypeImage || msg.MessageType == MessageTypeFile) { + // Non-text files without a configured knowledge base still cannot enter QA. + if msg.Content == "" && msg.MessageType == MessageTypeFile { logger.Infof(ctx, "[IM] Skipping QA for non-text message without content: type=%s", msg.MessageType) if err := adapter.SendReply(ctx, msg, &ReplyMessage{ Content: "当前渠道未配置文件知识库,无法处理图片/文件消息。请在渠道设置中配置文件知识库后再发送,或直接用文字描述您的问题。", @@ -1282,6 +1357,24 @@ func (s *Service) HandleMessage(ctx context.Context, msg *IncomingMessage, chann } } + if msg.MessageType == MessageTypeImage { + if customAgent == nil || !customAgent.Config.ImageUploadEnabled { + return adapter.SendReply(ctx, msg, &ReplyMessage{ + Content: "当前机器人未启用图片识别,请在智能体设置中开启图片上传并配置视觉模型。", + IsFinal: true, + }) + } + if strings.TrimSpace(msg.FileKey) == "" { + return adapter.SendReply(ctx, msg, &ReplyMessage{Content: "图片消息缺少有效下载凭证,请重新发送图片。", IsFinal: true}) + } + if _, ok := adapter.(FileDownloader); !ok { + return adapter.SendReply(ctx, msg, &ReplyMessage{Content: "当前钉钉渠道暂不支持图片下载。", IsFinal: true}) + } + if strings.TrimSpace(msg.Content) == "" { + msg.Content = "请识别这张图片的内容,并回答图片中的问题。" + } + } + // ── Slash-command dispatch ── // Commands are handled before the QA pipeline so they respond instantly. if cmd, args, ok := s.cmdRegistry.Parse(msg.Content); ok { @@ -1415,6 +1508,21 @@ func (s *Service) executeQARequest(req *qaRequest) { // runQA after the assistant message is created (that's when we have the // sessionID + messageID needed to poll StreamManager). + // Keep large image bytes out of the bounded queue. Download, validate and + // encode only after this worker owns a concurrency slot. + if req.msg.MessageType == MessageTypeImage && len(req.msg.ImageURLs) == 0 { + downloader, ok := req.adapter.(FileDownloader) + if !ok { + _ = req.adapter.SendReply(ctx, req.msg, &ReplyMessage{Content: "当前钉钉渠道暂不支持图片下载。", IsFinal: true}) + return + } + if err := prepareIMImageForQA(ctx, req.msg, downloader); err != nil { + logger.Warnf(ctx, "[IM] Failed to prepare image for QA: %v", err) + _ = req.adapter.SendReply(ctx, req.msg, &ReplyMessage{Content: "图片下载或格式校验失败,请发送10MB以内的 JPG 或 PNG 图片。", IsFinal: true}) + return + } + } + // kbIDs is left empty so the QA pipeline resolves them from the agent config. var kbIDs []string @@ -1432,10 +1540,10 @@ func (s *Service) executeQARequest(req *qaRequest) { } // Non-streaming fallback: collect full answer then send. - answer, err := s.runQA(ctx, req.session, req.msg.Content, req.agent, kbIDs, req.userKey, req.msg.Quote) + answer, err := s.runQA(ctx, req.session, req.msg.Content, req.agent, kbIDs, req.userKey, req.msg.Quote, req.msg.ImageURLs) if err != nil { logger.Errorf(ctx, "[IM] QA failed: %v, sending fallback reply", err) - answer = "抱歉,处理您的问题时出现了异常,请稍后再试。" + answer = imQAErrorReply(req.msg.ImageURLs) } reply := &ReplyMessage{ @@ -2132,7 +2240,7 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage, // Run QA async go func() { var err error - req := buildIMQARequest(session, msg.Content, assistantMsg.ID, userMsg.ID, customAgent, kbIDs, msg.Quote) + req := buildIMQARequest(session, msg.Content, assistantMsg.ID, userMsg.ID, customAgent, kbIDs, msg.Quote, msg.ImageURLs) if req.QuotedContext != "" { logger.Debugf(qaCtx, "[IM] QuotedContext set: length=%d", len(req.QuotedContext)) } @@ -2158,6 +2266,9 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage, defer ticker.Stop() flush := func() { + if !allowIncrementalIMFlush(msg) { + return + } bufMu.Lock() parts := getStreamParts() agentRunning := useAgent && !agentDone @@ -2211,10 +2322,13 @@ loop: bufMu.Unlock() finalDisplay := cleanIMContent(ctx, FormatIMFinalFromParts(parts), tenant, s.defaultFileSvc) - if noVisibleContent || finalDisplay == "" { + if replacement, override := failClosedIMImageAnswer(answer, finalErr, msg.ImageURLs); override { + answer = replacement + finalDisplay = replacement + } else if noVisibleContent || finalDisplay == "" { fallback := "抱歉,我暂时无法回答这个问题。" if finalErr != nil { - fallback = "抱歉,处理您的问题时出现了异常,请稍后再试。" + fallback = imQAErrorReply(msg.ImageURLs) } finalDisplay = fallback if answer == "" { @@ -2222,14 +2336,7 @@ loop: } } - if err := streamer.FinalizeStream(ctx, msg, streamID, finalDisplay); err != nil { - logger.Warnf(ctx, "[IM] FinalizeStream failed: %v", err) - } - - // End the stream - if err := streamer.EndStream(ctx, msg, streamID); err != nil { - logger.Warnf(ctx, "[IM] EndStream failed: %v", err) - } + deliveryErr := finalizeAndEndIMStream(ctx, streamer, msg, streamID, finalDisplay) if answer == "" { answer = "抱歉,我暂时无法回答这个问题。" @@ -2242,28 +2349,49 @@ loop: } logger.Infof(ctx, "[IM] Stream reply sent: platform=%s user=%s answer_len=%d", msg.Platform, msg.UserID, len(answer)) - return nil + return deliveryErr +} + +func finalizeAndEndIMStream(ctx context.Context, streamer StreamSender, msg *IncomingMessage, streamID, finalDisplay string) error { + var deliveryErr error + if err := streamer.FinalizeStream(ctx, msg, streamID, finalDisplay); err != nil { + logger.Warnf(ctx, "[IM] FinalizeStream failed: %v", err) + deliveryErr = fmt.Errorf("finalize IM stream: %w", err) + } + + // End the stream even when finalization failed so adapter resources are released. + if err := streamer.EndStream(ctx, msg, streamID); err != nil { + logger.Warnf(ctx, "[IM] EndStream failed: %v", err) + if deliveryErr == nil { + deliveryErr = fmt.Errorf("end IM stream: %w", err) + } + } + return deliveryErr } // fallbackNonStream is used when streaming initialization fails. func (s *Service) fallbackNonStream(ctx context.Context, msg *IncomingMessage, session *types.Session, customAgent *types.CustomAgent, kbIDs []string, adapter Adapter, userKey string, tenant *types.Tenant) error { - answer, err := s.runQA(ctx, session, msg.Content, customAgent, kbIDs, userKey, msg.Quote) + answer, err := s.runQA(ctx, session, msg.Content, customAgent, kbIDs, userKey, msg.Quote, msg.ImageURLs) if err != nil { logger.Errorf(ctx, "[IM] QA fallback failed: %v", err) - answer = "抱歉,处理您的问题时出现了异常,请稍后再试。" + answer = imQAErrorReply(msg.ImageURLs) } return adapter.SendReply(ctx, msg, &ReplyMessage{Content: formatIMOutboundAnswer(ctx, answer, tenant, s.defaultFileSvc), IsFinal: true}) } // runQA executes the WeKnora QA pipeline and returns the full answer text. -func (s *Service) runQA(ctx context.Context, session *types.Session, query string, customAgent *types.CustomAgent, kbIDs []string, userKey string, quote *QuotedMessage) (string, error) { +func (s *Service) runQA(ctx context.Context, session *types.Session, query string, customAgent *types.CustomAgent, kbIDs []string, userKey string, quote *QuotedMessage, imageURLs ...[]string) (string, error) { // Cancellable context (no hard deadline): each agent round has its own // LLMCallTimeout. The context can still be cancelled by /stop. ctx, cancel := context.WithCancel(ctx) defer cancel() eventBus := event.NewEventBus() + var images []string + if len(imageURLs) > 0 { + images = imageURLs[0] + } // Thread-safe answer collection var answerMu sync.Mutex @@ -2365,7 +2493,7 @@ func (s *Service) runQA(ctx context.Context, session *types.Session, query strin // Run QA async go func() { var err error - req := buildIMQARequest(session, query, assistantMsg.ID, userMsg.ID, customAgent, kbIDs, quote) + req := buildIMQARequest(session, query, assistantMsg.ID, userMsg.ID, customAgent, kbIDs, quote, images) if req.QuotedContext != "" { logger.Debugf(ctx, "[IM] QuotedContext set: length=%d", len(req.QuotedContext)) } @@ -2406,6 +2534,14 @@ func (s *Service) runQA(ctx context.Context, session *types.Session, query strin qaError := qaErr answerMu.Unlock() + if replacement, override := failClosedIMImageAnswer(answer, qaError, images); override { + assistantMsg.Content = replacement + assistantMsg.IsCompleted = true + if updateErr := s.messageService.UpdateMessage(ctx, assistantMsg); updateErr != nil { + logger.Warnf(ctx, "[IM] Failed to persist image QA error: %v", updateErr) + } + return "", qaError + } if answer == "" && qaError != nil { return "", qaError } diff --git a/internal/im/stream_delivery_error_test.go b/internal/im/stream_delivery_error_test.go new file mode 100644 index 00000000..d67b0be5 --- /dev/null +++ b/internal/im/stream_delivery_error_test.go @@ -0,0 +1,42 @@ +package im + +import ( + "context" + "errors" + "strings" + "testing" +) + +type failingFinalStreamSender struct { + finalizeErr error + endErr error + ended bool +} + +func (*failingFinalStreamSender) StartStream(context.Context, *IncomingMessage) (string, error) { + return "stream", nil +} +func (*failingFinalStreamSender) UpdateStreamContent(context.Context, *IncomingMessage, string, string) error { + return nil +} +func (s *failingFinalStreamSender) FinalizeStream(context.Context, *IncomingMessage, string, string) error { + return s.finalizeErr +} +func (s *failingFinalStreamSender) EndStream(context.Context, *IncomingMessage, string) error { + s.ended = true + return s.endErr +} + +func TestFinalizeAndEndIMStreamPropagatesDeliveryFailure(t *testing.T) { + sender := &failingFinalStreamSender{ + finalizeErr: errors.New("simulated finalize failure"), + endErr: errors.New("simulated end failure"), + } + err := finalizeAndEndIMStream(context.Background(), sender, &IncomingMessage{}, "stream", "final") + if err == nil || !strings.Contains(err.Error(), "finalize IM stream") { + t.Fatalf("error = %v; want finalize delivery error", err) + } + if !sender.ended { + t.Fatal("EndStream must run even after FinalizeStream fails") + } +} diff --git a/internal/logger/llm_logger.go b/internal/logger/llm_logger.go index 12ddd55b..b3417aea 100644 --- a/internal/logger/llm_logger.go +++ b/internal/logger/llm_logger.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + secutils "github.com/Tencent/WeKnora/internal/utils" ) var llmDebug struct { @@ -36,10 +38,14 @@ func configureLLMDebugLog() { dir = "llm_debug" } - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { fmt.Fprintf(os.Stderr, "llm_debug: failed to create dir %s: %v\n", dir, err) return } + if err := os.Chmod(dir, 0o700); err != nil { + fmt.Fprintf(os.Stderr, "llm_debug: failed to secure dir %s: %v\n", dir, err) + return + } llmDebug.dir = dir llmDebug.enabled = true @@ -101,12 +107,16 @@ func LLMDebugLog(ctx context.Context, record *LLMCallRecord) { defer llmDebug.mu.Unlock() path := filepath.Join(llmDebug.dir, filename) - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { fmt.Fprintf(os.Stderr, "llm_debug: open %s: %v\n", path, err) return } defer f.Close() + if err := f.Chmod(0o600); err != nil { + fmt.Fprintf(os.Stderr, "llm_debug: secure %s: %v\n", path, err) + return + } _, _ = f.WriteString(text) } @@ -132,16 +142,17 @@ func formatRecord(r *LLMCallRecord) string { } for _, s := range r.Sections { + content := secutils.RedactDataURLsForLog(s.Content) b.WriteString(fmt.Sprintf("\n---------- %s ----------\n", s.Title)) - b.WriteString(s.Content) - if !strings.HasSuffix(s.Content, "\n") { + b.WriteString(content) + if !strings.HasSuffix(content, "\n") { b.WriteString("\n") } } if r.Error != "" { b.WriteString("\n---------- Error ----------\n") - b.WriteString(r.Error) + b.WriteString(secutils.RedactDataURLsForLog(r.Error)) b.WriteString("\n") } @@ -163,7 +174,7 @@ func extractRequestID(ctx context.Context) string { // LLMCallRecord holds all information for one model API call. type LLMCallRecord struct { - CallType string // "Chat", "Chat Stream", "Embedding", "Rerank", "VLM" + CallType string // "Chat", "Chat Stream", "Embedding", "Rerank", "VLM" Model string Duration time.Duration Sections []RecordSection @@ -213,11 +224,7 @@ func FormatMessages(messages []LLMMessage) string { b.WriteString("\n") } for _, img := range m.Images { - if len([]rune(img)) > 80 { - b.WriteString(fmt.Sprintf("[image: %s (%d bytes)]\n", TruncateRunes(img, 80), len(img))) - } else { - b.WriteString(fmt.Sprintf("[image: %s]\n", img)) - } + b.WriteString(fmt.Sprintf("[image: redacted (%d bytes)]\n", len(img))) } for _, tc := range m.ToolCalls { b.WriteString(fmt.Sprintf(" -> tool_call: id=%s, func=%s, args=%s\n", tc.ID, tc.FuncName, tc.Arguments)) diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index 42da1f42..b1992d15 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -17,6 +17,32 @@ func newEntry(level logrus.Level, msg string, data logrus.Fields) *logrus.Entry return e } +func TestLLMCallRecordFormatRedactsDataURIInSectionsAndErrors(t *testing.T) { + secret := "TOPSECRETBASE64" + record := &LLMCallRecord{ + Sections: []RecordSection{{Title: "Response", Content: "data:image/png;base64," + secret}}, + Error: "provider echoed data:text/plain," + secret, + } + got := formatRecord(record) + if strings.Contains(got, secret) || strings.Contains(strings.ToLower(got), "data:") { + t.Fatalf("record leaked data URI: %q", got) + } +} + +func TestFormatMessagesRedactsImageValues(t *testing.T) { + secret := "TOPSECRETBASE64" + got := FormatMessages([]LLMMessage{{ + Role: "user", + Images: []string{"data:image/png;base64," + secret}, + }}) + if strings.Contains(got, secret) || strings.Contains(strings.ToLower(got), "data:image") { + t.Fatalf("FormatMessages leaked image value: %q", got) + } + if !strings.Contains(got, "redacted") { + t.Fatalf("FormatMessages missing redaction marker: %q", got) + } +} + func TestFormat_DefaultModeUnchanged(t *testing.T) { f := &CustomFormatter{} // no template, no color entry := newEntry(logrus.InfoLevel, "hello", logrus.Fields{ diff --git a/internal/models/chat/anthropic.go b/internal/models/chat/anthropic.go index 8fbfc84b..c0a47902 100644 --- a/internal/models/chat/anthropic.go +++ b/internal/models/chat/anthropic.go @@ -3,7 +3,9 @@ package chat import ( "bytes" "context" + "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -15,19 +17,37 @@ import ( secutils "github.com/Tencent/WeKnora/internal/utils" ) -const anthropicVersion = "2023-06-01" +const ( + anthropicVersion = "2023-06-01" + anthropicResponseBodyLimit = 1 << 20 +) type AnthropicChat struct { - modelName string - modelID string - baseURL string - apiKey string - customHeaders map[string]string + modelName string + modelID string + baseURL string + apiKey string + supportsVision bool + customHeaders map[string]string } +func (c *AnthropicChat) SupportsImageInput() bool { return c.supportsVision } + type anthropicMessage struct { Role string `json:"role"` - Content string `json:"content"` + Content any `json:"content"` +} + +type anthropicContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Source *anthropicImageSource `json:"source,omitempty"` +} + +type anthropicImageSource struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` } type anthropicRequest struct { @@ -61,6 +81,7 @@ type anthropicResponse struct { type anthropicStreamEvent struct { Type string `json:"type"` + Index *int `json:"index,omitempty"` Message *struct { Usage struct { InputTokens int `json:"input_tokens"` @@ -69,6 +90,9 @@ type anthropicStreamEvent struct { CacheReadInputTokens int `json:"cache_read_input_tokens"` } `json:"usage"` } `json:"message,omitempty"` + ContentBlock *struct { + Type string `json:"type"` + } `json:"content_block,omitempty"` Delta *struct { Type string `json:"type"` Text string `json:"text"` @@ -86,6 +110,117 @@ type anthropicStreamEvent struct { } `json:"error,omitempty"` } +type anthropicStreamProtocolState struct { + messageStarted bool + finishReason string + messageStopped bool + activeBlocks map[int]string + completedBlocks map[int]struct{} + nextBlockIndex int +} + +func validAnthropicStopReason(reason string) bool { + switch reason { + case "end_turn", "max_tokens", "stop_sequence", "tool_use", "pause_turn", "refusal", "model_context_window_exceeded": + return true + default: + return false + } +} + +func anthropicDeltaMatchesBlock(blockType, deltaType string) bool { + switch deltaType { + case "text_delta", "citations_delta": + return blockType == "text" + case "thinking_delta", "signature_delta": + return blockType == "thinking" + case "input_json_delta": + return blockType == "tool_use" || blockType == "server_tool_use" || blockType == "mcp_tool_use" + default: + return false + } +} + +func (s *anthropicStreamProtocolState) observe(event *anthropicStreamEvent) error { + if event == nil { + return errors.New("anthropic stream event is missing") + } + if event.Type == "error" || event.Error != nil { + return errors.New("anthropic stream failed") + } + if s.messageStopped { + if event.Type == "ping" { + return nil + } + return errors.New("anthropic event followed message_stop") + } + if event.Delta != nil && event.Delta.StopReason != "" && event.Type != "message_delta" { + return errors.New("anthropic stop reason appeared on an invalid event") + } + + switch event.Type { + case "message_start": + if s.messageStarted || s.finishReason != "" { + return errors.New("anthropic message_start is out of order") + } + s.messageStarted = true + s.activeBlocks = make(map[int]string) + s.completedBlocks = make(map[int]struct{}) + case "message_delta": + if !s.messageStarted || s.finishReason != "" || len(s.activeBlocks) != 0 || event.Delta == nil || !validAnthropicStopReason(event.Delta.StopReason) { + return errors.New("anthropic message_delta has an invalid stop reason") + } + s.finishReason = event.Delta.StopReason + case "message_stop": + if !s.messageStarted || s.finishReason == "" || len(s.activeBlocks) != 0 { + return errors.New("anthropic message_stop preceded valid completion") + } + s.messageStopped = true + case "content_block_start": + if !s.messageStarted || s.finishReason != "" || event.Index == nil || *event.Index < 0 || event.ContentBlock == nil || strings.TrimSpace(event.ContentBlock.Type) == "" { + return errors.New("anthropic content_block_start is invalid") + } + index := *event.Index + if len(s.activeBlocks) != 0 || index != s.nextBlockIndex { + return errors.New("anthropic content block index is out of order") + } + if _, active := s.activeBlocks[index]; active { + return errors.New("anthropic content block started twice") + } + if _, completed := s.completedBlocks[index]; completed { + return errors.New("anthropic completed content block restarted") + } + s.activeBlocks[index] = event.ContentBlock.Type + case "content_block_delta": + if !s.messageStarted || s.finishReason != "" || event.Index == nil || event.Delta == nil { + return errors.New("anthropic content_block_delta is invalid") + } + blockType, active := s.activeBlocks[*event.Index] + if !active || !anthropicDeltaMatchesBlock(blockType, event.Delta.Type) { + return errors.New("anthropic content block delta is out of order") + } + case "content_block_stop": + if !s.messageStarted || s.finishReason != "" || event.Index == nil { + return errors.New("anthropic content_block_stop is invalid") + } + index := *event.Index + if _, active := s.activeBlocks[index]; !active { + return errors.New("anthropic content block stop is out of order") + } + delete(s.activeBlocks, index) + s.completedBlocks[index] = struct{}{} + s.nextBlockIndex++ + case "ping": + default: + return errors.New("anthropic stream event type is invalid") + } + return nil +} + +func (s *anthropicStreamProtocolState) complete() bool { + return s.messageStarted && s.messageStopped && len(s.activeBlocks) == 0 && validAnthropicStopReason(s.finishReason) +} + func NewAnthropicChat(config *ChatConfig) (*AnthropicChat, error) { if config.BaseURL != "" { if err := secutils.ValidateURLForSSRF(config.BaseURL); err != nil { @@ -102,16 +237,31 @@ func NewAnthropicChat(config *ChatConfig) (*AnthropicChat, error) { } return &AnthropicChat{ - modelName: config.ModelName, - modelID: config.ModelID, - baseURL: baseURL, - apiKey: config.APIKey, - customHeaders: config.CustomHeaders, + modelName: config.ModelName, + modelID: config.ModelID, + baseURL: baseURL, + apiKey: config.APIKey, + supportsVision: config.SupportsVision, + customHeaders: config.CustomHeaders, }, nil } +func readAnthropicResponseBody(body io.ReadCloser) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(body, anthropicResponseBodyLimit+1)) + if err != nil { + return nil, errors.New("read Anthropic response failed") + } + if len(data) > anthropicResponseBodyLimit { + return nil, errors.New("Anthropic response body too large") + } + return data, nil +} + func (c *AnthropicChat) Chat(ctx context.Context, messages []Message, opts *ChatOptions) (*types.ChatResponse, error) { - reqBody := c.buildRequest(messages, opts) + reqBody, err := c.buildRequest(messages, opts) + if err != nil { + return nil, err + } jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) @@ -136,13 +286,13 @@ func (c *AnthropicChat) Chat(ctx context.Context, messages []Message, opts *Chat resp, err := rawHTTPClient.Do(httpReq) if err != nil { - return nil, fmt.Errorf("send request: %w", err) + return nil, errors.New("send Anthropic request failed") } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := readAnthropicResponseBody(resp.Body) if err != nil { - return nil, fmt.Errorf("read response: %w", err) + return nil, err } if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") { @@ -151,7 +301,7 @@ func (c *AnthropicChat) Chat(ctx context.Context, messages []Message, opts *Chat return nil, err } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, chatResp.Content) + return nil, remoteAPIStatusError(resp.StatusCode, body) } logUsage(ctx, c.modelName, &chatResp.Usage) return chatResp, nil @@ -159,13 +309,10 @@ func (c *AnthropicChat) Chat(ctx context.Context, messages []Message, opts *Chat var chatResp anthropicResponse if err := json.Unmarshal(body, &chatResp); err != nil { - return nil, fmt.Errorf("decode response: %w", err) + return nil, errors.New("decode Anthropic response failed") } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - if chatResp.Error != nil && chatResp.Error.Message != "" { - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, chatResp.Error.Message) - } - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + return nil, remoteAPIStatusError(resp.StatusCode, body) } result := c.parseResponse(&chatResp) @@ -174,7 +321,10 @@ func (c *AnthropicChat) Chat(ctx context.Context, messages []Message, opts *Chat } func (c *AnthropicChat) ChatStream(ctx context.Context, messages []Message, opts *ChatOptions) (<-chan types.StreamResponse, error) { - reqBody := c.buildRequest(messages, opts) + reqBody, err := c.buildRequest(messages, opts) + if err != nil { + return nil, err + } reqBody.Stream = true jsonData, err := json.Marshal(reqBody) if err != nil { @@ -198,12 +348,15 @@ func (c *AnthropicChat) ChatStream(ctx context.Context, messages []Message, opts resp, err := rawHTTPClient.Do(httpReq) if err != nil { - return nil, fmt.Errorf("send request: %w", err) + return nil, errors.New("send Anthropic stream request failed") } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - body, _ := io.ReadAll(resp.Body) + body, readErr := readAnthropicResponseBody(resp.Body) resp.Body.Close() - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + if readErr != nil { + return nil, readErr + } + return nil, remoteAPIStatusError(resp.StatusCode, body) } streamChan := make(chan types.StreamResponse) @@ -248,7 +401,7 @@ func isAnthropicVersionedBaseURL(baseURL string) bool { return strings.HasSuffix(path, "/v1") || strings.HasSuffix(path, "/v1beta") } -func (c *AnthropicChat) buildRequest(messages []Message, opts *ChatOptions) anthropicRequest { +func (c *AnthropicChat) buildRequest(messages []Message, opts *ChatOptions) (anthropicRequest, error) { req := anthropicRequest{ Model: c.modelName, MaxTokens: 1024, @@ -276,22 +429,75 @@ func (c *AnthropicChat) buildRequest(messages []Message, opts *ChatOptions) anth if content == "" { content = textFromMultiContent(msg.MultiContent) } - if content == "" { + images := append([]string(nil), msg.Images...) + for _, part := range msg.MultiContent { + if part.Type == "image_url" && part.ImageURL != nil && strings.TrimSpace(part.ImageURL.URL) != "" { + images = append(images, part.ImageURL.URL) + } + } + if len(images) == 0 && content == "" { continue } switch msg.Role { case "system": + if len(images) > 0 { + return anthropicRequest{}, fmt.Errorf("anthropic system messages cannot contain images") + } systemParts = append(systemParts, content) case "assistant": + if len(images) > 0 { + return anthropicRequest{}, fmt.Errorf("anthropic assistant messages cannot contain images") + } req.Messages = append(req.Messages, anthropicMessage{Role: "assistant", Content: content}) - case "user": - req.Messages = append(req.Messages, anthropicMessage{Role: "user", Content: content}) default: - req.Messages = append(req.Messages, anthropicMessage{Role: "user", Content: content}) + if len(images) == 0 { + req.Messages = append(req.Messages, anthropicMessage{Role: "user", Content: content}) + continue + } + blocks := make([]anthropicContentBlock, 0, len(images)+1) + if content != "" { + blocks = append(blocks, anthropicContentBlock{Type: "text", Text: content}) + } + for _, imageURL := range images { + block, err := anthropicImageBlock(imageURL) + if err != nil { + return anthropicRequest{}, err + } + blocks = append(blocks, block) + } + req.Messages = append(req.Messages, anthropicMessage{Role: "user", Content: blocks}) } } req.System = strings.Join(systemParts, "\n\n") - return req + return req, nil +} + +func anthropicImageBlock(raw string) (anthropicContentBlock, error) { + metadata, payload, ok := strings.Cut(strings.TrimSpace(raw), ",") + if !ok { + return anthropicContentBlock{}, fmt.Errorf("anthropic image input must be a JPEG or PNG data URI") + } + metadata = strings.ToLower(metadata) + var mediaType string + switch metadata { + case "data:image/jpeg;base64", "data:image/jpg;base64": + mediaType = "image/jpeg" + case "data:image/png;base64": + mediaType = "image/png" + default: + return anthropicContentBlock{}, fmt.Errorf("anthropic image input must be a JPEG or PNG data URI") + } + if _, err := base64.StdEncoding.DecodeString(payload); err != nil { + return anthropicContentBlock{}, fmt.Errorf("anthropic image data is invalid") + } + return anthropicContentBlock{ + Type: "image", + Source: &anthropicImageSource{ + Type: "base64", + MediaType: mediaType, + Data: payload, + }, + }, nil } func textFromMultiContent(parts []MessageContentPart) string { @@ -330,7 +536,7 @@ func (c *AnthropicChat) parseResponse(resp *anthropicResponse) *types.ChatRespon func parseAnthropicSSE(reader io.Reader) (*types.ChatResponse, error) { sseReader := NewSSEReader(reader) var contentParts []string - var finishReason string + var protocol anthropicStreamProtocolState var inputTokens int var outputTokens int @@ -353,8 +559,8 @@ func parseAnthropicSSE(reader io.Reader) (*types.ChatResponse, error) { if err := json.Unmarshal(event.Data, &streamEvent); err != nil { return nil, fmt.Errorf("decode SSE response: %w", err) } - if streamEvent.Error != nil && streamEvent.Error.Message != "" { - return nil, fmt.Errorf("API stream error: %s", streamEvent.Error.Message) + if err := protocol.observe(&streamEvent); err != nil { + return nil, errors.New("anthropic stream protocol failed") } if streamEvent.Message != nil { inputTokens = max(inputTokens, streamEvent.Message.Usage.InputTokens) @@ -364,19 +570,19 @@ func parseAnthropicSSE(reader io.Reader) (*types.ChatResponse, error) { if streamEvent.Delta.Type == "text_delta" && streamEvent.Delta.Text != "" { contentParts = append(contentParts, streamEvent.Delta.Text) } - if streamEvent.Delta.StopReason != "" { - finishReason = streamEvent.Delta.StopReason - } } if streamEvent.Usage != nil { inputTokens = max(inputTokens, streamEvent.Usage.InputTokens) outputTokens = max(outputTokens, streamEvent.Usage.OutputTokens) } } + if !protocol.complete() { + return nil, errors.New("anthropic stream ended before completion") + } return &types.ChatResponse{ Content: strings.Join(contentParts, ""), - FinishReason: finishReason, + FinishReason: protocol.finishReason, Usage: types.TokenUsage{ PromptTokens: inputTokens, CompletionTokens: outputTokens, @@ -391,38 +597,43 @@ func processAnthropicStream(ctx context.Context, model string, resp *http.Respon sseReader := NewSSEReader(resp.Body) var usage *types.TokenUsage - var finishReason string + var protocol anthropicStreamProtocolState + + emitTerminal := func() { + if !protocol.complete() { + streamChan <- types.StreamResponse{ + ResponseType: types.ResponseTypeError, + Content: "anthropic stream ended before completion", + Done: true, + } + return + } + logUsage(ctx, model, usage) + streamChan <- types.StreamResponse{ + ResponseType: types.ResponseTypeAnswer, + Content: "", + Done: true, + Usage: usage, + FinishReason: protocol.finishReason, + } + } for { event, err := sseReader.ReadEvent() if err != nil { if err == io.EOF { - logUsage(ctx, model, usage) - streamChan <- types.StreamResponse{ - ResponseType: types.ResponseTypeAnswer, - Content: "", - Done: true, - Usage: usage, - FinishReason: finishReason, - } + emitTerminal() } else { streamChan <- types.StreamResponse{ ResponseType: types.ResponseTypeError, - Content: err.Error(), + Content: "anthropic stream read failed", Done: true, } } return } if event.Done { - logUsage(ctx, model, usage) - streamChan <- types.StreamResponse{ - ResponseType: types.ResponseTypeAnswer, - Content: "", - Done: true, - Usage: usage, - FinishReason: finishReason, - } + emitTerminal() return } if len(event.Data) == 0 { @@ -433,15 +644,15 @@ func processAnthropicStream(ctx context.Context, model string, resp *http.Respon if err := json.Unmarshal(event.Data, &streamEvent); err != nil { streamChan <- types.StreamResponse{ ResponseType: types.ResponseTypeError, - Content: fmt.Sprintf("decode SSE response: %v", err), + Content: "anthropic stream response was invalid", Done: true, } return } - if streamEvent.Error != nil && streamEvent.Error.Message != "" { + if err := protocol.observe(&streamEvent); err != nil { streamChan <- types.StreamResponse{ ResponseType: types.ResponseTypeError, - Content: streamEvent.Error.Message, + Content: "anthropic stream protocol failed", Done: true, } return @@ -450,9 +661,6 @@ func processAnthropicStream(ctx context.Context, model string, resp *http.Respon usage = mergeAnthropicUsage(usage, streamEvent.Message.Usage.InputTokens, streamEvent.Message.Usage.OutputTokens) } if streamEvent.Delta != nil { - if streamEvent.Delta.StopReason != "" { - finishReason = streamEvent.Delta.StopReason - } if streamEvent.Delta.Type == "text_delta" && streamEvent.Delta.Text != "" { streamChan <- types.StreamResponse{ ResponseType: types.ResponseTypeAnswer, diff --git a/internal/models/chat/anthropic_error_redaction_test.go b/internal/models/chat/anthropic_error_redaction_test.go new file mode 100644 index 00000000..b4dcabd9 --- /dev/null +++ b/internal/models/chat/anthropic_error_redaction_test.go @@ -0,0 +1,38 @@ +package chat + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/Tencent/WeKnora/internal/types" +) + +func TestAnthropicProviderErrorDoesNotExposeImagePayload(t *testing.T) { + secret := "TOPSECRETIMAGEPAYLOAD" + payload := "data: {\"type\":\"error\",\"error\":{\"message\":\"data:image/png;base64," + secret + "\"}}\n\n" + + _, err := parseAnthropicSSE(strings.NewReader(payload)) + if err == nil { + t.Fatal("expected Anthropic SSE error") + } + if strings.Contains(err.Error(), secret) || strings.Contains(strings.ToLower(err.Error()), "data:image") { + t.Fatalf("non-stream error leaked provider payload: %v", err) + } + + resp := &http.Response{Body: io.NopCloser(strings.NewReader(payload))} + stream := make(chan types.StreamResponse) + go processAnthropicStream(context.Background(), "vision-test", resp, stream) + var terminal types.StreamResponse + for chunk := range stream { + terminal = chunk + } + if terminal.ResponseType != types.ResponseTypeError { + t.Fatalf("terminal response = %#v; want error", terminal) + } + if strings.Contains(terminal.Content, secret) || strings.Contains(strings.ToLower(terminal.Content), "data:image") { + t.Fatalf("stream error leaked provider payload: %q", terminal.Content) + } +} diff --git a/internal/models/chat/anthropic_terminal_edge_test.go b/internal/models/chat/anthropic_terminal_edge_test.go new file mode 100644 index 00000000..88d467b5 --- /dev/null +++ b/internal/models/chat/anthropic_terminal_edge_test.go @@ -0,0 +1,223 @@ +package chat + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/Tencent/WeKnora/internal/types" +) + +const anthropicTestMessageStart = "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n" +const anthropicTestBlockStart0 = "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" +const anthropicTestBlockDelta0 = "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"x\"}}\n\n" +const anthropicTestBlockStop0 = "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + +func invalidAnthropicBlockPayloads() map[string]string { + return map[string]string{ + "stop before start": anthropicTestMessageStart + anthropicTestBlockStop0, + "delta before start": anthropicTestMessageStart + anthropicTestBlockDelta0, + "delta after stop": anthropicTestMessageStart + anthropicTestBlockStart0 + anthropicTestBlockStop0 + anthropicTestBlockDelta0, + "duplicate start": anthropicTestMessageStart + anthropicTestBlockStart0 + anthropicTestBlockStart0, + "duplicate stop": anthropicTestMessageStart + anthropicTestBlockStart0 + anthropicTestBlockStop0 + anthropicTestBlockStop0, + "mismatched index": anthropicTestMessageStart + anthropicTestBlockStart0 + "data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"x\"}}\n\n", + "index gap": anthropicTestMessageStart + "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\"}}\n\n", + "concurrent block start": anthropicTestMessageStart + anthropicTestBlockStart0 + "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\"}}\n\n", + "mismatched delta type": anthropicTestMessageStart + anthropicTestBlockStart0 + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\"}}\n\n", + } +} + +func TestAnthropicContentBlockLifecycleRejectedByBothParsers(t *testing.T) { + for name, payload := range invalidAnthropicBlockPayloads() { + t.Run(name+"/aggregate", func(t *testing.T) { + if _, err := parseAnthropicSSE(strings.NewReader(payload)); err == nil { + t.Fatal("aggregate parser accepted invalid content-block lifecycle") + } + }) + t.Run(name+"/stream", func(t *testing.T) { + resp := &http.Response{Body: io.NopCloser(strings.NewReader(payload))} + stream := make(chan types.StreamResponse, 8) + go processAnthropicStream(context.Background(), "test", resp, stream) + sawError := false + for item := range stream { + if item.Done && item.ResponseType == types.ResponseTypeAnswer { + t.Fatalf("stream accepted invalid content-block lifecycle: %#v", item) + } + if item.Done && item.ResponseType == types.ResponseTypeError { + sawError = true + } + } + if !sawError { + t.Fatal("stream did not emit an error terminal") + } + }) + } +} + +func TestAnthropicContentBlockLifecycleAcceptsSequentialBlocks(t *testing.T) { + payload := anthropicTestMessageStart + anthropicTestBlockStart0 + anthropicTestBlockDelta0 + anthropicTestBlockStop0 + + "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n" + + "data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"thinking_delta\"}}\n\n" + + "data: {\"type\":\"content_block_stop\",\"index\":1}\n\n" + + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n" + + "data: {\"type\":\"message_stop\"}\n\n" + response, err := parseAnthropicSSE(strings.NewReader(payload)) + if err != nil || response.FinishReason != "end_turn" || response.Content != "x" { + t.Fatalf("valid sequential blocks rejected: response=%#v err=%v", response, err) + } + + resp := &http.Response{Body: io.NopCloser(strings.NewReader(payload))} + stream := make(chan types.StreamResponse, 8) + go processAnthropicStream(context.Background(), "test", resp, stream) + successTerminals := 0 + for item := range stream { + if item.Done && item.ResponseType == types.ResponseTypeError { + t.Fatalf("valid stream rejected: %#v", item) + } + if item.Done && item.ResponseType == types.ResponseTypeAnswer { + successTerminals++ + } + } + if successTerminals != 1 { + t.Fatalf("success terminals=%d, want 1", successTerminals) + } +} + +func TestAnthropicProtocolAcceptsOfficialStopReasons(t *testing.T) { + reasons := []string{"end_turn", "max_tokens", "stop_sequence", "tool_use", "pause_turn", "refusal", "model_context_window_exceeded"} + for _, reason := range reasons { + t.Run(reason, func(t *testing.T) { + var state anthropicStreamProtocolState + start := anthropicStreamEvent{Type: "message_start"} + if err := state.observe(&start); err != nil { + t.Fatalf("message_start: %v", err) + } + delta := anthropicStreamEvent{Type: "message_delta"} + delta.Delta = &struct { + Type string `json:"type"` + Text string `json:"text"` + StopReason string `json:"stop_reason"` + }{StopReason: reason} + if err := state.observe(&delta); err != nil { + t.Fatalf("message_delta: %v", err) + } + stop := anthropicStreamEvent{Type: "message_stop"} + if err := state.observe(&stop); err != nil || !state.complete() { + t.Fatalf("message_stop err=%v complete=%v", err, state.complete()) + } + }) + } +} + +func TestParseAnthropicSSERejectsPrematureEOF(t *testing.T) { + payload := anthropicTestMessageStart + anthropicTestBlockStart0 + "event: content_block_delta\n" + + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n" + if _, err := parseAnthropicSSE(strings.NewReader(payload)); err == nil { + t.Fatal("expected premature Anthropic EOF to fail closed") + } +} + +func TestParseAnthropicSSERejectsMalformedPacketAfterFinishReason(t *testing.T) { + payload := anthropicTestMessageStart + "event: message_delta\n" + + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n" + + "event: broken\n" + + "data: {not-json}\n\n" + if _, err := parseAnthropicSSE(strings.NewReader(payload)); err == nil { + t.Fatal("expected trailing malformed packet to override finish reason") + } +} + +func TestParseAnthropicSSERejectsInvalidOrOutOfOrderCompletion(t *testing.T) { + cases := map[string]string{ + "invalid stop reason": anthropicTestMessageStart + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"made_up\"}}\n\n" + + "data: {\"type\":\"message_stop\"}\n\n", + "message stop first": "data: {\"type\":\"message_stop\"}\n\n" + + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n", + "stop reason wrong event": anthropicTestMessageStart + "data: {\"type\":\"content_block_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n" + + "data: {\"type\":\"message_stop\"}\n\n", + "empty provider error after completion": anthropicTestMessageStart + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n" + + "data: {\"type\":\"message_stop\"}\n\n" + + "data: {\"type\":\"error\",\"error\":{\"type\":\"api_error\",\"message\":\"\"}}\n\n", + } + for name, payload := range cases { + t.Run(name, func(t *testing.T) { + if _, err := parseAnthropicSSE(strings.NewReader(payload)); err == nil { + t.Fatal("expected invalid Anthropic completion protocol to fail") + } + }) + } +} + +func TestProcessAnthropicStreamRejectsPrematureDone(t *testing.T) { + payload := anthropicTestMessageStart + anthropicTestBlockStart0 + anthropicTestBlockDelta0 + + "data: [DONE]\n\n" + resp := &http.Response{Body: io.NopCloser(strings.NewReader(payload))} + stream := make(chan types.StreamResponse, 4) + go processAnthropicStream(context.Background(), "test", resp, stream) + for item := range stream { + if item.Done && item.ResponseType == types.ResponseTypeAnswer { + t.Fatalf("premature [DONE] produced success: %#v", item) + } + } +} + +func TestProcessAnthropicStreamRejectsEmptyProviderErrorAfterCompletion(t *testing.T) { + payload := anthropicTestMessageStart + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n" + + "data: {\"type\":\"message_stop\"}\n\n" + + "data: {\"type\":\"error\",\"error\":{\"type\":\"api_error\",\"message\":\"\"}}\n\n" + resp := &http.Response{Body: io.NopCloser(strings.NewReader(payload))} + stream := make(chan types.StreamResponse, 4) + go processAnthropicStream(context.Background(), "test", resp, stream) + for item := range stream { + if item.Done && item.ResponseType == types.ResponseTypeAnswer { + t.Fatalf("empty-message provider error was ignored: %#v", item) + } + if item.Done && item.ResponseType == types.ResponseTypeError { + return + } + } + t.Fatal("expected provider error terminal") +} + +func TestProcessAnthropicStreamRejectsPrematureEOF(t *testing.T) { + payload := anthropicTestMessageStart + anthropicTestBlockStart0 + "event: content_block_delta\n" + + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n" + resp := &http.Response{Body: io.NopCloser(strings.NewReader(payload))} + stream := make(chan types.StreamResponse, 4) + go processAnthropicStream(context.Background(), "test", resp, stream) + + successTerminals, errorTerminals := 0, 0 + for item := range stream { + if item.Done && item.ResponseType == types.ResponseTypeAnswer { + successTerminals++ + } + if item.Done && item.ResponseType == types.ResponseTypeError { + errorTerminals++ + } + } + if successTerminals != 0 || errorTerminals != 1 { + t.Fatalf("terminal counts success=%d error=%d; want 0/1", successTerminals, errorTerminals) + } +} + +func TestProcessAnthropicStreamRejectsMalformedPacketAfterFinishReason(t *testing.T) { + payload := anthropicTestMessageStart + "event: message_delta\n" + + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n" + + "event: broken\n" + + "data: {not-json}\n\n" + resp := &http.Response{Body: io.NopCloser(strings.NewReader(payload))} + stream := make(chan types.StreamResponse, 4) + go processAnthropicStream(context.Background(), "test", resp, stream) + + for item := range stream { + if item.Done && item.ResponseType == types.ResponseTypeAnswer { + t.Fatalf("unexpected success terminal before trailing malformed packet: %#v", item) + } + if item.ResponseType == types.ResponseTypeError { + return + } + } + t.Fatal("expected terminal stream error") +} diff --git a/internal/models/chat/anthropic_test.go b/internal/models/chat/anthropic_test.go index f188177f..f4a9dbac 100644 --- a/internal/models/chat/anthropic_test.go +++ b/internal/models/chat/anthropic_test.go @@ -13,6 +13,33 @@ import ( "github.com/stretchr/testify/require" ) +func TestAnthropicBuildRequestPreservesImageDataURI(t *testing.T) { + chat := &AnthropicChat{modelName: "claude-test"} + req, err := chat.buildRequest([]Message{{ + Role: "user", + Content: "describe this image", + Images: []string{"data:image/png;base64,YWJj"}, + }}, nil) + require.NoError(t, err) + require.Len(t, req.Messages, 1) + blocks, ok := req.Messages[0].Content.([]anthropicContentBlock) + require.True(t, ok, "content type = %T", req.Messages[0].Content) + require.Len(t, blocks, 2) + assert.Equal(t, "text", blocks[0].Type) + assert.Equal(t, "describe this image", blocks[0].Text) + assert.Equal(t, "image", blocks[1].Type) + require.NotNil(t, blocks[1].Source) + assert.Equal(t, "base64", blocks[1].Source.Type) + assert.Equal(t, "image/png", blocks[1].Source.MediaType) + assert.Equal(t, "YWJj", blocks[1].Source.Data) +} + +func TestAnthropicBuildRequestRejectsMalformedImage(t *testing.T) { + chat := &AnthropicChat{modelName: "claude-test"} + _, err := chat.buildRequest([]Message{{Role: "user", Content: "describe", Images: []string{"not-an-image"}}}, nil) + require.Error(t, err) +} + func TestAnthropicChat(t *testing.T) { t.Setenv("SSRF_WHITELIST", "127.0.0.1") @@ -147,8 +174,14 @@ func TestAnthropicChat_SSEResponse(t *testing.T) { _, _ = w.Write([]byte(`event: message_start data: {"type":"message_start","message":{"usage":{"input_tokens":0,"output_tokens":0}}} +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + event: content_block_delta -data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}} +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} event: message_delta data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":114,"output_tokens":5}} @@ -191,8 +224,14 @@ func TestAnthropicChat_ChatStream(t *testing.T) { _, _ = w.Write([]byte(`event: message_start data: {"type":"message_start","message":{"usage":{"input_tokens":0,"output_tokens":0}}} +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + event: content_block_delta -data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}} +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} event: message_delta data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":114,"output_tokens":5}} diff --git a/internal/models/chat/chat.go b/internal/models/chat/chat.go index 47ed6941..d7568eb0 100644 --- a/internal/models/chat/chat.go +++ b/internal/models/chat/chat.go @@ -98,14 +98,27 @@ type Chat interface { GetModelID() string } +type imageInputCapable interface { + SupportsImageInput() bool +} + +// SupportsImageInput reports whether the concrete Chat transport can preserve +// image input. Unknown implementations fail closed instead of trusting model +// metadata alone. +func SupportsImageInput(model Chat) bool { + capable, ok := model.(imageInputCapable) + return ok && capable.SupportsImageInput() +} + type ChatConfig struct { - Source types.ModelSource - BaseURL string - ModelName string - APIKey string - ModelID string - Provider string - ExtraConfig map[string]string + Source types.ModelSource + BaseURL string + ModelName string + APIKey string + ModelID string + Provider string + SupportsVision bool + ExtraConfig map[string]string // CustomHeaders 允许在调用远程 OpenAI 兼容 API 时附加自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。 CustomHeaders map[string]string AppID string @@ -120,17 +133,23 @@ func ConfigFromModel(m *types.Model, appID, appSecret string) *ChatConfig { if m == nil { return nil } + supportsVision := m.Parameters.SupportsVision + if provider.ProviderName(m.Parameters.Provider) == provider.ProviderWeKnoraCloud && + m.Type == types.ModelTypeKnowledgeQA && strings.EqualFold(strings.TrimSpace(m.Name), "chat") { + supportsVision = true + } return &ChatConfig{ - ModelID: m.ID, - APIKey: m.Parameters.APIKey, - BaseURL: m.Parameters.BaseURL, - ModelName: m.Name, - Source: m.Source, - Provider: m.Parameters.Provider, - ExtraConfig: m.Parameters.ExtraConfig, - CustomHeaders: m.Parameters.CustomHeaders, - AppID: appID, - AppSecret: appSecret, + ModelID: m.ID, + APIKey: m.Parameters.APIKey, + BaseURL: m.Parameters.BaseURL, + ModelName: m.Name, + Source: m.Source, + Provider: m.Parameters.Provider, + SupportsVision: supportsVision, + ExtraConfig: m.Parameters.ExtraConfig, + CustomHeaders: m.Parameters.CustomHeaders, + AppID: appID, + AppSecret: appSecret, } } diff --git a/internal/models/chat/image_resolve.go b/internal/models/chat/image_resolve.go index 82b263df..0f2b2677 100644 --- a/internal/models/chat/image_resolve.go +++ b/internal/models/chat/image_resolve.go @@ -74,24 +74,3 @@ func readLocalStorageBytes(storagePath string) []byte { } return data } - -// isMultimodalNotSupportedError checks if an error indicates the model does not -// support multimodal/image input. -func isMultimodalNotSupportedError(err error) bool { - if err == nil { - return false - } - msg := strings.ToLower(err.Error()) - return (strings.Contains(msg, "multimodal") || strings.Contains(msg, "image") || strings.Contains(msg, "vision")) && - (strings.Contains(msg, "not support") || strings.Contains(msg, "unsupported") || strings.Contains(msg, "400")) -} - -// stripImagesFromMessages returns a copy of messages with all image data removed. -func stripImagesFromMessages(messages []Message) []Message { - cleaned := make([]Message, len(messages)) - for i, msg := range messages { - cleaned[i] = msg - cleaned[i].Images = nil - } - return cleaned -} diff --git a/internal/models/chat/langfuse_image_test.go b/internal/models/chat/langfuse_image_test.go new file mode 100644 index 00000000..c3b54e85 --- /dev/null +++ b/internal/models/chat/langfuse_image_test.go @@ -0,0 +1,26 @@ +package chat + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestBuildLangfuseMessagesRedactsMultiContentImage(t *testing.T) { + secret := "TOPSECRETBASE64" + msgs := buildLangfuseMessages([]Message{{ + Role: "user", + MultiContent: []MessageContentPart{ + {Type: "image_url", ImageURL: &ImageURL{URL: "data:image/png;base64," + secret}}, + {Type: "text", Text: "describe"}, + }, + }}) + encoded, err := json.Marshal(msgs) + if err != nil { + t.Fatal(err) + } + serialized := string(encoded) + if strings.Contains(serialized, secret) || strings.Contains(strings.ToLower(serialized), "data:image") { + t.Fatalf("Langfuse input leaked image payload: %s", serialized) + } +} diff --git a/internal/models/chat/langfuse_wrapper.go b/internal/models/chat/langfuse_wrapper.go index bc75fdc8..7cbbd1c1 100644 --- a/internal/models/chat/langfuse_wrapper.go +++ b/internal/models/chat/langfuse_wrapper.go @@ -2,10 +2,12 @@ package chat import ( "context" + "errors" "time" "github.com/Tencent/WeKnora/internal/tracing/langfuse" "github.com/Tencent/WeKnora/internal/types" + secutils "github.com/Tencent/WeKnora/internal/utils" ) // langfuseChat wraps a Chat implementation and emits a Langfuse generation @@ -16,8 +18,9 @@ type langfuseChat struct { inner Chat } -func (l *langfuseChat) GetModelName() string { return l.inner.GetModelName() } -func (l *langfuseChat) GetModelID() string { return l.inner.GetModelID() } +func (l *langfuseChat) GetModelName() string { return l.inner.GetModelName() } +func (l *langfuseChat) GetModelID() string { return l.inner.GetModelID() } +func (l *langfuseChat) SupportsImageInput() bool { return SupportsImageInput(l.inner) } func (l *langfuseChat) Chat(ctx context.Context, messages []Message, opts *ChatOptions) (*types.ChatResponse, error) { mgr := langfuse.GetManager() @@ -47,7 +50,7 @@ func (l *langfuseChat) Chat(ctx context.Context, messages []Message, opts *ChatO resp.Content, resp.ReasoningContent, resp.FinishReason, resp.ToolCalls, ) } - gen.Finish(output, usage, err) + gen.Finish(output, usage, sanitizeLangfuseError(err)) return resp, err } @@ -71,7 +74,7 @@ func (l *langfuseChat) ChatStream(ctx context.Context, messages []Message, opts ch, err := l.inner.ChatStream(genCtx, messages, opts) if err != nil { - gen.Finish(nil, nil, err) + gen.Finish(nil, nil, sanitizeLangfuseError(err)) return ch, err } if ch == nil { @@ -131,10 +134,21 @@ func buildLangfuseMessages(messages []Message) []map[string]interface{} { "role": m.Role, } if m.Content != "" { - entry["content"] = m.Content + entry["content"] = secutils.RedactDataURLsForLog(m.Content) } if len(m.MultiContent) > 0 { - entry["content"] = m.MultiContent + parts := make([]MessageContentPart, len(m.MultiContent)) + copy(parts, m.MultiContent) + for i := range parts { + parts[i].Text = secutils.RedactDataURLsForLog(parts[i].Text) + if parts[i].ImageURL == nil { + continue + } + imageURL := *parts[i].ImageURL + imageURL.URL = "[image-url-redacted]" + parts[i].ImageURL = &imageURL + } + entry["content"] = parts } if m.Name != "" { entry["name"] = m.Name @@ -143,10 +157,15 @@ func buildLangfuseMessages(messages []Message) []map[string]interface{} { entry["tool_call_id"] = m.ToolCallID } if len(m.ToolCalls) > 0 { - entry["tool_calls"] = m.ToolCalls + calls := make([]ToolCall, len(m.ToolCalls)) + copy(calls, m.ToolCalls) + for i := range calls { + calls[i].Function.Arguments = secutils.RedactDataURLsForLog(calls[i].Function.Arguments) + } + entry["tool_calls"] = calls } if m.ReasoningContent != "" { - entry["reasoning_content"] = m.ReasoningContent + entry["reasoning_content"] = secutils.RedactDataURLsForLog(m.ReasoningContent) } out = append(out, entry) } @@ -157,17 +176,32 @@ func buildLangfuseGenerationOutput( content, reasoningContent, finishReason string, toolCalls []types.LLMToolCall, ) map[string]interface{} { + var safeToolCalls []types.LLMToolCall + if toolCalls != nil { + safeToolCalls = make([]types.LLMToolCall, len(toolCalls)) + copy(safeToolCalls, toolCalls) + } + for i := range safeToolCalls { + safeToolCalls[i].Function.Arguments = secutils.RedactDataURLsForLog(safeToolCalls[i].Function.Arguments) + } output := map[string]interface{}{ - "content": content, - "tool_calls": toolCalls, + "content": secutils.RedactDataURLsForLog(content), + "tool_calls": safeToolCalls, "finish_reason": finishReason, } if reasoningContent != "" { - output["reasoning_content"] = reasoningContent + output["reasoning_content"] = secutils.RedactDataURLsForLog(reasoningContent) } return output } +func sanitizeLangfuseError(err error) error { + if err == nil { + return nil + } + return errors.New(secutils.RedactDataURLsForLog(err.Error())) +} + func buildLangfuseModelParams(opts *ChatOptions) map[string]interface{} { if opts == nil { return nil diff --git a/internal/models/chat/llm_debug.go b/internal/models/chat/llm_debug.go index 698a6193..2aa76c85 100644 --- a/internal/models/chat/llm_debug.go +++ b/internal/models/chat/llm_debug.go @@ -29,7 +29,7 @@ func buildLLMMessages(messages []Message) []logger.LLMMessage { parts = append(parts, mc.Text) case "image_url": if mc.ImageURL != nil { - parts = append(parts, fmt.Sprintf("[image_url: %s]", truncateForDebug(mc.ImageURL.URL, 120))) + parts = append(parts, fmt.Sprintf("[image_url: redacted (%d bytes)]", len(mc.ImageURL.URL))) } } } diff --git a/internal/models/chat/llm_debug_image_test.go b/internal/models/chat/llm_debug_image_test.go new file mode 100644 index 00000000..01aa4f3e --- /dev/null +++ b/internal/models/chat/llm_debug_image_test.go @@ -0,0 +1,27 @@ +package chat + +import ( + "strings" + "testing" +) + +func TestBuildLLMMessagesRedactsMultiContentImageURL(t *testing.T) { + secret := "TOPSECRETBASE64" + messages := buildLLMMessages([]Message{{ + Role: "user", + MultiContent: []MessageContentPart{{ + Type: "image_url", + ImageURL: &ImageURL{URL: "data:image/png;base64," + secret}, + }}, + }}) + if len(messages) != 1 { + t.Fatalf("messages length = %d", len(messages)) + } + got := messages[0].Content + if strings.Contains(got, secret) || strings.Contains(strings.ToLower(got), "data:image") { + t.Fatalf("buildLLMMessages leaked image URL: %q", got) + } + if !strings.Contains(got, "redacted") { + t.Fatalf("buildLLMMessages missing redaction marker: %q", got) + } +} diff --git a/internal/models/chat/llm_debug_wrapper.go b/internal/models/chat/llm_debug_wrapper.go index a676ef94..9a026b32 100644 --- a/internal/models/chat/llm_debug_wrapper.go +++ b/internal/models/chat/llm_debug_wrapper.go @@ -16,8 +16,9 @@ type debugChat struct { inner Chat } -func (d *debugChat) GetModelName() string { return d.inner.GetModelName() } -func (d *debugChat) GetModelID() string { return d.inner.GetModelID() } +func (d *debugChat) GetModelName() string { return d.inner.GetModelName() } +func (d *debugChat) GetModelID() string { return d.inner.GetModelID() } +func (d *debugChat) SupportsImageInput() bool { return SupportsImageInput(d.inner) } func (d *debugChat) Chat(ctx context.Context, messages []Message, opts *ChatOptions) (*types.ChatResponse, error) { callStart := time.Now() diff --git a/internal/models/chat/model_fail_closed_edge_test.go b/internal/models/chat/model_fail_closed_edge_test.go new file mode 100644 index 00000000..ce5916ab --- /dev/null +++ b/internal/models/chat/model_fail_closed_edge_test.go @@ -0,0 +1,29 @@ +package chat + +import ( + "io" + "strings" + "testing" +) + +func TestOllamaBuildChatRequestRejectsAnyUnresolvableImage(t *testing.T) { + c := &OllamaChat{supportsVision: true} + _, err := c.buildChatRequest([]Message{{ + Role: "user", + Content: "describe", + Images: []string{"data:image/png;base64,%%%INVALID%%%"}, + }}, nil, false) + if err == nil { + t.Fatal("expected malformed image to fail closed") + } + if strings.Contains(err.Error(), "%%%INVALID%%%") { + t.Fatalf("image value leaked in error: %v", err) + } +} + +func TestReadAnthropicResponseBodyRejectsOversize(t *testing.T) { + _, err := readAnthropicResponseBody(io.NopCloser(strings.NewReader(strings.Repeat("x", anthropicResponseBodyLimit+1)))) + if err == nil { + t.Fatal("expected oversized Anthropic response rejection") + } +} diff --git a/internal/models/chat/ollama.go b/internal/models/chat/ollama.go index 989a06fa..7e6ed9c1 100644 --- a/internal/models/chat/ollama.go +++ b/internal/models/chat/ollama.go @@ -3,7 +3,7 @@ package chat import ( "context" "encoding/json" - "fmt" + "errors" "io" "net/http" "strconv" @@ -13,27 +13,33 @@ import ( "github.com/Tencent/WeKnora/internal/logger" "github.com/Tencent/WeKnora/internal/models/utils/ollama" "github.com/Tencent/WeKnora/internal/types" + secutils "github.com/Tencent/WeKnora/internal/utils" ollamaapi "github.com/ollama/ollama/api" ) // OllamaChat 实现了基于 Ollama 的聊天 type OllamaChat struct { - modelName string - modelID string - ollamaService *ollama.OllamaService + modelName string + modelID string + supportsVision bool + ollamaService *ollama.OllamaService } +func (c *OllamaChat) SupportsImageInput() bool { return c.supportsVision } + // NewOllamaChat 创建 Ollama 聊天实例 func NewOllamaChat(config *ChatConfig, ollamaService *ollama.OllamaService) (*OllamaChat, error) { return &OllamaChat{ - modelName: config.ModelName, - modelID: config.ModelID, - ollamaService: ollamaService, + modelName: config.ModelName, + modelID: config.ModelID, + supportsVision: config.SupportsVision, + ollamaService: ollamaService, }, nil } -// convertMessages 转换消息格式为Ollama API格式 -func (c *OllamaChat) convertMessages(messages []Message) []ollamaapi.Message { +// convertMessages converts messages and fails closed if any declared image +// cannot be resolved. Silently dropping one image would change the user's input. +func (c *OllamaChat) convertMessages(messages []Message) ([]ollamaapi.Message, error) { ollamaMessages := make([]ollamaapi.Message, 0, len(messages)) for _, msg := range messages { msgOllama := ollamaapi.Message{ @@ -46,47 +52,69 @@ func (c *OllamaChat) convertMessages(messages []Message) []ollamaapi.Message { } if len(msg.Images) > 0 && msg.Role == "user" { for _, imgURL := range msg.Images { - if imgData := resolveImageForOllama(imgURL); imgData != nil { - msgOllama.Images = append(msgOllama.Images, imgData) + imgData, err := resolveImageForOllama(imgURL) + if err != nil { + return nil, err } + msgOllama.Images = append(msgOllama.Images, imgData) } } ollamaMessages = append(ollamaMessages, msgOllama) } - return ollamaMessages + return ollamaMessages, nil } // resolveImageForOllama resolves an image URL into raw bytes for Ollama. // Handles local serving paths (/files/...), data URIs, and remote HTTP URLs. -func resolveImageForOllama(imageURL string) ollamaapi.ImageData { +func resolveImageForOllama(imageURL string) (ollamaapi.ImageData, error) { if data := resolveImageURLForOllama(imageURL); data != nil { - return data + return data, nil } if strings.HasPrefix(imageURL, "http://") || strings.HasPrefix(imageURL, "https://") { - client := &http.Client{Timeout: 30 * time.Second} + if err := secutils.ValidateURLForSSRF(imageURL); err != nil { + return nil, errors.New("ollama image URL is not allowed") + } + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + Proxy: nil, + DialContext: secutils.SSRFSafeDialContext, + }, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } resp, err := client.Get(imageURL) if err != nil { - return nil + return nil, errors.New("ollama image download failed") } defer resp.Body.Close() - data, err := io.ReadAll(io.LimitReader(resp.Body, 20*1024*1024)) - if err != nil { - return nil + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, errors.New("ollama image download failed") } - return data + const maxOllamaImageBytes = 20 * 1024 * 1024 + data, err := io.ReadAll(io.LimitReader(resp.Body, maxOllamaImageBytes+1)) + if err != nil { + return nil, errors.New("ollama image download failed") + } + if len(data) == 0 || len(data) > maxOllamaImageBytes { + return nil, errors.New("ollama image payload is invalid") + } + return data, nil } - return nil + return nil, errors.New("ollama image payload is invalid") } // buildChatRequest 构建聊天请求参数 -func (c *OllamaChat) buildChatRequest(messages []Message, opts *ChatOptions, isStream bool) *ollamaapi.ChatRequest { +func (c *OllamaChat) buildChatRequest(messages []Message, opts *ChatOptions, isStream bool) (*ollamaapi.ChatRequest, error) { // 设置流式标志 streamFlag := isStream - // 构建请求参数 + convertedMessages, err := c.convertMessages(messages) + if err != nil { + return nil, err + } chatReq := &ollamaapi.ChatRequest{ Model: c.modelName, - Messages: c.convertMessages(messages), + Messages: convertedMessages, Stream: &streamFlag, Options: make(map[string]interface{}), } @@ -113,7 +141,7 @@ func (c *OllamaChat) buildChatRequest(messages []Message, opts *ChatOptions, isS } } - return chatReq + return chatReq, nil } // Chat 进行非流式聊天 @@ -124,7 +152,10 @@ func (c *OllamaChat) Chat(ctx context.Context, messages []Message, opts *ChatOpt } // 构建请求参数 - chatReq := c.buildChatRequest(messages, opts, false) + chatReq, err := c.buildChatRequest(messages, opts, false) + if err != nil { + return nil, err + } // 记录请求日志 logger.GetLogger(ctx).Infof("发送聊天请求到模型 %s", c.modelName) @@ -134,7 +165,7 @@ func (c *OllamaChat) Chat(ctx context.Context, messages []Message, opts *ChatOpt var promptTokens, completionTokens int // 使用 Ollama 客户端发送请求 - err := c.ollamaService.Chat(ctx, chatReq, func(resp ollamaapi.ChatResponse) error { + err = c.ollamaService.Chat(ctx, chatReq, func(resp ollamaapi.ChatResponse) error { responseContent = resp.Message.Content // 当 Content 为空但 Thinking 有内容时(如推理模型未正确配置 thinking 参数),使用 Thinking 作为兜底 if responseContent == "" && resp.Message.Thinking != "" { @@ -151,7 +182,7 @@ func (c *OllamaChat) Chat(ctx context.Context, messages []Message, opts *ChatOpt return nil }) if err != nil { - return nil, fmt.Errorf("聊天请求失败: %w", err) + return nil, errors.New("ollama chat failed") } usage := types.TokenUsage{ @@ -180,7 +211,10 @@ func (c *OllamaChat) ChatStream( } // 构建请求参数 - chatReq := c.buildChatRequest(messages, opts, true) + chatReq, err := c.buildChatRequest(messages, opts, true) + if err != nil { + return nil, err + } // 记录请求日志 logger.GetLogger(ctx).Infof("发送流式聊天请求到模型 %s", c.modelName) @@ -193,6 +227,8 @@ func (c *OllamaChat) ChatStream( defer close(streamChan) var thinking thinkingEmitter + var terminalUsage *types.TokenUsage + providerDone := false err := c.ollamaService.Chat(ctx, chatReq, func(resp ollamaapi.ChatResponse) error { // 发送思考内容(支持 Qwen3、DeepSeek 等推理模型) if resp.Message.Thinking != "" { @@ -247,32 +283,32 @@ func (c *OllamaChat) ChatStream( } if resp.Done { - var usage *types.TokenUsage + providerDone = true if resp.PromptEvalCount > 0 || resp.EvalCount > 0 { - usage = &types.TokenUsage{ + terminalUsage = &types.TokenUsage{ PromptTokens: resp.PromptEvalCount, CompletionTokens: resp.EvalCount, TotalTokens: resp.PromptEvalCount + resp.EvalCount, } } - logUsage(ctx, c.modelName, usage) - streamChan <- types.StreamResponse{ - ResponseType: types.ResponseTypeAnswer, - Done: true, - Usage: usage, - } } return nil }) - if err != nil { - logger.GetLogger(ctx).Errorf("流式聊天请求失败: %v", err) - // 发送错误响应 + if err != nil || !providerDone { + logger.GetLogger(ctx).Error("流式聊天请求失败") streamChan <- types.StreamResponse{ ResponseType: types.ResponseTypeError, - Content: err.Error(), + Content: "ollama stream failed", Done: true, } + return + } + logUsage(ctx, c.modelName, terminalUsage) + streamChan <- types.StreamResponse{ + ResponseType: types.ResponseTypeAnswer, + Done: true, + Usage: terminalUsage, } }() diff --git a/internal/models/chat/openai_stream.go b/internal/models/chat/openai_stream.go index cec9b1a5..87b619d3 100644 --- a/internal/models/chat/openai_stream.go +++ b/internal/models/chat/openai_stream.go @@ -122,7 +122,7 @@ func (c *RemoteAPIChat) processStream( for { response, err := stream.Recv() if err != nil { - if err == io.EOF { + if err == io.EOF && state.lastFinishReason != "" { logUsage(ctx, c.modelName, state.usage) toolCalls := state.buildOrderedToolCalls() streamChan <- types.StreamResponse{ @@ -134,9 +134,10 @@ func (c *RemoteAPIChat) processStream( FinishReason: state.lastFinishReason, } } else { + logger.Warnf(ctx, "Chat completion stream ended before finish reason") streamChan <- types.StreamResponse{ ResponseType: types.ResponseTypeError, - Content: err.Error(), + Content: "chat completion stream failed", Done: true, } } @@ -178,23 +179,26 @@ func (c *RemoteAPIChat) processRawHTTPStream( for { event, err := reader.ReadEvent() if err != nil { - if err == io.EOF { + if err == io.EOF && state.lastFinishReason != "" { logUsage(ctx, c.modelName, state.usage) - toolCalls := state.buildOrderedToolCalls() streamChan <- types.StreamResponse{ ResponseType: types.ResponseTypeAnswer, - Content: "", Done: true, - ToolCalls: toolCalls, + ToolCalls: state.buildOrderedToolCalls(), Usage: state.usage, + FinishReason: state.lastFinishReason, } + return + } + if err != io.EOF { + logger.Warnf(ctx, "Chat stream read failed") } else { - logger.Errorf(ctx, "Stream read error: %v", err) - streamChan <- types.StreamResponse{ - ResponseType: types.ResponseTypeError, - Content: err.Error(), - Done: true, - } + logger.Warnf(ctx, "Chat stream ended before finish reason") + } + streamChan <- types.StreamResponse{ + ResponseType: types.ResponseTypeError, + Content: "chat stream ended before completion", + Done: true, } return } @@ -204,6 +208,14 @@ func (c *RemoteAPIChat) processRawHTTPStream( } if event.Done { + if state.lastFinishReason == "" { + streamChan <- types.StreamResponse{ + ResponseType: types.ResponseTypeError, + Content: "chat stream ended before completion", + Done: true, + } + return + } logUsage(ctx, c.modelName, state.usage) toolCalls := state.buildOrderedToolCalls() streamChan <- types.StreamResponse{ @@ -212,6 +224,7 @@ func (c *RemoteAPIChat) processRawHTTPStream( Done: true, ToolCalls: toolCalls, Usage: state.usage, + FinishReason: state.lastFinishReason, } return } @@ -241,8 +254,13 @@ func (c *RemoteAPIChat) processRawHTTPStream( } if err := json.Unmarshal(event.Data, &streamResp); err != nil { - logger.Errorf(ctx, "Failed to parse stream response: %v", err) - continue + logger.Warnf(ctx, "Failed to parse chat stream response") + streamChan <- types.StreamResponse{ + ResponseType: types.ResponseTypeError, + Content: "chat stream response was invalid", + Done: true, + } + return } if streamResp.Usage != nil { @@ -453,38 +471,18 @@ func (c *RemoteAPIChat) processStreamDelta( streamChan <- types.StreamResponse{ ResponseType: types.ResponseTypeAnswer, Content: delta.Content, - Done: isDone, + Done: false, ToolCalls: state.buildOrderedToolCalls(), FinishReason: string(choice.FinishReason), } } - if isDone && len(state.toolCallMap) > 0 { - streamChan <- types.StreamResponse{ - ResponseType: types.ResponseTypeAnswer, - Content: "", - Done: true, - ToolCalls: state.buildOrderedToolCalls(), - FinishReason: string(choice.FinishReason), - } - } - - // Ensure thinking done is sent when stream finishes without any answer content - // (e.g., model only produced reasoning then hit finish_reason with empty content). + // Ensure thinking done is sent when the provider supplied a finish reason. + // The outer stream reader emits the sole terminal answer after it confirms + // the provider ended successfully. if isDone { state.finish(streamChan) } - - // Catch-all: isDone but none of the above branches sent a response with - // FinishReason (empty content, no tool calls, no thinking). This prevents - // the finish_reason from being lost in the streaming pipeline. - if isDone && delta.Content == "" && len(state.toolCallMap) == 0 { - streamChan <- types.StreamResponse{ - ResponseType: types.ResponseTypeAnswer, - Done: true, - FinishReason: string(choice.FinishReason), - } - } } // processToolCallsDelta 处理 tool calls 的增量更新 diff --git a/internal/models/chat/plan_a_fail_closed_test.go b/internal/models/chat/plan_a_fail_closed_test.go new file mode 100644 index 00000000..21def1b7 --- /dev/null +++ b/internal/models/chat/plan_a_fail_closed_test.go @@ -0,0 +1,129 @@ +package chat + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Tencent/WeKnora/internal/types" +) + +func TestRemoteAPIProviderErrorDoesNotExposeImagePayload(t *testing.T) { + t.Setenv("SSRF_WHITELIST", "127.0.0.1") + secret := "TOPSECRETIMAGEPAYLOAD" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"message":"data:image/png;base64,` + secret + `"}}`)) + })) + defer server.Close() + + model, err := NewRemoteAPIChat(&ChatConfig{ + Source: types.ModelSourceRemote, + BaseURL: server.URL + "/v1", + ModelName: "vision-test", + APIKey: "test-key", + Provider: "openai", + SupportsVision: true, + }) + if err != nil { + t.Fatal(err) + } + _, err = model.Chat(context.Background(), []Message{{ + Role: "user", Content: "inspect", Images: []string{"data:image/png;base64," + secret}, + }}, nil) + if err == nil { + t.Fatal("expected provider error") + } + if strings.Contains(err.Error(), secret) || strings.Contains(strings.ToLower(err.Error()), "data:image") { + t.Fatalf("provider error leaked image payload: %v", err) + } +} + +func TestProcessRawHTTPStreamPrematureEOFFailsClosed(t *testing.T) { + body := "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n" + resp := &http.Response{Body: io.NopCloser(strings.NewReader(body))} + stream := make(chan types.StreamResponse) + model := &RemoteAPIChat{modelName: "cloud-vision"} + go model.processRawHTTPStream(context.Background(), resp, stream, nil) + + var got []types.StreamResponse + for chunk := range stream { + got = append(got, chunk) + } + if len(got) == 0 || got[len(got)-1].ResponseType != types.ResponseTypeError { + t.Fatalf("stream = %#v; want terminal error on premature EOF", got) + } + if got[len(got)-1].Content != "chat stream ended before completion" { + t.Fatalf("terminal error = %q", got[len(got)-1].Content) + } +} + +func TestProcessRawHTTPStreamFinishReasonThenEOFCompletes(t *testing.T) { + body := "data: {\"choices\":[{\"delta\":{\"content\":\"answer\"},\"finish_reason\":\"stop\"}]}\n\n" + resp := &http.Response{Body: io.NopCloser(strings.NewReader(body))} + stream := make(chan types.StreamResponse) + model := &RemoteAPIChat{modelName: "cloud-vision"} + go model.processRawHTTPStream(context.Background(), resp, stream, nil) + + var got []types.StreamResponse + for chunk := range stream { + got = append(got, chunk) + } + if len(got) == 0 { + t.Fatal("expected stream chunks") + } + last := got[len(got)-1] + if last.ResponseType == types.ResponseTypeError || !last.Done || last.FinishReason != "stop" { + t.Fatalf("stream = %#v; want successful stop completion", got) + } +} + +func TestProcessRawHTTPStreamFinishReasonThenMalformedPacketFailsWithoutSuccessTerminal(t *testing.T) { + body := "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" + + "data: {not-json}\n\n" + resp := &http.Response{Body: io.NopCloser(strings.NewReader(body))} + stream := make(chan types.StreamResponse, 8) + model := &RemoteAPIChat{modelName: "stream-test"} + go model.processRawHTTPStream(context.Background(), resp, stream, nil) + + successTerminals, errorTerminals := 0, 0 + for item := range stream { + if item.Done && item.ResponseType == types.ResponseTypeAnswer { + successTerminals++ + } + if item.Done && item.ResponseType == types.ResponseTypeError { + errorTerminals++ + } + } + if successTerminals != 0 || errorTerminals != 1 { + t.Fatalf("terminal counts success=%d error=%d; want 0/1", successTerminals, errorTerminals) + } +} + +func TestProcessRawHTTPStreamEmitsExactlyOneSuccessTerminal(t *testing.T) { + body := "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" + + "data: [DONE]\n\n" + resp := &http.Response{Body: io.NopCloser(strings.NewReader(body))} + stream := make(chan types.StreamResponse, 8) + model := &RemoteAPIChat{modelName: "stream-test"} + go model.processRawHTTPStream(context.Background(), resp, stream, nil) + + successTerminals := 0 + for item := range stream { + if item.Done && item.ResponseType == types.ResponseTypeAnswer { + successTerminals++ + } + if item.ResponseType == types.ResponseTypeError { + t.Fatalf("unexpected stream error: %#v", item) + } + } + if successTerminals != 1 { + t.Fatalf("success terminals = %d, want 1", successTerminals) + } +} diff --git a/internal/models/chat/remote_api.go b/internal/models/chat/remote_api.go index a936406a..f4843b09 100644 --- a/internal/models/chat/remote_api.go +++ b/internal/models/chat/remote_api.go @@ -21,14 +21,15 @@ import ( // providerAdapter(见 provider.go),thinking 编码委托给 ThinkingStrategy // (见 thinking.go)。 type RemoteAPIChat struct { - modelName string - client *openai.Client - modelID string - baseURL string - apiKey string - provider provider.ProviderName - appID string - appSecret string + modelName string + client *openai.Client + modelID string + baseURL string + apiKey string + provider provider.ProviderName + supportsVision bool + appID string + appSecret string // customHeaders 为用户在模型配置中指定的自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。 customHeaders map[string]string @@ -38,6 +39,8 @@ type RemoteAPIChat struct { thinkingOverride ThinkingStrategy } +func (c *RemoteAPIChat) SupportsImageInput() bool { return c.supportsVision } + // NewRemoteAPIChat 创建远程 API 聊天实例 func NewRemoteAPIChat(chatConfig *ChatConfig) (*RemoteAPIChat, error) { if chatConfig.BaseURL != "" { @@ -103,6 +106,7 @@ func NewRemoteAPIChat(chatConfig *ChatConfig) (*RemoteAPIChat, error) { baseURL: chatConfig.BaseURL, apiKey: apiKey, provider: providerName, + supportsVision: chatConfig.SupportsVision, appID: chatConfig.AppID, appSecret: chatConfig.AppSecret, customHeaders: chatConfig.CustomHeaders, @@ -120,7 +124,11 @@ func (c *RemoteAPIChat) authCreds() authCreds { // transform and parameter shaping (but not thinking, which may wrap the body). func (c *RemoteAPIChat) shapedRequest(messages []Message, opts *ChatOptions, isStream bool) openai.ChatCompletionRequest { req := c.BuildChatCompletionRequest(messages, opts, isStream) - req.Messages = c.adapter.TransformMessages(req.Messages) + // WeKnora Cloud text models retain the legacy plain-text downgrade, while + // vision-capable Chat models must keep the OpenAI-compatible image parts. + if c.provider != provider.ProviderWeKnoraCloud || !c.supportsVision { + req.Messages = c.adapter.TransformMessages(req.Messages) + } c.adapter.ShapeRequest(&req, opts, isStream) return req } @@ -180,15 +188,7 @@ func (c *RemoteAPIChat) Chat(ctx context.Context, messages []Message, opts *Chat c.logRequest(timeoutCtx, req, false) resp, err := c.client.CreateChatCompletion(timeoutCtx, req) if err != nil { - if isMultimodalNotSupportedError(err) { - logger.Warnf(timeoutCtx, "[LLM Request] Model %s does not support multimodal, retrying without images", c.modelName) - cleaned := stripImagesFromMessages(messages) - req = c.shapedRequest(cleaned, opts, false) - resp, err = c.client.CreateChatCompletion(timeoutCtx, req) - } - if err != nil { - return nil, fmt.Errorf("create chat completion: %w", err) - } + return nil, fmt.Errorf("create chat completion failed") } result, err := c.parseCompletionResponse(&resp) @@ -231,13 +231,13 @@ func (c *RemoteAPIChat) chatWithRawHTTP(ctx context.Context, endpoint string, cu resp, err := rawHTTPClient.Do(httpReq) if err != nil { - return nil, fmt.Errorf("send request: %w", err) + return nil, fmt.Errorf("send chat request failed") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return nil, remoteAPIStatusError(resp.StatusCode, body) } body, err := io.ReadAll(resp.Body) @@ -259,6 +259,10 @@ func (c *RemoteAPIChat) chatWithRawHTTP(ctx context.Context, endpoint string, cu return result, nil } +func remoteAPIStatusError(status int, _ []byte) error { + return fmt.Errorf("API request failed with status %d", status) +} + // ChatStream 进行流式聊天 func (c *RemoteAPIChat) ChatStream(ctx context.Context, messages []Message, opts *ChatOptions) (<-chan types.StreamResponse, error) { // 仅在调用方未设置 deadline 时附加兜底超时;流式调用默认超时更长, @@ -287,17 +291,9 @@ func (c *RemoteAPIChat) ChatStream(ctx context.Context, messages []Message, opts stream, err := c.client.CreateChatCompletionStream(timeoutCtx, req) if err != nil { - if isMultimodalNotSupportedError(err) { - logger.Warnf(timeoutCtx, "[LLM Stream] Model %s does not support multimodal, retrying without images", c.modelName) - cleaned := stripImagesFromMessages(messages) - req = c.shapedRequest(cleaned, opts, true) - stream, err = c.client.CreateChatCompletionStream(timeoutCtx, req) - } - if err != nil { - cancel() - close(streamChan) - return nil, fmt.Errorf("create chat completion stream: %w", err) - } + cancel() + close(streamChan) + return nil, fmt.Errorf("create chat completion stream failed") } go func() { @@ -363,13 +359,13 @@ func (c *RemoteAPIChat) chatStreamWithRawHTTP(ctx context.Context, endpoint stri resp, err := rawHTTPClient.Do(httpReq) if err != nil { - return nil, fmt.Errorf("send request: %w", err) + return nil, fmt.Errorf("send chat request failed") } if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) resp.Body.Close() - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + return nil, remoteAPIStatusError(resp.StatusCode, body) } streamChan := make(chan types.StreamResponse) diff --git a/internal/models/chat/remote_api_test.go b/internal/models/chat/remote_api_test.go index db755e52..e0ed56d3 100644 --- a/internal/models/chat/remote_api_test.go +++ b/internal/models/chat/remote_api_test.go @@ -3,7 +3,11 @@ package chat import ( "context" "encoding/json" + "net/http" + "net/http/httptest" "os" + "strings" + "sync/atomic" "testing" "time" @@ -13,6 +17,17 @@ import ( "github.com/stretchr/testify/require" ) +func TestRemoteAPIStatusErrorDoesNotExposeResponseBody(t *testing.T) { + secret := "data:image/png;base64,TOPSECRET" + err := remoteAPIStatusError(http.StatusBadRequest, []byte(secret)) + if strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), "TOPSECRET") { + t.Fatalf("remote API error leaked response body: %v", err) + } + if !strings.Contains(err.Error(), "400") { + t.Fatalf("remote API error = %v, want status code", err) + } +} + func newTestRemoteChat(t *testing.T) *RemoteAPIChat { t.Helper() @@ -533,3 +548,44 @@ func TestTokenUsage_CachedTokensJSONOmitempty(t *testing.T) { assert.Contains(t, string(b), `"cached_tokens":7`) }) } + +func newMultimodalUnsupportedTestChat(t *testing.T, calls *atomic.Int32) *RemoteAPIChat { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"message":"image input is not supported","type":"invalid_request_error","code":"unsupported"}}`)) + })) + t.Cleanup(server.Close) + + chat := newTestRemoteChat(t) + cfg := openai.DefaultConfig("test-key") + cfg.BaseURL = server.URL + chat.client = openai.NewClientWithConfig(cfg) + return chat +} + +func TestRemoteAPIChat_MultimodalUnsupportedDoesNotStripImages(t *testing.T) { + messages := []Message{{ + Role: "user", + Content: "read this image", + Images: []string{"data:image/png;base64,iVBORw0KGgo="}, + }} + + t.Run("non-streaming", func(t *testing.T) { + var calls atomic.Int32 + chat := newMultimodalUnsupportedTestChat(t, &calls) + _, err := chat.Chat(context.Background(), messages, &ChatOptions{}) + require.Error(t, err) + assert.EqualValues(t, 1, calls.Load(), "image input must not be removed and retried as text") + }) + + t.Run("streaming", func(t *testing.T) { + var calls atomic.Int32 + chat := newMultimodalUnsupportedTestChat(t, &calls) + _, err := chat.ChatStream(context.Background(), messages, &ChatOptions{}) + require.Error(t, err) + assert.EqualValues(t, 1, calls.Load(), "image input must not be removed and retried as text") + }) +} diff --git a/internal/models/chat/stream_raw_dump.go b/internal/models/chat/stream_raw_dump.go index fad3c20b..050a2ae3 100644 --- a/internal/models/chat/stream_raw_dump.go +++ b/internal/models/chat/stream_raw_dump.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + secutils "github.com/Tencent/WeKnora/internal/utils" ) // streamRawDumpDir returns the directory for per-stream raw packet dumps. @@ -43,7 +45,10 @@ func newStreamPacketDumper(modelName string, request any) *streamPacketDumper { if dir == "" { return nil } - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil + } + if err := os.Chmod(dir, 0o700); err != nil { return nil } @@ -70,10 +75,14 @@ func newStreamPacketDumper(modelName string, request any) *streamPacketDumper { return nil } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return nil } + if err := f.Chmod(0o600); err != nil { + _ = f.Close() + return nil + } d := &streamPacketDumper{file: f, path: path, model: modelName} _ = d.writeRequest(request) @@ -81,11 +90,19 @@ func newStreamPacketDumper(modelName string, request any) *streamPacketDumper { } func (d *streamPacketDumper) writeRequest(request any) error { + raw, err := json.Marshal(request) + if err != nil { + return err + } + var redacted any + if err := json.Unmarshal([]byte(secutils.CompactImageDataURLForLog(string(raw))), &redacted); err != nil { + return err + } line, err := json.Marshal(map[string]any{ "type": "request", "model": d.model, "timestamp": time.Now().UTC().Format(time.RFC3339Nano), - "data": request, + "data": redacted, }) if err != nil { return err @@ -112,6 +129,7 @@ func (d *streamPacketDumper) WritePacketRaw(raw []byte) { if len(raw) == 0 { return } + raw = []byte(secutils.CompactImageDataURLForLog(string(raw))) d.mu.Lock() defer d.mu.Unlock() @@ -139,6 +157,7 @@ func (d *streamPacketDumper) WriteError(message string) { if d == nil || d.file == nil || strings.TrimSpace(message) == "" { return } + message = secutils.CompactImageDataURLForLog(message) line, err := json.Marshal(map[string]any{ "type": "error", "model": d.model, @@ -163,7 +182,7 @@ func (d *streamPacketDumper) WriteHTTPError(statusCode int, body []byte) { "status_code": statusCode, } if len(body) > 0 { - trimmed := bytesTrimSpace(body) + trimmed := []byte(secutils.CompactImageDataURLForLog(string(bytesTrimSpace(body)))) if json.Valid(trimmed) { var parsed any if json.Unmarshal(trimmed, &parsed) == nil { diff --git a/internal/models/chat/stream_raw_dump_image_test.go b/internal/models/chat/stream_raw_dump_image_test.go new file mode 100644 index 00000000..23231f72 --- /dev/null +++ b/internal/models/chat/stream_raw_dump_image_test.go @@ -0,0 +1,51 @@ +package chat + +import ( + "os" + "strings" + "testing" +) + +func TestStreamPacketDumperRedactsImageRequestAndUsesPrivatePermissions(t *testing.T) { + dir := t.TempDir() + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("WEKNORA_LLM_STREAM_RAW_DUMP_DIR", dir) + secret := strings.Repeat("TOPSECRET", 30) + dumper := newStreamPacketDumper("vision-model", map[string]any{ + "messages": []any{map[string]any{ + "role": "user", + "image_url": map[string]any{"url": "data:image/png;base64," + secret}, + }}, + }) + if dumper == nil { + t.Fatal("expected dumper") + } + path := dumper.Path() + dumper.Close() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read dump: %v", err) + } + if strings.Contains(string(data), secret) || strings.Contains(string(data), "data:image/") { + t.Fatalf("image data leaked to dump: %s", data) + } + if !strings.Contains(string(data), "[image-data-redacted]") { + t.Fatalf("missing redaction marker: %s", data) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat dump: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("dump permissions = %o, want 600", got) + } + dirInfo, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat dump directory: %v", err) + } + if got := dirInfo.Mode().Perm(); got != 0o700 { + t.Fatalf("dump directory permissions = %o, want 700", got) + } +} diff --git a/internal/models/chat/weknora_cloud_image_test.go b/internal/models/chat/weknora_cloud_image_test.go new file mode 100644 index 00000000..b43eadc8 --- /dev/null +++ b/internal/models/chat/weknora_cloud_image_test.go @@ -0,0 +1,80 @@ +package chat + +import ( + "testing" + + "github.com/Tencent/WeKnora/internal/models/provider" + "github.com/Tencent/WeKnora/internal/types" + "github.com/stretchr/testify/require" +) + +func TestRemoteAPIChat_WeKnoraCloudTransformsImagesByCapability(t *testing.T) { + messages := []Message{{ + Role: "user", + Content: "请识别图片", + Images: []string{"data:image/png;base64,iVBORw0KGgo="}, + }} + + build := func(t *testing.T, supportsVision bool) *RemoteAPIChat { + t.Helper() + model, err := NewRemoteAPIChat(&ChatConfig{ + Source: types.ModelSourceRemote, + ModelName: "cloud-chat", + ModelID: "cloud-chat", + Provider: string(provider.ProviderWeKnoraCloud), + AppID: "test-app", + AppSecret: "test-secret", + SupportsVision: supportsVision, + }) + require.NoError(t, err) + return model + } + + t.Run("vision chat preserves image parts", func(t *testing.T) { + req := build(t, true).shapedRequest(messages, &ChatOptions{}, false) + require.Len(t, req.Messages, 1) + require.Len(t, req.Messages[0].MultiContent, 2) + require.NotNil(t, req.Messages[0].MultiContent[0].ImageURL) + require.Equal(t, messages[0].Images[0], req.Messages[0].MultiContent[0].ImageURL.URL) + }) + + t.Run("text chat keeps legacy downgrade", func(t *testing.T) { + req := build(t, false).shapedRequest(messages, &ChatOptions{}, false) + require.Len(t, req.Messages, 1) + require.Empty(t, req.Messages[0].MultiContent) + require.Equal(t, messages[0].Content, req.Messages[0].Content) + }) +} + +func TestConfigFromModelRecognizesExistingWeKnoraCloudChatAsVision(t *testing.T) { + cloudChat := &types.Model{ + Name: "chat", + Type: types.ModelTypeKnowledgeQA, + Source: types.ModelSourceRemote, + Parameters: types.ModelParameters{ + Provider: string(provider.ProviderWeKnoraCloud), + }, + } + genericChat := &types.Model{ + Name: "chat", + Type: types.ModelTypeKnowledgeQA, + Source: types.ModelSourceRemote, + } + + require.True(t, ConfigFromModel(cloudChat, "", "").SupportsVision) + require.False(t, ConfigFromModel(genericChat, "", "").SupportsVision) +} + +func TestSupportsImageInputUsesRuntimeTransportCapability(t *testing.T) { + visionRemote := &RemoteAPIChat{supportsVision: true} + textRemote := &RemoteAPIChat{supportsVision: false} + anthropicText := &AnthropicChat{} + anthropicVision := &AnthropicChat{supportsVision: true} + + require.True(t, SupportsImageInput(visionRemote)) + require.False(t, SupportsImageInput(textRemote)) + require.False(t, SupportsImageInput(anthropicText)) + require.True(t, SupportsImageInput(anthropicVision)) + require.True(t, SupportsImageInput(&debugChat{inner: visionRemote})) + require.True(t, SupportsImageInput(&langfuseChat{inner: visionRemote})) +} diff --git a/internal/types/chat_manage.go b/internal/types/chat_manage.go index 15d33314..85090577 100644 --- a/internal/types/chat_manage.go +++ b/internal/types/chat_manage.go @@ -221,15 +221,16 @@ func (c *ChatManage) Clone() *ChatManage { Images: append([]string(nil), c.Images...), VLMModelID: c.VLMModelID, ChatModelSupportsVision: c.ChatModelSupportsVision, - Attachments: append(MessageAttachments(nil), c.Attachments...), - TenantID: c.TenantID, - WebSearchEnabled: c.WebSearchEnabled, - WebSearchProviderID: c.WebSearchProviderID, - WebSearchMaxResults: c.WebSearchMaxResults, - WebFetchEnabled: c.WebFetchEnabled, - WebFetchTopN: c.WebFetchTopN, - Language: c.Language, - IntentPromptOverrides: maps.Clone(c.IntentPromptOverrides), + + Attachments: append(MessageAttachments(nil), c.Attachments...), + TenantID: c.TenantID, + WebSearchEnabled: c.WebSearchEnabled, + WebSearchProviderID: c.WebSearchProviderID, + WebSearchMaxResults: c.WebSearchMaxResults, + WebFetchEnabled: c.WebFetchEnabled, + WebFetchTopN: c.WebFetchTopN, + Language: c.Language, + IntentPromptOverrides: maps.Clone(c.IntentPromptOverrides), }, PipelineState: PipelineState{ RewriteQuery: c.RewriteQuery, diff --git a/internal/types/qa_request.go b/internal/types/qa_request.go index 7932403b..50eb841d 100644 --- a/internal/types/qa_request.go +++ b/internal/types/qa_request.go @@ -4,21 +4,22 @@ package types // replacing the previous 14-parameter method signatures. // EventBus is passed separately to avoid circular dependency with the event package. type QARequest struct { - Session *Session // The conversation session - Query string // User query text - AssistantMessageID string // Pre-created assistant message ID - SummaryModelID string // Optional model override; empty = use agent/KB default - CustomAgent *CustomAgent // Optional custom agent for config override - KnowledgeBaseIDs []string // Knowledge base IDs to search (from request + @mentions) - KnowledgeIDs []string // Specific knowledge (file) IDs to search - TagScopes []TagScope // Tag-constrained KB scopes from @mentions - MCPServiceIDs []string // Per-request MCP service IDs from @mentions - SkillNames []string // Per-request preloaded skill names from @mentions - ImageURLs []string // Image URLs for multimodal input - ImageDescription string // VLM-generated image description (fallback for non-vision models) - UserMessageID string // Created user message ID - WebSearchEnabled bool // Whether web search is enabled for this request - EnableMemory bool // Whether memory feature is enabled - QuotedContext string // Quoted message content from IM quote-reply (appended at LLM prompt stage, not used for retrieval) - Attachments MessageAttachments // File attachments (processed and ready for prompt injection) + Session *Session // The conversation session + Query string // User query text + AssistantMessageID string // Pre-created assistant message ID + SummaryModelID string // Optional model override; empty = use agent/KB default + CustomAgent *CustomAgent // Optional custom agent for config override + KnowledgeBaseIDs []string // Knowledge base IDs to search (from request + @mentions) + KnowledgeIDs []string // Specific knowledge (file) IDs to search + TagScopes []TagScope // Tag-constrained KB scopes from @mentions + MCPServiceIDs []string // Per-request MCP service IDs from @mentions + SkillNames []string // Per-request preloaded skill names from @mentions + ImageURLs []string // Image URLs for multimodal input + ImageDescription string // VLM-generated image description (fallback for non-vision models) + + UserMessageID string // Created user message ID + WebSearchEnabled bool // Whether web search is enabled for this request + EnableMemory bool // Whether memory feature is enabled + QuotedContext string // Quoted message content from IM quote-reply (appended at LLM prompt stage, not used for retrieval) + Attachments MessageAttachments // File attachments (processed and ready for prompt injection) } diff --git a/internal/utils/log_sanitize.go b/internal/utils/log_sanitize.go index 9e04c48c..f577a15b 100644 --- a/internal/utils/log_sanitize.go +++ b/internal/utils/log_sanitize.go @@ -1,29 +1,136 @@ package utils import ( - "regexp" "strconv" + "strings" ) -var imageDataURLPatternForLog = regexp.MustCompile(`data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+`) - const ( - defaultMaxLogChars = 12000 - defaultMaxDataURLPreview = 96 + defaultMaxLogChars = 12000 + dataURLRedaction = "[image-data-redacted]" ) -// CompactImageDataURLForLog shortens large image data URLs for log output. +// CompactImageDataURLForLog removes every data URI before log or dump output. +// It deliberately handles non-canonical forms because debug paths may receive +// JSON-escaped or percent-encoded requests in addition to ordinary data: URLs. func CompactImageDataURLForLog(raw string) string { - masked := imageDataURLPatternForLog.ReplaceAllStringFunc(raw, func(match string) string { - if len(match) <= defaultMaxDataURLPreview { - return match - } - hidden := len(match) - defaultMaxDataURLPreview - return match[:defaultMaxDataURLPreview] + "..." - }) - + masked := RedactDataURLsForLog(raw) if len(masked) <= defaultMaxLogChars { return masked } return masked[:defaultMaxLogChars] + "... (truncated, total " + strconv.Itoa(len(masked)) + " chars)" } + +// RedactDataURLsForLog removes data URIs without truncating surrounding text. +func RedactDataURLsForLog(raw string) string { + return redactDataURLs(raw) +} + +func redactDataURLs(raw string) string { + var out strings.Builder + out.Grow(len(raw)) + for i := 0; i < len(raw); { + prefixLen := dataURLPrefixLen(raw[i:]) + if prefixLen == 0 { + out.WriteByte(raw[i]) + i++ + continue + } + + out.WriteString(dataURLRedaction) + i += prefixLen + for i < len(raw) && !isDataURLTerminator(raw, i) { + i++ + } + } + return out.String() +} + +// dataURLPrefixLen recognizes "data:" when each ASCII character is either +// literal, percent encoded, or represented as one-or-more JSON escape layers. +func dataURLPrefixLen(value string) int { + pos := 0 + for i := 0; i < len("data:"); i++ { + consumed := encodedASCIILen(value[pos:], "data:"[i]) + if consumed == 0 { + return 0 + } + pos += consumed + } + return pos +} + +func encodedASCIILen(value string, want byte) int { + if len(value) > 0 && strings.EqualFold(value[:1], string(want)) { + return 1 + } + if len(value) >= 3 && value[0] == '%' { + pos := 0 + for len(value) >= pos+3 && strings.EqualFold(value[pos:pos+3], "%25") { + pos += 3 + } + if pos == 0 { + if decoded, ok := decodeHexByte(value[1], value[2]); ok && decoded == want { + return 3 + } + } else if len(value) >= pos+2 { + if decoded, ok := decodeHexByte(value[pos], value[pos+1]); ok && decoded == want { + return pos + 2 + } + } + } + backslashes := 0 + for backslashes < len(value) && value[backslashes] == '\\' { + backslashes++ + } + if backslashes > 0 && len(value) >= backslashes+5 && (value[backslashes] == 'u' || value[backslashes] == 'U') { + hex := value[backslashes+1 : backslashes+5] + if strings.EqualFold(hex[:2], "00") { + if decoded, ok := decodeHexByte(hex[2], hex[3]); ok && decoded == want { + return backslashes + 5 + } + } + } + return 0 +} + +func decodeHexByte(high, low byte) (byte, bool) { + hex := func(ch byte) (byte, bool) { + switch { + case ch >= '0' && ch <= '9': + return ch - '0', true + case ch >= 'a' && ch <= 'f': + return ch - 'a' + 10, true + case ch >= 'A' && ch <= 'F': + return ch - 'A' + 10, true + default: + return 0, false + } + } + hi, ok := hex(high) + if !ok { + return 0, false + } + lo, ok := hex(low) + if !ok { + return 0, false + } + return hi<<4 | lo, true +} + +// Data characters may legally include punctuation such as parentheses and +// backslashes. Stop only at whitespace or an unescaped serialized-string quote. +func isDataURLTerminator(raw string, pos int) bool { + switch raw[pos] { + case ' ', '\t', '\r', '\n': + return true + case '"': + backslashes := 0 + for i := pos - 1; i >= 0 && raw[i] == '\\'; i-- { + backslashes++ + } + return backslashes%2 == 0 + default: + return false + } +} diff --git a/internal/utils/log_sanitize_edge_test.go b/internal/utils/log_sanitize_edge_test.go new file mode 100644 index 00000000..886b841d --- /dev/null +++ b/internal/utils/log_sanitize_edge_test.go @@ -0,0 +1,29 @@ +package utils + +import ( + "strings" + "testing" +) + +func TestCompactImageDataURLForLogRedactsEscapedAndLegalPayloadForms(t *testing.T) { + tests := []struct { + name string + raw string + secret string + }{ + {"encoded leading d", `\u0064ata:image/png;base64,ESCAPEDLEADING`, "ESCAPEDLEADING"}, + {"fully percent encoded prefix", `%64%61%74%61%3Aimage/png;base64,PERCENTPREFIX`, "PERCENTPREFIX"}, + {"escaped payload sequence", `data:image/png;base64,BEFORE\u002bAFTER`, "AFTER"}, + {"parenthesis is legal payload", `data:text/plain,BEFORE(AFTER)SUFFIX next`, "SUFFIX"}, + {"apostrophe is legal payload", `data:image/png;name=foo'bar;base64,APOSTROPHESECRET next`, "APOSTROPHESECRET"}, + {"double percent encoded prefix", `%2564%2561%2574%2561%253Aimage/png;base64,DOUBLEPERCENT`, "DOUBLEPERCENT"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CompactImageDataURLForLog(tt.raw) + if strings.Contains(got, tt.secret) || !strings.Contains(got, dataURLRedaction) { + t.Fatalf("CompactImageDataURLForLog(%q) = %q", tt.raw, got) + } + }) + } +} diff --git a/internal/utils/log_sanitize_test.go b/internal/utils/log_sanitize_test.go new file mode 100644 index 00000000..ba6921c9 --- /dev/null +++ b/internal/utils/log_sanitize_test.go @@ -0,0 +1,29 @@ +package utils + +import ( + "strings" + "testing" +) + +func TestCompactImageDataURLForLogFullyRedactsImages(t *testing.T) { + cases := []string{ + "data:image/png;base64,YWJj", + "DATA:image/png;BASE64," + strings.Repeat("QUJD", 100), + `{"image":"data:image\\/png;base64,TOPSECRET"}`, + "data:image/png,%89PNG%0D%0A", + "data:text/plain,NONIMAGESECRET", + "data%3Aimage%2Fpng%3Bbase64%2CENCODEDSECRET", + `data\u003aimage/png;base64,ESCAPEDSECRET`, + } + for _, raw := range cases { + got := CompactImageDataURLForLog(raw) + if !strings.Contains(got, "[image-data-redacted]") { + t.Fatalf("CompactImageDataURLForLog(%q) = %q", raw, got) + } + for _, secret := range []string{"YWJj", "QUJD", "TOPSECRET", "%89PNG", "NONIMAGESECRET", "ENCODEDSECRET", "ESCAPEDSECRET"} { + if strings.Contains(got, secret) { + t.Fatalf("sanitized output retained %q: %q", secret, got) + } + } + } +} diff --git a/internal/utils/security.go b/internal/utils/security.go index 0857444c..beb845ca 100644 --- a/internal/utils/security.go +++ b/internal/utils/security.go @@ -226,6 +226,14 @@ var restrictedHostSuffixes = []string{ var restrictedIPv4Ranges = []*net.IPNet{ // 100.64.0.0/10 - Carrier-grade NAT (RFC 6598) mustParseCIDR("100.64.0.0/10"), + // 192.31.196.0/24 - AS112-v4 special-purpose service + mustParseCIDR("192.31.196.0/24"), + // 192.52.193.0/24 - AMT special-purpose service + mustParseCIDR("192.52.193.0/24"), + // 192.88.99.0/24 - Deprecated 6to4 relay anycast + mustParseCIDR("192.88.99.0/24"), + // 192.175.48.0/24 - Direct Delegation AS112 service + mustParseCIDR("192.175.48.0/24"), // 198.18.0.0/15 - Network device benchmark testing (RFC 2544) mustParseCIDR("198.18.0.0/15"), // 198.51.100.0/24 - TEST-NET-2 for documentation (RFC 5737) @@ -250,6 +258,22 @@ var restrictedIPv4Ranges = []*net.IPNet{ mustParseCIDR("172.20.0.0/16"), } +// restrictedIPv6Ranges contains special-purpose address space that must not be +// used as an outbound media-fetch destination. These ranges are not ordinary +// public unicast destinations and some can translate or encapsulate IPv4. +var restrictedIPv6Ranges = []*net.IPNet{ + mustParseCIDR("64:ff9b::/96"), + mustParseCIDR("64:ff9b:1::/48"), + mustParseCIDR("100::/64"), + mustParseCIDR("100:0:0:1::/64"), + mustParseCIDR("2001::/23"), + mustParseCIDR("2001:db8::/32"), + mustParseCIDR("2002::/16"), + mustParseCIDR("2620:4f:8000::/48"), + mustParseCIDR("3fff::/20"), + mustParseCIDR("5f00::/16"), +} + // mustParseCIDR parses a CIDR string and panics on error func mustParseCIDR(s string) *net.IPNet { _, ipNet, err := net.ParseCIDR(s) @@ -289,6 +313,19 @@ func isRestrictedIP(ip net.IP) (bool, string) { // Check IPv6-specific restrictions if ip.To4() == nil && len(ip) == 16 { + // Preserve explicit diagnostics for tunneling prefixes before the broader + // IANA special-purpose range table. + if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 { + return true, "Teredo tunneling address" + } + if ip[0] == 0x20 && ip[1] == 0x02 { + return true, "6to4 special-purpose address" + } + for _, cidr := range restrictedIPv6Ranges { + if cidr.Contains(ip) { + return true, fmt.Sprintf("restricted range %s", cidr.String()) + } + } // Site-local (deprecated but still blocked): fec0::/10 if ip[0] == 0xfe && (ip[1]&0xc0) == 0xc0 { return true, "site-local IPv6 address" @@ -304,19 +341,6 @@ func isRestrictedIP(ip net.IP) (bool, string) { return true, fmt.Sprintf("IPv4-mapped %s", reason) } } - // Teredo tunneling addresses: 2001:0000::/32 - // Embed arbitrary IPv4 in the payload; can reach internal hosts via relay. - if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 { - return true, "Teredo tunneling address" - } - // 6to4 addresses: 2002::/16 - // Bits 16-47 carry an IPv4 address; block when embedded IPv4 is restricted. - if ip[0] == 0x20 && ip[1] == 0x02 { - embeddedIP := net.IP(ip[2:6]) - if restricted, reason := isRestrictedIP(embeddedIP); restricted { - return true, fmt.Sprintf("6to4 embedded %s", reason) - } - } } return false, "" diff --git a/internal/utils/security_stage2_test.go b/internal/utils/security_stage2_test.go new file mode 100644 index 00000000..4740e9ad --- /dev/null +++ b/internal/utils/security_stage2_test.go @@ -0,0 +1,25 @@ +package utils + +import "testing" + +func TestIsPublicIPRejectsSpecialPurposeIPv6(t *testing.T) { + for _, raw := range []string{ + "192.31.196.1", + "192.52.193.1", + "192.88.99.1", + "192.175.48.1", + "100::1", + "100:0:0:1::1", + "2001:1::1", + "2001:2::1", + "2001:10::1", + "2001:20::1", + "64:ff9b:1::1", + "2002:0808:0808::1", + "2620:4f:8000::1", + } { + if IsPublicIP(parseIPForTest(t, raw)) { + t.Errorf("IsPublicIP(%s) = true; want false for special-purpose IPv6", raw) + } + } +} diff --git a/internal/utils/security_test.go b/internal/utils/security_test.go index 22fba126..56f4b7ea 100644 --- a/internal/utils/security_test.go +++ b/internal/utils/security_test.go @@ -266,7 +266,7 @@ func TestIsRestrictedIP_IPv6(t *testing.T) { {"site-local", "fec0::1", true, "site-local"}, {"Teredo", "2001:0000:4136:e378:8000:63bf:3fff:fdd2", true, "Teredo"}, {"6to4 private", "2002:c0a8:0101::1", true, "6to4"}, - {"6to4 public", "2002:0808:0808::1", false, ""}, + {"6to4 public endpoint still special-purpose", "2002:0808:0808::1", true, "6to4"}, {"public IPv6", "2001:4860:4860::8888", false, ""}, }