diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go index ca20c84849..1cbbc79cc2 100644 --- a/internal/translator/openai/claude/openai_claude_response.go +++ b/internal/translator/openai/claude/openai_claude_response.go @@ -43,6 +43,8 @@ type ConvertOpenAIResponseToAnthropicParams struct { MessageStarted bool // Track if message_stop has been sent MessageStopSent bool + // Accumulated annotations (url_citation) from OpenAI web search + AnnotationsRaw []string // Tool call content block index mapping ToolCallBlockIndexes map[int]int // Index assigned to text content block @@ -190,6 +192,15 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI param.ContentAccumulator.WriteString(content.String()) } + // Handle annotations (url_citation from web search) + if annotations := delta.Get("annotations"); annotations.Exists() && annotations.IsArray() { + annotations.ForEach(func(_, ann gjson.Result) bool { + compacted := strings.ReplaceAll(strings.ReplaceAll(ann.Raw, "\n", ""), "\r", "") + param.AnnotationsRaw = append(param.AnnotationsRaw, compacted) + return true + }) + } + // Handle tool calls if toolCalls := delta.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() { if param.ToolCallsAccumulator == nil { @@ -300,6 +311,13 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI if cachedTokens > 0 { messageDeltaJSON, _ = sjson.Set(messageDeltaJSON, "usage.cache_read_input_tokens", cachedTokens) } + // Attach accumulated annotations as citations + if len(param.AnnotationsRaw) > 0 { + messageDeltaJSON, _ = sjson.SetRaw(messageDeltaJSON, "citations", "[]") + for _, raw := range param.AnnotationsRaw { + messageDeltaJSON, _ = sjson.SetRaw(messageDeltaJSON, "citations.-1", raw) + } + } results = append(results, "event: message_delta\ndata: "+messageDeltaJSON+"\n\n") param.MessageDeltaSent = true @@ -415,6 +433,16 @@ func convertOpenAINonStreamingToAnthropic(rawJSON []byte) []string { if finishReason := choice.Get("finish_reason"); finishReason.Exists() { out, _ = sjson.Set(out, "stop_reason", mapOpenAIFinishReasonToAnthropic(finishReason.String())) } + + // Handle annotations (url_citation from web search) + if annotations := choice.Get("message.annotations"); annotations.Exists() && annotations.IsArray() && len(annotations.Array()) > 0 { + out, _ = sjson.SetRaw(out, "citations", "[]") + annotations.ForEach(func(_, ann gjson.Result) bool { + compacted := strings.ReplaceAll(strings.ReplaceAll(ann.Raw, "\n", ""), "\r", "") + out, _ = sjson.SetRaw(out, "citations.-1", compacted) + return true + }) + } } // Set usage information @@ -665,6 +693,16 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina return true }) } + + // Handle annotations (url_citation from web search) + if annotations := message.Get("annotations"); annotations.Exists() && annotations.IsArray() && len(annotations.Array()) > 0 { + out, _ = sjson.SetRaw(out, "citations", "[]") + annotations.ForEach(func(_, ann gjson.Result) bool { + compacted := strings.ReplaceAll(strings.ReplaceAll(ann.Raw, "\n", ""), "\r", "") + out, _ = sjson.SetRaw(out, "citations.-1", compacted) + return true + }) + } } } diff --git a/internal/translator/openai/gemini/openai_gemini_response.go b/internal/translator/openai/gemini/openai_gemini_response.go index 040f805ce8..7196eda96b 100644 --- a/internal/translator/openai/gemini/openai_gemini_response.go +++ b/internal/translator/openai/gemini/openai_gemini_response.go @@ -24,6 +24,8 @@ type ConvertOpenAIResponseToGeminiParams struct { ContentAccumulator strings.Builder // Track if this is the first chunk IsFirstChunk bool + // Accumulated annotations (url_citation) from OpenAI web search + AnnotationsRaw []string } // ToolCallAccumulator holds the state for accumulating tool call data @@ -146,6 +148,15 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR chunkOutputs = append(chunkOutputs, contentTemplate) } + // Handle annotations (url_citation from web search) + if annotations := delta.Get("annotations"); annotations.Exists() && annotations.IsArray() { + annotations.ForEach(func(_, ann gjson.Result) bool { + compacted := strings.ReplaceAll(strings.ReplaceAll(ann.Raw, "\n", ""), "\r", "") + (*param).(*ConvertOpenAIResponseToGeminiParams).AnnotationsRaw = append((*param).(*ConvertOpenAIResponseToGeminiParams).AnnotationsRaw, compacted) + return true + }) + } + if len(chunkOutputs) > 0 { results = append(results, chunkOutputs...) return true @@ -224,6 +235,16 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator) } + // Attach accumulated annotations as groundingMetadata + if len((*param).(*ConvertOpenAIResponseToGeminiParams).AnnotationsRaw) > 0 { + gm := `{}` + gm, _ = sjson.SetRaw(gm, "citations", "[]") + for _, raw := range (*param).(*ConvertOpenAIResponseToGeminiParams).AnnotationsRaw { + gm, _ = sjson.SetRaw(gm, "citations.-1", raw) + } + template, _ = sjson.SetRaw(template, "candidates.0.groundingMetadata", gm) + } + results = append(results, template) return true } @@ -600,6 +621,18 @@ func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, origina out, _ = sjson.Set(out, "candidates.0.finishReason", geminiFinishReason) } + // Handle annotations as groundingMetadata + if annotations := message.Get("annotations"); annotations.Exists() && annotations.IsArray() && len(annotations.Array()) > 0 { + gm := `{}` + gm, _ = sjson.SetRaw(gm, "citations", "[]") + annotations.ForEach(func(_, ann gjson.Result) bool { + compacted := strings.ReplaceAll(strings.ReplaceAll(ann.Raw, "\n", ""), "\r", "") + gm, _ = sjson.SetRaw(gm, "citations.-1", compacted) + return true + }) + out, _ = sjson.SetRaw(out, "candidates.0.groundingMetadata", gm) + } + // Set index out, _ = sjson.Set(out, "candidates.0.index", choiceIdx) diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 9a64798bd7..eb70c4d63b 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -165,8 +165,8 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu // Only function tools need structural conversion because Chat Completions nests details under "function". toolType := tool.Get("type").String() if toolType != "" && toolType != "function" && tool.IsObject() { - // Almost all providers lack built-in tools, so we just ignore them. - // chatCompletionsTools = append(chatCompletionsTools, tool.Value()) + // Pass through built-in tools (web_search, etc.) as-is + chatCompletionsTools = append(chatCompletionsTools, tool.Value()) return true } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go index 151528526c..f13d57fa5f 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -38,6 +38,8 @@ type oaiToResponsesState struct { // function item done state FuncArgsDone map[int]bool FuncItemDone map[int]bool + // Accumulated annotations (url_citation) from OpenAI web search + AnnotationsRaw []string // usage aggregation PromptTokens int64 CachedTokens int64 @@ -232,6 +234,15 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.MsgTextBuf[idx].WriteString(c.String()) } + // Handle annotations (url_citation from web search) + if annotations := delta.Get("annotations"); annotations.Exists() && annotations.IsArray() { + annotations.ForEach(func(_, ann gjson.Result) bool { + compacted := strings.ReplaceAll(strings.ReplaceAll(ann.Raw, "\n", ""), "\r", "") + st.AnnotationsRaw = append(st.AnnotationsRaw, compacted) + return true + }) + } + // reasoning_content (OpenAI reasoning incremental text) if rc := delta.Get("reasoning_content"); rc.Exists() && rc.String() != "" { // On first appearance, add reasoning item and part @@ -386,6 +397,11 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, partDone, _ = sjson.Set(partDone, "output_index", i) partDone, _ = sjson.Set(partDone, "content_index", 0) partDone, _ = sjson.Set(partDone, "part.text", fullText) + if len(st.AnnotationsRaw) > 0 { + for _, raw := range st.AnnotationsRaw { + partDone, _ = sjson.SetRaw(partDone, "part.annotations.-1", raw) + } + } out = append(out, emitRespEvent("response.content_part.done", partDone)) itemDone := `{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}` @@ -393,6 +409,11 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, itemDone, _ = sjson.Set(itemDone, "output_index", i) itemDone, _ = sjson.Set(itemDone, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i)) itemDone, _ = sjson.Set(itemDone, "item.content.0.text", fullText) + if len(st.AnnotationsRaw) > 0 { + for _, raw := range st.AnnotationsRaw { + itemDone, _ = sjson.SetRaw(itemDone, "item.content.0.annotations.-1", raw) + } + } out = append(out, emitRespEvent("response.output_item.done", itemDone)) st.MsgItemDone[i] = true } @@ -544,6 +565,11 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, item := `{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}` item, _ = sjson.Set(item, "id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i)) item, _ = sjson.Set(item, "content.0.text", txt) + if len(st.AnnotationsRaw) > 0 { + for _, raw := range st.AnnotationsRaw { + item, _ = sjson.SetRaw(item, "content.0.annotations.-1", raw) + } + } outputsWrapper, _ = sjson.SetRaw(outputsWrapper, "arr.-1", item) } } @@ -730,6 +756,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co item := `{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}` item, _ = sjson.Set(item, "id", fmt.Sprintf("msg_%s_%d", id, int(choice.Get("index").Int()))) item, _ = sjson.Set(item, "content.0.text", c.String()) + // Populate annotations from message if present + if annotations := msg.Get("annotations"); annotations.Exists() && annotations.IsArray() && len(annotations.Array()) > 0 { + annotations.ForEach(func(_, ann gjson.Result) bool { + compacted := strings.ReplaceAll(strings.ReplaceAll(ann.Raw, "\n", ""), "\r", "") + item, _ = sjson.SetRaw(item, "content.0.annotations.-1", compacted) + return true + }) + } outputsWrapper, _ = sjson.SetRaw(outputsWrapper, "arr.-1", item) } diff --git a/test/builtin_tools_translation_test.go b/test/builtin_tools_translation_test.go index 07d7671544..24262dc083 100644 --- a/test/builtin_tools_translation_test.go +++ b/test/builtin_tools_translation_test.go @@ -33,7 +33,7 @@ func TestOpenAIToCodex_PreservesBuiltinTools(t *testing.T) { } } -func TestOpenAIResponsesToOpenAI_IgnoresBuiltinTools(t *testing.T) { +func TestOpenAIResponsesToOpenAI_PassesBuiltinTools(t *testing.T) { in := []byte(`{ "model":"gpt-5", "input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}], @@ -42,7 +42,13 @@ func TestOpenAIResponsesToOpenAI_IgnoresBuiltinTools(t *testing.T) { out := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "gpt-5", in, false) - if got := gjson.GetBytes(out, "tools.#").Int(); got != 0 { - t.Fatalf("expected 0 tools (builtin tools not supported in Chat Completions), got %d: %s", got, string(out)) + if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 { + t.Fatalf("expected 1 tool (builtin tools passed through to Chat Completions), got %d: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.type").String(); got != "web_search" { + t.Fatalf("expected tools[0].type=web_search, got %q", got) + } + if got := gjson.GetBytes(out, "tools.0.search_context_size").String(); got != "low" { + t.Fatalf("expected tools[0].search_context_size=low, got %q", got) } } diff --git a/test/openai_websearch_translation_test.go b/test/openai_websearch_translation_test.go new file mode 100644 index 0000000000..a6bd141241 --- /dev/null +++ b/test/openai_websearch_translation_test.go @@ -0,0 +1,313 @@ +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 TestResponsesToOpenAI_PassesBuiltinWebSearchTool(t *testing.T) { + in := []byte(`{ + "model":"gpt-4o", + "input":[{"role":"user","content":[{"type":"input_text","text":"search the web"}]}], + "tools":[ + {"type":"web_search_preview"}, + {"type":"function","name":"calc","description":"Calculate","parameters":{"type":"object","properties":{}}} + ] + }`) + + out := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "gpt-4o", 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 passed through as-is + tool0Type := gjson.GetBytes(out, "tools.0.type").String() + if tool0Type != "web_search_preview" { + t.Fatalf("expected tools[0].type=web_search_preview, got %q", tool0Type) + } + + // Second should be converted to function format + tool1Type := gjson.GetBytes(out, "tools.1.type").String() + if tool1Type != "function" { + t.Fatalf("expected tools[1].type=function, got %q", tool1Type) + } +} + +// --- OpenAI→Claude response tests --- + +func TestOpenAIToClaude_StreamAnnotationsAsCitations(t *testing.T) { + ctx := context.Background() + model := "gpt-4o" + reqJSON := []byte(`{"stream":true}`) + var param any + + // First chunk with role + sse1 := []byte(`data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, model, reqJSON, reqJSON, sse1, ¶m) + + // Content chunk + sse2 := []byte(`data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"The answer is here."},"finish_reason":null}]}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, model, reqJSON, reqJSON, sse2, ¶m) + + // First annotation chunk + sse3 := []byte(`data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"annotations":[{"type":"url_citation","url":"https://example.com/1","title":"First","start_index":0,"end_index":10}]},"finish_reason":null}]}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, model, reqJSON, reqJSON, sse3, ¶m) + + // Second annotation chunk (tests multi-chunk accumulation) + sse3b := []byte(`data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"annotations":[{"type":"url_citation","url":"https://example.com/2","title":"Second","start_index":11,"end_index":19}]},"finish_reason":null}]}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, model, reqJSON, reqJSON, sse3b, ¶m) + + // Finish + usage chunk + sse4 := []byte(`data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":50,"completion_tokens":20,"total_tokens":70}}`) + results := sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, model, reqJSON, reqJSON, sse4, ¶m) + + var messageDelta string + for _, r := range results { + if gjson.Get(r, "type").String() == "message_delta" { + messageDelta = r + break + } + } + if messageDelta == "" { + t.Fatalf("expected message_delta event, got: %v", results) + } + + citCount := gjson.Get(messageDelta, "citations.#").Int() + if citCount != 2 { + t.Fatalf("expected 2 citations on message_delta, got %d: %s", citCount, messageDelta) + } + if url := gjson.Get(messageDelta, "citations.0.url").String(); url != "https://example.com/1" { + t.Fatalf("expected citations[0].url=https://example.com/1, got %q", url) + } + if url := gjson.Get(messageDelta, "citations.1.url").String(); url != "https://example.com/2" { + t.Fatalf("expected citations[1].url=https://example.com/2, got %q", url) + } +} + +func TestOpenAIToClaude_NonStreamAnnotationsAsCitations(t *testing.T) { + ctx := context.Background() + model := "gpt-4o" + reqJSON := []byte(`{}`) + + // Non-streaming response with annotations + rawJSON := []byte(`{ + "id":"chatcmpl-ns","object":"chat.completion","created":1700000000,"model":"gpt-4o", + "choices":[{ + "index":0,"message":{ + "role":"assistant","content":"The answer is here.", + "annotations":[ + {"type":"url_citation","url":"https://example.com/1","title":"First","start_index":0,"end_index":10}, + {"type":"url_citation","url":"https://example.com/2","title":"Second","start_index":11,"end_index":19} + ] + },"finish_reason":"stop" + }], + "usage":{"prompt_tokens":50,"completion_tokens":20,"total_tokens":70} + }`) + + var param any + out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, model, reqJSON, reqJSON, rawJSON, ¶m) + + // Verify citations on response + citCount := gjson.Get(out, "citations.#").Int() + if citCount != 2 { + t.Fatalf("expected 2 citations, got %d: %s", citCount, out) + } + if url := gjson.Get(out, "citations.0.url").String(); url != "https://example.com/1" { + t.Fatalf("expected citations[0].url=https://example.com/1, got %q", url) + } + if url := gjson.Get(out, "citations.1.url").String(); url != "https://example.com/2" { + t.Fatalf("expected citations[1].url=https://example.com/2, got %q", url) + } + + // Verify text content still present + textContent := gjson.Get(out, "content.0.text").String() + if textContent != "The answer is here." { + t.Fatalf("expected text content, got %q", textContent) + } +} + +// --- OpenAI→Gemini response tests --- + +func TestOpenAIToGemini_StreamAnnotationsAsGroundingMetadata(t *testing.T) { + ctx := context.Background() + model := "gpt-4o" + reqJSON := []byte(`{}`) + var param any + + // First chunk with role + sse1 := []byte(`data: {"id":"chatcmpl-gem","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatGemini, model, reqJSON, reqJSON, sse1, ¶m) + + // Content chunk + sse2 := []byte(`data: {"id":"chatcmpl-gem","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Gemini answer."},"finish_reason":null}]}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatGemini, model, reqJSON, reqJSON, sse2, ¶m) + + // Annotation chunk + sse3 := []byte(`data: {"id":"chatcmpl-gem","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"annotations":[{"type":"url_citation","url":"https://gemini.test","title":"Gemini Source","start_index":0,"end_index":14}]},"finish_reason":null}]}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatGemini, model, reqJSON, reqJSON, sse3, ¶m) + + // Finish reason chunk + sse4 := []byte(`data: {"id":"chatcmpl-gem","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`) + results := sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatGemini, model, reqJSON, reqJSON, sse4, ¶m) + + // Find chunk with groundingMetadata + var finalChunk string + for _, r := range results { + if gjson.Get(r, "candidates.0.groundingMetadata").Exists() { + finalChunk = r + break + } + } + if finalChunk == "" { + t.Fatalf("expected groundingMetadata on finish chunk, got: %v", results) + } + + citCount := gjson.Get(finalChunk, "candidates.0.groundingMetadata.citations.#").Int() + if citCount != 1 { + t.Fatalf("expected 1 citation in groundingMetadata, got %d: %s", citCount, finalChunk) + } + citURL := gjson.Get(finalChunk, "candidates.0.groundingMetadata.citations.0.url").String() + if citURL != "https://gemini.test" { + t.Fatalf("expected citations[0].url=https://gemini.test, got %q", citURL) + } +} + +func TestOpenAIToGemini_NonStreamAnnotationsAsGroundingMetadata(t *testing.T) { + ctx := context.Background() + model := "gpt-4o" + reqJSON := []byte(`{}`) + + rawJSON := []byte(`{ + "id":"chatcmpl-gns","object":"chat.completion","created":1700000000,"model":"gpt-4o", + "choices":[{ + "index":0,"message":{ + "role":"assistant","content":"Gemini non-stream.", + "annotations":[ + {"type":"url_citation","url":"https://gemini-ns.test","title":"GNS Source","start_index":0,"end_index":18} + ] + },"finish_reason":"stop" + }], + "usage":{"prompt_tokens":40,"completion_tokens":15,"total_tokens":55} + }`) + + var param any + out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatGemini, model, reqJSON, reqJSON, rawJSON, ¶m) + + if !gjson.Get(out, "candidates.0.groundingMetadata").Exists() { + t.Fatalf("expected groundingMetadata, got: %s", out) + } + + citCount := gjson.Get(out, "candidates.0.groundingMetadata.citations.#").Int() + if citCount != 1 { + t.Fatalf("expected 1 citation, 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 url=https://gemini-ns.test, got %q", citURL) + } +} + +// --- OpenAI CC→Responses tests --- + +func TestOpenAIToResponses_StreamAnnotationsPopulated(t *testing.T) { + ctx := context.Background() + model := "gpt-4o" + reqJSON := []byte(`{}`) + var param any + + // First chunk + sse1 := []byte(`data: {"id":"chatcmpl-resp","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse1, ¶m) + + // Content + sse2 := []byte(`data: {"id":"chatcmpl-resp","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Responses answer."},"finish_reason":null}]}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse2, ¶m) + + // Annotation + sse3 := []byte(`data: {"id":"chatcmpl-resp","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"annotations":[{"type":"url_citation","url":"https://resp.test","title":"Resp Source","start_index":0,"end_index":17}]},"finish_reason":null}]}`) + sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse3, ¶m) + + // Finish + usage + sse4 := []byte(`data: {"id":"chatcmpl-resp","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":50,"completion_tokens":20,"total_tokens":70}}`) + results := sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, sse4, ¶m) + + // Verify content_part.done has annotations + var partDone string + for _, r := range results { + if gjson.Get(r, "type").String() == "response.content_part.done" { + partDone = r + break + } + } + if partDone == "" { + t.Fatalf("expected response.content_part.done, got: %v", results) + } + if cnt := gjson.Get(partDone, "part.annotations.#").Int(); cnt != 1 { + t.Fatalf("expected 1 annotation on content_part.done, got %d: %s", cnt, partDone) + } + if url := gjson.Get(partDone, "part.annotations.0.url").String(); url != "https://resp.test" { + t.Fatalf("expected part.annotations[0].url=https://resp.test, got %q", url) + } + + // Verify output_item.done has annotations + var itemDone string + for _, r := range results { + if gjson.Get(r, "type").String() == "response.output_item.done" { + if gjson.Get(r, "item.type").String() == "message" { + itemDone = r + break + } + } + } + if itemDone == "" { + t.Fatalf("expected response.output_item.done with message, got: %v", results) + } + annCount := gjson.Get(itemDone, "item.content.0.annotations.#").Int() + if annCount != 1 { + t.Fatalf("expected 1 annotation on output_item.done, got %d: %s", annCount, itemDone) + } + if url := gjson.Get(itemDone, "item.content.0.annotations.0.url").String(); url != "https://resp.test" { + t.Fatalf("expected annotations[0].url=https://resp.test, got %q", url) + } +} + +func TestOpenAIToResponses_NonStreamAnnotationsPopulated(t *testing.T) { + ctx := context.Background() + model := "gpt-4o" + reqJSON := []byte(`{}`) + + rawJSON := []byte(`{ + "id":"chatcmpl-rns","object":"chat.completion","created":1700000000,"model":"gpt-4o", + "choices":[{ + "index":0,"message":{ + "role":"assistant","content":"Non-stream response.", + "annotations":[ + {"type":"url_citation","url":"https://rns.test","title":"RNS Source","start_index":0,"end_index":20} + ] + },"finish_reason":"stop" + }], + "usage":{"prompt_tokens":40,"completion_tokens":15,"total_tokens":55} + }`) + + var param any + out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatOpenAI, sdktranslator.FormatOpenAIResponse, model, reqJSON, reqJSON, rawJSON, ¶m) + + // Find message output item + annCount := gjson.Get(out, "output.#(type==\"message\").content.0.annotations.#").Int() + if annCount != 1 { + t.Fatalf("expected 1 annotation on message output, got %d: %s", annCount, out) + } + annURL := gjson.Get(out, "output.#(type==\"message\").content.0.annotations.0.url").String() + if annURL != "https://rns.test" { + t.Fatalf("expected annotations[0].url=https://rns.test, got %q", annURL) + } +}