feat(dingtalk): enable secure multimodal image QA
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -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)
|
||||
@@ -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。
|
||||
@@ -57,7 +57,7 @@ export function buildWkcModelConfig(
|
||||
truncate_prompt_tokens: 0,
|
||||
}
|
||||
}
|
||||
if (kind === 'vllm') {
|
||||
if (kind === 'chat' || kind === 'vllm') {
|
||||
parameters.supports_vision = true
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+346
-37
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+165
-29
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -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 的增量更新
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+121
-14
@@ -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] + "...<omitted " + strconv.Itoa(hidden) + " chars>"
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-13
@@ -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, ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ""},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user