package wire import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "git.flytoex.net/yuanwei/flyto-agent/core/pkg/flyto" ) // --- buildUsageEvent --- func TestBuildUsageEvent_NilUsage(t *testing.T) { chunk := openaiChunk{} // Usage is nil evt := buildUsageEvent(chunk, "end_turn") if evt.StopReason != "end_turn" { t.Errorf("StopReason = %q", evt.StopReason) } if evt.InputTokens != 0 || evt.OutputTokens != 0 { t.Errorf("tokens should be 0 when usage is nil: in=%d out=%d", evt.InputTokens, evt.OutputTokens) } } func TestBuildUsageEvent_WithUsage(t *testing.T) { chunk := openaiChunk{} // openaiChunk.Usage is an embedded anonymous struct -- set it via JSON. raw := `{"usage":{"prompt_tokens":100,"completion_tokens":50,"cache_write_tokens":20}}` if err := json.Unmarshal([]byte(raw), &chunk); err != nil { t.Fatalf("unmarshal: %v", err) } evt := buildUsageEvent(chunk, "tool_use") if evt.InputTokens != 100 { t.Errorf("InputTokens = %d, want 100", evt.InputTokens) } if evt.OutputTokens != 50 { t.Errorf("OutputTokens = %d, want 50", evt.OutputTokens) } if evt.CacheCreationTokens != 20 { t.Errorf("CacheCreationTokens = %d, want 20", evt.CacheCreationTokens) } } func TestBuildUsageEvent_WithCacheRead(t *testing.T) { raw := `{"usage":{"prompt_tokens":200,"completion_tokens":30,"prompt_tokens_details":{"cached_tokens":150}}}` var chunk openaiChunk if err := json.Unmarshal([]byte(raw), &chunk); err != nil { t.Fatalf("unmarshal: %v", err) } evt := buildUsageEvent(chunk, "end_turn") if evt.CacheReadTokens != 150 { t.Errorf("CacheReadTokens = %d, want 150", evt.CacheReadTokens) } } // TestBuildUsageEvent_DeepSeekTopLevelCacheHit covers the DeepSeek // OpenAI-compat fallback path: usage.prompt_cache_hit_tokens at the // top level (NOT prompt_tokens_details.cached_tokens) maps to // CacheReadTokens. This is the wire-side half of the deepseek // provider's SupportsCaching=true claim. // // 覆盖 DeepSeek OpenAI 兼容 fallback 路径: usage.prompt_cache_hit_tokens // 顶级字段 (而非 prompt_tokens_details.cached_tokens 嵌套) 映射到 // CacheReadTokens. 这是 deepseek provider SupportsCaching=true 在 wire // 层的兑现. func TestBuildUsageEvent_DeepSeekTopLevelCacheHit(t *testing.T) { raw := `{"usage":{"prompt_tokens":200,"completion_tokens":30,"prompt_cache_hit_tokens":120,"prompt_cache_miss_tokens":80}}` var chunk openaiChunk if err := json.Unmarshal([]byte(raw), &chunk); err != nil { t.Fatalf("unmarshal: %v", err) } evt := buildUsageEvent(chunk, "end_turn") if evt.CacheReadTokens != 120 { t.Errorf("CacheReadTokens = %d, want 120 (deepseek top-level field)", evt.CacheReadTokens) } } // TestBuildUsageEvent_NestedFieldWinsOverTopLevel guards the fallback // precedence: when both nested OpenAI and top-level DeepSeek fields // are present, the nested value wins (avoids double-count for any // hypothetical provider that populates both). // // 守护 fallback 优先级: 嵌套 OpenAI 字段与顶级 DeepSeek 字段同时存在时, // 嵌套字段优先 (防止假设性双填 provider 重复计数). func TestBuildUsageEvent_NestedFieldWinsOverTopLevel(t *testing.T) { raw := `{"usage":{"prompt_tokens":200,"completion_tokens":30,"prompt_tokens_details":{"cached_tokens":150},"prompt_cache_hit_tokens":999}}` var chunk openaiChunk if err := json.Unmarshal([]byte(raw), &chunk); err != nil { t.Fatalf("unmarshal: %v", err) } evt := buildUsageEvent(chunk, "end_turn") if evt.CacheReadTokens != 150 { t.Errorf("CacheReadTokens = %d, want 150 (nested wins over top-level)", evt.CacheReadTokens) } } // --- flytoMessagesToOpenAI --- func TestFlytoMessagesToOpenAI_SystemPrompt(t *testing.T) { msgs := []flyto.Message{ flyto.UserText("hello"), } result := flytoMessagesToOpenAI(msgs, "You are helpful.", false, "") if len(result) < 2 { t.Fatalf("expected at least 2 messages (system + user), got %d", len(result)) } if result[0].Role != "system" { t.Errorf("first message role = %q, want system", result[0].Role) } // system prompt content should contain "You are helpful." var content string if err := json.Unmarshal(result[0].Content, &content); err == nil { if content != "You are helpful." { t.Errorf("system content = %q", content) } } } func TestFlytoMessagesToOpenAI_CacheSystem(t *testing.T) { result := flytoMessagesToOpenAI(nil, "cached system", true, "") if len(result) == 0 { t.Fatal("expected system message") } // When cacheSystem=true, content is an array with cache_control. var blocks []json.RawMessage if err := json.Unmarshal(result[0].Content, &blocks); err != nil { t.Fatalf("cacheSystem content should be array: %v", err) } if len(blocks) == 0 { t.Fatal("cache blocks empty") } } func TestFlytoMessagesToOpenAI_NoSystemPrompt(t *testing.T) { msgs := []flyto.Message{ flyto.UserText("hi"), flyto.AssistantText("hello"), } result := flytoMessagesToOpenAI(msgs, "", false, "") if len(result) != 2 { t.Fatalf("expected 2 messages, got %d", len(result)) } if result[0].Role != "user" { t.Errorf("first role = %q, want user", result[0].Role) } if result[1].Role != "assistant" { t.Errorf("second role = %q, want assistant", result[1].Role) } } // TestFlytoMessagesToOpenAI_ToolCallIDDedupe covers Bug W (r25 实证): // 模型在 final response 内重复 emit 同一 tool_use block, DeepSeek // 严协议 reject "Duplicate value for tool_call_id ... in message[N]". // wire 层 transport-level dedup 让所有 provider 路径行为一致. // // 覆盖 Bug W (r25 实证): 单 message 内同 tool_use_id 重复出现时, wire // 层 transport 防御性去重, 防止 DeepSeek 严协议 4xx 同时让 OpenAI/ // Anthropic 松路径行为一致. func TestFlytoMessagesToOpenAI_ToolCallIDDedupe(t *testing.T) { msgs := []flyto.Message{ { Role: flyto.RoleAssistant, Blocks: []flyto.Block{ flyto.ToolUseBlock("call_dup", "Bash", map[string]any{"cmd": "ls"}), flyto.TextBlock("Let me run this."), flyto.ToolUseBlock("call_dup", "Bash", map[string]any{"cmd": "ls"}), }, }, } result := flytoMessagesToOpenAI(msgs, "", false, "") if len(result) != 1 { t.Fatalf("expected 1 message, got %d", len(result)) } if len(result[0].ToolCalls) != 1 { t.Errorf("ToolCalls len = %d, want 1 (dedup'd)", len(result[0].ToolCalls)) } if result[0].ToolCalls[0].ID != "call_dup" { t.Errorf("ToolCalls[0].ID = %q, want call_dup", result[0].ToolCalls[0].ID) } } // TestFlytoMessagesToOpenAI_DistinctToolCallIDs verifies dedup does NOT // reject distinct IDs in the same message (e.g. parallel tool calls). // // 覆盖去重不误伤: 同 message 内不同 tool_use_id (并行工具调用) 全保留. func TestFlytoMessagesToOpenAI_DistinctToolCallIDs(t *testing.T) { msgs := []flyto.Message{ { Role: flyto.RoleAssistant, Blocks: []flyto.Block{ flyto.ToolUseBlock("call_1", "Bash", map[string]any{"cmd": "ls"}), flyto.ToolUseBlock("call_2", "Read", map[string]any{"path": "/tmp"}), }, }, } result := flytoMessagesToOpenAI(msgs, "", false, "") if len(result[0].ToolCalls) != 2 { t.Errorf("ToolCalls len = %d, want 2 (parallel calls)", len(result[0].ToolCalls)) } } // TestFlytoMessagesToOpenAI_ReasoningPassback_StringMode covers ADR-0007 // capability-aware passback: mode "string" injects prior assistant // turn's BlockThinking into reasoning_content field (DeepSeek-R1 // protocol). // // 覆盖 ADR-0007 capability-aware passback: mode="string" 时 inject // reasoning_content (DeepSeek-R1 协议). func TestFlytoMessagesToOpenAI_ReasoningPassback_StringMode(t *testing.T) { msgs := []flyto.Message{ { Role: flyto.RoleAssistant, Blocks: []flyto.Block{ flyto.ThinkingBlock("First thought.", nil), flyto.ThinkingBlock("Then refined it.", nil), flyto.TextBlock("Final answer."), }, }, } result := flytoMessagesToOpenAI(msgs, "", false, "string") if len(result) != 1 { t.Fatalf("expected 1 message, got %d", len(result)) } want := "First thought.\nThen refined it." if result[0].ReasoningContent != want { t.Errorf("ReasoningContent = %q, want %q", result[0].ReasoningContent, want) } } // TestFlytoMessagesToOpenAI_ReasoningPassback_NoneMode verifies mode // "none" / "" / "details_array" all skip reasoning_content injection // (zero-regression for OpenAI o1/o3, current default behavior). // // 覆盖 mode="none"/""/" details_array" 都跳过 inject (零回归). func TestFlytoMessagesToOpenAI_ReasoningPassback_NoneMode(t *testing.T) { msgs := []flyto.Message{ { Role: flyto.RoleAssistant, Blocks: []flyto.Block{ flyto.ThinkingBlock("Server-managed state.", nil), flyto.TextBlock("Answer."), }, }, } for _, mode := range []string{"", "none", "details_array"} { result := flytoMessagesToOpenAI(msgs, "", false, mode) if result[0].ReasoningContent != "" { t.Errorf("mode=%q: ReasoningContent = %q, want empty (no inject)", mode, result[0].ReasoningContent) } } } // TestFlytoMessagesToOpenAI_EmptyToolUseID verifies blocks with empty // ToolUseID are not deduped (defensive: empty-key map collision would // silently drop legit blocks). // // 覆盖空 ToolUseID 不去重: 防止空键 map collision 误吞合法块. func TestFlytoMessagesToOpenAI_EmptyToolUseID(t *testing.T) { msgs := []flyto.Message{ { Role: flyto.RoleAssistant, Blocks: []flyto.Block{ flyto.ToolUseBlock("", "Bash", map[string]any{"cmd": "ls"}), flyto.ToolUseBlock("", "Read", map[string]any{"path": "/tmp"}), }, }, } result := flytoMessagesToOpenAI(msgs, "", false, "") if len(result[0].ToolCalls) != 2 { t.Errorf("ToolCalls len = %d, want 2 (empty IDs not deduped)", len(result[0].ToolCalls)) } } func TestFlytoMessagesToOpenAI_ToolUseAndResult(t *testing.T) { msgs := []flyto.Message{ { Role: flyto.RoleAssistant, Blocks: []flyto.Block{ flyto.ToolUseBlock("call_1", "Bash", map[string]any{"command": "ls"}), }, }, { Role: flyto.RoleUser, Blocks: []flyto.Block{ flyto.ToolResultBlock("call_1", "file1.go\nfile2.go", false), }, }, } result := flytoMessagesToOpenAI(msgs, "", false, "") // assistant message should have tool_calls if len(result) < 2 { t.Fatalf("expected at least 2 messages, got %d", len(result)) } if len(result[0].ToolCalls) == 0 { t.Error("assistant message should have tool_calls") } if result[0].ToolCalls[0].ID != "call_1" { t.Errorf("tool call ID = %q", result[0].ToolCalls[0].ID) } // tool result message if result[1].ToolCallID != "call_1" { t.Errorf("tool result ToolCallID = %q", result[1].ToolCallID) } } // --- FetchOpenRouterModels ADR-0007 扩展 --- // TestFetchOpenRouterModels_LiveMetadataExpanded covers ADR-0007 C5: // OpenRouter live API metadata fields (architecture.input_modalities / // top_provider.max_completion_tokens / pricing.input_cache_read / // supported_parameters) auto-mapped to ModelInfo capability fields. // Fixture mirrors deepseek-v4-flash 实证返回结构 (r24 调研 agent 抓取). // // 覆盖 ADR-0007 C5: live API 字段自动映射到 ModelInfo 各 capability. // fixture 镜像 r24 实证 deepseek-v4-flash 返回结构. func TestFetchOpenRouterModels_LiveMetadataExpanded(t *testing.T) { // r24 实证 fixture: deepseek-v4-flash + 一条 vision 模型作对照. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "data": [ { "id": "deepseek/deepseek-v4-flash", "name": "DeepSeek V4 Flash", "context_length": 1048576, "max_completion_tokens": 1048576, "pricing": {"prompt": "0.000001", "completion": "0.000002", "input_cache_read": "0.0000005"}, "supported_parameters": ["temperature", "top_p", "tools", "reasoning"], "architecture": {"input_modalities": ["text"]}, "top_provider": {"max_completion_tokens": 384000} }, { "id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6", "context_length": 200000, "max_completion_tokens": 64000, "pricing": {"prompt": "0.000003", "completion": "0.000015", "input_cache_read": "0"}, "supported_parameters": ["temperature", "top_p", "tools"], "architecture": {"input_modalities": ["text", "image"]}, "top_provider": {"max_completion_tokens": 64000} } ] }`)) })) defer srv.Close() c := NewOpenAICompatClient("test-key", srv.URL) models, err := c.FetchOpenRouterModels(context.Background()) if err != nil { t.Fatalf("FetchOpenRouterModels: %v", err) } if len(models) != 2 { t.Fatalf("expected 2 models, got %d", len(models)) } // deepseek-v4-flash: top_provider.max_completion_tokens 384000 优先 // 于 root 1048576 (实际后端上界). deepseek := models[0] if deepseek.MaxOutputTokens != 384000 { t.Errorf("MaxOutputTokens = %d, want 384000 (top_provider override)", deepseek.MaxOutputTokens) } if !deepseek.SupportsThinking { t.Error("SupportsThinking = false, want true (supported_parameters has 'reasoning')") } if !deepseek.SupportsCaching { t.Error("SupportsCaching = false, want true (input_cache_read='0.0000005' non-zero)") } if deepseek.SupportsVision { t.Error("SupportsVision = true, want false (input_modalities=['text'])") } if deepseek.ProviderKind != "aggregator" { t.Errorf("ProviderKind = %q, want aggregator", deepseek.ProviderKind) } if deepseek.ToolNameRegex != `^[a-zA-Z0-9_-]+$` { t.Errorf("ToolNameRegex = %q, want OpenAI-compat default", deepseek.ToolNameRegex) } // claude-sonnet-4.6: input_cache_read=="0" -> SupportsCaching=false; // input_modalities 含 image -> SupportsVision=true. claude := models[1] if claude.SupportsCaching { t.Error("SupportsCaching = true, want false (input_cache_read='0')") } if !claude.SupportsVision { t.Error("SupportsVision = false, want true (input_modalities has 'image')") } } // --- parseNonSSEError --- func TestParseNonSSEError_OpenAIFormat(t *testing.T) { body := []byte(`{"error":{"message":"Invalid API key","type":"auth_error"}}`) err := parseNonSSEError(body, "application/json") if err == nil { t.Fatal("expected error") } if got := err.Error(); !strContains(got, "Invalid API key") { t.Errorf("error = %q, should contain 'Invalid API key'", got) } } func TestParseNonSSEError_MiniMaxBaseResp(t *testing.T) { body := []byte(`{"base_resp":{"status_code":1004,"status_msg":"rate limit exceeded"}}`) err := parseNonSSEError(body, "application/json") if err == nil { t.Fatal("expected error") } if got := err.Error(); !strContains(got, "1004") || !strContains(got, "rate limit") { t.Errorf("error = %q, should contain status code and message", got) } } func TestParseNonSSEError_NoError(t *testing.T) { body := []byte(`{"ok":true}`) err := parseNonSSEError(body, "application/json") // No error fields present -- should fall through. // The function returns nil or a generic error depending on implementation. _ = err // no crash is the main check } func TestParseNonSSEError_InvalidJSON(t *testing.T) { body := []byte(`not json at all`) err := parseNonSSEError(body, "text/plain") // Should not crash on invalid JSON. _ = err } // TestParseNonSSEError_OpenRouterNestedRaw_SiliconFlow covers the // real r22 (Bug U) failure mode: OpenRouter wraps the underlying // SiliconFlow HTTP 400 inside error.metadata.raw with a generic // top-level message "Provider returned error". Without unwrapping the // raw branch, the operator only sees the wrapper text and the actual // problem (tool name regex violation) is silenced. // // 覆盖 r22 (Bug U) 真实 failure: OpenRouter 把底层 SiliconFlow HTTP 400 // 塞进 error.metadata.raw, 顶层 message 是泛化 "Provider returned error". // 不解 raw 分支调用方看不到真错 (tool name regex 违反). func TestParseNonSSEError_OpenRouterNestedRaw_SiliconFlow(t *testing.T) { // r22 实证 body (字段排列与 OpenRouter 真实返回一致). body := []byte(`{"error":{"message":"Provider returned error","code":400,"metadata":{"raw":"{\"code\":20015,\"message\":\"Invalid 'tools[0].function.name': string does not match pattern. Expected a string that matches the pattern '^[a-zA-Z0-9_-]+$'.\",\"data\":null}","provider_name":"SiliconFlow","is_byok":false}}}`) err := parseNonSSEError(body, "application/json") if err == nil { t.Fatal("expected error") } got := err.Error() if !strContains(got, "SiliconFlow") { t.Errorf("error = %q, want provider name in surface", got) } if !strContains(got, "20015") || !strContains(got, "tools[0].function.name") { t.Errorf("error = %q, want underlying code + message surfaced", got) } // Generic wrapper message must NOT be the surfaced text. // 通用 wrapper 消息必须**不是**最终上抛的文本. if strContains(got, "Provider returned error") { t.Errorf("error = %q, raw nested branch should override generic wrapper", got) } } // TestParseNonSSEError_OpenRouterNestedRaw_OpenAIShape covers the // other common nested-raw shape: OpenRouter forwarding an OpenAI-style // {"error":{"message":...,"type":...}} payload from the underlying // provider. // // 覆盖嵌套 raw 另一常见形态: OpenRouter 转发底层 OpenAI 形态 // {"error":{"message":...,"type":...}}. func TestParseNonSSEError_OpenRouterNestedRaw_OpenAIShape(t *testing.T) { body := []byte(`{"error":{"message":"Provider returned error","metadata":{"raw":"{\"error\":{\"message\":\"context_length_exceeded\",\"type\":\"invalid_request_error\"}}","provider_name":"OpenAI"}}}`) err := parseNonSSEError(body, "application/json") if err == nil { t.Fatal("expected error") } got := err.Error() if !strContains(got, "context_length_exceeded") { t.Errorf("error = %q, want underlying message surfaced", got) } if !strContains(got, "OpenAI") { t.Errorf("error = %q, want provider name surfaced", got) } } // TestParseNonSSEError_OpenRouterNestedRaw_UnrecognizedFallback covers // the fallback path: when metadata.raw is present but in an unknown // shape, we must not silently swallow the wrapper -- fall back to the // top-level message (still better than no error at all). // // 覆盖兜底路径: metadata.raw 存在但形态未知时, 不能静默吞掉 wrapper -- // 回退到顶层 message (仍比无错好). func TestParseNonSSEError_OpenRouterNestedRaw_UnrecognizedFallback(t *testing.T) { body := []byte(`{"error":{"message":"Provider returned error","metadata":{"raw":"this is not json"}}}`) err := parseNonSSEError(body, "application/json") if err == nil { t.Fatal("expected error") } got := err.Error() if !strContains(got, "Provider returned error") { t.Errorf("error = %q, want fallback to top-level message when raw unparseable", got) } } // --- parseOpenRouterPrice --- func TestParseOpenRouterPrice_Normal(t *testing.T) { // OpenRouter prices are per-token; we multiply by 1M to get per-1M-tokens. got := parseOpenRouterPrice("0.000003") // $3 per 1M if got < 2.99 || got > 3.01 { t.Errorf("parseOpenRouterPrice(0.000003) = %f, want ~3.0", got) } } func TestParseOpenRouterPrice_Zero(t *testing.T) { if got := parseOpenRouterPrice("0"); got != 0 { t.Errorf("got %f, want 0", got) } if got := parseOpenRouterPrice(""); got != 0 { t.Errorf("empty string: got %f, want 0", got) } } func TestParseOpenRouterPrice_NA(t *testing.T) { // OpenRouter sometimes returns "N/A". got := parseOpenRouterPrice("N/A") if got != 0 { t.Errorf("N/A: got %f, want 0", got) } } // strContains is a test helper (avoids importing strings for one function). // Named to avoid conflict with schema_test.go's contains(). func strContains(s, sub string) bool { for i := 0; i <= len(s)-len(sub); i++ { if s[i:i+len(sub)] == sub { return true } } return false }