Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions internal/translator/openai/claude/openai_claude_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
})
}
}
}

Expand Down
33 changes: 33 additions & 0 deletions internal/translator/openai/gemini/openai_gemini_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -386,13 +397,23 @@ 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"}}`
itemDone, _ = sjson.Set(itemDone, "sequence_number", nextSeq())
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
}
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
}

Expand Down
12 changes: 9 additions & 3 deletions test/builtin_tools_translation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]}],
Expand All @@ -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)
}
}
Loading
Loading