diff --git a/README.md b/README.md index 214fe6009..3b0228eb1 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB - Amp CLI and IDE extensions support with provider routing - Streaming and non-streaming responses - Function calling/tools support +- Google Search grounding, code execution, and URL context tool passthrough - Multimodal input support (text and images) - Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Qwen and iFlow) - Simple CLI authentication flows (Gemini, OpenAI, Claude, Qwen and iFlow) @@ -76,6 +77,89 @@ CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and A **→ [Complete Amp CLI Integration Guide](https://help.router-for.me/agent-client/amp-cli.html)** +## Google Search Grounding + +Gemini models support [Google Search grounding](https://ai.google.dev/gemini-api/docs/google-search) which lets the model search the web before responding. CLIProxyAPI passes through `google_search`, `code_execution`, and `url_context` tools across all API formats (OpenAI Chat Completions, OpenAI Responses, and Claude Messages). + +### Enabling Google Search + +Include `{"google_search": {}}` in your `tools` array. Works with all three API formats: + +**OpenAI Chat Completions:** +```bash +curl http://localhost:8080/v1/chat/completions \ + -H "Authorization: Bearer YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gemini-2.5-flash", + "messages": [{"role": "user", "content": "What happened in the news today?"}], + "tools": [{"google_search": {}}] + }' +``` + +**Claude Messages:** +```bash +curl http://localhost:8080/v1/messages \ + -H "x-api-key: YOUR_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gemini-2.5-flash", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "What happened in the news today?"}], + "tools": [{"google_search": {}}] + }' +``` + +### Grounding Metadata in Responses + +When Google Search is used, the upstream Gemini response includes `groundingMetadata` with search queries, source URLs, and text-to-source mappings. CLIProxyAPI passes this through as a `grounding_metadata` field on the response: + +**OpenAI Chat Completions** — on each choice object (`choices[i].grounding_metadata`): +```json +{ + "choices": [{ + "message": {"role": "assistant", "content": "..."}, + "finish_reason": "stop", + "grounding_metadata": { + "webSearchQueries": ["query used by the model"], + "groundingChunks": [{"web": {"uri": "https://...", "title": "...", "domain": "..."}}], + "groundingSupports": [{"segment": {"text": "...", "startIndex": 0, "endIndex": 100}, "groundingChunkIndices": [0]}], + "searchEntryPoint": {"renderedContent": ""}, + "retrievalMetadata": {} + } + }] +} +``` + +**Claude Messages** — on the top-level response object (`grounding_metadata`): +```json +{ + "type": "message", + "content": [{"type": "text", "text": "..."}], + "grounding_metadata": { + "webSearchQueries": ["..."], + "groundingChunks": [{"web": {"uri": "...", "title": "..."}}], + "groundingSupports": [...] + } +} +``` + +**OpenAI Responses API** — on the response object (`grounding_metadata`): +```json +{ + "id": "resp_...", + "output": [...], + "grounding_metadata": { + "webSearchQueries": ["..."], + "groundingChunks": [{"web": {"uri": "...", "title": "..."}}], + "groundingSupports": [...] + } +} +``` + +> **Note:** `grounding_metadata` is a passthrough of Google's raw `groundingMetadata` object. The field is additive and will be silently ignored by standard OpenAI/Claude SDKs. See the [Gemini grounding docs](https://ai.google.dev/gemini-api/docs/google-search) for the full schema. + ## SDK Docs - Usage: [docs/sdk-usage.md](docs/sdk-usage.md) diff --git a/README_CN.md b/README_CN.md index b7c45df72..d448e4002 100644 --- a/README_CN.md +++ b/README_CN.md @@ -43,6 +43,7 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元 - 新增 iFlow 支持(OAuth 登录) - 支持流式与非流式响应 - 函数调用/工具支持 +- Google 搜索 grounding、代码执行、URL 上下文工具透传 - 多模态输入(文本、图片) - 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude、Qwen 与 iFlow) - 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude、Qwen 与 iFlow) @@ -75,6 +76,89 @@ CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支 **→ [Amp CLI 完整集成指南](https://help.router-for.me/cn/agent-client/amp-cli.html)** +## Google 搜索 Grounding + +Gemini 模型支持 [Google 搜索 grounding](https://ai.google.dev/gemini-api/docs/google-search),允许模型在回答前搜索网络。CLIProxyAPI 在所有 API 格式(OpenAI Chat Completions、OpenAI Responses 和 Claude Messages)中透传 `google_search`、`code_execution` 和 `url_context` 工具。 + +### 启用 Google 搜索 + +在 `tools` 数组中加入 `{"google_search": {}}`,适用于所有三种 API 格式: + +**OpenAI Chat Completions:** +```bash +curl http://localhost:8080/v1/chat/completions \ + -H "Authorization: Bearer YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gemini-2.5-flash", + "messages": [{"role": "user", "content": "今天有什么新闻?"}], + "tools": [{"google_search": {}}] + }' +``` + +**Claude Messages:** +```bash +curl http://localhost:8080/v1/messages \ + -H "x-api-key: YOUR_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gemini-2.5-flash", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "今天有什么新闻?"}], + "tools": [{"google_search": {}}] + }' +``` + +### 响应中的 Grounding 元数据 + +当使用 Google 搜索时,上游 Gemini 响应包含 `groundingMetadata`(搜索查询、来源 URL、文本与来源的映射关系)。CLIProxyAPI 将其作为 `grounding_metadata` 字段透传到响应中: + +**OpenAI Chat Completions** — 位于每个 choice 对象上 (`choices[i].grounding_metadata`): +```json +{ + "choices": [{ + "message": {"role": "assistant", "content": "..."}, + "finish_reason": "stop", + "grounding_metadata": { + "webSearchQueries": ["模型使用的搜索查询"], + "groundingChunks": [{"web": {"uri": "https://...", "title": "...", "domain": "..."}}], + "groundingSupports": [{"segment": {"text": "...", "startIndex": 0, "endIndex": 100}, "groundingChunkIndices": [0]}], + "searchEntryPoint": {"renderedContent": ""}, + "retrievalMetadata": {} + } + }] +} +``` + +**Claude Messages** — 位于顶层响应对象上 (`grounding_metadata`): +```json +{ + "type": "message", + "content": [{"type": "text", "text": "..."}], + "grounding_metadata": { + "webSearchQueries": ["..."], + "groundingChunks": [{"web": {"uri": "...", "title": "..."}}], + "groundingSupports": [...] + } +} +``` + +**OpenAI Responses API** — 位于响应对象上 (`grounding_metadata`): +```json +{ + "id": "resp_...", + "output": [...], + "grounding_metadata": { + "webSearchQueries": ["..."], + "groundingChunks": [{"web": {"uri": "...", "title": "..."}}], + "groundingSupports": [...] + } +} +``` + +> **注意:** `grounding_metadata` 是 Google 原始 `groundingMetadata` 对象的直接透传。该字段是附加性的,标准 OpenAI/Claude SDK 会静默忽略它。完整 schema 请参阅 [Gemini grounding 文档](https://ai.google.dev/gemini-api/docs/google-search)。 + ## SDK 文档 - 使用文档:[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 65ad2b191..3d07badd4 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -310,15 +310,33 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } // tools - toolsJSON := "" toolDeclCount := 0 allowedToolKeys := []string{"name", "description", "behavior", "parameters", "parametersJsonSchema", "response", "responseJsonSchema"} + functionToolNode := `{}` + var extraToolNodes []string toolsResult := gjson.GetBytes(rawJSON, "tools") if toolsResult.IsArray() { - toolsJSON = `[{"functionDeclarations":[]}]` toolsResults := toolsResult.Array() for i := 0; i < len(toolsResults); i++ { toolResult := toolsResults[i] + // Handle google_search passthrough + if gs := toolResult.Get("google_search"); gs.Exists() { + node, _ := sjson.SetRaw(`{}`, "googleSearch", gs.Raw) + extraToolNodes = append(extraToolNodes, node) + continue + } + // Handle code_execution passthrough + if ce := toolResult.Get("code_execution"); ce.Exists() { + node, _ := sjson.SetRaw(`{}`, "codeExecution", ce.Raw) + extraToolNodes = append(extraToolNodes, node) + continue + } + // Handle url_context passthrough + if uc := toolResult.Get("url_context"); uc.Exists() { + node, _ := sjson.SetRaw(`{}`, "urlContext", uc.Raw) + extraToolNodes = append(extraToolNodes, node) + continue + } inputSchemaResult := toolResult.Get("input_schema") if inputSchemaResult.Exists() && inputSchemaResult.IsObject() { // Sanitize the input schema for Antigravity API compatibility @@ -331,18 +349,32 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } tool, _ = sjson.Delete(tool, toolKey) } - toolsJSON, _ = sjson.SetRaw(toolsJSON, "0.functionDeclarations.-1", tool) + if toolDeclCount == 0 { + functionToolNode, _ = sjson.SetRaw(functionToolNode, "functionDeclarations", `[]`) + } + functionToolNode, _ = sjson.SetRaw(functionToolNode, "functionDeclarations.-1", tool) toolDeclCount++ } } } + toolsJSON := "" + if toolDeclCount > 0 || len(extraToolNodes) > 0 { + toolsNode := `[]` + if toolDeclCount > 0 { + toolsNode, _ = sjson.SetRaw(toolsNode, "-1", functionToolNode) + } + for _, node := range extraToolNodes { + toolsNode, _ = sjson.SetRaw(toolsNode, "-1", node) + } + toolsJSON = toolsNode + } // Build output Gemini CLI request JSON out := `{"model":"","request":{"contents":[]}}` out, _ = sjson.Set(out, "model", modelName) // Inject interleaved thinking hint when both tools and thinking are active - hasTools := toolDeclCount > 0 + hasTools := toolDeclCount > 0 || len(extraToolNodes) > 0 thinkingResult := gjson.GetBytes(rawJSON, "thinking") thinkingType := thinkingResult.Get("type").String() hasThinking := thinkingResult.Exists() && thinkingResult.IsObject() && (thinkingType == "enabled" || thinkingType == "adaptive") @@ -372,7 +404,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ if hasContents { out, _ = sjson.SetRaw(out, "request.contents", contentsJSON) } - if toolDeclCount > 0 { + if toolDeclCount > 0 || len(extraToolNodes) > 0 { out, _ = sjson.SetRaw(out, "request.tools", toolsJSON) } diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go index 3c834f6f2..c7c8113f5 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -39,6 +39,7 @@ type Params struct { HasSentFinalEvents bool // Indicates if final content/message events have been sent HasToolUse bool // Indicates if tool use was observed in the stream HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output + GroundingMetadataRaw string // Cached compact groundingMetadata JSON for the message_delta event // Signature caching support CurrentThinkingText strings.Builder // Accumulates thinking text for signature caching @@ -291,6 +292,11 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq } } + // Cache groundingMetadata for inclusion in the message_delta event. + if gm := gjson.GetBytes(rawJSON, "response.candidates.0.groundingMetadata"); gm.Exists() { + params.GroundingMetadataRaw = strings.ReplaceAll(strings.ReplaceAll(gm.Raw, "\n", ""), "\r", "") + } + if params.HasUsageMetadata && params.HasFinishReason { appendFinalEvents(params, &output, false) } @@ -339,6 +345,9 @@ func appendFinalEvents(params *Params, output *string, force bool) { log.Warnf("antigravity claude response: failed to set cache_read_input_tokens: %v", err) } } + if params.GroundingMetadataRaw != "" { + delta, _ = sjson.SetRaw(delta, "grounding_metadata", params.GroundingMetadataRaw) + } *output = *output + delta + "\n\n\n" params.HasSentFinalEvents = true @@ -515,6 +524,11 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or } } + // Extract and attach groundingMetadata if present. + if gm := root.Get("response.candidates.0.groundingMetadata"); gm.Exists() { + responseJSON, _ = sjson.SetRaw(responseJSON, "grounding_metadata", gm.Raw) + } + return responseJSON } diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go index af9ffef19..11b646f14 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go @@ -216,6 +216,11 @@ func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalReq template, _ = sjson.Set(template, "choices.0.native_finish_reason", strings.ToLower(upstreamFinishReason)) } + // Extract and attach groundingMetadata if present (compact to prevent SSE newline framing issues). + if gm := gjson.GetBytes(rawJSON, "response.candidates.0.groundingMetadata"); gm.Exists() { + template, _ = sjson.SetRaw(template, "choices.0.grounding_metadata", strings.ReplaceAll(strings.ReplaceAll(gm.Raw, "\n", ""), "\r", "")) + } + return []string{template} } diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index ea53da054..77d1b3dfa 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -333,6 +333,9 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream anthropicTools = append(anthropicTools, gjson.Parse(anthropicTool).Value()) return true }) + } else if toolType := tool.Get("type").String(); toolType != "" { + // Pass through Claude built-in tools (web_search_20250305, code_execution, etc.) as-is + anthropicTools = append(anthropicTools, tool.Value()) } return true }) diff --git a/internal/translator/claude/gemini/claude_gemini_response.go b/internal/translator/claude/gemini/claude_gemini_response.go index c38f8ae78..15590fce3 100644 --- a/internal/translator/claude/gemini/claude_gemini_response.go +++ b/internal/translator/claude/gemini/claude_gemini_response.go @@ -37,6 +37,10 @@ type ConvertAnthropicResponseToGeminiParams struct { // Keyed by content_block index from Claude SSE events ToolUseNames map[int]string // function/tool name per block index ToolUseArgs map[int]*strings.Builder // accumulates partial_json across deltas + + // Web search citations passthrough + WebSearchResultsRaw []string + CitationsRaw []string } // ConvertClaudeResponseToGemini converts Claude Code streaming response format to Gemini format. @@ -97,12 +101,17 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original (*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID = message.Get("id").String() (*param).(*ConvertAnthropicResponseToGeminiParams).Model = message.Get("model").String() } + // Reset web search accumulators for new message + (*param).(*ConvertAnthropicResponseToGeminiParams).WebSearchResultsRaw = nil + (*param).(*ConvertAnthropicResponseToGeminiParams).CitationsRaw = nil return []string{} case "content_block_start": // Start of a content block - record tool_use name by index for functionCall assembly if cb := root.Get("content_block"); cb.Exists() { - if cb.Get("type").String() == "tool_use" { + blockType := cb.Get("type").String() + switch blockType { + case "tool_use": idx := int(root.Get("index").Int()) if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames == nil { (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames = map[int]string{} @@ -110,6 +119,18 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original if name := cb.Get("name"); name.Exists() { (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames[idx] = name.String() } + case "web_search_tool_result": + if content := cb.Get("content"); content.Exists() && content.IsArray() { + for _, result := range content.Array() { + if result.Get("type").String() == "web_search_result" { + compacted := strings.ReplaceAll(strings.ReplaceAll(result.Raw, "\n", ""), "\r", "") + (*param).(*ConvertAnthropicResponseToGeminiParams).WebSearchResultsRaw = append( + (*param).(*ConvertAnthropicResponseToGeminiParams).WebSearchResultsRaw, compacted) + } + } + } + case "server_tool_use": + // silently skip } } return []string{} @@ -150,6 +171,14 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original b.WriteString(pj.String()) } return []string{} + case "citations_delta": + // Citation from web_search - accumulate for passthrough + if citation := delta.Get("citation"); citation.Exists() { + compacted := strings.ReplaceAll(strings.ReplaceAll(citation.Raw, "\n", ""), "\r", "") + (*param).(*ConvertAnthropicResponseToGeminiParams).CitationsRaw = append( + (*param).(*ConvertAnthropicResponseToGeminiParams).CitationsRaw, compacted) + } + return []string{} } } return []string{template} @@ -241,6 +270,25 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original } template, _ = sjson.Set(template, "candidates.0.finishReason", "STOP") + // Attach accumulated web search results and citations as groundingMetadata + params := (*param).(*ConvertAnthropicResponseToGeminiParams) + if len(params.WebSearchResultsRaw) > 0 || len(params.CitationsRaw) > 0 { + gm := `{}` + if len(params.WebSearchResultsRaw) > 0 { + gm, _ = sjson.SetRaw(gm, "webSearchResults", "[]") + for _, raw := range params.WebSearchResultsRaw { + gm, _ = sjson.SetRaw(gm, "webSearchResults.-1", raw) + } + } + if len(params.CitationsRaw) > 0 { + gm, _ = sjson.SetRaw(gm, "citations", "[]") + for _, raw := range params.CitationsRaw { + gm, _ = sjson.SetRaw(gm, "citations.-1", raw) + } + } + template, _ = sjson.SetRaw(template, "candidates.0.groundingMetadata", gm) + } + return []string{template} case "message_stop": // Final message with usage information - no additional output needed @@ -315,6 +363,8 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, var finalUsageJSON string var responseID string var createdAt int64 + var webSearchResultsRaw []string + var citationsRaw []string for _, eventData := range streamingEvents { if len(eventData) == 0 { @@ -341,14 +391,28 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, // Prepare for content block; record tool_use name by index for later functionCall assembly idx := int(root.Get("index").Int()) if cb := root.Get("content_block"); cb.Exists() { - if cb.Get("type").String() == "tool_use" { + blockType := cb.Get("type").String() + switch blockType { + case "tool_use": if newParam.ToolUseNames == nil { newParam.ToolUseNames = map[int]string{} } if name := cb.Get("name"); name.Exists() { newParam.ToolUseNames[idx] = name.String() } + case "web_search_tool_result": + if content := cb.Get("content"); content.Exists() && content.IsArray() { + for _, result := range content.Array() { + if result.Get("type").String() == "web_search_result" { + compacted := strings.ReplaceAll(strings.ReplaceAll(result.Raw, "\n", ""), "\r", "") + webSearchResultsRaw = append(webSearchResultsRaw, compacted) + } + } + } + case "server_tool_use": + // silently skip } + _ = idx } continue @@ -383,6 +447,11 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, if pj := delta.Get("partial_json"); pj.Exists() { newParam.ToolUseArgs[idx].WriteString(pj.String()) } + case "citations_delta": + if citation := delta.Get("citation"); citation.Exists() { + compacted := strings.ReplaceAll(strings.ReplaceAll(citation.Raw, "\n", ""), "\r", "") + citationsRaw = append(citationsRaw, compacted) + } } } @@ -482,6 +551,24 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, template, _ = sjson.SetRaw(template, "usageMetadata", finalUsageJSON) } + // Attach web search results and citations as groundingMetadata + if len(webSearchResultsRaw) > 0 || len(citationsRaw) > 0 { + gm := `{}` + if len(webSearchResultsRaw) > 0 { + gm, _ = sjson.SetRaw(gm, "webSearchResults", "[]") + for _, raw := range webSearchResultsRaw { + gm, _ = sjson.SetRaw(gm, "webSearchResults.-1", raw) + } + } + if len(citationsRaw) > 0 { + gm, _ = sjson.SetRaw(gm, "citations", "[]") + for _, raw := range citationsRaw { + gm, _ = sjson.SetRaw(gm, "citations.-1", raw) + } + } + template, _ = sjson.SetRaw(template, "candidates.0.groundingMetadata", gm) + } + return template } diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index 3cad18825..735214df4 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -263,7 +263,8 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream if tools := root.Get("tools"); tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 { hasAnthropicTools := false tools.ForEach(func(_, tool gjson.Result) bool { - if tool.Get("type").String() == "function" { + toolType := tool.Get("type").String() + if toolType == "function" { function := tool.Get("function") anthropicTool := `{"name":"","description":""}` anthropicTool, _ = sjson.Set(anthropicTool, "name", function.Get("name").String()) @@ -278,6 +279,10 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream out, _ = sjson.SetRaw(out, "tools.-1", anthropicTool) hasAnthropicTools = true + } else if toolType != "" { + // Pass through Claude built-in tools (web_search_20250305, code_execution, etc.) as-is + out, _ = sjson.SetRaw(out, "tools.-1", tool.Raw) + hasAnthropicTools = true } return true }) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_response.go b/internal/translator/claude/openai/chat-completions/claude_openai_response.go index 0ddfeaecb..a8b828972 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_response.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_response.go @@ -27,6 +27,10 @@ type ConvertAnthropicResponseToOpenAIParams struct { FinishReason string // Tool calls accumulator for streaming ToolCallsAccumulator map[int]*ToolCallAccumulator + // Web search results accumulated from web_search_tool_result content blocks + WebSearchResultsRaw []string + // Citations accumulated from citations_delta events + CitationsRaw []string } // ToolCallAccumulator holds the state for accumulating tool call data @@ -100,15 +104,19 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator == nil { (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator) } + // Reset web search accumulators for new message + (*param).(*ConvertAnthropicResponseToOpenAIParams).WebSearchResultsRaw = nil + (*param).(*ConvertAnthropicResponseToOpenAIParams).CitationsRaw = nil } return []string{template} case "content_block_start": - // Start of a content block (text, tool use, or reasoning) + // Start of a content block (text, tool use, reasoning, server_tool_use, web_search_tool_result) if contentBlock := root.Get("content_block"); contentBlock.Exists() { blockType := contentBlock.Get("type").String() - if blockType == "tool_use" { + switch blockType { + case "tool_use": // Start of tool call - initialize accumulator to track arguments toolCallID := contentBlock.Get("id").String() toolName := contentBlock.Get("name").String() @@ -125,12 +133,27 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original // Don't output anything yet - wait for complete tool call return []string{} + case "web_search_tool_result": + // Accumulate web search results for passthrough + if content := contentBlock.Get("content"); content.Exists() && content.IsArray() { + for _, result := range content.Array() { + if result.Get("type").String() == "web_search_result" { + compacted := strings.ReplaceAll(strings.ReplaceAll(result.Raw, "\n", ""), "\r", "") + (*param).(*ConvertAnthropicResponseToOpenAIParams).WebSearchResultsRaw = append( + (*param).(*ConvertAnthropicResponseToOpenAIParams).WebSearchResultsRaw, compacted) + } + } + } + return []string{} + case "server_tool_use": + // Server-side tool use (e.g. web_search) - silently skip + return []string{} } } return []string{} case "content_block_delta": - // Handle content delta (text, tool use arguments, or reasoning content) + // Handle content delta (text, tool use arguments, reasoning, or citations) hasContent := false if delta := root.Get("delta"); delta.Exists() { deltaType := delta.Get("type").String() @@ -148,6 +171,15 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original template, _ = sjson.Set(template, "choices.0.delta.reasoning_content", thinking.String()) hasContent = true } + case "citations_delta": + // Citation from web_search - accumulate for passthrough and emit on chunk + if citation := delta.Get("citation"); citation.Exists() { + compacted := strings.ReplaceAll(strings.ReplaceAll(citation.Raw, "\n", ""), "\r", "") + (*param).(*ConvertAnthropicResponseToOpenAIParams).CitationsRaw = append( + (*param).(*ConvertAnthropicResponseToOpenAIParams).CitationsRaw, compacted) + template, _ = sjson.SetRaw(template, "choices.0.delta.citations.-1", compacted) + hasContent = true + } case "input_json_delta": // Tool use input delta - accumulate arguments for tool calls if partialJSON := delta.Get("partial_json"); partialJSON.Exists() { @@ -212,6 +244,22 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original template, _ = sjson.Set(template, "usage.total_tokens", inputTokens+outputTokens) template, _ = sjson.Set(template, "usage.prompt_tokens_details.cached_tokens", cacheReadInputTokens) } + + // Attach accumulated web search results and citations on final message_delta + params := (*param).(*ConvertAnthropicResponseToOpenAIParams) + if len(params.WebSearchResultsRaw) > 0 { + template, _ = sjson.SetRaw(template, "choices.0.web_search_results", "[]") + for _, raw := range params.WebSearchResultsRaw { + template, _ = sjson.SetRaw(template, "choices.0.web_search_results.-1", raw) + } + } + if len(params.CitationsRaw) > 0 { + template, _ = sjson.SetRaw(template, "choices.0.citations", "[]") + for _, raw := range params.CitationsRaw { + template, _ = sjson.SetRaw(template, "choices.0.citations.-1", raw) + } + } + return []string{template} case "message_stop": @@ -287,6 +335,8 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina var stopReason string var contentParts []string var reasoningParts []string + var webSearchResultsRaw []string + var citationsRaw []string toolCallsAccumulator := make(map[int]*ToolCallAccumulator) for _, chunk := range chunks { @@ -306,16 +356,26 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina // Handle different content block types at the beginning if contentBlock := root.Get("content_block"); contentBlock.Exists() { blockType := contentBlock.Get("type").String() - if blockType == "thinking" { - // Start of thinking/reasoning content - skip for now as it's handled in delta + switch blockType { + case "thinking": continue - } else if blockType == "tool_use" { - // Initialize tool call accumulator for this index + case "tool_use": index := int(root.Get("index").Int()) toolCallsAccumulator[index] = &ToolCallAccumulator{ ID: contentBlock.Get("id").String(), Name: contentBlock.Get("name").String(), } + case "web_search_tool_result": + if content := contentBlock.Get("content"); content.Exists() && content.IsArray() { + for _, result := range content.Array() { + if result.Get("type").String() == "web_search_result" { + compacted := strings.ReplaceAll(strings.ReplaceAll(result.Raw, "\n", ""), "\r", "") + webSearchResultsRaw = append(webSearchResultsRaw, compacted) + } + } + } + case "server_tool_use": + continue } } @@ -334,6 +394,11 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina if thinking := delta.Get("thinking"); thinking.Exists() { reasoningParts = append(reasoningParts, thinking.String()) } + case "citations_delta": + if citation := delta.Get("citation"); citation.Exists() { + compacted := strings.ReplaceAll(strings.ReplaceAll(citation.Raw, "\n", ""), "\r", "") + citationsRaw = append(citationsRaw, compacted) + } case "input_json_delta": // Accumulate tool call arguments if partialJSON := delta.Get("partial_json"); partialJSON.Exists() { @@ -428,5 +493,19 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina out, _ = sjson.Set(out, "choices.0.finish_reason", mapAnthropicStopReasonToOpenAI(stopReason)) } + // Attach web search results and citations on the choice (consistent with Gemini grounding_metadata placement) + if len(webSearchResultsRaw) > 0 { + out, _ = sjson.SetRaw(out, "choices.0.web_search_results", "[]") + for _, raw := range webSearchResultsRaw { + out, _ = sjson.SetRaw(out, "choices.0.web_search_results.-1", raw) + } + } + if len(citationsRaw) > 0 { + out, _ = sjson.SetRaw(out, "choices.0.citations", "[]") + for _, raw := range citationsRaw { + out, _ = sjson.SetRaw(out, "choices.0.citations.-1", raw) + } + } + return out } diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 337f9be93..896ec08fb 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -289,6 +289,13 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte if tools := root.Get("tools"); tools.Exists() && tools.IsArray() { toolsJSON := "[]" tools.ForEach(func(_, tool gjson.Result) bool { + toolType := tool.Get("type").String() + if toolType != "" && toolType != "function" { + // Pass through Claude built-in tools (web_search_20250305, code_execution, etc.) as-is + toolsJSON, _ = sjson.SetRaw(toolsJSON, "-1", tool.Raw) + return true + } + tJSON := `{"name":"","description":"","input_schema":{}}` if n := tool.Get("name"); n.Exists() { tJSON, _ = sjson.Set(tJSON, "name", n.String()) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go index e77b09e13..91e03d657 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -36,6 +36,9 @@ type claudeToResponsesState struct { InputTokens int64 OutputTokens int64 UsageSeen bool + // web search citations passthrough + WebSearchResultsRaw []string + CitationsRaw []string } var dataTag = []byte("data:") @@ -91,6 +94,8 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.FuncArgsBuf = make(map[int]*strings.Builder) st.FuncNames = make(map[int]string) st.FuncCallIDs = make(map[int]string) + st.WebSearchResultsRaw = nil + st.CitationsRaw = nil st.InputTokens = 0 st.OutputTokens = 0 st.UsageSeen = false @@ -172,6 +177,18 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin part, _ = sjson.Set(part, "output_index", idx) out = append(out, emitEvent("response.reasoning_summary_part.added", part)) st.ReasoningPartAdded = true + } else if typ == "web_search_tool_result" { + // Accumulate web search results for passthrough + if content := cb.Get("content"); content.Exists() && content.IsArray() { + for _, result := range content.Array() { + if result.Get("type").String() == "web_search_result" { + compacted := strings.ReplaceAll(strings.ReplaceAll(result.Raw, "\n", ""), "\r", "") + st.WebSearchResultsRaw = append(st.WebSearchResultsRaw, compacted) + } + } + } + } else if typ == "server_tool_use" { + // Server-side tool use (e.g. web_search) - silently skip } case "content_block_delta": d := root.Get("delta") @@ -215,6 +232,12 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin out = append(out, emitEvent("response.reasoning_summary_text.delta", msg)) } } + } else if dt == "citations_delta" { + // Citation from web_search - accumulate for passthrough + if citation := d.Get("citation"); citation.Exists() { + compacted := strings.ReplaceAll(strings.ReplaceAll(citation.Raw, "\n", ""), "\r", "") + st.CitationsRaw = append(st.CitationsRaw, compacted) + } } case "content_block_stop": idx := int(root.Get("index").Int()) @@ -425,6 +448,20 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin completed, _ = sjson.Set(completed, "response.usage.total_tokens", total) } } + // Attach accumulated web search results and citations + if len(st.WebSearchResultsRaw) > 0 { + completed, _ = sjson.SetRaw(completed, "response.web_search_results", "[]") + for _, raw := range st.WebSearchResultsRaw { + completed, _ = sjson.SetRaw(completed, "response.web_search_results.-1", raw) + } + } + if len(st.CitationsRaw) > 0 { + completed, _ = sjson.SetRaw(completed, "response.citations", "[]") + for _, raw := range st.CitationsRaw { + completed, _ = sjson.SetRaw(completed, "response.citations.-1", raw) + } + } + out = append(out, emitEvent("response.completed", completed)) } @@ -459,16 +496,18 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string // Aggregation state var ( - responseID string - createdAt int64 - currentMsgID string - currentFCID string - textBuf strings.Builder - reasoningBuf strings.Builder - reasoningActive bool - reasoningItemID string - inputTokens int64 - outputTokens int64 + responseID string + createdAt int64 + currentMsgID string + currentFCID string + textBuf strings.Builder + reasoningBuf strings.Builder + reasoningActive bool + reasoningItemID string + inputTokens int64 + outputTokens int64 + webSearchResultsRaw []string + citationsRaw []string ) // Per-index tool call aggregation @@ -516,6 +555,17 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string case "thinking": reasoningActive = true reasoningItemID = fmt.Sprintf("rs_%s_%d", responseID, idx) + case "web_search_tool_result": + if content := cb.Get("content"); content.Exists() && content.IsArray() { + for _, result := range content.Array() { + if result.Get("type").String() == "web_search_result" { + compacted := strings.ReplaceAll(strings.ReplaceAll(result.Raw, "\n", ""), "\r", "") + webSearchResultsRaw = append(webSearchResultsRaw, compacted) + } + } + } + case "server_tool_use": + continue } case "content_block_delta": @@ -543,6 +593,11 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string reasoningBuf.WriteString(t.String()) } } + case "citations_delta": + if citation := d.Get("citation"); citation.Exists() { + compacted := strings.ReplaceAll(strings.ReplaceAll(citation.Raw, "\n", ""), "\r", "") + citationsRaw = append(citationsRaw, compacted) + } } case "content_block_stop": @@ -684,5 +739,19 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string } } + // Attach web search results and citations if present + if len(webSearchResultsRaw) > 0 { + out, _ = sjson.SetRaw(out, "web_search_results", "[]") + for _, raw := range webSearchResultsRaw { + out, _ = sjson.SetRaw(out, "web_search_results.-1", raw) + } + } + if len(citationsRaw) > 0 { + out, _ = sjson.SetRaw(out, "citations", "[]") + for _, raw := range citationsRaw { + out, _ = sjson.SetRaw(out, "citations.-1", raw) + } + } + return out } diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go index ee6613814..c061c7886 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -145,8 +145,28 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] // tools if toolsResult := gjson.GetBytes(rawJSON, "tools"); toolsResult.IsArray() { - hasTools := false + hasFunction := false + functionToolNode := `{}` + var extraToolNodes []string toolsResult.ForEach(func(_, toolResult gjson.Result) bool { + // Handle google_search passthrough + if gs := toolResult.Get("google_search"); gs.Exists() { + node, _ := sjson.SetRaw(`{}`, "googleSearch", gs.Raw) + extraToolNodes = append(extraToolNodes, node) + return true + } + // Handle code_execution passthrough + if ce := toolResult.Get("code_execution"); ce.Exists() { + node, _ := sjson.SetRaw(`{}`, "codeExecution", ce.Raw) + extraToolNodes = append(extraToolNodes, node) + return true + } + // Handle url_context passthrough + if uc := toolResult.Get("url_context"); uc.Exists() { + node, _ := sjson.SetRaw(`{}`, "urlContext", uc.Raw) + extraToolNodes = append(extraToolNodes, node) + return true + } inputSchemaResult := toolResult.Get("input_schema") if inputSchemaResult.Exists() && inputSchemaResult.IsObject() { inputSchema := inputSchemaResult.Raw @@ -157,16 +177,25 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] tool, _ = sjson.Delete(tool, "type") tool, _ = sjson.Delete(tool, "cache_control") if gjson.Valid(tool) && gjson.Parse(tool).IsObject() { - if !hasTools { - out, _ = sjson.SetRaw(out, "request.tools", `[{"functionDeclarations":[]}]`) - hasTools = true + if !hasFunction { + functionToolNode, _ = sjson.SetRaw(functionToolNode, "functionDeclarations", `[]`) + hasFunction = true } - out, _ = sjson.SetRaw(out, "request.tools.0.functionDeclarations.-1", tool) + functionToolNode, _ = sjson.SetRaw(functionToolNode, "functionDeclarations.-1", tool) } } return true }) - if !hasTools { + if hasFunction || len(extraToolNodes) > 0 { + toolsNode := `[]` + if hasFunction { + toolsNode, _ = sjson.SetRaw(toolsNode, "-1", functionToolNode) + } + for _, node := range extraToolNodes { + toolsNode, _ = sjson.SetRaw(toolsNode, "-1", node) + } + out, _ = sjson.SetRaw(out, "request.tools", toolsNode) + } else { out, _ = sjson.Delete(out, "request.tools") } } diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go index 1126f1ee4..f6f23922e 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go @@ -253,6 +253,11 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque template, _ = sjson.Set(template, "usage.output_tokens", candidatesTokenCountResult.Int()+thoughtsTokenCount) template, _ = sjson.Set(template, "usage.input_tokens", usageResult.Get("promptTokenCount").Int()) + // Attach groundingMetadata if present (compact to prevent SSE newline framing issues). + if gm := gjson.GetBytes(rawJSON, "response.candidates.0.groundingMetadata"); gm.Exists() { + template, _ = sjson.SetRaw(template, "grounding_metadata", strings.ReplaceAll(strings.ReplaceAll(gm.Raw, "\n", ""), "\r", "")) + } + output = output + template + "\n\n\n" } } @@ -366,6 +371,11 @@ func ConvertGeminiCLIResponseToClaudeNonStream(_ context.Context, _ string, orig } out, _ = sjson.Set(out, "stop_reason", stopReason) + // Extract and attach groundingMetadata if present. + if gm := root.Get("response.candidates.0.groundingMetadata"); gm.Exists() { + out, _ = sjson.SetRaw(out, "grounding_metadata", gm.Raw) + } + if inputTokens == int64(0) && outputTokens == int64(0) && !root.Get("response.usageMetadata").Exists() { out, _ = sjson.Delete(out, "usage") } diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go index 0415e0149..14889056c 100644 --- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go +++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go @@ -210,6 +210,11 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ } } + // Extract and attach groundingMetadata if present (compact to prevent SSE newline framing issues). + if gm := gjson.GetBytes(rawJSON, "response.candidates.0.groundingMetadata"); gm.Exists() { + template, _ = sjson.SetRaw(template, "choices.0.grounding_metadata", strings.ReplaceAll(strings.ReplaceAll(gm.Raw, "\n", ""), "\r", "")) + } + return []string{template} } diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index e882f769a..ff22b3efa 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -125,8 +125,28 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) // tools if toolsResult := gjson.GetBytes(rawJSON, "tools"); toolsResult.IsArray() { - hasTools := false + hasFunction := false + functionToolNode := `{}` + var extraToolNodes []string toolsResult.ForEach(func(_, toolResult gjson.Result) bool { + // Handle google_search passthrough + if gs := toolResult.Get("google_search"); gs.Exists() { + node, _ := sjson.SetRaw(`{}`, "googleSearch", gs.Raw) + extraToolNodes = append(extraToolNodes, node) + return true + } + // Handle code_execution passthrough + if ce := toolResult.Get("code_execution"); ce.Exists() { + node, _ := sjson.SetRaw(`{}`, "codeExecution", ce.Raw) + extraToolNodes = append(extraToolNodes, node) + return true + } + // Handle url_context passthrough + if uc := toolResult.Get("url_context"); uc.Exists() { + node, _ := sjson.SetRaw(`{}`, "urlContext", uc.Raw) + extraToolNodes = append(extraToolNodes, node) + return true + } inputSchemaResult := toolResult.Get("input_schema") if inputSchemaResult.Exists() && inputSchemaResult.IsObject() { inputSchema := inputSchemaResult.Raw @@ -137,16 +157,25 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) tool, _ = sjson.Delete(tool, "type") tool, _ = sjson.Delete(tool, "cache_control") if gjson.Valid(tool) && gjson.Parse(tool).IsObject() { - if !hasTools { - out, _ = sjson.SetRaw(out, "tools", `[{"functionDeclarations":[]}]`) - hasTools = true + if !hasFunction { + functionToolNode, _ = sjson.SetRaw(functionToolNode, "functionDeclarations", `[]`) + hasFunction = true } - out, _ = sjson.SetRaw(out, "tools.0.functionDeclarations.-1", tool) + functionToolNode, _ = sjson.SetRaw(functionToolNode, "functionDeclarations.-1", tool) } } return true }) - if !hasTools { + if hasFunction || len(extraToolNodes) > 0 { + toolsNode := `[]` + if hasFunction { + toolsNode, _ = sjson.SetRaw(toolsNode, "-1", functionToolNode) + } + for _, node := range extraToolNodes { + toolsNode, _ = sjson.SetRaw(toolsNode, "-1", node) + } + out, _ = sjson.SetRaw(out, "tools", toolsNode) + } else { out, _ = sjson.Delete(out, "tools") } } diff --git a/internal/translator/gemini/claude/gemini_claude_response.go b/internal/translator/gemini/claude/gemini_claude_response.go index cfc06921d..11c53f6db 100644 --- a/internal/translator/gemini/claude/gemini_claude_response.go +++ b/internal/translator/gemini/claude/gemini_claude_response.go @@ -259,6 +259,11 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR template, _ = sjson.Set(template, "usage.output_tokens", candidatesTokenCountResult.Int()+thoughtsTokenCount) template, _ = sjson.Set(template, "usage.input_tokens", usageResult.Get("promptTokenCount").Int()) + // Attach groundingMetadata if present (compact to prevent SSE newline framing issues). + if gm := gjson.GetBytes(rawJSON, "candidates.0.groundingMetadata"); gm.Exists() { + template, _ = sjson.SetRaw(template, "grounding_metadata", strings.ReplaceAll(strings.ReplaceAll(gm.Raw, "\n", ""), "\r", "")) + } + output = output + template + "\n\n\n" } } @@ -372,6 +377,11 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina } out, _ = sjson.Set(out, "stop_reason", stopReason) + // Extract and attach groundingMetadata if present. + if gm := root.Get("candidates.0.groundingMetadata"); gm.Exists() { + out, _ = sjson.SetRaw(out, "grounding_metadata", gm.Raw) + } + if inputTokens == int64(0) && outputTokens == int64(0) && !root.Get("usageMetadata").Exists() { out, _ = sjson.Delete(out, "usage") } diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go index ee581c46e..0f093b6d3 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go @@ -238,6 +238,11 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR } } + // Extract and attach groundingMetadata if present (compact to prevent SSE newline framing issues). + if gm := candidate.Get("groundingMetadata"); gm.Exists() { + template, _ = sjson.SetRaw(template, "choices.0.grounding_metadata", strings.ReplaceAll(strings.ReplaceAll(gm.Raw, "\n", ""), "\r", "")) + } + responseStrings = append(responseStrings, template) return true // continue loop }) @@ -397,6 +402,11 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina choiceTemplate, _ = sjson.Set(choiceTemplate, "native_finish_reason", "tool_calls") } + // Extract and attach groundingMetadata if present. + if gm := candidate.Get("groundingMetadata"); gm.Exists() { + choiceTemplate, _ = sjson.SetRaw(choiceTemplate, "grounding_metadata", gm.Raw) + } + // Append the constructed choice to the main choices array. template, _ = sjson.SetRaw(template, "choices.-1", choiceTemplate) return true diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index 1ddb1f36d..0f947f799 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -317,9 +317,29 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte // Convert tools to Gemini functionDeclarations format if tools := root.Get("tools"); tools.Exists() && tools.IsArray() { - geminiTools := `[{"functionDeclarations":[]}]` + hasFunction := false + functionToolNode := `{}` + var extraToolNodes []string tools.ForEach(func(_, tool gjson.Result) bool { + // Handle google_search passthrough + if gs := tool.Get("google_search"); gs.Exists() { + node, _ := sjson.SetRaw(`{}`, "googleSearch", gs.Raw) + extraToolNodes = append(extraToolNodes, node) + return true + } + // Handle code_execution passthrough + if ce := tool.Get("code_execution"); ce.Exists() { + node, _ := sjson.SetRaw(`{}`, "codeExecution", ce.Raw) + extraToolNodes = append(extraToolNodes, node) + return true + } + // Handle url_context passthrough + if uc := tool.Get("url_context"); uc.Exists() { + node, _ := sjson.SetRaw(`{}`, "urlContext", uc.Raw) + extraToolNodes = append(extraToolNodes, node) + return true + } if tool.Get("type").String() == "function" { funcDecl := `{"name":"","description":"","parametersJsonSchema":{}}` @@ -348,14 +368,24 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte funcDecl, _ = sjson.SetRaw(funcDecl, "parametersJsonSchema", cleaned) } - geminiTools, _ = sjson.SetRaw(geminiTools, "0.functionDeclarations.-1", funcDecl) + if !hasFunction { + functionToolNode, _ = sjson.SetRaw(functionToolNode, "functionDeclarations", `[]`) + hasFunction = true + } + functionToolNode, _ = sjson.SetRaw(functionToolNode, "functionDeclarations.-1", funcDecl) } return true }) - // Only add tools if there are function declarations - if funcDecls := gjson.Get(geminiTools, "0.functionDeclarations"); funcDecls.Exists() && len(funcDecls.Array()) > 0 { - out, _ = sjson.SetRaw(out, "tools", geminiTools) + if hasFunction || len(extraToolNodes) > 0 { + toolsNode := `[]` + if hasFunction { + toolsNode, _ = sjson.SetRaw(toolsNode, "-1", functionToolNode) + } + for _, node := range extraToolNodes { + toolsNode, _ = sjson.SetRaw(toolsNode, "-1", node) + } + out, _ = sjson.SetRaw(out, "tools", toolsNode) } } diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go index 985897fab..ebe3fdf49 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go @@ -554,6 +554,11 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, } } + // Extract and attach groundingMetadata if present (compact to prevent SSE newline framing issues). + if gm := root.Get("candidates.0.groundingMetadata"); gm.Exists() { + completed, _ = sjson.SetRaw(completed, "response.grounding_metadata", strings.ReplaceAll(strings.ReplaceAll(gm.Raw, "\n", ""), "\r", "")) + } + out = append(out, emitEvent("response.completed", completed)) } @@ -754,5 +759,10 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string } } + // Extract and attach groundingMetadata if present. + if gm := root.Get("candidates.0.groundingMetadata"); gm.Exists() { + resp, _ = sjson.SetRaw(resp, "grounding_metadata", gm.Raw) + } + return resp } diff --git a/test/claude_websearch_translation_test.go b/test/claude_websearch_translation_test.go new file mode 100644 index 000000000..acfc41c44 --- /dev/null +++ b/test/claude_websearch_translation_test.go @@ -0,0 +1,529 @@ +package test + +import ( + "context" + "testing" + + _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" + + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + "github.com/tidwall/gjson" +) + +// --- Request translation tests --- + +func TestOpenAIToClaude_PreservesBuiltinWebSearchTool(t *testing.T) { + in := []byte(`{ + "model":"claude-haiku", + "messages":[{"role":"user","content":"hi"}], + "tools":[ + {"type":"web_search_20250305","name":"web_search","max_uses":1}, + {"type":"function","function":{"name":"get_weather","description":"Get weather","parameters":{"type":"object","properties":{}}}} + ] + }`) + + out := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, "claude-haiku", in, false) + + toolCount := gjson.GetBytes(out, "tools.#").Int() + if toolCount != 2 { + t.Fatalf("expected 2 tools, got %d: %s", toolCount, string(out)) + } + + // First tool should be the built-in web_search passed through as-is + tool0Type := gjson.GetBytes(out, "tools.0.type").String() + if tool0Type != "web_search_20250305" { + t.Fatalf("expected tools[0].type=web_search_20250305, got %q", tool0Type) + } + tool0Name := gjson.GetBytes(out, "tools.0.name").String() + if tool0Name != "web_search" { + t.Fatalf("expected tools[0].name=web_search, got %q", tool0Name) + } + tool0MaxUses := gjson.GetBytes(out, "tools.0.max_uses").Int() + if tool0MaxUses != 1 { + t.Fatalf("expected tools[0].max_uses=1, got %d", tool0MaxUses) + } + + // Second tool should be converted to Claude function format + tool1Name := gjson.GetBytes(out, "tools.1.name").String() + if tool1Name != "get_weather" { + t.Fatalf("expected tools[1].name=get_weather, got %q", tool1Name) + } +} + +func TestOpenAIResponsesToClaude_PreservesBuiltinWebSearchTool(t *testing.T) { + in := []byte(`{ + "model":"claude-haiku", + "input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}], + "tools":[ + {"type":"web_search_20250305","name":"web_search","max_uses":2}, + {"type":"function","name":"calc","description":"Calculate","parameters":{"type":"object","properties":{}}} + ] + }`) + + out := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatClaude, "claude-haiku", in, false) + + toolCount := gjson.GetBytes(out, "tools.#").Int() + if toolCount != 2 { + t.Fatalf("expected 2 tools, got %d: %s", toolCount, string(out)) + } + + tool0Type := gjson.GetBytes(out, "tools.0.type").String() + if tool0Type != "web_search_20250305" { + t.Fatalf("expected tools[0].type=web_search_20250305, got %q", tool0Type) + } + tool0MaxUses := gjson.GetBytes(out, "tools.0.max_uses").Int() + if tool0MaxUses != 2 { + t.Fatalf("expected tools[0].max_uses=2, got %d", tool0MaxUses) + } +} + +// --- Response translation tests (streaming) --- + +// Simulates the Claude SSE events for a web_search response and verifies the +// OpenAI Chat Completions streaming output contains web_search_results and citations. +func TestClaudeToOpenAI_StreamWebSearchResultsAndCitations(t *testing.T) { + ctx := context.Background() + model := "claude-haiku-4-5-20251001" + reqJSON := []byte(`{}`) + var param any + + // 1. message_start + sse1 := []byte(`data: {"type":"message_start","message":{"id":"msg_test123","model":"claude-haiku-4-5-20251001","type":"message","role":"assistant","content":[],"usage":{"input_tokens":100,"output_tokens":10}}}`) + results := sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse1, ¶m) + if len(results) == 0 { + t.Fatal("expected output for message_start") + } + + // 2. content_block_start with server_tool_use (should be silently skipped) + sse2 := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srvtoolu_test","name":"web_search","input":{}}}`) + results = sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse2, ¶m) + if len(results) != 0 { + t.Fatalf("expected empty output for server_tool_use, got %d results", len(results)) + } + + // 3. content_block_stop for server_tool_use + sse3 := []byte(`data: {"type":"content_block_stop","index":0}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse3, ¶m) + + // 4. content_block_start with web_search_tool_result (should accumulate results) + sse4 := []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_test","content":[{"type":"web_search_result","title":"Test Result","url":"https://example.com","encrypted_content":"abc123"}]}}`) + results = sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse4, ¶m) + if len(results) != 0 { + t.Fatalf("expected empty output for web_search_tool_result, got %d results", len(results)) + } + + // 5. content_block_stop for web_search_tool_result + sse5 := []byte(`data: {"type":"content_block_stop","index":1}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse5, ¶m) + + // 6. content_block_start with text block + sse6 := []byte(`data: {"type":"content_block_start","index":2,"content_block":{"type":"text","text":""}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse6, ¶m) + + // 7. citations_delta + sse7 := []byte(`data: {"type":"content_block_delta","index":2,"delta":{"type":"citations_delta","citation":{"type":"web_search_result_location","cited_text":"test cited text","url":"https://example.com","title":"Test Result","encrypted_index":"enc123"}}}`) + results = sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse7, ¶m) + if len(results) == 0 { + t.Fatal("expected output for citations_delta") + } + citationChunk := results[0] + if !gjson.Get(citationChunk, "choices.0.delta.citations.0.url").Exists() { + t.Fatalf("expected citation in delta chunk, got: %s", citationChunk) + } + + // 8. text_delta + sse8 := []byte(`data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"The answer is here."}}`) + results = sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse8, ¶m) + if len(results) == 0 { + t.Fatal("expected output for text_delta") + } + + // 9. message_delta (final) - should contain accumulated web_search_results and citations + sse9 := []byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":50}}`) + results = sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse9, ¶m) + if len(results) == 0 { + t.Fatal("expected output for message_delta") + } + finalChunk := results[0] + + // Verify web_search_results on choice + wsrCount := gjson.Get(finalChunk, "choices.0.web_search_results.#").Int() + if wsrCount != 1 { + t.Fatalf("expected 1 web_search_result on choices.0, got %d: %s", wsrCount, finalChunk) + } + wsrTitle := gjson.Get(finalChunk, "choices.0.web_search_results.0.title").String() + if wsrTitle != "Test Result" { + t.Fatalf("expected web_search_results[0].title=Test Result, got %q", wsrTitle) + } + + // Verify citations on choice + citCount := gjson.Get(finalChunk, "choices.0.citations.#").Int() + if citCount != 1 { + t.Fatalf("expected 1 citation on choices.0, got %d: %s", citCount, finalChunk) + } + citURL := gjson.Get(finalChunk, "choices.0.citations.0.url").String() + if citURL != "https://example.com" { + t.Fatalf("expected citations[0].url=https://example.com, got %q", citURL) + } +} + +// --- Response translation tests (non-streaming) --- + +// Feeds all Claude SSE events at once to the non-stream translator and verifies +// web_search_results and citations are placed on choices.0. +func TestClaudeToOpenAI_NonStreamWebSearchResultsAndCitations(t *testing.T) { + ctx := context.Background() + model := "claude-haiku-4-5-20251001" + reqJSON := []byte(`{}`) + + // All SSE events concatenated (the non-stream translator parses all at once) + allSSE := []byte( + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_test456\",\"model\":\"claude-haiku-4-5-20251001\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"usage\":{\"input_tokens\":100,\"output_tokens\":10}}}\n\n" + + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"server_tool_use\",\"id\":\"srvtoolu_test\",\"name\":\"web_search\",\"input\":{}}}\n\n" + + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + + "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"web_search_tool_result\",\"tool_use_id\":\"srvtoolu_test\",\"content\":[{\"type\":\"web_search_result\",\"title\":\"ESPN Result\",\"url\":\"https://espn.com/test\",\"encrypted_content\":\"xyz\"},{\"type\":\"web_search_result\",\"title\":\"CBS Result\",\"url\":\"https://cbs.com/test\",\"encrypted_content\":\"abc\"}]}}\n\n" + + "data: {\"type\":\"content_block_stop\",\"index\":1}\n\n" + + "data: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" + + "data: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"citations_delta\",\"citation\":{\"type\":\"web_search_result_location\",\"cited_text\":\"Seahawks won\",\"url\":\"https://espn.com/test\",\"title\":\"ESPN Result\",\"encrypted_index\":\"enc1\"}}}\n\n" + + "data: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"text_delta\",\"text\":\"The Seahawks won the Super Bowl.\"}}\n\n" + + "data: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"citations_delta\",\"citation\":{\"type\":\"web_search_result_location\",\"cited_text\":\"29-13 victory\",\"url\":\"https://cbs.com/test\",\"title\":\"CBS Result\",\"encrypted_index\":\"enc2\"}}}\n\n" + + "data: {\"type\":\"content_block_stop\",\"index\":2}\n\n" + + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":50}}\n\n" + + "data: {\"type\":\"message_stop\"}\n\n") + + var param any + out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, allSSE, ¶m) + + // Verify web_search_results at choices.0 level + wsrCount := gjson.Get(out, "choices.0.web_search_results.#").Int() + if wsrCount != 2 { + t.Fatalf("expected 2 web_search_results on choices.0, got %d: %s", wsrCount, out) + } + if title := gjson.Get(out, "choices.0.web_search_results.0.title").String(); title != "ESPN Result" { + t.Fatalf("expected first result title=ESPN Result, got %q", title) + } + if title := gjson.Get(out, "choices.0.web_search_results.1.title").String(); title != "CBS Result" { + t.Fatalf("expected second result title=CBS Result, got %q", title) + } + + // Verify citations at choices.0 level + citCount := gjson.Get(out, "choices.0.citations.#").Int() + if citCount != 2 { + t.Fatalf("expected 2 citations on choices.0, got %d: %s", citCount, out) + } + + // Verify NOT at root level (consistency check) + if gjson.Get(out, "web_search_results").Exists() { + t.Fatal("web_search_results should NOT be at root level, should be on choices.0") + } + if gjson.Get(out, "citations").Exists() { + t.Fatal("citations should NOT be at root level, should be on choices.0") + } + + // Verify text content is present + content := gjson.Get(out, "choices.0.message.content").String() + if content != "The Seahawks won the Super Bowl." { + t.Fatalf("expected text content, got %q", content) + } +} + +// --- Gemini output format test --- + +func TestClaudeToGemini_StreamWebSearchAsGroundingMetadata(t *testing.T) { + ctx := context.Background() + model := "claude-haiku-4-5-20251001" + reqJSON := []byte(`{}`) + var param any + + // message_start + sse1 := []byte(`data: {"type":"message_start","message":{"id":"msg_gem1","model":"claude-haiku-4-5-20251001","type":"message","role":"assistant","content":[],"usage":{"input_tokens":50,"output_tokens":5}}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatGemini, model, reqJSON, reqJSON, sse1, ¶m) + + // web_search_tool_result + sse2 := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"web_search_tool_result","tool_use_id":"srv_1","content":[{"type":"web_search_result","title":"Gemini Test","url":"https://gemini.test","encrypted_content":"gem123"}]}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatGemini, model, reqJSON, reqJSON, sse2, ¶m) + + // content_block_stop + sse3 := []byte(`data: {"type":"content_block_stop","index":0}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatGemini, model, reqJSON, reqJSON, sse3, ¶m) + + // text block start + sse4 := []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatGemini, model, reqJSON, reqJSON, sse4, ¶m) + + // citations_delta + sse5 := []byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"citations_delta","citation":{"type":"web_search_result_location","cited_text":"gemini cited","url":"https://gemini.test","title":"Gemini Test","encrypted_index":"genc1"}}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatGemini, model, reqJSON, reqJSON, sse5, ¶m) + + // text_delta + sse6 := []byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hello from Gemini."}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatGemini, model, reqJSON, reqJSON, sse6, ¶m) + + // message_delta - should have groundingMetadata + sse7 := []byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":20}}`) + results := sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatGemini, model, reqJSON, reqJSON, sse7, ¶m) + if len(results) == 0 { + t.Fatal("expected output for message_delta") + } + finalChunk := results[0] + + // Verify groundingMetadata exists on candidates.0 + if !gjson.Get(finalChunk, "candidates.0.groundingMetadata").Exists() { + t.Fatalf("expected groundingMetadata on candidates.0, got: %s", finalChunk) + } + + // Verify webSearchResults inside groundingMetadata + wsrCount := gjson.Get(finalChunk, "candidates.0.groundingMetadata.webSearchResults.#").Int() + if wsrCount != 1 { + t.Fatalf("expected 1 webSearchResult in groundingMetadata, got %d: %s", wsrCount, finalChunk) + } + wsrTitle := gjson.Get(finalChunk, "candidates.0.groundingMetadata.webSearchResults.0.title").String() + if wsrTitle != "Gemini Test" { + t.Fatalf("expected webSearchResults[0].title=Gemini Test, got %q", wsrTitle) + } + + // Verify citations inside groundingMetadata + citCount := gjson.Get(finalChunk, "candidates.0.groundingMetadata.citations.#").Int() + if citCount != 1 { + t.Fatalf("expected 1 citation in groundingMetadata, got %d: %s", citCount, finalChunk) + } +} + +// --- OpenAI Responses format tests --- + +// Streaming: Verifies web_search_results and citations appear on response.completed event +// inside the response object (response.web_search_results, response.citations). +func TestClaudeToOpenAIResponses_StreamWebSearchResultsAndCitations(t *testing.T) { + ctx := context.Background() + model := "claude-haiku-4-5-20251001" + reqJSON := []byte(`{}`) + var param any + + // message_start + sse1 := []byte(`data: {"type":"message_start","message":{"id":"msg_resp1","model":"claude-haiku-4-5-20251001","type":"message","role":"assistant","content":[],"usage":{"input_tokens":80,"output_tokens":5}}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse1, ¶m) + + // server_tool_use (should be silently skipped) + sse2 := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"srvtoolu_resp","name":"web_search","input":{}}}`) + results := sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse2, ¶m) + if len(results) != 0 { + t.Fatalf("expected empty output for server_tool_use in responses format, got %d results", len(results)) + } + + // content_block_stop for server_tool_use + sse3 := []byte(`data: {"type":"content_block_stop","index":0}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse3, ¶m) + + // web_search_tool_result + sse4 := []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_resp","content":[{"type":"web_search_result","title":"Responses Test","url":"https://responses.test","encrypted_content":"resp123"}]}}`) + results = sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse4, ¶m) + if len(results) != 0 { + t.Fatalf("expected empty output for web_search_tool_result in responses format, got %d results", len(results)) + } + + // content_block_stop + sse5 := []byte(`data: {"type":"content_block_stop","index":1}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse5, ¶m) + + // text block + sse6 := []byte(`data: {"type":"content_block_start","index":2,"content_block":{"type":"text","text":""}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse6, ¶m) + + // citations_delta + sse7 := []byte(`data: {"type":"content_block_delta","index":2,"delta":{"type":"citations_delta","citation":{"type":"web_search_result_location","cited_text":"responses cited","url":"https://responses.test","title":"Responses Test","encrypted_index":"renc1"}}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse7, ¶m) + + // text_delta + sse8 := []byte(`data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"Responses answer."}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse8, ¶m) + + // content_block_stop + sse9 := []byte(`data: {"type":"content_block_stop","index":2}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse9, ¶m) + + // message_delta (usage update) + sse10 := []byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":30}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse10, ¶m) + + // message_stop (triggers response.completed in Responses format) + sse11 := []byte(`data: {"type":"message_stop"}`) + results = sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse11, ¶m) + + // Find the response.completed event + var completedEvent string + for _, r := range results { + if gjson.Get(r, "type").String() == "response.completed" { + completedEvent = r + break + } + } + if completedEvent == "" { + t.Fatalf("expected response.completed event, got events: %v", results) + } + + // Verify web_search_results on response object + wsrCount := gjson.Get(completedEvent, "response.web_search_results.#").Int() + if wsrCount != 1 { + t.Fatalf("expected 1 web_search_result on response, got %d: %s", wsrCount, completedEvent) + } + wsrTitle := gjson.Get(completedEvent, "response.web_search_results.0.title").String() + if wsrTitle != "Responses Test" { + t.Fatalf("expected web_search_results[0].title=Responses Test, got %q", wsrTitle) + } + + // Verify citations on response object + citCount := gjson.Get(completedEvent, "response.citations.#").Int() + if citCount != 1 { + t.Fatalf("expected 1 citation on response, got %d: %s", citCount, completedEvent) + } + citURL := gjson.Get(completedEvent, "response.citations.0.url").String() + if citURL != "https://responses.test" { + t.Fatalf("expected citations[0].url=https://responses.test, got %q", citURL) + } +} + +// Non-streaming: Verifies web_search_results and citations on root (which IS the response object). +func TestClaudeToOpenAIResponses_NonStreamWebSearchResultsAndCitations(t *testing.T) { + ctx := context.Background() + model := "claude-haiku-4-5-20251001" + reqJSON := []byte(`{}`) + + allSSE := []byte( + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_respns\",\"model\":\"claude-haiku-4-5-20251001\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"usage\":{\"input_tokens\":80,\"output_tokens\":5}}}\n\n" + + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"server_tool_use\",\"id\":\"srvtoolu_ns\",\"name\":\"web_search\",\"input\":{}}}\n\n" + + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + + "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"web_search_tool_result\",\"tool_use_id\":\"srvtoolu_ns\",\"content\":[{\"type\":\"web_search_result\",\"title\":\"NonStream Resp\",\"url\":\"https://nonstream.test\",\"encrypted_content\":\"ns1\"}]}}\n\n" + + "data: {\"type\":\"content_block_stop\",\"index\":1}\n\n" + + "data: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" + + "data: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"citations_delta\",\"citation\":{\"type\":\"web_search_result_location\",\"cited_text\":\"ns cited\",\"url\":\"https://nonstream.test\",\"title\":\"NonStream Resp\",\"encrypted_index\":\"nsenc\"}}}\n\n" + + "data: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"text_delta\",\"text\":\"NonStream response.\"}}\n\n" + + "data: {\"type\":\"content_block_stop\",\"index\":2}\n\n" + + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":20}}\n\n" + + "data: {\"type\":\"message_stop\"}\n\n") + + var param any + out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, allSSE, ¶m) + + // Non-streaming Responses: out IS the response object, so web_search_results at root = response level + wsrCount := gjson.Get(out, "web_search_results.#").Int() + if wsrCount != 1 { + t.Fatalf("expected 1 web_search_result, got %d: %s", wsrCount, out) + } + wsrTitle := gjson.Get(out, "web_search_results.0.title").String() + if wsrTitle != "NonStream Resp" { + t.Fatalf("expected web_search_results[0].title=NonStream Resp, got %q", wsrTitle) + } + + // Verify citations + citCount := gjson.Get(out, "citations.#").Int() + if citCount != 1 { + t.Fatalf("expected 1 citation, got %d: %s", citCount, out) + } + citURL := gjson.Get(out, "citations.0.url").String() + if citURL != "https://nonstream.test" { + t.Fatalf("expected citations[0].url=https://nonstream.test, got %q", citURL) + } + + // Verify text content is present + outputText := gjson.Get(out, "output.#(type==\"message\").content.0.text").String() + if outputText != "NonStream response." { + t.Fatalf("expected text content 'NonStream response.', got %q", outputText) + } +} + +// --- Gemini non-streaming test --- + +func TestClaudeToGemini_NonStreamWebSearchAsGroundingMetadata(t *testing.T) { + ctx := context.Background() + model := "claude-haiku-4-5-20251001" + reqJSON := []byte(`{}`) + + allSSE := []byte( + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_gns\",\"model\":\"claude-haiku-4-5-20251001\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"usage\":{\"input_tokens\":60,\"output_tokens\":5}}}\n\n" + + "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"web_search_tool_result\",\"tool_use_id\":\"srv_gns\",\"content\":[{\"type\":\"web_search_result\",\"title\":\"Gemini NS\",\"url\":\"https://gemini-ns.test\",\"encrypted_content\":\"gns1\"}]}}\n\n" + + "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + + "data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" + + "data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"citations_delta\",\"citation\":{\"type\":\"web_search_result_location\",\"cited_text\":\"gemini ns cited\",\"url\":\"https://gemini-ns.test\",\"title\":\"Gemini NS\",\"encrypted_index\":\"gnsenc\"}}}\n\n" + + "data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Gemini non-stream.\"}}\n\n" + + "data: {\"type\":\"content_block_stop\",\"index\":1}\n\n" + + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":15}}\n\n" + + "data: {\"type\":\"message_stop\"}\n\n") + + var param any + out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatGemini, model, reqJSON, reqJSON, allSSE, ¶m) + + // Verify groundingMetadata on candidates.0 + if !gjson.Get(out, "candidates.0.groundingMetadata").Exists() { + t.Fatalf("expected groundingMetadata on candidates.0, got: %s", out) + } + + // Verify webSearchResults + wsrCount := gjson.Get(out, "candidates.0.groundingMetadata.webSearchResults.#").Int() + if wsrCount != 1 { + t.Fatalf("expected 1 webSearchResult in groundingMetadata, got %d: %s", wsrCount, out) + } + wsrTitle := gjson.Get(out, "candidates.0.groundingMetadata.webSearchResults.0.title").String() + if wsrTitle != "Gemini NS" { + t.Fatalf("expected webSearchResults[0].title=Gemini NS, got %q", wsrTitle) + } + + // Verify citations + citCount := gjson.Get(out, "candidates.0.groundingMetadata.citations.#").Int() + if citCount != 1 { + t.Fatalf("expected 1 citation in groundingMetadata, got %d: %s", citCount, out) + } + citURL := gjson.Get(out, "candidates.0.groundingMetadata.citations.0.url").String() + if citURL != "https://gemini-ns.test" { + t.Fatalf("expected citations[0].url=https://gemini-ns.test, got %q", citURL) + } + + // Verify text content + textPart := gjson.Get(out, "candidates.0.content.parts.0.text").String() + if textPart != "Gemini non-stream." { + t.Fatalf("expected text='Gemini non-stream.', got %q", textPart) + } +} + +// --- Accumulator reset test --- + +func TestClaudeToOpenAI_StreamAccumulatorResetOnNewMessage(t *testing.T) { + ctx := context.Background() + model := "claude-haiku-4-5-20251001" + reqJSON := []byte(`{}`) + var param any + + // First message with web search data + sse1 := []byte(`data: {"type":"message_start","message":{"id":"msg_first","model":"claude-haiku-4-5-20251001","type":"message","role":"assistant","content":[],"usage":{"input_tokens":50,"output_tokens":5}}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse1, ¶m) + + // Add web search result + sse2 := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"web_search_tool_result","tool_use_id":"srv_1","content":[{"type":"web_search_result","title":"First Message Result","url":"https://first.com","encrypted_content":"first"}]}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse2, ¶m) + + // Now simulate a second message_start (should reset accumulators) + sse3 := []byte(`data: {"type":"message_start","message":{"id":"msg_second","model":"claude-haiku-4-5-20251001","type":"message","role":"assistant","content":[],"usage":{"input_tokens":50,"output_tokens":5}}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse3, ¶m) + + // Text block without any web search + sse4 := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse4, ¶m) + + sse5 := []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"No search here."}}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse5, ¶m) + + // message_delta for second message - should NOT have web_search_results from first message + sse6 := []byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":10}}`) + results := sdktranslator.TranslateStream(ctx, sdktranslator.FormatClaude, sdktranslator.FormatOpenAI, model, reqJSON, reqJSON, sse6, ¶m) + if len(results) == 0 { + t.Fatal("expected output for message_delta") + } + finalChunk := results[0] + + // Verify NO web_search_results leaked from first message + if gjson.Get(finalChunk, "choices.0.web_search_results").Exists() { + t.Fatalf("web_search_results from first message leaked to second message: %s", finalChunk) + } + if gjson.Get(finalChunk, "choices.0.citations").Exists() { + t.Fatalf("citations from first message leaked to second message: %s", finalChunk) + } +}