Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View file

@ -0,0 +1,20 @@
package claude
import (
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
translator.Register(
Claude,
OpenAI,
ConvertClaudeRequestToOpenAI,
interfaces.TranslateResponse{
Stream: ConvertOpenAIResponseToClaude,
NonStream: ConvertOpenAIResponseToClaudeNonStream,
TokenCount: ClaudeTokenCount,
},
)
}

View file

@ -0,0 +1,69 @@
package claude
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertClaudeRequestToOpenAIWithCompatPreservesEmptySignatureThinking(t *testing.T) {
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""}]}]}`)
withoutCompat := ConvertClaudeRequestToOpenAI("deepseek-v4", payload, false)
if gjson.GetBytes(withoutCompat, "messages.0.reasoning_content").Exists() {
t.Fatalf("default translation preserved empty-signature reasoning: %s", withoutCompat)
}
withCompat := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false)
if gjson.GetBytes(withCompat, "messages.0.reasoning_content").String() != "reason" {
t.Fatalf("compat translation missing reasoning_content: %s", withCompat)
}
}
func TestConvertClaudeRequestToOpenAIWithCompatPreservesThinkingWithToolCalls(t *testing.T) {
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""},{"type":"text","text":"Reading files."},{"type":"tool_use","id":"call_1","name":"Read","input":{"path":"main.go"}}]}]}`)
result := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false)
assistant := gjson.GetBytes(result, "messages.0")
if got := assistant.Get("reasoning_content").String(); got != "reason" {
t.Fatalf("reasoning_content = %q, want %q; output: %s", got, "reason", result)
}
if !assistant.Get("tool_calls").Exists() {
t.Fatalf("tool_calls missing from compatible translation: %s", result)
}
}
func TestConvertClaudeRequestToOpenAIWithCompatDoesNotAddReasoningWithoutThinking(t *testing.T) {
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"Read","input":{}}]}]}`)
result := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false)
assistant := gjson.GetBytes(result, "messages.0")
if assistant.Get("reasoning_content").Exists() {
t.Fatalf("compatible translation added reasoning_content without thinking: %s", result)
}
if !assistant.Get("tool_calls").Exists() {
t.Fatalf("tool_calls missing from compatible translation: %s", result)
}
}
func TestConvertClaudeRequestToOpenAIWithCompatPreservesIncompatibleThinking(t *testing.T) {
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"claude#opaque"},{"type":"tool_use","id":"call_1","name":"Read","input":{}}]}]}`)
result := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false)
assistant := gjson.GetBytes(result, "messages.0")
if got := assistant.Get("reasoning_content").String(); got != "reason" {
t.Fatalf("reasoning_content = %q, want %q; output: %s", got, "reason", result)
}
if !assistant.Get("tool_calls").Exists() {
t.Fatalf("tool_calls missing from compatible translation: %s", result)
}
}
func TestConvertClaudeRequestToOpenAIWithoutCompatDoesNotAddReasoningForToolCalls(t *testing.T) {
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"Read","input":{}}]}]}`)
result := ConvertClaudeRequestToOpenAI("deepseek-v4", payload, false)
if gjson.GetBytes(result, "messages.0.reasoning_content").Exists() {
t.Fatalf("default translation added reasoning_content: %s", result)
}
}

View file

@ -0,0 +1,505 @@
// Package claude provides request translation functionality for Anthropic to OpenAI API.
// It handles parsing and transforming Anthropic API requests into OpenAI Chat Completions API format,
// extracting model information, system instructions, message contents, and tool declarations.
// The package performs JSON data transformation to ensure compatibility
// between Anthropic API format and OpenAI API's expected format.
package claude
import (
"strings"
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertClaudeRequestToOpenAI parses and transforms an Anthropic API request into OpenAI Chat Completions API format.
// It extracts the model name, system instruction, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the OpenAI API.
func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte {
return convertClaudeRequestToOpenAI(modelName, inputRawJSON, stream, false)
}
// ConvertClaudeRequestToOpenAIWithCompat preserves assistant thinking text
// for configured compatibility endpoints.
func ConvertClaudeRequestToOpenAIWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte {
return convertClaudeRequestToOpenAI(modelName, inputRawJSON, stream, true)
}
func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool, preserveThinkingBlocks bool) []byte {
rawJSON := inputRawJSON
// Base OpenAI Chat Completions API template
out := []byte(`{"model":"","messages":[]}`)
root := gjson.ParseBytes(rawJSON)
// Model mapping
out, _ = sjson.SetBytes(out, "model", modelName)
// Max tokens
if maxTokens := root.Get("max_tokens"); maxTokens.Exists() {
out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
}
// Temperature
if temp := root.Get("temperature"); temp.Exists() {
out, _ = sjson.SetBytes(out, "temperature", temp.Float())
} else if topP := root.Get("top_p"); topP.Exists() { // Top P
out, _ = sjson.SetBytes(out, "top_p", topP.Float())
}
// Stop sequences -> stop
if stopSequences := root.Get("stop_sequences"); stopSequences.Exists() {
if stopSequences.IsArray() {
var stops []string
stopSequences.ForEach(func(_, value gjson.Result) bool {
stops = append(stops, value.String())
return true
})
if len(stops) > 0 {
out, _ = sjson.SetBytes(out, "stop", stops)
}
}
}
// Stream
out, _ = sjson.SetBytes(out, "stream", stream)
// Thinking: Convert Claude thinking.budget_tokens to OpenAI reasoning_effort
if thinkingConfig := root.Get("thinking"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
if thinkingType := thinkingConfig.Get("type"); thinkingType.Exists() {
switch thinkingType.String() {
case "enabled":
if budgetTokens := thinkingConfig.Get("budget_tokens"); budgetTokens.Exists() {
budget := int(budgetTokens.Int())
if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" {
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
}
} else {
// No budget_tokens specified, default to "auto" for enabled thinking
if effort, ok := thinking.ConvertBudgetToLevel(-1); ok && effort != "" {
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
}
}
case "adaptive", "auto":
// Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6).
// Pass through directly; ApplyThinking handles clamping to target model's levels.
effort := ""
if v := root.Get("output_config.effort"); v.Exists() && v.Type == gjson.String {
effort = strings.ToLower(strings.TrimSpace(v.String()))
}
if effort != "" {
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
} else {
out, _ = sjson.SetBytes(out, "reasoning_effort", string(thinking.LevelXHigh))
}
case "disabled":
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
}
}
}
}
// Process messages and system.
messageCapacity := root.Get("messages.#").Int()
if root.Get("system").Exists() {
messageCapacity++
}
messageItems := translatorcommon.NewRawArrayItems(messageCapacity)
// Handle system message first.
systemContentItems := make([][]byte, 0, 2)
appendSystemContent := func(content gjson.Result) {
if !content.Exists() {
return
}
if content.Type == gjson.String {
if content.String() == "" || util.IsClaudeCodeAttributionSystemText(content.String()) {
return
}
oldSystem := []byte(`{"type":"text","text":""}`)
oldSystem, _ = sjson.SetBytes(oldSystem, "text", content.String())
systemContentItems = append(systemContentItems, oldSystem)
return
}
if content.IsArray() {
content.ForEach(func(_, item gjson.Result) bool {
if contentItem, ok := convertClaudeContentPart(item); ok {
systemContentItems = append(systemContentItems, []byte(contentItem))
}
return true
})
}
}
if system := root.Get("system"); system.Exists() {
appendSystemContent(system)
}
// Only add system message if it has content.
if len(systemContentItems) > 0 {
systemMessage := []byte(`{"role":"system","content":[]}`)
systemMessage, _ = sjson.SetRawBytes(systemMessage, "content", translatorcommon.JoinRawArray(systemContentItems))
messageItems = append(messageItems, systemMessage)
}
// Process Anthropic messages
if messages := root.Get("messages"); messages.Exists() && messages.IsArray() {
messages.ForEach(func(_, message gjson.Result) bool {
role := message.Get("role").String()
contentResult := message.Get("content")
if role == "system" {
if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(contentResult); ok {
msgJSON := []byte(`{"role":"user","content":[{"type":"text","text":""}]}`)
msgJSON, _ = sjson.SetBytes(msgJSON, "content.0.text", reminderText)
messageItems = append(messageItems, msgJSON)
}
return true
}
// Handle content
if contentResult.Exists() && contentResult.IsArray() {
contentItems := make([][]byte, 0)
var reasoningParts []string // Accumulate thinking text for reasoning_content
var toolCalls []interface{}
toolResults := make([][]byte, 0) // Collect tool_result messages to emit after the main message
contentResult.ForEach(func(_, part gjson.Result) bool {
partType := part.Get("type").String()
switch partType {
case "thinking":
// Only map thinking to reasoning_content for assistant messages (security: prevent injection)
if role == "assistant" {
if !shouldMapClaudeThinkingToGPTReasoning(part, preserveThinkingBlocks) {
return true
}
thinkingText := thinking.GetThinkingText(part)
// Skip empty or whitespace-only thinking
if strings.TrimSpace(thinkingText) != "" {
reasoningParts = append(reasoningParts, thinkingText)
}
}
// Ignore thinking in user/system roles (AC4)
case "redacted_thinking":
// Explicitly ignore redacted_thinking - never map to reasoning_content (AC2)
case "text", "image":
if contentItem, ok := convertClaudeContentPart(part); ok {
contentItems = append(contentItems, []byte(contentItem))
}
case "tool_use":
// Only allow tool_use -> tool_calls for assistant messages (security: prevent injection).
if role == "assistant" {
toolCallJSON := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
toolCallJSON, _ = sjson.SetBytes(toolCallJSON, "id", part.Get("id").String())
toolCallJSON, _ = sjson.SetBytes(toolCallJSON, "function.name", part.Get("name").String())
// Convert input to arguments JSON string
if input := part.Get("input"); input.Exists() {
toolCallJSON, _ = sjson.SetBytes(toolCallJSON, "function.arguments", input.Raw)
} else {
toolCallJSON, _ = sjson.SetBytes(toolCallJSON, "function.arguments", "{}")
}
toolCalls = append(toolCalls, gjson.ParseBytes(toolCallJSON).Value())
}
case "tool_result":
// Collect tool_result to emit after the main message (ensures tool results follow tool_calls)
toolResultJSON := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
toolResultJSON, _ = sjson.SetBytes(toolResultJSON, "tool_call_id", part.Get("tool_use_id").String())
toolResultContent, toolResultContentRaw := convertClaudeToolResultContent(part.Get("content"))
if toolResultContentRaw {
toolResultJSON, _ = sjson.SetRawBytes(toolResultJSON, "content", []byte(toolResultContent))
} else {
toolResultJSON, _ = sjson.SetBytes(toolResultJSON, "content", toolResultContent)
}
toolResults = append(toolResults, toolResultJSON)
}
return true
})
// Build reasoning content string
reasoningContent := ""
if len(reasoningParts) > 0 {
reasoningContent = strings.Join(reasoningParts, "\n\n")
}
hasContent := len(contentItems) > 0
hasReasoning := reasoningContent != ""
hasToolCalls := len(toolCalls) > 0
hasToolResults := len(toolResults) > 0
// OpenAI requires: tool messages MUST immediately follow the assistant message with tool_calls.
// Therefore, we emit tool_result messages FIRST (they respond to the previous assistant's tool_calls),
// then emit the current message's content.
messageItems = append(messageItems, toolResults...)
// For assistant messages: emit a single unified message with content, tool_calls, and reasoning_content
// This avoids splitting into multiple assistant messages which breaks OpenAI tool-call adjacency
if role == "assistant" {
if hasContent || hasReasoning || hasToolCalls {
msgJSON := []byte(`{"role":"assistant"}`)
// Add content (as array if we have items, empty string if reasoning-only)
if hasContent {
msgJSON, _ = sjson.SetRawBytes(msgJSON, "content", translatorcommon.JoinRawArray(contentItems))
} else {
// Ensure content field exists for OpenAI compatibility
msgJSON, _ = sjson.SetBytes(msgJSON, "content", "")
}
// Add reasoning_content if present
if hasReasoning {
msgJSON, _ = sjson.SetBytes(msgJSON, "reasoning_content", reasoningContent)
}
// Add tool_calls if present (in same message as content)
if hasToolCalls {
msgJSON, _ = sjson.SetBytes(msgJSON, "tool_calls", toolCalls)
}
messageItems = append(messageItems, msgJSON)
}
} else {
// For non-assistant roles: emit content message if we have content
// If the message only contains tool_results (no text/image), we still processed them above
if hasContent {
msgJSON := []byte(`{"role":""}`)
msgJSON, _ = sjson.SetBytes(msgJSON, "role", role)
msgJSON, _ = sjson.SetRawBytes(msgJSON, "content", translatorcommon.JoinRawArray(contentItems))
messageItems = append(messageItems, msgJSON)
} else if hasToolResults && !hasContent {
// tool_results already emitted above, no additional user message needed
}
}
} else if contentResult.Exists() && contentResult.Type == gjson.String {
// Simple string content
msgJSON := []byte(`{"role":"","content":""}`)
msgJSON, _ = sjson.SetBytes(msgJSON, "role", role)
msgJSON, _ = sjson.SetBytes(msgJSON, "content", contentResult.String())
messageItems = append(messageItems, msgJSON)
}
return true
})
}
// Set messages.
if len(messageItems) > 0 {
out = translatorcommon.SetRawArrayItems(out, "messages", messageItems)
}
// Process tools - convert Anthropic tools to OpenAI functions
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
var toolItems [][]byte
tools.ForEach(func(_, tool gjson.Result) bool {
openAIToolJSON := []byte(`{"type":"function","function":{"name":"","description":""}}`)
openAIToolJSON, _ = sjson.SetBytes(openAIToolJSON, "function.name", tool.Get("name").String())
openAIToolJSON, _ = sjson.SetBytes(openAIToolJSON, "function.description", tool.Get("description").String())
// Convert Anthropic input_schema to OpenAI function parameters
if inputSchema := tool.Get("input_schema"); inputSchema.Exists() {
openAIToolJSON, _ = sjson.SetBytes(openAIToolJSON, "function.parameters", normalizeObjectSchemaProperties(inputSchema.Value()))
}
toolItems = append(toolItems, openAIToolJSON)
return true
})
if len(toolItems) > 0 {
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
}
}
// Tool choice mapping - convert Anthropic tool_choice to OpenAI format
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
switch toolChoice.Get("type").String() {
case "auto":
out, _ = sjson.SetBytes(out, "tool_choice", "auto")
case "any":
out, _ = sjson.SetBytes(out, "tool_choice", "required")
case "tool":
// Specific tool choice
toolName := toolChoice.Get("name").String()
toolChoiceJSON := []byte(`{"type":"function","function":{"name":""}}`)
toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "function.name", toolName)
out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
default:
// Default to auto if not specified
out, _ = sjson.SetBytes(out, "tool_choice", "auto")
}
}
// Handle user parameter (for tracking)
if user := root.Get("user"); user.Exists() {
out, _ = sjson.SetBytes(out, "user", user.String())
}
return out
}
func normalizeObjectSchemaProperties(schema any) any {
switch value := schema.(type) {
case map[string]any:
if schemaType, ok := value["type"].(string); ok && schemaType == "object" {
if _, ok := value["properties"]; !ok {
value["properties"] = map[string]any{}
}
}
for key, child := range value {
value[key] = normalizeObjectSchemaProperties(child)
}
return value
case []any:
for i, child := range value {
value[i] = normalizeObjectSchemaProperties(child)
}
return value
default:
return schema
}
}
func shouldMapClaudeThinkingToGPTReasoning(part gjson.Result, preserveThinkingBlocks ...bool) bool {
preserveThinking := len(preserveThinkingBlocks) > 0 && preserveThinkingBlocks[0]
if preserveThinking {
return true
}
signature := part.Get("signature")
if !signature.Exists() || strings.TrimSpace(signature.String()) == "" {
return false
}
_, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderGPT, signature.String())
return ok
}
func convertClaudeContentPart(part gjson.Result) (string, bool) {
partType := part.Get("type").String()
switch partType {
case "text":
text := part.Get("text").String()
if strings.TrimSpace(text) == "" || util.IsClaudeCodeAttributionSystemText(text) {
return "", false
}
textContent := []byte(`{"type":"text","text":""}`)
textContent, _ = sjson.SetBytes(textContent, "text", text)
return string(textContent), true
case "image":
var imageURL string
if source := part.Get("source"); source.Exists() {
sourceType := source.Get("type").String()
switch sourceType {
case "base64":
mediaType := source.Get("media_type").String()
if mediaType == "" {
mediaType = "application/octet-stream"
}
data := source.Get("data").String()
if data != "" {
imageURL = "data:" + mediaType + ";base64," + data
}
case "url":
imageURL = source.Get("url").String()
}
}
if imageURL == "" {
imageURL = part.Get("url").String()
}
if imageURL == "" {
return "", false
}
imageContent := []byte(`{"type":"image_url","image_url":{"url":""}}`)
imageContent, _ = sjson.SetBytes(imageContent, "image_url.url", imageURL)
return string(imageContent), true
default:
return "", false
}
}
func convertClaudeToolResultContent(content gjson.Result) (string, bool) {
if !content.Exists() {
return "", false
}
if content.Type == gjson.String {
return content.String(), false
}
if content.IsArray() {
var parts []string
contentItems := make([][]byte, 0, 4)
hasImagePart := false
content.ForEach(func(_, item gjson.Result) bool {
switch {
case item.Type == gjson.String:
text := item.String()
parts = append(parts, text)
textContent := []byte(`{"type":"text","text":""}`)
textContent, _ = sjson.SetBytes(textContent, "text", text)
contentItems = append(contentItems, textContent)
case item.IsObject() && item.Get("type").String() == "text":
text := item.Get("text").String()
parts = append(parts, text)
textContent := []byte(`{"type":"text","text":""}`)
textContent, _ = sjson.SetBytes(textContent, "text", text)
contentItems = append(contentItems, textContent)
case item.IsObject() && item.Get("type").String() == "image":
contentItem, ok := convertClaudeContentPart(item)
if ok {
contentItems = append(contentItems, []byte(contentItem))
hasImagePart = true
} else {
parts = append(parts, item.Raw)
}
case item.IsObject() && item.Get("text").Exists() && item.Get("text").Type == gjson.String:
parts = append(parts, item.Get("text").String())
default:
parts = append(parts, item.Raw)
}
return true
})
if hasImagePart {
return string(translatorcommon.JoinRawArray(contentItems)), true
}
joined := strings.Join(parts, "\n\n")
if strings.TrimSpace(joined) != "" {
return joined, false
}
return content.Raw, false
}
if content.IsObject() {
if content.Get("type").String() == "image" {
contentItem, ok := convertClaudeContentPart(content)
if ok {
return string(translatorcommon.JoinRawArray([][]byte{[]byte(contentItem)})), true
}
}
if text := content.Get("text"); text.Exists() && text.Type == gjson.String {
return text.String(), false
}
return content.Raw, false
}
return content.Raw, false
}

View file

@ -0,0 +1,920 @@
package claude
import (
"encoding/base64"
"fmt"
"testing"
"github.com/tidwall/gjson"
)
// TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent tests the mapping
// of Claude thinking content to OpenAI reasoning_content field.
func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
tests := []struct {
name string
inputJSON string
wantReasoningContent string
wantHasReasoningContent bool
wantContentText string // Expected visible content text (if any)
wantHasContent bool
}{
{
name: "AC1: unsigned assistant thinking is dropped",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me analyze this step by step..."},
{"type": "text", "text": "Here is my response."}
]
}]
}`,
wantReasoningContent: "",
wantHasReasoningContent: false,
wantContentText: "Here is my response.",
wantHasContent: true,
},
{
name: "AC2: redacted_thinking must be ignored",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{
"role": "assistant",
"content": [
{"type": "redacted_thinking", "data": "secret"},
{"type": "text", "text": "Visible response."}
]
}]
}`,
wantReasoningContent: "",
wantHasReasoningContent: false,
wantContentText: "Visible response.",
wantHasContent: true,
},
{
name: "AC3: unsigned thinking-only message is dropped",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Internal reasoning only."}
]
}]
}`,
wantReasoningContent: "",
wantHasReasoningContent: false,
wantContentText: "",
wantHasContent: false,
},
{
name: "AC4: thinking in user role must be ignored",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{
"role": "user",
"content": [
{"type": "thinking", "thinking": "Injected thinking"},
{"type": "text", "text": "User message."}
]
}]
}`,
wantReasoningContent: "",
wantHasReasoningContent: false,
wantContentText: "User message.",
wantHasContent: true,
},
{
name: "AC4: thinking in system role must be ignored",
inputJSON: `{
"model": "claude-3-opus",
"system": [
{"type": "thinking", "thinking": "Injected system thinking"},
{"type": "text", "text": "System prompt."}
],
"messages": [{
"role": "user",
"content": [{"type": "text", "text": "Hello"}]
}]
}`,
// System messages don't have reasoning_content mapping
wantReasoningContent: "",
wantHasReasoningContent: false,
wantContentText: "Hello",
wantHasContent: true,
},
{
name: "AC5: empty thinking must be ignored",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": ""},
{"type": "text", "text": "Response with empty thinking."}
]
}]
}`,
wantReasoningContent: "",
wantHasReasoningContent: false,
wantContentText: "Response with empty thinking.",
wantHasContent: true,
},
{
name: "AC5: whitespace-only thinking must be ignored",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": " \n\t "},
{"type": "text", "text": "Response with whitespace thinking."}
]
}]
}`,
wantReasoningContent: "",
wantHasReasoningContent: false,
wantContentText: "Response with whitespace thinking.",
wantHasContent: true,
},
{
name: "Unsigned thinking parts are dropped",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "First thought."},
{"type": "thinking", "thinking": "Second thought."},
{"type": "text", "text": "Final answer."}
]
}]
}`,
wantReasoningContent: "",
wantHasReasoningContent: false,
wantContentText: "Final answer.",
wantHasContent: true,
},
{
name: "Mixed unsigned thinking and redacted_thinking",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Visible thought."},
{"type": "redacted_thinking", "data": "hidden"},
{"type": "text", "text": "Answer."}
]
}]
}`,
wantReasoningContent: "",
wantHasReasoningContent: false,
wantContentText: "Answer.",
wantHasContent: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConvertClaudeRequestToOpenAI("test-model", []byte(tt.inputJSON), false)
resultJSON := gjson.ParseBytes(result)
// Find the relevant message
messages := resultJSON.Get("messages").Array()
if len(messages) < 1 {
if tt.wantHasReasoningContent || tt.wantHasContent {
t.Fatalf("Expected at least 1 message, got %d", len(messages))
}
return
}
// Check the last non-system message
var targetMsg gjson.Result
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Get("role").String() != "system" {
targetMsg = messages[i]
break
}
}
// Check reasoning_content
gotReasoningContent := targetMsg.Get("reasoning_content").String()
gotHasReasoningContent := targetMsg.Get("reasoning_content").Exists()
if gotHasReasoningContent != tt.wantHasReasoningContent {
t.Errorf("reasoning_content existence = %v, want %v", gotHasReasoningContent, tt.wantHasReasoningContent)
}
if gotReasoningContent != tt.wantReasoningContent {
t.Errorf("reasoning_content = %q, want %q", gotReasoningContent, tt.wantReasoningContent)
}
// Check content
content := targetMsg.Get("content")
// content has meaningful content if it's a non-empty array, or a non-empty string
var gotHasContent bool
switch {
case content.IsArray():
gotHasContent = len(content.Array()) > 0
case content.Type == gjson.String:
gotHasContent = content.String() != ""
default:
gotHasContent = false
}
if gotHasContent != tt.wantHasContent {
t.Errorf("content existence = %v, want %v", gotHasContent, tt.wantHasContent)
}
if tt.wantHasContent && tt.wantContentText != "" {
// Find text content
var foundText string
content.ForEach(func(_, v gjson.Result) bool {
if v.Get("type").String() == "text" {
foundText = v.Get("text").String()
return false
}
return true
})
if foundText != tt.wantContentText {
t.Errorf("content text = %q, want %q", foundText, tt.wantContentText)
}
}
})
}
}
func TestConvertClaudeRequestToOpenAI_SignedThinkingCompatibility(t *testing.T) {
tests := []struct {
name string
signature string
wantReasoningContent string
wantHasReasoningContent bool
}{
{
name: "GPT-compatible signature keeps reasoning_content",
signature: validGPTChatReasoningSignature(),
wantReasoningContent: "provider state",
wantHasReasoningContent: true,
},
{
name: "Claude signature drops reasoning_content",
signature: "claude#EjQ=",
wantReasoningContent: "",
wantHasReasoningContent: false,
},
{
name: "Gemini signature drops reasoning_content",
signature: "gemini#EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA",
wantReasoningContent: "",
wantHasReasoningContent: false,
},
{
name: "Unknown signature drops reasoning_content",
signature: "not-a-provider-signature",
wantReasoningContent: "",
wantHasReasoningContent: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "provider state", "signature": "` + tt.signature + `"},
{"type": "text", "text": "visible answer"}
]
}]
}`
result := ConvertClaudeRequestToOpenAI("gpt-5", []byte(inputJSON), false)
assistantMsg := gjson.GetBytes(result, "messages.0")
gotReasoningContent := assistantMsg.Get("reasoning_content").String()
gotHasReasoningContent := assistantMsg.Get("reasoning_content").Exists()
if gotHasReasoningContent != tt.wantHasReasoningContent {
t.Fatalf("reasoning_content exists = %v, want %v. Output: %s", gotHasReasoningContent, tt.wantHasReasoningContent, string(result))
}
if gotReasoningContent != tt.wantReasoningContent {
t.Fatalf("reasoning_content = %q, want %q. Output: %s", gotReasoningContent, tt.wantReasoningContent, string(result))
}
if got := assistantMsg.Get("content.0.text").String(); got != "visible answer" {
t.Fatalf("visible content = %q, want visible answer. Output: %s", got, string(result))
}
})
}
}
// TestConvertClaudeRequestToOpenAI_UnsignedThinkingOnlyMessageDropped verifies
// that unsigned Claude thinking is not migrated into GPT reasoning state.
func TestConvertClaudeRequestToOpenAI_UnsignedThinkingOnlyMessageDropped(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"messages": [
{
"role": "user",
"content": [{"type": "text", "text": "What is 2+2?"}]
},
{
"role": "assistant",
"content": [{"type": "thinking", "thinking": "Let me calculate: 2+2=4"}]
},
{
"role": "user",
"content": [{"type": "text", "text": "Thanks"}]
}
]
}`
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
messages := resultJSON.Get("messages").Array()
if len(messages) != 2 {
t.Fatalf("Expected unsigned thinking-only assistant message to be dropped, got %d. Messages: %v", len(messages), resultJSON.Get("messages").Raw)
}
for _, message := range messages {
if message.Get("reasoning_content").Exists() {
t.Fatalf("unsigned thinking should not produce reasoning_content. Messages: %v", resultJSON.Get("messages").Raw)
}
}
}
func validGPTChatReasoningSignature() string {
raw := make([]byte, 1+8+16+16+32)
raw[0] = 0x80
raw[8] = 1
for i := 9; i < len(raw); i++ {
raw[i] = byte(i)
}
return base64.URLEncoding.EncodeToString(raw)
}
func TestConvertClaudeRequestToOpenAI_MessageSystemRoleWrapsAsUserReminder(t *testing.T) {
inputJSON := `{
"model": "claude-sonnet-4-5",
"system": [{"type": "text", "text": "Top-level rules"}],
"messages": [
{"role": "user", "content": [{"type": "text", "text": "Hello"}]},
{"role": "system", "content": "String mid-conversation rule"},
{"role": "assistant", "content": [{"type": "text", "text": "Hi there"}]},
{"role": "system", "content": [{"type": "text", "text": "Array mid-conversation rule"}]},
{"role": "user", "content": [{"type": "text", "text": "Follow up"}]}
]
}`
result := ConvertClaudeRequestToOpenAI("gpt-5", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
messages := resultJSON.Get("messages").Array()
if len(messages) != 6 {
t.Fatalf("Expected 6 messages, got %d: %s", len(messages), resultJSON.Get("messages").Raw)
}
roles := make([]string, 0, len(messages))
for _, message := range messages {
roles = append(roles, message.Get("role").String())
}
if got, want := roles, []string{"system", "user", "user", "assistant", "user", "user"}; fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("Unexpected message roles: got %v, want %v", got, want)
}
systemContent := messages[0].Get("content").Array()
if len(systemContent) != 1 {
t.Fatalf("Expected only top-level system content, got %d items: %s", len(systemContent), messages[0].Get("content").Raw)
}
if got := systemContent[0].Get("text").String(); got != "Top-level rules" {
t.Fatalf("system content = %q, want Top-level rules", got)
}
if got := messages[2].Get("content.0.text").String(); got != "<system-reminder>\nString mid-conversation rule\n</system-reminder>" {
t.Fatalf("unexpected string reminder text: %q", got)
}
if got := messages[4].Get("content.0.text").String(); got != "<system-reminder>\nArray mid-conversation rule\n</system-reminder>" {
t.Fatalf("unexpected array reminder text: %q", got)
}
}
func TestConvertClaudeRequestToOpenAI_SystemMessageScenarios(t *testing.T) {
tests := []struct {
name string
inputJSON string
wantHasSys bool
wantSysText string
}{
{
name: "No system field",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{"role": "user", "content": "hello"}]
}`,
wantHasSys: false,
},
{
name: "Empty string system field",
inputJSON: `{
"model": "claude-3-opus",
"system": "",
"messages": [{"role": "user", "content": "hello"}]
}`,
wantHasSys: false,
},
{
name: "String system field",
inputJSON: `{
"model": "claude-3-opus",
"system": "Be helpful",
"messages": [{"role": "user", "content": "hello"}]
}`,
wantHasSys: true,
wantSysText: "Be helpful",
},
{
name: "Array system field with text",
inputJSON: `{
"model": "claude-3-opus",
"system": [{"type": "text", "text": "Array system"}],
"messages": [{"role": "user", "content": "hello"}]
}`,
wantHasSys: true,
wantSysText: "Array system",
},
{
name: "Array system field with multiple text blocks",
inputJSON: `{
"model": "claude-3-opus",
"system": [
{"type": "text", "text": "Block 1"},
{"type": "text", "text": "Block 2"}
],
"messages": [{"role": "user", "content": "hello"}]
}`,
wantHasSys: true,
wantSysText: "Block 2", // We will update the test logic to check all blocks or specifically the second one
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConvertClaudeRequestToOpenAI("test-model", []byte(tt.inputJSON), false)
resultJSON := gjson.ParseBytes(result)
messages := resultJSON.Get("messages").Array()
hasSys := false
var sysMsg gjson.Result
if len(messages) > 0 && messages[0].Get("role").String() == "system" {
hasSys = true
sysMsg = messages[0]
}
if hasSys != tt.wantHasSys {
t.Errorf("got hasSystem = %v, want %v", hasSys, tt.wantHasSys)
}
if tt.wantHasSys {
// Check content - it could be string or array in OpenAI
content := sysMsg.Get("content")
var gotText string
if content.IsArray() {
arr := content.Array()
if len(arr) > 0 {
// Get the last element's text for validation
gotText = arr[len(arr)-1].Get("text").String()
}
} else {
gotText = content.String()
}
if tt.wantSysText != "" && gotText != tt.wantSysText {
t.Errorf("got system text = %q, want %q", gotText, tt.wantSysText)
}
}
})
}
}
func TestConvertClaudeRequestToOpenAI_ToolSchemaAddsMissingObjectProperties(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-3-opus",
"tools": [
{
"name": "empty_params",
"description": "No args",
"input_schema": {"type": "object"}
},
{
"name": "nested_params",
"description": "Nested args",
"input_schema": {
"type": "object",
"properties": {
"nested": {"type": "object"},
"items": {
"type": "array",
"items": {"type": "object"}
}
}
}
}
],
"messages": [{"role": "user", "content": "hello"}]
}`)
output := ConvertClaudeRequestToOpenAI("test-model", inputJSON, false)
outputJSON := gjson.ParseBytes(output)
if got := outputJSON.Get("tools.0.function.parameters.properties"); !got.Exists() || !got.IsObject() {
t.Fatalf("root object properties missing or invalid: %s", outputJSON.Get("tools.0.function.parameters").Raw)
}
if got := outputJSON.Get("tools.1.function.parameters.properties.nested.properties"); !got.Exists() || !got.IsObject() {
t.Fatalf("nested object properties missing or invalid: %s", outputJSON.Get("tools.1.function.parameters").Raw)
}
if got := outputJSON.Get("tools.1.function.parameters.properties.items.items.properties"); !got.Exists() || !got.IsObject() {
t.Fatalf("array item object properties missing or invalid: %s", outputJSON.Get("tools.1.function.parameters").Raw)
}
}
func TestConvertClaudeRequestToOpenAI_ToolResultOrderAndContent(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"messages": [
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}
]
},
{
"role": "user",
"content": [
{"type": "text", "text": "before"},
{"type": "tool_result", "tool_use_id": "call_1", "content": [{"type":"text","text":"tool ok"}]},
{"type": "text", "text": "after"}
]
}
]
}`
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
messages := resultJSON.Get("messages").Array()
// OpenAI requires: tool messages MUST immediately follow assistant(tool_calls).
// Correct order: assistant(tool_calls) + tool(result) + user(before+after)
if len(messages) != 3 {
t.Fatalf("Expected 3 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
}
if messages[0].Get("role").String() != "assistant" || !messages[0].Get("tool_calls").Exists() {
t.Fatalf("Expected messages[0] to be assistant tool_calls, got %s: %s", messages[0].Get("role").String(), messages[0].Raw)
}
// tool message MUST immediately follow assistant(tool_calls) per OpenAI spec
if messages[1].Get("role").String() != "tool" {
t.Fatalf("Expected messages[1] to be tool (must follow tool_calls), got %s", messages[1].Get("role").String())
}
if got := messages[1].Get("tool_call_id").String(); got != "call_1" {
t.Fatalf("Expected tool_call_id %q, got %q", "call_1", got)
}
if got := messages[1].Get("content").String(); got != "tool ok" {
t.Fatalf("Expected tool content %q, got %q", "tool ok", got)
}
// User message comes after tool message
if messages[2].Get("role").String() != "user" {
t.Fatalf("Expected messages[2] to be user, got %s", messages[2].Get("role").String())
}
// User message should contain both "before" and "after" text
if got := messages[2].Get("content.0.text").String(); got != "before" {
t.Fatalf("Expected user text[0] %q, got %q", "before", got)
}
if got := messages[2].Get("content.1.text").String(); got != "after" {
t.Fatalf("Expected user text[1] %q, got %q", "after", got)
}
}
func TestConvertClaudeRequestToOpenAI_ToolResultObjectContent(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"messages": [
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}
]
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_1", "content": {"foo": "bar"}}
]
}
]
}`
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
messages := resultJSON.Get("messages").Array()
// assistant(tool_calls) + tool(result)
if len(messages) != 2 {
t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
}
if messages[1].Get("role").String() != "tool" {
t.Fatalf("Expected messages[1] to be tool, got %s", messages[1].Get("role").String())
}
toolContent := messages[1].Get("content").String()
parsed := gjson.Parse(toolContent)
if parsed.Get("foo").String() != "bar" {
t.Fatalf("Expected tool content JSON foo=bar, got %q", toolContent)
}
}
func TestConvertClaudeRequestToOpenAI_ToolResultTextAndImageContent(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"messages": [
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "call_1",
"content": [
{"type": "text", "text": "tool ok"},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgoAAAANSUhEUg=="
}
}
]
}
]
}
]
}`
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
messages := resultJSON.Get("messages").Array()
if len(messages) != 2 {
t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
}
toolContent := messages[1].Get("content")
if !toolContent.IsArray() {
t.Fatalf("Expected tool content array, got %s", toolContent.Raw)
}
if got := toolContent.Get("0.type").String(); got != "text" {
t.Fatalf("Expected first tool content type %q, got %q", "text", got)
}
if got := toolContent.Get("0.text").String(); got != "tool ok" {
t.Fatalf("Expected first tool content text %q, got %q", "tool ok", got)
}
if got := toolContent.Get("1.type").String(); got != "image_url" {
t.Fatalf("Expected second tool content type %q, got %q", "image_url", got)
}
if got := toolContent.Get("1.image_url.url").String(); got != "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==" {
t.Fatalf("Unexpected image_url: %q", got)
}
}
func TestConvertClaudeRequestToOpenAI_ToolResultURLImageOnly(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"messages": [
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "call_1",
"content": {
"type": "image",
"source": {
"type": "url",
"url": "https://example.com/tool.png"
}
}
}
]
}
]
}`
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
messages := resultJSON.Get("messages").Array()
if len(messages) != 2 {
t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
}
toolContent := messages[1].Get("content")
if !toolContent.IsArray() {
t.Fatalf("Expected tool content array, got %s", toolContent.Raw)
}
if got := toolContent.Get("0.type").String(); got != "image_url" {
t.Fatalf("Expected tool content type %q, got %q", "image_url", got)
}
if got := toolContent.Get("0.image_url.url").String(); got != "https://example.com/tool.png" {
t.Fatalf("Unexpected image_url: %q", got)
}
}
func TestConvertClaudeRequestToOpenAI_AssistantTextToolUseTextOrder(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"messages": [
{
"role": "assistant",
"content": [
{"type": "text", "text": "pre"},
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}},
{"type": "text", "text": "post"}
]
}
]
}`
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
messages := resultJSON.Get("messages").Array()
// New behavior: content + tool_calls unified in single assistant message
// Expect: assistant(content[pre,post] + tool_calls)
if len(messages) != 1 {
t.Fatalf("Expected 1 message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
}
assistantMsg := messages[0]
if assistantMsg.Get("role").String() != "assistant" {
t.Fatalf("Expected messages[0] to be assistant, got %s", assistantMsg.Get("role").String())
}
// Should have both content and tool_calls in same message
if !assistantMsg.Get("tool_calls").Exists() {
t.Fatalf("Expected assistant message to have tool_calls")
}
if got := assistantMsg.Get("tool_calls.0.id").String(); got != "call_1" {
t.Fatalf("Expected tool_call id %q, got %q", "call_1", got)
}
if got := assistantMsg.Get("tool_calls.0.function.name").String(); got != "do_work" {
t.Fatalf("Expected tool_call name %q, got %q", "do_work", got)
}
// Content should have both pre and post text
if got := assistantMsg.Get("content.0.text").String(); got != "pre" {
t.Fatalf("Expected content[0] text %q, got %q", "pre", got)
}
if got := assistantMsg.Get("content.1.text").String(); got != "post" {
t.Fatalf("Expected content[1] text %q, got %q", "post", got)
}
}
func TestConvertClaudeRequestToOpenAI_AssistantThinkingToolUseThinkingSplit(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"messages": [
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "t1"},
{"type": "text", "text": "pre"},
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}},
{"type": "thinking", "thinking": "t2"},
{"type": "text", "text": "post"}
]
}
]
}`
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
messages := resultJSON.Get("messages").Array()
// Unsigned thinking is dropped, while text and tool_calls remain unified.
if len(messages) != 1 {
t.Fatalf("Expected 1 message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
}
assistantMsg := messages[0]
if assistantMsg.Get("role").String() != "assistant" {
t.Fatalf("Expected messages[0] to be assistant, got %s", assistantMsg.Get("role").String())
}
// Should have content with both pre and post
if got := assistantMsg.Get("content.0.text").String(); got != "pre" {
t.Fatalf("Expected content[0] text %q, got %q", "pre", got)
}
if got := assistantMsg.Get("content.1.text").String(); got != "post" {
t.Fatalf("Expected content[1] text %q, got %q", "post", got)
}
// Should have tool_calls
if !assistantMsg.Get("tool_calls").Exists() {
t.Fatalf("Expected assistant message to have tool_calls")
}
if assistantMsg.Get("reasoning_content").Exists() {
t.Fatalf("unsigned thinking should not produce reasoning_content: %s", assistantMsg.Raw)
}
}
func TestConvertClaudeRequestToOpenAI_StripsClaudeCodeAttribution(t *testing.T) {
inputJSON := []byte(`{
"model": "claude-sonnet-4-5",
"system": [
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.63.abc; cc_entrypoint=cli; cch=12345;"},
{"type": "text", "text": "User system prompt"}
],
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
}`)
output := ConvertClaudeRequestToOpenAI("gpt-5", inputJSON, false)
messages := gjson.GetBytes(output, "messages").Array()
if len(messages) == 0 || messages[0].Get("role").String() != "system" {
t.Fatalf("Expected first message to be system, got: %s", gjson.GetBytes(output, "messages").Raw)
}
content := messages[0].Get("content").Array()
if len(content) != 1 {
t.Fatalf("Expected 1 system content item after attribution strip, got %d: %s", len(content), messages[0].Get("content").Raw)
}
if got := content[0].Get("text").String(); got != "User system prompt" {
t.Fatalf("Unexpected system content: %q", got)
}
}
func TestConvertClaudeRequestToOpenAI_StopSequences(t *testing.T) {
tests := []struct {
name string
inputJSON string
wantStop []string
}{
{
name: "single stop sequence is emitted as array",
inputJSON: `{
"model": "claude-3-opus",
"stop_sequences": ["</block>"],
"messages": [{"role": "user", "content": "hi"}]
}`,
wantStop: []string{"</block>"},
},
{
name: "multiple stop sequences are emitted as array",
inputJSON: `{
"model": "claude-3-opus",
"stop_sequences": ["stop1", "stop2"],
"messages": [{"role": "user", "content": "hi"}]
}`,
wantStop: []string{"stop1", "stop2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := ConvertClaudeRequestToOpenAI("gpt-4o", []byte(tt.inputJSON), false)
stopRes := gjson.GetBytes(output, "stop")
if !stopRes.Exists() {
t.Fatalf("expected 'stop' field in output, got: %s", string(output))
}
if !stopRes.IsArray() {
t.Fatalf("expected 'stop' field to be JSON array, got: %s", stopRes.Raw)
}
items := stopRes.Array()
if len(items) != len(tt.wantStop) {
t.Fatalf("expected %d stop items, got %d (%v)", len(tt.wantStop), len(items), stopRes.Raw)
}
for i, want := range tt.wantStop {
if items[i].String() != want {
t.Errorf("stop[%d] = %q, want %q", i, items[i].String(), want)
}
}
})
}
}

View file

@ -0,0 +1,816 @@
// Package claude provides response translation functionality for OpenAI to Anthropic API.
// This package handles the conversion of OpenAI Chat Completions API responses into Anthropic API-compatible
// JSON format, transforming streaming events and non-streaming responses into the format
// expected by Anthropic API clients. It supports both streaming and non-streaming modes,
// handling text content, tool calls, and usage metadata appropriately.
package claude
import (
"bytes"
"context"
"fmt"
"sort"
"strings"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
var (
dataTag = []byte("data:")
)
// ConvertOpenAIResponseToAnthropicParams holds parameters for response conversion
type ConvertOpenAIResponseToAnthropicParams struct {
MessageID string
Model string
CreatedAt int64
ToolNameMap map[string]string
// SawToolCall is true once at least one tool_use content_block_start has
// been emitted on the wire. Using raw upstream tool_calls presence here
// can produce stop_reason=tool_use with zero announced tool blocks.
SawToolCall bool
// Content accumulator for streaming
ContentAccumulator strings.Builder
// Tool calls accumulator for streaming
ToolCallsAccumulator map[int]*ToolCallAccumulator
// Track if text content block has been started
TextContentBlockStarted bool
// Track if thinking content block has been started
ThinkingContentBlockStarted bool
// Track finish reason for later use
FinishReason string
// Track if content blocks have been stopped
ContentBlocksStopped bool
// Track if message_delta has been sent
MessageDeltaSent bool
// Track if message_start has been sent
MessageStarted bool
// Track if message_stop has been sent
MessageStopSent bool
// Tool call content block index mapping
ToolCallBlockIndexes map[int]int
// Index assigned to text content block
TextContentBlockIndex int
// Index assigned to thinking content block
ThinkingContentBlockIndex int
// Next available content block index
NextContentBlockIndex int
}
// ToolCallAccumulator holds the state for accumulating tool call data
type ToolCallAccumulator struct {
ID string
Name string
Arguments strings.Builder
// StartEmitted tracks whether content_block_start has already been sent
// for this tool index.
StartEmitted bool
}
// ConvertOpenAIResponseToClaude converts OpenAI streaming response format to Anthropic API format.
// This function processes OpenAI streaming chunks and transforms them into Anthropic-compatible JSON responses.
// It handles text content, tool calls, and usage metadata, outputting responses that match the Anthropic API format.
//
// Parameters:
// - ctx: The context for the request.
// - modelName: The name of the model.
// - rawJSON: The raw JSON response from the OpenAI API.
// - param: A pointer to a parameter object for the conversion.
//
// Returns:
// - [][]byte: A slice of byte chunks, each containing an Anthropic-compatible JSON response.
func ConvertOpenAIResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
if *param == nil {
*param = &ConvertOpenAIResponseToAnthropicParams{
MessageID: "",
Model: "",
CreatedAt: 0,
ToolNameMap: nil,
SawToolCall: false,
ContentAccumulator: strings.Builder{},
ToolCallsAccumulator: nil,
TextContentBlockStarted: false,
ThinkingContentBlockStarted: false,
FinishReason: "",
ContentBlocksStopped: false,
MessageDeltaSent: false,
ToolCallBlockIndexes: make(map[int]int),
TextContentBlockIndex: -1,
ThinkingContentBlockIndex: -1,
NextContentBlockIndex: 0,
}
}
if !bytes.HasPrefix(rawJSON, dataTag) {
return [][]byte{}
}
rawJSON = bytes.TrimSpace(rawJSON[5:])
if (*param).(*ConvertOpenAIResponseToAnthropicParams).ToolNameMap == nil {
(*param).(*ConvertOpenAIResponseToAnthropicParams).ToolNameMap = util.ToolNameMapFromClaudeRequest(originalRequestRawJSON)
}
// Check if this is the [DONE] marker
if bytes.Equal(bytes.TrimSpace(rawJSON), []byte("[DONE]")) {
return convertOpenAIDoneToAnthropic((*param).(*ConvertOpenAIResponseToAnthropicParams))
}
streamResult := gjson.GetBytes(originalRequestRawJSON, "stream")
if !streamResult.Exists() || (streamResult.Exists() && streamResult.Type == gjson.False) {
return convertOpenAINonStreamingToAnthropic(rawJSON)
} else {
return convertOpenAIStreamingChunkToAnthropic(rawJSON, (*param).(*ConvertOpenAIResponseToAnthropicParams))
}
}
func effectiveOpenAIFinishReason(param *ConvertOpenAIResponseToAnthropicParams) string {
if param == nil {
return ""
}
if param.SawToolCall {
return "tool_calls"
}
return param.FinishReason
}
// convertOpenAIStreamingChunkToAnthropic converts OpenAI streaming chunk to Anthropic streaming events
func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAIResponseToAnthropicParams) [][]byte {
root := gjson.ParseBytes(rawJSON)
var results [][]byte
// Initialize parameters if needed
if param.MessageID == "" {
param.MessageID = root.Get("id").String()
}
if param.Model == "" {
param.Model = root.Get("model").String()
}
if param.CreatedAt == 0 {
param.CreatedAt = root.Get("created").Int()
}
// Emit message_start on the very first chunk, regardless of whether it has a role field.
// Some providers (like Copilot) may send tool_calls in the first chunk without a role field.
if delta := root.Get("choices.0.delta"); delta.Exists() {
if !param.MessageStarted {
// Send message_start event
messageStartJSON := []byte(`{"type":"message_start","message":{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`)
messageStartJSON, _ = sjson.SetBytes(messageStartJSON, "message.id", param.MessageID)
messageStartJSON, _ = sjson.SetBytes(messageStartJSON, "message.model", param.Model)
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "message_start", messageStartJSON, 2))
param.MessageStarted = true
// Don't send content_block_start for text here - wait for actual content
}
// Handle reasoning content delta
if reasoning := delta.Get("reasoning_content"); reasoning.Exists() {
for _, reasoningText := range collectOpenAIReasoningTexts(reasoning) {
if reasoningText == "" {
continue
}
stopTextContentBlock(param, &results)
if !param.ThinkingContentBlockStarted {
if param.ThinkingContentBlockIndex == -1 {
param.ThinkingContentBlockIndex = param.NextContentBlockIndex
param.NextContentBlockIndex++
}
contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`
contentBlockStartJSONBytes := []byte(contentBlockStartJSON)
contentBlockStartJSONBytes, _ = sjson.SetBytes(contentBlockStartJSONBytes, "index", param.ThinkingContentBlockIndex)
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSONBytes, 2))
param.ThinkingContentBlockStarted = true
}
thinkingDeltaJSON := `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`
thinkingDeltaJSONBytes := []byte(thinkingDeltaJSON)
thinkingDeltaJSONBytes, _ = sjson.SetBytes(thinkingDeltaJSONBytes, "index", param.ThinkingContentBlockIndex)
thinkingDeltaJSONBytes, _ = sjson.SetBytes(thinkingDeltaJSONBytes, "delta.thinking", reasoningText)
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", thinkingDeltaJSONBytes, 2))
}
}
// Handle content delta
if content := delta.Get("content"); content.Exists() && content.String() != "" {
// Send content_block_start for text if not already sent
if !param.TextContentBlockStarted {
stopThinkingContentBlock(param, &results)
if param.TextContentBlockIndex == -1 {
param.TextContentBlockIndex = param.NextContentBlockIndex
param.NextContentBlockIndex++
}
contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`
contentBlockStartJSONBytes := []byte(contentBlockStartJSON)
contentBlockStartJSONBytes, _ = sjson.SetBytes(contentBlockStartJSONBytes, "index", param.TextContentBlockIndex)
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSONBytes, 2))
param.TextContentBlockStarted = true
}
contentDeltaJSON := `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`
contentDeltaJSONBytes := []byte(contentDeltaJSON)
contentDeltaJSONBytes, _ = sjson.SetBytes(contentDeltaJSONBytes, "index", param.TextContentBlockIndex)
contentDeltaJSONBytes, _ = sjson.SetBytes(contentDeltaJSONBytes, "delta.text", content.String())
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", contentDeltaJSONBytes, 2))
// Accumulate content
param.ContentAccumulator.WriteString(content.String())
}
// Handle tool calls
if toolCalls := delta.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
if param.ToolCallsAccumulator == nil {
param.ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
}
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
index := int(toolCall.Get("index").Int())
// Initialize accumulator if needed
if _, exists := param.ToolCallsAccumulator[index]; !exists {
param.ToolCallsAccumulator[index] = &ToolCallAccumulator{}
}
accumulator := param.ToolCallsAccumulator[index]
// Handle tool call ID. Only accept JSON-string, non-empty
// values so malformed upstream fields do not overwrite a
// valid ID or coerce into a content_block.id.
if id := toolCall.Get("id"); id.Exists() && id.Type == gjson.String {
if idStr := id.String(); idStr != "" {
accumulator.ID = idStr
}
}
// Handle function name and arguments
if function := toolCall.Get("function"); function.Exists() {
// Only record the name until content_block_start has been
// emitted. Some upstreams send "name": "" or repeat the
// field across chunks; reassigning after start could drift
// from what was already announced.
if !accumulator.StartEmitted {
if name := function.Get("name"); name.Exists() && name.Type == gjson.String && name.String() != "" {
accumulator.Name = util.MapToolName(param.ToolNameMap, name.String())
}
}
// Handle function arguments
if args := function.Get("arguments"); args.Exists() {
argsText := args.String()
if argsText != "" {
accumulator.Arguments.WriteString(argsText)
}
}
}
// Re-check on every chunk, not only chunks with a function
// object. Some upstreams split function.name and id across
// separate deltas.
if !accumulator.StartEmitted && accumulator.Name != "" && accumulator.ID != "" && !param.ContentBlocksStopped {
emitToolUseStart(param, index, accumulator, &results)
}
return true
})
}
}
// Handle finish_reason (but don't send message_delta/message_stop yet)
if finishReason := root.Get("choices.0.finish_reason"); finishReason.Exists() && finishReason.String() != "" {
reason := finishReason.String()
switch {
case param.SawToolCall:
param.FinishReason = "tool_calls"
case reason == "tool_calls":
param.FinishReason = "stop"
default:
param.FinishReason = reason
}
// Send content_block_stop for thinking content if needed
if param.ThinkingContentBlockStarted {
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", param.ThinkingContentBlockIndex)
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
param.ThinkingContentBlockStarted = false
param.ThinkingContentBlockIndex = -1
}
// Send content_block_stop for text if text content block was started
stopTextContentBlock(param, &results)
// Send content_block_stop for any tool calls
if !param.ContentBlocksStopped {
for _, index := range toolCallAccumulatorIndexes(param.ToolCallsAccumulator) {
accumulator := param.ToolCallsAccumulator[index]
if !emitBelatedToolUseStart(param, index, accumulator, &results) {
continue
}
blockIndex := param.toolContentBlockIndex(index)
// Send complete input_json_delta with all accumulated arguments
if accumulator.Arguments.Len() > 0 {
inputDeltaJSON := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
inputDeltaJSON, _ = sjson.SetBytes(inputDeltaJSON, "index", blockIndex)
inputDeltaJSON, _ = sjson.SetBytes(inputDeltaJSON, "delta.partial_json", util.FixJSON(accumulator.Arguments.String()))
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", inputDeltaJSON, 2))
}
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", blockIndex)
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
delete(param.ToolCallBlockIndexes, index)
}
param.ContentBlocksStopped = true
}
// Don't send message_delta here - wait for usage info or [DONE]
}
// Handle usage information separately (this comes in a later chunk)
// Only process if usage has actual values (not null)
if param.FinishReason != "" && !param.MessageDeltaSent {
usage := root.Get("usage")
var inputTokens, outputTokens, cachedTokens int64
if usage.Exists() && usage.Type != gjson.Null {
inputTokens, outputTokens, cachedTokens = extractOpenAIUsage(usage)
// Send message_delta with usage
messageDeltaJSON := []byte(`{"type":"message_delta","delta":{"stop_reason":"","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "delta.stop_reason", mapOpenAIFinishReasonToAnthropic(effectiveOpenAIFinishReason(param)))
messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "usage.input_tokens", inputTokens)
messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "usage.output_tokens", outputTokens)
if cachedTokens > 0 {
messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "usage.cache_read_input_tokens", cachedTokens)
}
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "message_delta", messageDeltaJSON, 2))
param.MessageDeltaSent = true
emitMessageStopIfNeeded(param, &results)
}
}
return results
}
// convertOpenAIDoneToAnthropic handles the [DONE] marker and sends final events
func convertOpenAIDoneToAnthropic(param *ConvertOpenAIResponseToAnthropicParams) [][]byte {
var results [][]byte
// Ensure all content blocks are stopped before final events
if param.ThinkingContentBlockStarted {
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", param.ThinkingContentBlockIndex)
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
param.ThinkingContentBlockStarted = false
param.ThinkingContentBlockIndex = -1
}
stopTextContentBlock(param, &results)
if !param.ContentBlocksStopped {
for _, index := range toolCallAccumulatorIndexes(param.ToolCallsAccumulator) {
accumulator := param.ToolCallsAccumulator[index]
if !emitBelatedToolUseStart(param, index, accumulator, &results) {
continue
}
blockIndex := param.toolContentBlockIndex(index)
if accumulator.Arguments.Len() > 0 {
inputDeltaJSON := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
inputDeltaJSON, _ = sjson.SetBytes(inputDeltaJSON, "index", blockIndex)
inputDeltaJSON, _ = sjson.SetBytes(inputDeltaJSON, "delta.partial_json", util.FixJSON(accumulator.Arguments.String()))
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", inputDeltaJSON, 2))
}
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", blockIndex)
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
delete(param.ToolCallBlockIndexes, index)
}
param.ContentBlocksStopped = true
}
// If we haven't sent message_delta yet (no usage info was received), send it now
if param.FinishReason != "" && !param.MessageDeltaSent {
messageDeltaJSON := []byte(`{"type":"message_delta","delta":{"stop_reason":"","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "delta.stop_reason", mapOpenAIFinishReasonToAnthropic(effectiveOpenAIFinishReason(param)))
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "message_delta", messageDeltaJSON, 2))
param.MessageDeltaSent = true
}
emitMessageStopIfNeeded(param, &results)
return results
}
// convertOpenAINonStreamingToAnthropic converts OpenAI non-streaming response to Anthropic format
func convertOpenAINonStreamingToAnthropic(rawJSON []byte) [][]byte {
root := gjson.ParseBytes(rawJSON)
out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
out, _ = sjson.SetBytes(out, "id", root.Get("id").String())
out, _ = sjson.SetBytes(out, "model", root.Get("model").String())
// Process message content and tool calls
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() && len(choices.Array()) > 0 {
choice := choices.Array()[0] // Take first choice
var contentBlocks [][]byte
reasoningNode := choice.Get("message.reasoning_content")
for _, reasoningText := range collectOpenAIReasoningTexts(reasoningNode) {
if reasoningText == "" {
continue
}
block := []byte(`{"type":"thinking","thinking":""}`)
block, _ = sjson.SetBytes(block, "thinking", reasoningText)
contentBlocks = append(contentBlocks, block)
}
// Handle text content
if content := choice.Get("message.content"); content.Exists() && content.String() != "" {
block := []byte(`{"type":"text","text":""}`)
block, _ = sjson.SetBytes(block, "text", content.String())
contentBlocks = append(contentBlocks, block)
}
// Handle tool calls
if toolCalls := choice.Get("message.tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
toolUseBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
toolUseBlock, _ = sjson.SetBytes(toolUseBlock, "id", util.SanitizeClaudeToolID(toolCall.Get("id").String()))
toolUseBlock, _ = sjson.SetBytes(toolUseBlock, "name", toolCall.Get("function.name").String())
argsStr := util.FixJSON(toolCall.Get("function.arguments").String())
if argsStr != "" && gjson.Valid(argsStr) {
argsJSON := gjson.Parse(argsStr)
if argsJSON.IsObject() {
toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(argsJSON.Raw))
} else {
toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(`{}`))
}
} else {
toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(`{}`))
}
contentBlocks = append(contentBlocks, toolUseBlock)
return true
})
}
if len(contentBlocks) > 0 {
out = translatorcommon.SetRawArrayItems(out, "content", contentBlocks)
}
// Set stop reason
if finishReason := choice.Get("finish_reason"); finishReason.Exists() {
out, _ = sjson.SetBytes(out, "stop_reason", mapOpenAIFinishReasonToAnthropic(finishReason.String()))
}
}
// Set usage information
if usage := root.Get("usage"); usage.Exists() {
inputTokens, outputTokens, cachedTokens := extractOpenAIUsage(usage)
out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
if cachedTokens > 0 {
out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens)
}
}
return [][]byte{out}
}
// mapOpenAIFinishReasonToAnthropic maps OpenAI finish reasons to Anthropic equivalents
func mapOpenAIFinishReasonToAnthropic(openAIReason string) string {
switch openAIReason {
case "stop":
return "end_turn"
case "length":
return "max_tokens"
case "tool_calls":
return "tool_use"
case "content_filter":
return "end_turn" // Anthropic doesn't have direct equivalent
case "function_call": // Legacy OpenAI
return "tool_use"
default:
return "end_turn"
}
}
func (p *ConvertOpenAIResponseToAnthropicParams) toolContentBlockIndex(openAIToolIndex int) int {
if idx, ok := p.ToolCallBlockIndexes[openAIToolIndex]; ok {
return idx
}
idx := p.NextContentBlockIndex
p.NextContentBlockIndex++
p.ToolCallBlockIndexes[openAIToolIndex] = idx
return idx
}
func collectOpenAIReasoningTexts(node gjson.Result) []string {
var texts []string
if !node.Exists() {
return texts
}
if node.IsArray() {
node.ForEach(func(_, value gjson.Result) bool {
texts = append(texts, collectOpenAIReasoningTexts(value)...)
return true
})
return texts
}
switch node.Type {
case gjson.String:
if text := node.String(); text != "" {
texts = append(texts, text)
}
case gjson.JSON:
if text := node.Get("text"); text.Exists() {
if textStr := text.String(); textStr != "" {
texts = append(texts, textStr)
}
} else if raw := node.Raw; raw != "" && !strings.HasPrefix(raw, "{") && !strings.HasPrefix(raw, "[") {
texts = append(texts, raw)
}
}
return texts
}
func stopThinkingContentBlock(param *ConvertOpenAIResponseToAnthropicParams, results *[][]byte) {
if !param.ThinkingContentBlockStarted {
return
}
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", param.ThinkingContentBlockIndex)
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
param.ThinkingContentBlockStarted = false
param.ThinkingContentBlockIndex = -1
}
func emitMessageStopIfNeeded(param *ConvertOpenAIResponseToAnthropicParams, results *[][]byte) {
if param.MessageStopSent {
return
}
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "message_stop", []byte(`{"type":"message_stop"}`), 2))
param.MessageStopSent = true
}
func stopTextContentBlock(param *ConvertOpenAIResponseToAnthropicParams, results *[][]byte) {
if !param.TextContentBlockStarted {
return
}
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", param.TextContentBlockIndex)
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
param.TextContentBlockStarted = false
param.TextContentBlockIndex = -1
}
func emitToolUseStart(param *ConvertOpenAIResponseToAnthropicParams, openAIToolIndex int, accumulator *ToolCallAccumulator, results *[][]byte) {
stopThinkingContentBlock(param, results)
stopTextContentBlock(param, results)
blockIndex := param.toolContentBlockIndex(openAIToolIndex)
contentBlockStartJSON := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`)
contentBlockStartJSON, _ = sjson.SetBytes(contentBlockStartJSON, "index", blockIndex)
contentBlockStartJSON, _ = sjson.SetBytes(contentBlockStartJSON, "content_block.id", util.SanitizeClaudeToolID(accumulator.ID))
contentBlockStartJSON, _ = sjson.SetBytes(contentBlockStartJSON, "content_block.name", accumulator.Name)
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSON, 2))
accumulator.StartEmitted = true
param.SawToolCall = true
}
// emitBelatedToolUseStart finalizes a tool_use block that never received a
// mid-stream start. Some OpenAI-compatible providers leave function.name empty
// for the whole stream; dropping those calls loses tool_use for Claude Code and
// can trigger retry loops. When name is still empty but the call has an id
// and/or arguments, synthesize tool_<index> instead of silently discarding it.
// Returns false when the accumulator has no usable tool-call signal.
func emitBelatedToolUseStart(param *ConvertOpenAIResponseToAnthropicParams, openAIToolIndex int, accumulator *ToolCallAccumulator, results *[][]byte) bool {
if accumulator == nil {
return false
}
if accumulator.StartEmitted {
return true
}
if accumulator.Name == "" && accumulator.ID == "" && accumulator.Arguments.Len() == 0 {
return false
}
if accumulator.Name == "" {
accumulator.Name = fmt.Sprintf("tool_%d", openAIToolIndex)
}
emitToolUseStart(param, openAIToolIndex, accumulator, results)
return true
}
func toolCallAccumulatorIndexes(accumulators map[int]*ToolCallAccumulator) []int {
indexes := make([]int, 0, len(accumulators))
for index := range accumulators {
indexes = append(indexes, index)
}
sort.Ints(indexes)
return indexes
}
// ConvertOpenAIResponseToClaudeNonStream converts a non-streaming OpenAI response to a non-streaming Anthropic response.
//
// Parameters:
// - ctx: The context for the request.
// - modelName: The name of the model.
// - rawJSON: The raw JSON response from the OpenAI API.
// - param: A pointer to a parameter object for the conversion.
//
// Returns:
// - []byte: An Anthropic-compatible JSON response.
func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
_ = requestRawJSON
root := gjson.ParseBytes(rawJSON)
toolNameMap := util.ToolNameMapFromClaudeRequest(originalRequestRawJSON)
out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
out, _ = sjson.SetBytes(out, "id", root.Get("id").String())
out, _ = sjson.SetBytes(out, "model", root.Get("model").String())
hasToolCall := false
stopReasonSet := false
var blocks [][]byte
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() && len(choices.Array()) > 0 {
choice := choices.Array()[0]
if finishReason := choice.Get("finish_reason"); finishReason.Exists() {
out, _ = sjson.SetBytes(out, "stop_reason", mapOpenAIFinishReasonToAnthropic(finishReason.String()))
stopReasonSet = true
}
if message := choice.Get("message"); message.Exists() {
if contentResult := message.Get("content"); contentResult.Exists() {
if contentResult.IsArray() {
var textBuilder strings.Builder
var thinkingBuilder strings.Builder
flushText := func() {
if textBuilder.Len() == 0 {
return
}
block := []byte(`{"type":"text","text":""}`)
block, _ = sjson.SetBytes(block, "text", textBuilder.String())
blocks = append(blocks, block)
textBuilder.Reset()
}
flushThinking := func() {
if thinkingBuilder.Len() == 0 {
return
}
block := []byte(`{"type":"thinking","thinking":""}`)
block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
blocks = append(blocks, block)
thinkingBuilder.Reset()
}
for _, item := range contentResult.Array() {
switch item.Get("type").String() {
case "text":
flushThinking()
textBuilder.WriteString(item.Get("text").String())
case "tool_calls":
flushThinking()
flushText()
toolCalls := item.Get("tool_calls")
if toolCalls.IsArray() {
toolCalls.ForEach(func(_, tc gjson.Result) bool {
hasToolCall = true
toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
toolUse, _ = sjson.SetBytes(toolUse, "id", util.SanitizeClaudeToolID(tc.Get("id").String()))
toolUse, _ = sjson.SetBytes(toolUse, "name", util.MapToolName(toolNameMap, tc.Get("function.name").String()))
argsStr := util.FixJSON(tc.Get("function.arguments").String())
if argsStr != "" && gjson.Valid(argsStr) {
argsJSON := gjson.Parse(argsStr)
if argsJSON.IsObject() {
toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(argsJSON.Raw))
} else {
toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(`{}`))
}
} else {
toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(`{}`))
}
blocks = append(blocks, toolUse)
return true
})
}
case "reasoning":
flushText()
if thinking := item.Get("text"); thinking.Exists() {
thinkingBuilder.WriteString(thinking.String())
}
default:
flushThinking()
flushText()
}
}
flushThinking()
flushText()
} else if contentResult.Type == gjson.String {
textContent := contentResult.String()
if textContent != "" {
block := []byte(`{"type":"text","text":""}`)
block, _ = sjson.SetBytes(block, "text", textContent)
blocks = append(blocks, block)
}
}
}
if reasoning := message.Get("reasoning_content"); reasoning.Exists() {
for _, reasoningText := range collectOpenAIReasoningTexts(reasoning) {
if reasoningText == "" {
continue
}
block := []byte(`{"type":"thinking","thinking":""}`)
block, _ = sjson.SetBytes(block, "thinking", reasoningText)
blocks = append(blocks, block)
}
}
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
hasToolCall = true
toolUseBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
toolUseBlock, _ = sjson.SetBytes(toolUseBlock, "id", util.SanitizeClaudeToolID(toolCall.Get("id").String()))
toolUseBlock, _ = sjson.SetBytes(toolUseBlock, "name", util.MapToolName(toolNameMap, toolCall.Get("function.name").String()))
argsStr := util.FixJSON(toolCall.Get("function.arguments").String())
if argsStr != "" && gjson.Valid(argsStr) {
argsJSON := gjson.Parse(argsStr)
if argsJSON.IsObject() {
toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(argsJSON.Raw))
} else {
toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(`{}`))
}
} else {
toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(`{}`))
}
blocks = append(blocks, toolUseBlock)
return true
})
}
}
}
if len(blocks) > 0 {
out, _ = sjson.SetRawBytes(out, "content", translatorcommon.JoinRawArray(blocks))
}
if respUsage := root.Get("usage"); respUsage.Exists() {
inputTokens, outputTokens, cachedTokens := extractOpenAIUsage(respUsage)
out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
if cachedTokens > 0 {
out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens)
}
}
if !stopReasonSet {
if hasToolCall {
out, _ = sjson.SetBytes(out, "stop_reason", "tool_use")
} else {
out, _ = sjson.SetBytes(out, "stop_reason", "end_turn")
}
}
return out
}
func ClaudeTokenCount(ctx context.Context, count int64) []byte {
return translatorcommon.ClaudeInputTokensJSON(count)
}
func extractOpenAIUsage(usage gjson.Result) (int64, int64, int64) {
if !usage.Exists() || usage.Type == gjson.Null {
return 0, 0, 0
}
inputTokens := usage.Get("prompt_tokens").Int()
outputTokens := usage.Get("completion_tokens").Int()
cachedTokens := usage.Get("prompt_tokens_details.cached_tokens").Int()
if cachedTokens > 0 {
if inputTokens >= cachedTokens {
inputTokens -= cachedTokens
} else {
inputTokens = 0
}
}
return inputTokens, outputTokens, cachedTokens
}

View file

@ -0,0 +1,450 @@
package claude
import (
"bytes"
"context"
"strings"
"testing"
"github.com/tidwall/gjson"
)
type sseEvent struct {
Type string
Payload string
}
func runStream(t *testing.T, originalReq string, chunks ...string) []sseEvent {
t.Helper()
var paramAny any
var emitted [][]byte
for _, chunk := range chunks {
emitted = append(emitted, ConvertOpenAIResponseToClaude(
context.Background(),
"",
[]byte(originalReq),
nil,
[]byte("data: "+chunk),
&paramAny,
)...)
}
emitted = append(emitted, ConvertOpenAIResponseToClaude(
context.Background(),
"",
[]byte(originalReq),
nil,
[]byte("data: [DONE]"),
&paramAny,
)...)
var events []sseEvent
for _, raw := range emitted {
s := string(raw)
if !strings.HasPrefix(s, "event: ") {
continue
}
nl := strings.Index(s, "\n")
if nl < 0 {
continue
}
typ := strings.TrimPrefix(s[:nl], "event: ")
rest := s[nl+1:]
if !strings.HasPrefix(rest, "data: ") {
continue
}
payload := strings.TrimRight(strings.TrimPrefix(rest, "data: "), "\n")
events = append(events, sseEvent{Type: typ, Payload: payload})
}
return events
}
func countByType(events []sseEvent, typ string) int {
n := 0
for _, e := range events {
if e.Type == typ {
n++
}
}
return n
}
func toolUseStarts(events []sseEvent) []sseEvent {
var out []sseEvent
for _, e := range events {
if e.Type != "content_block_start" {
continue
}
if gjson.Get(e.Payload, "content_block.type").String() == "tool_use" {
out = append(out, e)
}
}
return out
}
func blockIndices(events []sseEvent) []int64 {
var idx []int64
for _, e := range events {
if e.Type == "content_block_start" {
idx = append(idx, gjson.Get(e.Payload, "index").Int())
}
}
return idx
}
func lastStopReason(events []sseEvent) string {
for i := len(events) - 1; i >= 0; i-- {
if events[i].Type == "message_delta" {
return gjson.Get(events[i].Payload, "delta.stop_reason").String()
}
}
return ""
}
const streamReq = `{"stream":true}`
func TestStreaming_LateUsageOnlyDoesNotEmitAfterMessageStop(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`,
`{"id":"c1","model":"m","choices":[],"usage":{"prompt_tokens":1,"completion_tokens":1}}`,
)
if got := countByType(events, "message_delta"); got != 1 {
t.Fatalf("expected exactly one message_delta, got %d (events=%+v)", got, events)
}
if got := countByType(events, "message_stop"); got != 1 {
t.Fatalf("expected exactly one message_stop, got %d (events=%+v)", got, events)
}
if len(events) == 0 || events[len(events)-1].Type != "message_stop" {
t.Fatalf("message_stop must be the last semantic event (events=%+v)", events)
}
}
func TestConvertOpenAIResponseToClaude_StreamIgnoresNullToolNameDelta(t *testing.T) {
originalRequest := []byte(streamReq)
var param any
firstChunks := ConvertOpenAIResponseToClaude(
context.Background(),
"test-model",
originalRequest,
nil,
[]byte(`data: {"id":"chatcmpl_1","model":"test-model","created":1,"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"read_file","arguments":""}}]},"finish_reason":null}]}`),
&param,
)
firstOutput := bytes.Join(firstChunks, nil)
if !bytes.Contains(firstOutput, []byte(`"name":"read_file"`)) {
t.Fatalf("expected first chunk to start read_file tool block, got %s", string(firstOutput))
}
secondChunks := ConvertOpenAIResponseToClaude(
context.Background(),
"test-model",
originalRequest,
nil,
[]byte(`data: {"id":"chatcmpl_1","model":"test-model","created":1,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":null,"arguments":"{\"path\":\"/tmp/a\"}"}}]},"finish_reason":null}]}`),
&param,
)
secondOutput := bytes.Join(secondChunks, nil)
if bytes.Contains(secondOutput, []byte(`content_block_start`)) {
t.Fatalf("did not expect null tool name delta to start a new content block, got %s", string(secondOutput))
}
if bytes.Contains(secondOutput, []byte(`"name":""`)) {
t.Fatalf("did not expect null tool name delta to emit an empty tool name, got %s", string(secondOutput))
}
}
func TestStreamingTool_EmptyNameThroughout(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":"","arguments":""}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"","arguments":"{\"x\":1}"}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
starts := toolUseStarts(events)
if len(starts) != 1 {
t.Fatalf("expected one tool_use content_block_start with synthetic name, got %d (events=%+v)", len(starts), events)
}
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
}
if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_a" {
t.Fatalf("announced tool id = %q, want %q", id, "call_a")
}
if got := countByType(events, "content_block_delta"); got != 1 {
t.Fatalf("expected one content_block_delta for accumulated args, got %d", got)
}
if got := countByType(events, "content_block_stop"); got != 1 {
t.Fatalf("expected one content_block_stop, got %d", got)
}
if got := lastStopReason(events); got != "tool_use" {
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
}
}
func TestStreamingTool_NullName(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":null,"arguments":""}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
starts := toolUseStarts(events)
if len(starts) != 1 {
t.Fatalf("null name with id should belated-emit synthetic tool name; got %d", len(starts))
}
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
}
if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_a" {
t.Fatalf("announced tool id = %q, want %q", id, "call_a")
}
if got := countByType(events, "content_block_stop"); got != 1 {
t.Fatalf("expected one content_block_stop, got %d", got)
}
if got := lastStopReason(events); got != "tool_use" {
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
}
}
func TestStreamingTool_NonStringName(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":123,"arguments":""}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
starts := toolUseStarts(events)
if len(starts) != 1 {
t.Fatalf("non-string name with id should belated-emit synthetic tool name; got %d", len(starts))
}
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
}
}
func TestStreamingTool_RepeatedName(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":"do_it","arguments":""}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"do_it","arguments":"{\"x\""}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"do_it","arguments":":1}"}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
starts := toolUseStarts(events)
if len(starts) != 1 {
t.Fatalf("expected exactly one tool_use start, got %d", len(starts))
}
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
t.Fatalf("announced tool name = %q, want %q", name, "do_it")
}
if got := countByType(events, "content_block_stop"); got != 1 {
t.Fatalf("expected exactly one content_block_stop, got %d", got)
}
}
func TestStreamingTool_MixedEmptyNameAndValid(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[
{"index":0,"id":"call_empty","function":{"name":"","arguments":""}},
{"index":1,"id":"call_real","function":{"name":"do_it","arguments":""}}
]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[
{"index":1,"function":{"arguments":"{}"}}
]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
starts := toolUseStarts(events)
if len(starts) != 2 {
t.Fatalf("expected two tool_use starts (valid mid-stream + synthetic empty-name), got %d", len(starts))
}
// Valid name+id is emitted mid-stream first; empty-name is belated at finish.
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
t.Fatalf("first tool name = %q, want %q", name, "do_it")
}
if name := gjson.Get(starts[1].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("second tool name = %q, want %q", name, "tool_0")
}
if got := countByType(events, "content_block_stop"); got != 2 {
t.Fatalf("expected two content_block_stop events, got %d", got)
}
indices := blockIndices(events)
if len(indices) < 2 || indices[0] != 0 || indices[1] != 1 {
t.Fatalf("content_block_start indices must be [0,1], got %v", indices)
}
}
func TestStreamingTool_EmptyNameWithoutSignalIsSuppressed(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"","arguments":""}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
if got := len(toolUseStarts(events)); got != 0 {
t.Fatalf("empty name without id/args must stay suppressed; got %d", got)
}
if got := lastStopReason(events); got == "tool_use" {
t.Fatalf("stop_reason must not be tool_use when zero tool_use blocks were emitted; got %q", got)
}
}
func TestStreamingTool_EmptyIDDeferStart(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"","function":{"name":"do_it","arguments":""}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_real","function":{"arguments":"{}"}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
starts := toolUseStarts(events)
if len(starts) != 1 {
t.Fatalf("expected exactly one tool_use start once id arrived, got %d", len(starts))
}
if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_real" {
t.Fatalf("announced tool id = %q, want %q", id, "call_real")
}
}
func TestStreamingTool_IDInDeltaWithoutFunction(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"do_it"}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_real"}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
starts := toolUseStarts(events)
if len(starts) != 1 {
t.Fatalf("expected exactly one tool_use start when id arrives in a function-less delta, got %d", len(starts))
}
if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_real" {
t.Fatalf("announced tool id = %q, want %q", id, "call_real")
}
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
t.Fatalf("announced tool name = %q, want %q", name, "do_it")
}
if got := countByType(events, "content_block_stop"); got != 1 {
t.Fatalf("expected exactly one content_block_stop, got %d", got)
}
}
func TestStreamingTool_StopReasonWithEmittedTool(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":"do_it","arguments":"{}"}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`,
)
if got := lastStopReason(events); got != "tool_use" {
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
}
}
func TestStreamingTool_StopReasonWhenIDNeverArrives(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"do_it","arguments":""}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
starts := toolUseStarts(events)
if len(starts) != 1 {
t.Fatalf("expected one belated tool_use start with synthetic id, got %d", len(starts))
}
id := gjson.Get(starts[0].Payload, "content_block.id").String()
if !strings.HasPrefix(id, "toolu_") {
t.Fatalf("synthetic id should match toolu_<nanos>_<n>, got %q", id)
}
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
t.Fatalf("announced tool name = %q, want %q", name, "do_it")
}
if got := lastStopReason(events); got != "tool_use" {
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
}
}
func TestStreamingTool_BelatedStartsUseOpenAIToolIndexOrder(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[
{"index":2,"function":{"name":"third_tool","arguments":"{}"}},
{"index":0,"function":{"name":"first_tool","arguments":"{}"}},
{"index":1,"function":{"name":"second_tool","arguments":"{}"}}
]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
starts := toolUseStarts(events)
if len(starts) != 3 {
t.Fatalf("expected three belated tool_use starts, got %d", len(starts))
}
wantNames := []string{"first_tool", "second_tool", "third_tool"}
for i, wantName := range wantNames {
if name := gjson.Get(starts[i].Payload, "content_block.name").String(); name != wantName {
t.Fatalf("tool_use start %d name = %q, want %q (starts=%+v)", i, name, wantName, starts)
}
if blockIndex := gjson.Get(starts[i].Payload, "index").Int(); blockIndex != int64(i) {
t.Fatalf("tool_use start %d block index = %d, want %d", i, blockIndex, i)
}
}
}
func TestStreamingTool_LateIDAfterFinalization(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"do_it"}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_late"}]}}]}`,
)
starts := toolUseStarts(events)
if len(starts) != 1 {
t.Fatalf("expected one belated tool_use start, got %d", len(starts))
}
var sawMessageStop bool
for _, e := range events {
if e.Type == "message_stop" {
sawMessageStop = true
continue
}
if sawMessageStop {
switch e.Type {
case "content_block_start", "content_block_delta", "content_block_stop":
t.Fatalf("event %q emitted after message_stop (events=%+v)", e.Type, events)
}
}
}
}
func TestStreamingTool_StopReasonMixedEmptyNameAndValid(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[
{"index":0,"id":"call_empty","function":{"name":"","arguments":""}},
{"index":1,"id":"call_real","function":{"name":"do_it","arguments":"{}"}}
]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
if got := lastStopReason(events); got != "tool_use" {
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
}
if got := len(toolUseStarts(events)); got != 2 {
t.Fatalf("expected two tool_use starts, got %d", got)
}
}
func TestStreamingTool_EmptyNameArgsOnlyNoID(t *testing.T) {
events := runStream(t, streamReq,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"","arguments":"{\"q\":\"x\"}"}}]}}]}`,
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
)
starts := toolUseStarts(events)
if len(starts) != 1 {
t.Fatalf("expected one belated tool_use start for empty-name args-only call, got %d", len(starts))
}
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
}
id := gjson.Get(starts[0].Payload, "content_block.id").String()
if !strings.HasPrefix(id, "toolu_") {
t.Fatalf("synthetic id should match toolu_<nanos>_<n>, got %q", id)
}
if got := lastStopReason(events); got != "tool_use" {
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
}
}

View file

@ -0,0 +1,20 @@
package gemini
import (
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
translator.Register(
Gemini,
OpenAI,
ConvertGeminiRequestToOpenAI,
interfaces.TranslateResponse{
Stream: ConvertOpenAIResponseToGemini,
NonStream: ConvertOpenAIResponseToGeminiNonStream,
TokenCount: GeminiTokenCount,
},
)
}

View file

@ -0,0 +1,511 @@
// Package gemini provides request translation functionality for Gemini to OpenAI API.
// It handles parsing and transforming Gemini API requests into OpenAI Chat Completions API format,
// extracting model information, generation config, message contents, and tool declarations.
// The package performs JSON data transformation to ensure compatibility
// between Gemini API format and OpenAI API's expected format.
package gemini
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertGeminiRequestToOpenAI parses and transforms a Gemini API request into OpenAI Chat Completions API format.
// It extracts the model name, generation config, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the OpenAI API.
func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := inputRawJSON
// Base OpenAI Chat Completions API template
out := []byte(`{"model":"","messages":[]}`)
root := gjson.ParseBytes(rawJSON)
// Model mapping
out, _ = sjson.SetBytes(out, "model", modelName)
// Generation config mapping
if genConfig := root.Get("generationConfig"); genConfig.Exists() {
// Temperature
if temp := genConfig.Get("temperature"); temp.Exists() {
out, _ = sjson.SetBytes(out, "temperature", temp.Float())
}
// Max tokens
if maxTokens := genConfig.Get("maxOutputTokens"); maxTokens.Exists() {
out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
}
// Top P
if topP := genConfig.Get("topP"); topP.Exists() {
out, _ = sjson.SetBytes(out, "top_p", topP.Float())
}
// Top K (OpenAI doesn't have direct equivalent, but we can map it)
if topK := genConfig.Get("topK"); topK.Exists() {
// Store as custom parameter for potential use
out, _ = sjson.SetBytes(out, "top_k", topK.Int())
}
// Stop sequences
if stopSequences := genConfig.Get("stopSequences"); stopSequences.Exists() && stopSequences.IsArray() {
var stops []string
stopSequences.ForEach(func(_, value gjson.Result) bool {
stops = append(stops, value.String())
return true
})
if len(stops) > 0 {
out, _ = sjson.SetBytes(out, "stop", stops)
}
}
// Candidate count (OpenAI 'n' parameter)
if candidateCount := genConfig.Get("candidateCount"); candidateCount.Exists() {
out, _ = sjson.SetBytes(out, "n", candidateCount.Int())
}
if responseModalities := genConfig.Get("responseModalities"); responseModalities.Exists() && responseModalities.IsArray() {
var modalities []string
responseModalities.ForEach(func(_, value gjson.Result) bool {
switch strings.ToLower(strings.TrimSpace(value.String())) {
case "text":
modalities = append(modalities, "text")
case "image":
modalities = append(modalities, "image")
case "audio":
modalities = append(modalities, "audio")
}
return true
})
if len(modalities) > 0 {
out, _ = sjson.SetBytes(out, "modalities", modalities)
}
}
// Map Gemini thinkingConfig to OpenAI reasoning_effort.
// Always perform conversion to support allowCompat models that may not be in registry.
// Note: Google official Python SDK sends snake_case fields (thinking_level/thinking_budget).
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
thinkingLevel := thinkingConfig.Get("thinkingLevel")
if !thinkingLevel.Exists() {
thinkingLevel = thinkingConfig.Get("thinking_level")
}
if thinkingLevel.Exists() {
effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
if effort != "" {
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
}
} else {
thinkingBudget := thinkingConfig.Get("thinkingBudget")
if !thinkingBudget.Exists() {
thinkingBudget = thinkingConfig.Get("thinking_budget")
}
if thinkingBudget.Exists() {
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
}
}
}
}
}
// Stream parameter
out, _ = sjson.SetBytes(out, "stream", stream)
if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String {
out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
}
// Process contents (Gemini messages) -> OpenAI messages
messageCapacity := root.Get("contents.#").Int()
if root.Get("systemInstruction").Exists() || root.Get("system_instruction").Exists() {
messageCapacity++
}
messageItems := translatorcommon.NewRawArrayItems(messageCapacity)
toolCallIDsByName := make(map[string][]string) // Track tool call IDs per function name for matching
// System instruction -> OpenAI system message
// Gemini may provide `systemInstruction` or `system_instruction`; support both keys.
systemInstruction := root.Get("systemInstruction")
if !systemInstruction.Exists() {
systemInstruction = root.Get("system_instruction")
}
if systemInstruction.Exists() {
parts := systemInstruction.Get("parts")
contentItems := make([][]byte, 0, 2)
if parts.Exists() && parts.IsArray() {
parts.ForEach(func(_, part gjson.Result) bool {
if translatorcommon.IsGeminiThoughtPart(part) {
return true
}
// Handle text parts
if text := part.Get("text"); text.Exists() {
contentPart := []byte(`{"type":"text","text":""}`)
contentPart, _ = sjson.SetBytes(contentPart, "text", text.String())
contentItems = append(contentItems, contentPart)
}
// Handle inline data (e.g., images)
if contentPart, ok := openAIContentPartFromGeminiInlineData(part); ok {
contentItems = append(contentItems, contentPart)
}
if contentPart, ok := openAIContentPartFromGeminiFileData(part); ok {
contentItems = append(contentItems, contentPart)
}
return true
})
}
if len(contentItems) > 0 {
msg := []byte(`{"role":"system","content":[]}`)
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
messageItems = append(messageItems, msg)
}
}
if contents := root.Get("contents"); contents.Exists() && contents.IsArray() {
msgIdx := 0
contents.ForEach(func(_, content gjson.Result) bool {
role := content.Get("role").String()
parts := content.Get("parts")
// Convert role: model -> assistant
if role == "model" {
role = "assistant"
}
msg := []byte(`{"role":"","content":""}`)
msg, _ = sjson.SetBytes(msg, "role", role)
var textBuilder strings.Builder
contentItems := make([][]byte, 0, 4)
onlyTextContent := true
toolCallItems := make([][]byte, 0, 2)
droppedThought := false
if parts.Exists() && parts.IsArray() {
partIdx := 0
parts.ForEach(func(_, part gjson.Result) bool {
currentPartIdx := partIdx
partIdx++
if translatorcommon.IsGeminiThoughtPart(part) {
droppedThought = true
return true
}
// Handle text parts
if text := part.Get("text"); text.Exists() {
formattedText := text.String()
textBuilder.WriteString(formattedText)
contentPart := []byte(`{"type":"text","text":""}`)
contentPart, _ = sjson.SetBytes(contentPart, "text", formattedText)
contentItems = append(contentItems, contentPart)
}
// Handle inline data (e.g., images)
if contentPart, ok := openAIContentPartFromGeminiInlineData(part); ok {
onlyTextContent = false
contentItems = append(contentItems, contentPart)
}
if contentPart, ok := openAIContentPartFromGeminiFileData(part); ok {
onlyTextContent = false
contentItems = append(contentItems, contentPart)
}
// Handle function calls (Gemini) -> tool calls (OpenAI)
if functionCall := part.Get("functionCall"); functionCall.Exists() {
funcName := functionCall.Get("name").String()
argsRaw := ""
if args := functionCall.Get("args"); args.Exists() {
argsRaw = args.Raw
}
toolCallID := explicitGeminiToolID(functionCall)
if toolCallID == "" {
toolCallID = deterministicToolCallID("call", msgIdx, currentPartIdx, funcName, argsRaw)
}
toolCallIDsByName[funcName] = append(toolCallIDsByName[funcName], toolCallID)
toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
toolCall, _ = sjson.SetBytes(toolCall, "id", toolCallID)
toolCall, _ = sjson.SetBytes(toolCall, "function.name", funcName)
// Convert args to arguments JSON string
if argsRaw != "" {
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", argsRaw)
} else {
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", "{}")
}
toolCallItems = append(toolCallItems, toolCall)
}
// Handle function responses (Gemini) -> tool role messages (OpenAI)
if functionResponse := part.Get("functionResponse"); functionResponse.Exists() {
funcName := functionResponse.Get("name").String()
// Create tool message for function response
toolMsg := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
responseRaw := ""
// Convert response.content to JSON string
if response := functionResponse.Get("response"); response.Exists() {
if contentField := response.Get("content"); contentField.Exists() {
responseRaw = contentField.Raw
toolMsg, _ = sjson.SetBytes(toolMsg, "content", responseRaw)
} else {
responseRaw = response.Raw
toolMsg, _ = sjson.SetBytes(toolMsg, "content", responseRaw)
}
}
if toolCallID := explicitGeminiToolID(functionResponse); toolCallID != "" {
toolMsg, _ = sjson.SetBytes(toolMsg, "tool_call_id", toolCallID)
if queue := toolCallIDsByName[funcName]; len(queue) > 0 {
for i, id := range queue {
if id == toolCallID {
toolCallIDsByName[funcName] = append(queue[:i], queue[i+1:]...)
break
}
}
}
} else if queue := toolCallIDsByName[funcName]; len(queue) > 0 {
toolCallID := queue[0]
toolCallIDsByName[funcName] = queue[1:]
toolMsg, _ = sjson.SetBytes(toolMsg, "tool_call_id", toolCallID)
} else {
// Generate a deterministic tool call ID fallback if none available
fallbackID := deterministicToolCallID("response", msgIdx, currentPartIdx, funcName, responseRaw)
toolMsg, _ = sjson.SetBytes(toolMsg, "tool_call_id", fallbackID)
}
messageItems = append(messageItems, toolMsg)
}
return true
})
}
// Set content
if len(contentItems) > 0 {
if onlyTextContent {
msg, _ = sjson.SetBytes(msg, "content", textBuilder.String())
} else {
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
}
}
// Set tool calls if any.
if len(toolCallItems) > 0 {
msg, _ = sjson.SetRawBytes(msg, "tool_calls", translatorcommon.JoinRawArray(toolCallItems))
}
if droppedThought && len(contentItems) == 0 && len(toolCallItems) == 0 {
msgIdx++
return true
}
messageItems = append(messageItems, msg)
msgIdx++
return true
})
}
out = translatorcommon.SetRawArrayItems(out, "messages", messageItems)
// Tools mapping: Gemini tools -> OpenAI tools
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
var toolItems [][]byte
tools.ForEach(func(_, tool gjson.Result) bool {
if functionDeclarations := tool.Get("functionDeclarations"); functionDeclarations.Exists() && functionDeclarations.IsArray() {
functionDeclarations.ForEach(func(_, funcDecl gjson.Result) bool {
openAITool := []byte(`{"type":"function","function":{"name":"","description":""}}`)
openAITool, _ = sjson.SetBytes(openAITool, "function.name", funcDecl.Get("name").String())
openAITool, _ = sjson.SetBytes(openAITool, "function.description", funcDecl.Get("description").String())
// Convert parameters schema
if parameters := funcDecl.Get("parameters"); parameters.Exists() {
openAITool, _ = sjson.SetRawBytes(openAITool, "function.parameters", []byte(parameters.Raw))
} else if parameters := funcDecl.Get("parametersJsonSchema"); parameters.Exists() {
openAITool, _ = sjson.SetRawBytes(openAITool, "function.parameters", []byte(parameters.Raw))
}
toolItems = append(toolItems, openAITool)
return true
})
}
return true
})
if len(toolItems) > 0 {
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
}
}
// Tool choice mapping (Gemini doesn't have direct equivalent, but we can handle it)
if toolConfig := root.Get("toolConfig"); toolConfig.Exists() {
if functionCallingConfig := toolConfig.Get("functionCallingConfig"); functionCallingConfig.Exists() {
mode := functionCallingConfig.Get("mode").String()
allowedNames := functionCallingConfig.Get("allowedFunctionNames")
switch mode {
case "NONE":
out, _ = sjson.SetBytes(out, "tool_choice", "none")
case "AUTO":
out, _ = sjson.SetBytes(out, "tool_choice", "auto")
case "ANY":
allowedNameItems := allowedNames.Array()
if allowedNames.IsArray() && len(allowedNameItems) == 1 {
choice := []byte(`{"type":"function","function":{"name":""}}`)
choice, _ = sjson.SetBytes(choice, "function.name", allowedNameItems[0].String())
out, _ = sjson.SetRawBytes(out, "tool_choice", choice)
} else {
out, _ = sjson.SetBytes(out, "tool_choice", "required")
}
}
}
}
return out
}
func deterministicToolCallID(kind string, msgIdx, partIdx int, name, payload string) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%d|%d|%s|%s", kind, msgIdx, partIdx, name, payload)))
return "call_" + hex.EncodeToString(sum[:12])
}
func explicitGeminiToolID(node gjson.Result) string {
if id := strings.TrimSpace(node.Get("id").String()); id != "" {
return id
}
if callID := strings.TrimSpace(node.Get("call_id").String()); callID != "" {
return callID
}
return strings.TrimSpace(node.Get("callId").String())
}
func openAIContentPartFromGeminiInlineData(part gjson.Result) ([]byte, bool) {
inlineData := part.Get("inlineData")
if !inlineData.Exists() {
inlineData = part.Get("inline_data")
}
if !inlineData.Exists() {
return nil, false
}
mimeType := inlineData.Get("mimeType").String()
if mimeType == "" {
mimeType = inlineData.Get("mime_type").String()
}
if mimeType == "" {
mimeType = "application/octet-stream"
}
data := inlineData.Get("data").String()
if data == "" {
return nil, false
}
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
lowerMimeType := strings.ToLower(mimeType)
switch {
case strings.HasPrefix(lowerMimeType, "image/"):
contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`)
contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", dataURL)
return contentPart, true
case strings.HasPrefix(lowerMimeType, "audio/"):
contentPart := []byte(`{"type":"input_audio","input_audio":{"data":"","format":""}}`)
contentPart, _ = sjson.SetBytes(contentPart, "input_audio.data", data)
contentPart, _ = sjson.SetBytes(contentPart, "input_audio.format", openAIInputAudioFormatFromMIME(mimeType))
return contentPart, true
case strings.HasPrefix(lowerMimeType, "video/"):
contentPart := []byte(`{"type":"video_url","video_url":{"url":""}}`)
contentPart, _ = sjson.SetBytes(contentPart, "video_url.url", dataURL)
return contentPart, true
default:
contentPart := []byte(`{"type":"file","file":{"filename":"","file_data":""}}`)
contentPart, _ = sjson.SetBytes(contentPart, "file.filename", openAIFileNameFromMIME(mimeType))
contentPart, _ = sjson.SetBytes(contentPart, "file.file_data", data)
return contentPart, true
}
}
func openAIContentPartFromGeminiFileData(part gjson.Result) ([]byte, bool) {
fileData := part.Get("fileData")
if !fileData.Exists() {
fileData = part.Get("file_data")
}
if !fileData.Exists() {
return nil, false
}
fileURI := fileData.Get("fileUri").String()
if fileURI == "" {
fileURI = fileData.Get("file_uri").String()
}
if fileURI == "" {
return nil, false
}
mimeType := fileData.Get("mimeType").String()
if mimeType == "" {
mimeType = fileData.Get("mime_type").String()
}
lowerMimeType := strings.ToLower(mimeType)
if strings.HasPrefix(lowerMimeType, "image/") {
contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`)
contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", fileURI)
return contentPart, true
}
if strings.HasPrefix(lowerMimeType, "video/") {
contentPart := []byte(`{"type":"video_url","video_url":{"url":""}}`)
contentPart, _ = sjson.SetBytes(contentPart, "video_url.url", fileURI)
return contentPart, true
}
if strings.HasPrefix(lowerMimeType, "application/") || strings.HasPrefix(lowerMimeType, "text/") {
contentPart := []byte(`{"type":"file","file":{"filename":"","file_url":""}}`)
contentPart, _ = sjson.SetBytes(contentPart, "file.filename", openAIFileNameFromMIME(mimeType))
contentPart, _ = sjson.SetBytes(contentPart, "file.file_url", fileURI)
return contentPart, true
}
fileInfo := "File: " + fileURI
if mimeType != "" {
fileInfo += " (Type: " + mimeType + ")"
}
contentPart := []byte(`{"type":"text","text":""}`)
contentPart, _ = sjson.SetBytes(contentPart, "text", fileInfo)
return contentPart, true
}
func openAIInputAudioFormatFromMIME(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case "audio/wav", "audio/wave", "audio/x-wav":
return "wav"
case "audio/flac":
return "flac"
case "audio/opus", "audio/ogg":
return "opus"
case "audio/pcm", "audio/l16":
return "pcm16"
default:
return "mp3"
}
}
func openAIFileNameFromMIME(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case "application/pdf":
return "document.pdf"
case "text/plain":
return "document.txt"
case "text/csv":
return "document.csv"
case "application/json":
return "document.json"
case "application/xml", "text/xml":
return "document.xml"
default:
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(mimeType)), "video/") {
return "video"
}
return "document"
}
}

View file

@ -0,0 +1,444 @@
package gemini
import (
"strings"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertGeminiRequestToOpenAI_FunctionResponsesConsumeToolCallIDsFIFO(t *testing.T) {
inputJSON := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "read_file", "args": {"path": "a.txt"}}},
{"functionCall": {"name": "grep", "args": {"pattern": "needle"}}},
{"functionCall": {"name": "list_dir", "args": {"path": "."}}}
]
},
{
"role": "function",
"parts": [
{"functionResponse": {"name": "read_file", "response": {"result": "a"}}},
{"functionResponse": {"name": "grep", "response": {"result": "b"}}},
{"functionResponse": {"name": "list_dir", "response": {"result": "c"}}}
]
}
]
}`)
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
firstID := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String()
secondID := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String()
thirdID := gjson.GetBytes(out, "messages.0.tool_calls.2.id").String()
if firstID == "" || secondID == "" || thirdID == "" {
t.Fatalf("expected all assistant tool call IDs to be set. Output: %s", string(out))
}
if firstID == secondID || secondID == thirdID || firstID == thirdID {
t.Fatalf("expected distinct assistant tool call IDs, got %q, %q, %q", firstID, secondID, thirdID)
}
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != firstID {
t.Fatalf("messages.1.tool_call_id = %q, want %q. Output: %s", got, firstID, string(out))
}
if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != secondID {
t.Fatalf("messages.2.tool_call_id = %q, want %q. Output: %s", got, secondID, string(out))
}
if got := gjson.GetBytes(out, "messages.3.tool_call_id").String(); got != thirdID {
t.Fatalf("messages.3.tool_call_id = %q, want %q. Output: %s", got, thirdID, string(out))
}
}
func TestConvertGeminiRequestToOpenAI_FunctionResponseWithoutPriorCallGetsFallbackID(t *testing.T) {
inputJSON := []byte(`{
"contents": [
{
"role": "function",
"parts": [
{"functionResponse": {"name": "read_file", "response": {"result": "ok"}}}
]
}
]
}`)
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
toolCallID := gjson.GetBytes(out, "messages.0.tool_call_id").String()
if !strings.HasPrefix(toolCallID, "call_") {
t.Fatalf("fallback tool_call_id = %q, want call_ prefix. Output: %s", toolCallID, string(out))
}
}
func TestConvertGeminiRequestToOpenAI_ExtraFunctionResponsesUseFallbackID(t *testing.T) {
inputJSON := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "read_file", "args": {"path": "a.txt"}}}
]
},
{
"role": "function",
"parts": [
{"functionResponse": {"name": "read_file", "response": {"result": "a"}}},
{"functionResponse": {"name": "read_file", "response": {"result": "extra"}}}
]
}
]
}`)
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
callID := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String()
firstResponseID := gjson.GetBytes(out, "messages.1.tool_call_id").String()
extraResponseID := gjson.GetBytes(out, "messages.2.tool_call_id").String()
if firstResponseID != callID {
t.Fatalf("messages.1.tool_call_id = %q, want %q. Output: %s", firstResponseID, callID, string(out))
}
if !strings.HasPrefix(extraResponseID, "call_") {
t.Fatalf("extra response fallback tool_call_id = %q, want call_ prefix. Output: %s", extraResponseID, string(out))
}
if extraResponseID == callID {
t.Fatalf("extra response reused consumed tool_call_id %q. Output: %s", extraResponseID, string(out))
}
}
func TestConvertGeminiRequestToOpenAI_PreservesExplicitFunctionCallIDs(t *testing.T) {
tests := []struct {
name string
callField string
responseField string
want string
}{
{
name: "id",
callField: `"id":"call_gateway_id"`,
responseField: `"id":"call_gateway_id"`,
want: "call_gateway_id",
},
{
name: "call_id",
callField: `"call_id":"call_gateway_call_id"`,
responseField: `"call_id":"call_gateway_call_id"`,
want: "call_gateway_call_id",
},
{
name: "callId",
callField: `"callId":"call_gateway_camel_id"`,
responseField: `"callId":"call_gateway_camel_id"`,
want: "call_gateway_camel_id",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inputJSON := []byte(`{
"contents": [
{"role": "model", "parts": [{"functionCall": {"name": "lookup", ` + tt.callField + `, "args": {"q": "x"}}}]},
{"role": "function", "parts": [{"functionResponse": {"name": "lookup", ` + tt.responseField + `, "response": {"result": "ok"}}}]}
]
}`)
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != tt.want {
t.Fatalf("tool call id = %q, want %q. Output: %s", got, tt.want, string(out))
}
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != tt.want {
t.Fatalf("tool response id = %q, want %q. Output: %s", got, tt.want, string(out))
}
})
}
}
func TestConvertGeminiRequestToOpenAI_AcceptsSnakeInlineData(t *testing.T) {
out := ConvertGeminiRequestToOpenAI("gpt-test", []byte(`{"contents":[{"role":"user","parts":[{"inline_data":{"mime_type":"image/png","data":"aGVsbG8="}}]}]}`), false)
if got := gjson.GetBytes(out, "messages.0.content.0.image_url.url").String(); got != "data:image/png;base64,aGVsbG8=" {
t.Fatalf("image url = %q, want data:image/png;base64,aGVsbG8=. Output: %s", got, string(out))
}
}
func TestConvertGeminiRequestToOpenAI_SplitsNonImageInlineDataByMIME(t *testing.T) {
out := ConvertGeminiRequestToOpenAI("gpt-test", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"UklGRg=="}},{"inlineData":{"mimeType":"video/mp4","data":"AAAAIGZ0eXA="}},{"inlineData":{"mimeType":"application/pdf","data":"JVBERi0="}}]}]}`), false)
if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "input_audio" {
t.Fatalf("audio content type = %q, want input_audio. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.1.type").String(); got != "video_url" {
t.Fatalf("video content type = %q, want video_url. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.2.type").String(); got != "file" {
t.Fatalf("document content type = %q, want file. Output: %s", got, string(out))
}
if gjson.GetBytes(out, "messages.0.content.#(type==\"image_url\")").Exists() {
t.Fatalf("non-image inlineData must not be converted to image_url. Output: %s", string(out))
}
}
func TestConvertGeminiRequestToOpenAI_DropsHiddenThoughtParts(t *testing.T) {
t.Run("thought-only turn", func(t *testing.T) {
out := ConvertGeminiRequestToOpenAI("openai-test", []byte(`{
"contents":[
{"role":"model","parts":[{"thought":true,"text":"internal reasoning","thoughtSignature":"opaque-provider-state"}]},
{"role":"user","parts":[{"text":"continue"}]}
]
}`), false)
messages := gjson.GetBytes(out, "messages").Array()
if len(messages) != 1 || messages[0].Get("role").String() != "user" || messages[0].Get("content").String() != "continue" {
t.Fatalf("hidden thought turn was not dropped. Output: %s", string(out))
}
})
t.Run("mixed turn", func(t *testing.T) {
out := ConvertGeminiRequestToOpenAI("openai-test", []byte(`{
"contents":[{"role":"model","parts":[
{"thought":true,"text":"internal reasoning","thoughtSignature":"opaque-provider-state"},
{"text":"visible answer"}
]}]
}`), false)
messages := gjson.GetBytes(out, "messages").Array()
if len(messages) != 1 || messages[0].Get("role").String() != "assistant" || messages[0].Get("content").String() != "visible answer" {
t.Fatalf("hidden thought was not dropped independently of visible text. Output: %s", string(out))
}
})
}
func TestConvertGeminiRequestToOpenAI_DeterministicToolCallIDs(t *testing.T) {
inputJSON := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "read_file", "args": {"path": "main.go"}}},
{"functionCall": {"name": "grep", "args": {"pattern": "TODO"}}}
]
},
{
"role": "function",
"parts": [
{"functionResponse": {"name": "read_file", "response": {"result": "code"}}},
{"functionResponse": {"name": "grep", "response": {"result": "matches"}}}
]
}
]
}`)
firstOut := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
firstCall0 := gjson.GetBytes(firstOut, "messages.0.tool_calls.0.id").String()
firstCall1 := gjson.GetBytes(firstOut, "messages.0.tool_calls.1.id").String()
firstResp0 := gjson.GetBytes(firstOut, "messages.1.tool_call_id").String()
firstResp1 := gjson.GetBytes(firstOut, "messages.2.tool_call_id").String()
if !strings.HasPrefix(firstCall0, "call_") || !strings.HasPrefix(firstCall1, "call_") {
t.Fatalf("expected tool call IDs to have call_ prefix, got %q, %q", firstCall0, firstCall1)
}
if firstResp0 != firstCall0 {
t.Fatalf("expected first response ID %q to match first call ID %q", firstResp0, firstCall0)
}
if firstResp1 != firstCall1 {
t.Fatalf("expected second response ID %q to match second call ID %q", firstResp1, firstCall1)
}
for i := 0; i < 100; i++ {
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != firstCall0 {
t.Fatalf("iteration %d: tool_calls.0.id = %q, want %q", i, got, firstCall0)
}
if got := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String(); got != firstCall1 {
t.Fatalf("iteration %d: tool_calls.1.id = %q, want %q", i, got, firstCall1)
}
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != firstResp0 {
t.Fatalf("iteration %d: messages.1.tool_call_id = %q, want %q", i, got, firstResp0)
}
if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != firstResp1 {
t.Fatalf("iteration %d: messages.2.tool_call_id = %q, want %q", i, got, firstResp1)
}
}
}
func TestConvertGeminiRequestToOpenAI_SameNameCallsInSameMessageDistinct(t *testing.T) {
inputJSON := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "read_file", "args": {"path": "a.txt"}}},
{"functionCall": {"name": "read_file", "args": {"path": "a.txt"}}}
]
},
{
"role": "function",
"parts": [
{"functionResponse": {"name": "read_file", "response": {"result": "first"}}},
{"functionResponse": {"name": "read_file", "response": {"result": "second"}}}
]
}
]
}`)
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
id0 := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String()
id1 := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String()
if id0 == id1 {
t.Fatalf("expected distinct IDs for same-name calls in same message, got both %q", id0)
}
resp0 := gjson.GetBytes(out, "messages.1.tool_call_id").String()
resp1 := gjson.GetBytes(out, "messages.2.tool_call_id").String()
if resp0 != id0 {
t.Fatalf("expected first response to match first call ID %q, got %q", id0, resp0)
}
if resp1 != id1 {
t.Fatalf("expected second response to match second call ID %q, got %q", id1, resp1)
}
}
func TestConvertGeminiRequestToOpenAI_InterleavedPerNameFIFOMatching(t *testing.T) {
// Interleaved calls: toolA, toolB, toolA, toolB
// Responses returned grouped by tool: toolB, toolA, toolB, toolA
inputJSON := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "tool_a", "args": {"step": 1}}},
{"functionCall": {"name": "tool_b", "args": {"step": 1}}},
{"functionCall": {"name": "tool_a", "args": {"step": 2}}},
{"functionCall": {"name": "tool_b", "args": {"step": 2}}}
]
},
{
"role": "function",
"parts": [
{"functionResponse": {"name": "tool_b", "response": {"step": 1}}},
{"functionResponse": {"name": "tool_a", "response": {"step": 1}}},
{"functionResponse": {"name": "tool_b", "response": {"step": 2}}},
{"functionResponse": {"name": "tool_a", "response": {"step": 2}}}
]
}
]
}`)
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
callA1 := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String()
callB1 := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String()
callA2 := gjson.GetBytes(out, "messages.0.tool_calls.2.id").String()
callB2 := gjson.GetBytes(out, "messages.0.tool_calls.3.id").String()
// Responses:
// messages[1] = tool_b (step 1) -> should match callB1
// messages[2] = tool_a (step 1) -> should match callA1
// messages[3] = tool_b (step 2) -> should match callB2
// messages[4] = tool_a (step 2) -> should match callA2
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != callB1 {
t.Fatalf("first response (tool_b) = %q, want callB1 %q", got, callB1)
}
if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != callA1 {
t.Fatalf("second response (tool_a) = %q, want callA1 %q", got, callA1)
}
if got := gjson.GetBytes(out, "messages.3.tool_call_id").String(); got != callB2 {
t.Fatalf("third response (tool_b) = %q, want callB2 %q", got, callB2)
}
if got := gjson.GetBytes(out, "messages.4.tool_call_id").String(); got != callA2 {
t.Fatalf("fourth response (tool_a) = %q, want callA2 %q", got, callA2)
}
}
func TestConvertGeminiRequestToOpenAI_DeterministicFallbackOrphanResponse(t *testing.T) {
inputJSON := []byte(`{
"contents": [
{
"role": "function",
"parts": [
{"functionResponse": {"name": "orphan_tool", "response": {"result": "standalone"}}}
]
}
]
}`)
firstOut := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
firstID := gjson.GetBytes(firstOut, "messages.0.tool_call_id").String()
if !strings.HasPrefix(firstID, "call_") {
t.Fatalf("expected fallback tool_call_id with call_ prefix, got %q", firstID)
}
for i := 0; i < 100; i++ {
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
if got := gjson.GetBytes(out, "messages.0.tool_call_id").String(); got != firstID {
t.Fatalf("iteration %d: orphan fallback tool_call_id = %q, want %q", i, got, firstID)
}
}
}
func TestConvertGeminiRequestToOpenAI_ExplicitCallInheritedByImplicitResponse(t *testing.T) {
inputJSON := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "lookup", "id": "explicit_call_1", "args": {"q": "foo"}}}
]
},
{
"role": "function",
"parts": [
{"functionResponse": {"name": "lookup", "response": {"result": "bar"}}}
]
}
]
}`)
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "explicit_call_1" {
t.Fatalf("tool call ID = %q, want explicit_call_1", got)
}
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "explicit_call_1" {
t.Fatalf("tool response ID = %q, want explicit_call_1", got)
}
}
func TestConvertGeminiRequestToOpenAI_OutOrderExplicitResponseDoesNotDuplicateID(t *testing.T) {
// Calls: foo (id=call_1), foo (id=call_2), foo (id=call_3)
// Responses: 1st response has explicit id=call_2, 2nd and 3rd are implicit.
// Expected responses order: call_2, call_1, call_3.
inputJSON := []byte(`{
"contents": [
{
"role": "model",
"parts": [
{"functionCall": {"name": "foo", "id": "call_1", "args": {"n": 1}}},
{"functionCall": {"name": "foo", "id": "call_2", "args": {"n": 2}}},
{"functionCall": {"name": "foo", "id": "call_3", "args": {"n": 3}}}
]
},
{
"role": "function",
"parts": [
{"functionResponse": {"name": "foo", "id": "call_2", "response": {"r": 2}}},
{"functionResponse": {"name": "foo", "response": {"r": 1}}},
{"functionResponse": {"name": "foo", "response": {"r": 3}}}
]
}
]
}`)
out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
resp1 := gjson.GetBytes(out, "messages.1.tool_call_id").String()
resp2 := gjson.GetBytes(out, "messages.2.tool_call_id").String()
resp3 := gjson.GetBytes(out, "messages.3.tool_call_id").String()
if resp1 != "call_2" {
t.Fatalf("first response = %q, want call_2", resp1)
}
if resp2 != "call_1" {
t.Fatalf("second response = %q, want call_1", resp2)
}
if resp3 != "call_3" {
t.Fatalf("third response = %q, want call_3", resp3)
}
}

View file

@ -0,0 +1,720 @@
// Package gemini provides response translation functionality for OpenAI to Gemini API.
// This package handles the conversion of OpenAI Chat Completions API responses into Gemini API-compatible
// JSON format, transforming streaming events and non-streaming responses into the format
// expected by Gemini API clients. It supports both streaming and non-streaming modes,
// handling text content, tool calls, and usage metadata appropriately.
package gemini
import (
"bytes"
"context"
"fmt"
"strconv"
"strings"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertOpenAIResponseToGeminiParams holds parameters for response conversion
type ConvertOpenAIResponseToGeminiParams struct {
// Tool calls accumulator for streaming
ToolCallsAccumulator map[int]*ToolCallAccumulator
// Content accumulator for streaming
ContentAccumulator strings.Builder
// Track if this is the first chunk
IsFirstChunk bool
}
// ToolCallAccumulator holds the state for accumulating tool call data
type ToolCallAccumulator struct {
ID string
Name string
Arguments strings.Builder
}
// ConvertOpenAIResponseToGemini converts OpenAI Chat Completions streaming response format to Gemini API format.
// This function processes OpenAI streaming chunks and transforms them into Gemini-compatible JSON responses.
// It handles text content, tool calls, and usage metadata, outputting responses that match the Gemini API format.
//
// Parameters:
// - ctx: The context for the request.
// - modelName: The name of the model.
// - rawJSON: The raw JSON response from the OpenAI API.
// - param: A pointer to a parameter object for the conversion.
//
// Returns:
// - [][]byte: A slice of Gemini-compatible JSON responses.
func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
if *param == nil {
*param = &ConvertOpenAIResponseToGeminiParams{
ToolCallsAccumulator: nil,
ContentAccumulator: strings.Builder{},
IsFirstChunk: false,
}
}
// Handle [DONE] marker
if bytes.Equal(bytes.TrimSpace(rawJSON), []byte("[DONE]")) {
return [][]byte{}
}
if bytes.HasPrefix(rawJSON, []byte("data:")) {
rawJSON = bytes.TrimSpace(rawJSON[5:])
}
root := gjson.ParseBytes(rawJSON)
// Initialize accumulators if needed
if (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator == nil {
(*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
}
// Process choices
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
// Handle empty choices array (usage-only chunk)
if len(choices.Array()) == 0 {
// This is a usage-only chunk, handle usage and return
if usage := root.Get("usage"); usage.Exists() {
template := []byte(`{"candidates":[],"usageMetadata":{}}`)
// Set model if available
if model := root.Get("model"); model.Exists() {
template, _ = sjson.SetBytes(template, "model", model.String())
}
template = setGeminiUsageMetadataFromOpenAIUsage(template, usage)
return [][]byte{template}
}
return [][]byte{}
}
var results [][]byte
choices.ForEach(func(choiceIndex, choice gjson.Result) bool {
// Base Gemini response template without finishReason; set when known
template := []byte(`{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`)
// Set model if available
if model := root.Get("model"); model.Exists() {
template, _ = sjson.SetBytes(template, "model", model.String())
}
_ = int(choice.Get("index").Int()) // choiceIdx not used in streaming
delta := choice.Get("delta")
baseTemplate := append([]byte(nil), template...)
// Handle role (only in first chunk)
if role := delta.Get("role"); role.Exists() && (*param).(*ConvertOpenAIResponseToGeminiParams).IsFirstChunk {
// OpenAI assistant -> Gemini model
if role.String() == "assistant" {
template, _ = sjson.SetBytes(template, "candidates.0.content.role", "model")
}
(*param).(*ConvertOpenAIResponseToGeminiParams).IsFirstChunk = false
results = append(results, template)
return true
}
var chunkOutputs [][]byte
// Handle reasoning/thinking delta
if reasoning := delta.Get("reasoning_content"); reasoning.Exists() {
for _, reasoningText := range extractReasoningTexts(reasoning) {
if reasoningText == "" {
continue
}
reasoningTemplate := append([]byte(nil), baseTemplate...)
reasoningTemplate, _ = sjson.SetBytes(reasoningTemplate, "candidates.0.content.parts.0.thought", true)
reasoningTemplate, _ = sjson.SetBytes(reasoningTemplate, "candidates.0.content.parts.0.text", reasoningText)
chunkOutputs = append(chunkOutputs, reasoningTemplate)
}
}
// Handle content delta
if content := delta.Get("content"); content.Exists() && content.String() != "" {
contentText := content.String()
(*param).(*ConvertOpenAIResponseToGeminiParams).ContentAccumulator.WriteString(contentText)
// Create text part for this delta
contentTemplate := append([]byte(nil), baseTemplate...)
contentTemplate, _ = sjson.SetBytes(contentTemplate, "candidates.0.content.parts.0.text", contentText)
chunkOutputs = append(chunkOutputs, contentTemplate)
}
if len(chunkOutputs) > 0 {
results = append(results, chunkOutputs...)
return true
}
// Handle tool calls delta
if toolCalls := delta.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
toolIndex := int(toolCall.Get("index").Int())
toolID := toolCall.Get("id").String()
toolType := toolCall.Get("type").String()
function := toolCall.Get("function")
// Skip non-function tool calls explicitly marked as other types.
if toolType != "" && toolType != "function" {
return true
}
// OpenAI streaming deltas may omit the type field while still carrying function data.
if !function.Exists() {
return true
}
functionName := function.Get("name").String()
functionArgs := function.Get("arguments").String()
// Initialize accumulator if needed so later deltas without type can append arguments.
if _, exists := (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator[toolIndex]; !exists {
(*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator[toolIndex] = &ToolCallAccumulator{
ID: toolID,
Name: functionName,
}
}
acc := (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator[toolIndex]
// Update ID if provided
if toolID != "" {
acc.ID = toolID
}
// Update name if provided
if functionName != "" {
acc.Name = functionName
}
// Accumulate arguments
if functionArgs != "" {
acc.Arguments.WriteString(functionArgs)
}
return true
})
// Don't output anything for tool call deltas - wait for completion
return true
}
// Handle finish reason
if finishReason := choice.Get("finish_reason"); finishReason.Exists() {
geminiFinishReason := mapOpenAIFinishReasonToGemini(finishReason.String())
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", geminiFinishReason)
// If we have accumulated tool calls, output them now
if len((*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator) > 0 {
partIndex := 0
for _, accumulator := range (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator {
idPath := fmt.Sprintf("candidates.0.content.parts.%d.functionCall.id", partIndex)
namePath := fmt.Sprintf("candidates.0.content.parts.%d.functionCall.name", partIndex)
argsPath := fmt.Sprintf("candidates.0.content.parts.%d.functionCall.args", partIndex)
if accumulator.ID != "" {
template, _ = sjson.SetBytes(template, idPath, accumulator.ID)
}
template, _ = sjson.SetBytes(template, namePath, accumulator.Name)
template, _ = sjson.SetRawBytes(template, argsPath, []byte(parseArgsToObjectRaw(accumulator.Arguments.String())))
partIndex++
}
// Clear accumulators
(*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
}
results = append(results, template)
return true
}
// Handle usage information
if usage := root.Get("usage"); usage.Exists() {
template = setGeminiUsageMetadataFromOpenAIUsage(template, usage)
results = append(results, template)
return true
}
return true
})
return results
}
return [][]byte{}
}
// mapOpenAIFinishReasonToGemini maps OpenAI finish reasons to Gemini finish reasons
func mapOpenAIFinishReasonToGemini(openAIReason string) string {
switch openAIReason {
case "stop":
return "STOP"
case "length":
return "MAX_TOKENS"
case "tool_calls":
return "STOP" // Gemini doesn't have a specific tool_calls finish reason
case "content_filter":
return "SAFETY"
default:
return "STOP"
}
}
// parseArgsToObjectRaw safely parses a JSON string of function arguments into an object JSON string.
// It returns "{}" if the input is empty or cannot be parsed as a JSON object.
func parseArgsToObjectRaw(argsStr string) string {
trimmed := strings.TrimSpace(argsStr)
if trimmed == "" || trimmed == "{}" {
return "{}"
}
// First try strict JSON
if gjson.Valid(trimmed) {
strict := gjson.Parse(trimmed)
if strict.IsObject() {
return strict.Raw
}
}
// Tolerant parse: handle streams where values are barewords (e.g., 北京, celsius)
tolerant := tolerantParseJSONObjectRaw(trimmed)
if tolerant != "{}" {
return tolerant
}
// Fallback: return empty object when parsing fails
return "{}"
}
func escapeSjsonPathKey(key string) string {
key = strings.ReplaceAll(key, `\`, `\\`)
key = strings.ReplaceAll(key, `.`, `\.`)
return key
}
// tolerantParseJSONObjectRaw attempts to parse a JSON-like object string into a JSON object string, tolerating
// bareword values (unquoted strings) commonly seen during streamed tool calls.
// Example input: {"location": 北京, "unit": celsius}
func tolerantParseJSONObjectRaw(s string) string {
// Ensure we operate within the outermost braces if present
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start == -1 || end == -1 || start >= end {
return "{}"
}
content := s[start+1 : end]
runes := []rune(content)
n := len(runes)
i := 0
result := []byte(`{}`)
for i < n {
// Skip whitespace and commas
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t' || runes[i] == ',') {
i++
}
if i >= n {
break
}
// Expect quoted key
if runes[i] != '"' {
// Unable to parse this segment reliably; skip to next comma
for i < n && runes[i] != ',' {
i++
}
continue
}
// Parse JSON string for key
keyToken, nextIdx := parseJSONStringRunes(runes, i)
if nextIdx == -1 {
break
}
keyName := jsonStringTokenToRawString(keyToken)
sjsonKey := escapeSjsonPathKey(keyName)
i = nextIdx
// Skip whitespace
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t') {
i++
}
if i >= n || runes[i] != ':' {
break
}
i++ // skip ':'
// Skip whitespace
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t') {
i++
}
if i >= n {
break
}
// Parse value (string, number, object/array, bareword)
switch runes[i] {
case '"':
// JSON string
valToken, ni := parseJSONStringRunes(runes, i)
if ni == -1 {
// Malformed; treat as empty string
result, _ = sjson.SetBytes(result, sjsonKey, "")
i = n
} else {
result, _ = sjson.SetBytes(result, sjsonKey, jsonStringTokenToRawString(valToken))
i = ni
}
case '{', '[':
// Bracketed value: attempt to capture balanced structure
seg, ni := captureBracketed(runes, i)
if ni == -1 {
i = n
} else {
if gjson.Valid(seg) {
result, _ = sjson.SetRawBytes(result, sjsonKey, []byte(seg))
} else {
result, _ = sjson.SetBytes(result, sjsonKey, seg)
}
i = ni
}
default:
// Bare token until next comma or end
j := i
for j < n && runes[j] != ',' {
j++
}
token := strings.TrimSpace(string(runes[i:j]))
// Interpret common JSON atoms and numbers; otherwise treat as string
if token == "true" {
result, _ = sjson.SetBytes(result, sjsonKey, true)
} else if token == "false" {
result, _ = sjson.SetBytes(result, sjsonKey, false)
} else if token == "null" {
result, _ = sjson.SetBytes(result, sjsonKey, nil)
} else if numVal, ok := tryParseNumber(token); ok {
result, _ = sjson.SetBytes(result, sjsonKey, numVal)
} else {
result, _ = sjson.SetBytes(result, sjsonKey, token)
}
i = j
}
// Skip trailing whitespace and optional comma before next pair
for i < n && (runes[i] == ' ' || runes[i] == '\n' || runes[i] == '\r' || runes[i] == '\t') {
i++
}
if i < n && runes[i] == ',' {
i++
}
}
return string(result)
}
// parseJSONStringRunes returns the JSON string token (including quotes) and the index just after it.
func parseJSONStringRunes(runes []rune, start int) (string, int) {
if start >= len(runes) || runes[start] != '"' {
return "", -1
}
i := start + 1
escaped := false
for i < len(runes) {
r := runes[i]
if r == '\\' && !escaped {
escaped = true
i++
continue
}
if r == '"' && !escaped {
return string(runes[start : i+1]), i + 1
}
escaped = false
i++
}
return string(runes[start:]), -1
}
// jsonStringTokenToRawString converts a JSON string token (including quotes) to a raw Go string value.
func jsonStringTokenToRawString(token string) string {
r := gjson.Parse(token)
if r.Type == gjson.String {
return r.String()
}
// Fallback: strip surrounding quotes if present
if len(token) >= 2 && token[0] == '"' && token[len(token)-1] == '"' {
return token[1 : len(token)-1]
}
return token
}
// captureBracketed captures a balanced JSON object/array starting at index i.
// Returns the segment string and the index just after it; -1 if malformed.
func captureBracketed(runes []rune, i int) (string, int) {
if i >= len(runes) {
return "", -1
}
startRune := runes[i]
var endRune rune
if startRune == '{' {
endRune = '}'
} else if startRune == '[' {
endRune = ']'
} else {
return "", -1
}
depth := 0
j := i
inStr := false
escaped := false
for j < len(runes) {
r := runes[j]
if inStr {
if r == '\\' && !escaped {
escaped = true
j++
continue
}
if r == '"' && !escaped {
inStr = false
} else {
escaped = false
}
j++
continue
}
if r == '"' {
inStr = true
j++
continue
}
if r == startRune {
depth++
} else if r == endRune {
depth--
if depth == 0 {
return string(runes[i : j+1]), j + 1
}
}
j++
}
return string(runes[i:]), -1
}
// tryParseNumber attempts to parse a string as an int or float.
func tryParseNumber(s string) (interface{}, bool) {
if s == "" {
return nil, false
}
// Try integer
if i64, errParseInt := strconv.ParseInt(s, 10, 64); errParseInt == nil {
return i64, true
}
if u64, errParseUInt := strconv.ParseUint(s, 10, 64); errParseUInt == nil {
return u64, true
}
if f64, errParseFloat := strconv.ParseFloat(s, 64); errParseFloat == nil {
return f64, true
}
return nil, false
}
// ConvertOpenAIResponseToGeminiNonStream converts a non-streaming OpenAI response to a non-streaming Gemini response.
//
// Parameters:
// - ctx: The context for the request.
// - modelName: The name of the model.
// - rawJSON: The raw JSON response from the OpenAI API.
// - param: A pointer to a parameter object for the conversion.
//
// Returns:
// - []byte: A Gemini-compatible JSON response.
func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
root := gjson.ParseBytes(rawJSON)
// Base Gemini response template without finishReason; set when known
out := []byte(`{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`)
// Set model if available
if model := root.Get("model"); model.Exists() {
out, _ = sjson.SetBytes(out, "model", model.String())
}
var allParts [][]byte
// Process choices
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
choices.ForEach(func(choiceIndex, choice gjson.Result) bool {
choiceIdx := int(choice.Get("index").Int())
message := choice.Get("message")
// Set role
if role := message.Get("role"); role.Exists() {
if role.String() == "assistant" {
out, _ = sjson.SetBytes(out, "candidates.0.content.role", "model")
}
}
partIndex := 0
ensurePart := func(idx int) []byte {
for len(allParts) <= idx {
allParts = append(allParts, []byte(`{}`))
}
return allParts[idx]
}
// Handle reasoning content before visible text
if reasoning := message.Get("reasoning_content"); reasoning.Exists() {
for _, reasoningText := range extractReasoningTexts(reasoning) {
if reasoningText == "" {
continue
}
part := ensurePart(partIndex)
part, _ = sjson.SetBytes(part, "thought", true)
part, _ = sjson.SetBytes(part, "text", reasoningText)
allParts[partIndex] = part
partIndex++
}
}
// Handle content first
if content := message.Get("content"); content.Exists() && content.String() != "" {
part := ensurePart(partIndex)
part, _ = sjson.SetBytes(part, "text", content.String())
allParts[partIndex] = part
partIndex++
}
// Handle tool calls
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
if toolCall.Get("type").String() == "function" {
function := toolCall.Get("function")
functionName := function.Get("name").String()
functionArgs := function.Get("arguments").String()
functionID := toolCall.Get("id").String()
part := ensurePart(partIndex)
if functionID != "" {
part, _ = sjson.SetBytes(part, "functionCall.id", functionID)
}
part, _ = sjson.SetBytes(part, "functionCall.name", functionName)
part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(parseArgsToObjectRaw(functionArgs)))
allParts[partIndex] = part
partIndex++
}
return true
})
}
// Handle finish reason
if finishReason := choice.Get("finish_reason"); finishReason.Exists() {
geminiFinishReason := mapOpenAIFinishReasonToGemini(finishReason.String())
out, _ = sjson.SetBytes(out, "candidates.0.finishReason", geminiFinishReason)
}
// Set index
out, _ = sjson.SetBytes(out, "candidates.0.index", choiceIdx)
return true
})
if len(allParts) > 0 {
out, _ = sjson.SetRawBytes(out, "candidates.0.content.parts", translatorcommon.JoinRawArray(allParts))
}
}
// Handle usage information
if usage := root.Get("usage"); usage.Exists() {
out = setGeminiUsageMetadataFromOpenAIUsage(out, usage)
}
return out
}
func GeminiTokenCount(ctx context.Context, count int64) []byte {
return translatorcommon.GeminiTokenCountJSON(count)
}
func reasoningTokensFromUsage(usage gjson.Result) int64 {
if usage.Exists() {
if v := usage.Get("completion_tokens_details.reasoning_tokens"); v.Exists() {
return v.Int()
}
if v := usage.Get("output_tokens_details.reasoning_tokens"); v.Exists() {
return v.Int()
}
}
return 0
}
func setGeminiUsageMetadataFromOpenAIUsage(out []byte, usage gjson.Result) []byte {
promptTokens, hasPromptTokens := tokenCountFromUsage(usage, "prompt_tokens", "input_tokens")
completionTokens, hasCompletionTokens := tokenCountFromUsage(usage, "completion_tokens", "output_tokens")
totalTokens, hasTotalTokens := tokenCountFromUsage(usage, "total_tokens")
if hasPromptTokens {
out, _ = sjson.SetBytes(out, "usageMetadata.promptTokenCount", promptTokens)
}
if hasCompletionTokens {
out, _ = sjson.SetBytes(out, "usageMetadata.candidatesTokenCount", completionTokens)
}
if hasTotalTokens {
out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", totalTokens)
} else if hasPromptTokens || hasCompletionTokens {
out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", promptTokens+completionTokens)
}
if reasoningTokens := reasoningTokensFromUsage(usage); reasoningTokens > 0 {
out, _ = sjson.SetBytes(out, "usageMetadata.thoughtsTokenCount", reasoningTokens)
}
if cachedTokens := cachedTokensFromUsage(usage); cachedTokens > 0 {
out, _ = sjson.SetBytes(out, "usageMetadata.cachedContentTokenCount", cachedTokens)
}
return out
}
func tokenCountFromUsage(usage gjson.Result, paths ...string) (int64, bool) {
for _, path := range paths {
if v := usage.Get(path); v.Exists() {
return v.Int(), true
}
}
return 0, false
}
func cachedTokensFromUsage(usage gjson.Result) int64 {
if usage.Exists() {
if v := usage.Get("prompt_tokens_details.cached_tokens"); v.Exists() {
return v.Int()
}
if v := usage.Get("input_tokens_details.cached_tokens"); v.Exists() {
return v.Int()
}
}
return 0
}
func extractReasoningTexts(node gjson.Result) []string {
var texts []string
if !node.Exists() {
return texts
}
if node.IsArray() {
node.ForEach(func(_, value gjson.Result) bool {
texts = append(texts, extractReasoningTexts(value)...)
return true
})
return texts
}
switch node.Type {
case gjson.String:
texts = append(texts, node.String())
case gjson.JSON:
if text := node.Get("text"); text.Exists() {
texts = append(texts, text.String())
} else if raw := strings.TrimSpace(node.Raw); raw != "" && !strings.HasPrefix(raw, "{") && !strings.HasPrefix(raw, "[") {
texts = append(texts, raw)
}
}
return texts
}

View file

@ -0,0 +1,87 @@
package gemini
import (
"context"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIResponseToGeminiNonStreamPreservesToolCallID(t *testing.T) {
raw := []byte(`{"choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_chat_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]}}]}`)
out := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.id").String(); got != "call_chat_1" {
t.Fatalf("functionCall.id = %q, want call_chat_1", got)
}
if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.args.q").String(); got != "x" {
t.Fatalf("functionCall.args.q = %q, want x", got)
}
}
func TestConvertOpenAIResponseToGeminiStreamPreservesToolCallID(t *testing.T) {
var param any
ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_stream_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]}}]}`), &param)
out := ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, []byte(`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`), &param)
if len(out) == 0 {
t.Fatalf("stream output is empty")
}
if got := gjson.GetBytes(out[len(out)-1], "candidates.0.content.parts.0.functionCall.id").String(); got != "call_stream_1" {
t.Fatalf("functionCall.id = %q, want call_stream_1", got)
}
if got := gjson.GetBytes(out[len(out)-1], "candidates.0.content.parts.0.functionCall.args.q").String(); got != "x" {
t.Fatalf("functionCall.args.q = %q, want x", got)
}
}
func TestConvertOpenAIResponseToGeminiNonStream_MultiChoicePartsOverlay(t *testing.T) {
// Scenario 1: First choice has tool call, second choice has text on part 0 -> fields merge
raw1 := []byte(`{"choices":[
{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]}},
{"index":1,"message":{"role":"assistant","content":"choice 1 text"}}
]}`)
out1 := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, raw1, nil)
parts1 := gjson.GetBytes(out1, "candidates.0.content.parts").Array()
if len(parts1) != 1 {
t.Fatalf("expected 1 merged part, got %d. Output: %s", len(parts1), out1)
}
if parts1[0].Get("text").String() != "choice 1 text" {
t.Fatalf("expected text to be 'choice 1 text', got %q", parts1[0].Get("text").String())
}
if parts1[0].Get("functionCall.id").String() != "call_1" {
t.Fatalf("expected functionCall.id to be preserved as 'call_1', got %q", parts1[0].Get("functionCall.id").String())
}
// Scenario 2: Reasoning in choice 0, text in choice 1 on part 0 -> thought preserved, text updated
raw2 := []byte(`{"choices":[
{"index":0,"message":{"role":"assistant","reasoning_content":"initial thought"}},
{"index":1,"message":{"role":"assistant","content":"final text"}}
]}`)
out2 := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, raw2, nil)
parts2 := gjson.GetBytes(out2, "candidates.0.content.parts").Array()
if len(parts2) != 1 {
t.Fatalf("expected 1 merged part, got %d. Output: %s", len(parts2), out2)
}
if !parts2[0].Get("thought").Bool() {
t.Fatalf("expected thought: true to be preserved")
}
if parts2[0].Get("text").String() != "final text" {
t.Fatalf("expected text to be 'final text', got %q", parts2[0].Get("text").String())
}
// Scenario 3: Text in choice 0, functionCall in choice 1 on part 0 -> text preserved, functionCall added
raw3 := []byte(`{"choices":[
{"index":0,"message":{"role":"assistant","content":"original text"}},
{"index":1,"message":{"role":"assistant","tool_calls":[{"id":"call_2","type":"function","function":{"name":"search","arguments":"{}"}}]}}
]}`)
out3 := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, raw3, nil)
parts3 := gjson.GetBytes(out3, "candidates.0.content.parts").Array()
if len(parts3) != 1 {
t.Fatalf("expected 1 merged part, got %d. Output: %s", len(parts3), out3)
}
if parts3[0].Get("text").String() != "original text" {
t.Fatalf("expected text to be 'original text', got %q", parts3[0].Get("text").String())
}
if parts3[0].Get("functionCall.id").String() != "call_2" {
t.Fatalf("expected functionCall.id to be 'call_2', got %q", parts3[0].Get("functionCall.id").String())
}
}

View file

@ -0,0 +1,28 @@
package chat_completions
import (
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
translator.Register(
OpenAI,
Interactions,
ConvertOpenAIRequestToInteractions,
interfaces.TranslateResponse{
Stream: ConvertInteractionsResponseToOpenAI,
NonStream: ConvertInteractionsResponseToOpenAINonStream,
},
)
translator.Register(
Interactions,
OpenAI,
ConvertInteractionsRequestToOpenAI,
interfaces.TranslateResponse{
Stream: ConvertOpenAIResponseToInteractions,
NonStream: ConvertOpenAIResponseToInteractionsNonStream,
},
)
}

View file

@ -0,0 +1,408 @@
package chat_completions
import (
"fmt"
"strings"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func ConvertInteractionsRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte {
root := gjson.ParseBytes(inputRawJSON)
out := []byte(`{"model":"","messages":[]}`)
out, _ = sjson.SetBytes(out, "model", firstNonEmpty(modelName, root.Get("model").String()))
if stream || root.Get("stream").Bool() {
out, _ = sjson.SetBytes(out, "stream", true)
}
messageCapacity := root.Get("input.#").Int()
if interactionsText(root.Get("system_instruction")) != "" {
messageCapacity++
}
messageItems := translatorcommon.NewRawArrayItems(messageCapacity)
appendInteractionsSystemToOpenAI(&messageItems, root)
appendInteractionsInputToOpenAIMessages(&messageItems, root.Get("input"))
out = translatorcommon.SetRawArrayItems(out, "messages", messageItems)
out = copyInteractionsToolsToOpenAI(out, root)
out = copyInteractionsGenerationConfigToOpenAI(out, root)
out = copyInteractionsOpenAITopLevel(out, root)
return out
}
func appendInteractionsSystemToOpenAI(items *[][]byte, root gjson.Result) {
text := interactionsText(root.Get("system_instruction"))
if text == "" {
return
}
msg := []byte(`{"role":"system","content":""}`)
msg, _ = sjson.SetBytes(msg, "content", text)
*items = append(*items, msg)
}
func appendInteractionsInputToOpenAIMessages(items *[][]byte, input gjson.Result) {
if input.Type == gjson.String {
msg := []byte(`{"role":"user","content":""}`)
msg, _ = sjson.SetBytes(msg, "content", input.String())
*items = append(*items, msg)
return
}
if input.IsArray() {
input.ForEach(func(_, step gjson.Result) bool {
appendInteractionsStepToOpenAI(items, step, "user")
return true
})
return
}
if input.IsObject() {
appendInteractionsStepToOpenAI(items, input, "user")
}
}
func appendInteractionsStepToOpenAI(items *[][]byte, step gjson.Result, defaultRole string) {
switch step.Get("type").String() {
case "user_input":
appendInteractionsMessageToOpenAI(items, step, "user")
case "model_output":
appendInteractionsMessageToOpenAI(items, step, "assistant")
case "thought":
appendInteractionsThoughtToOpenAI(items, step)
case "function_call":
appendInteractionsFunctionCallToOpenAI(items, step)
case "function_result":
appendInteractionsFunctionResultToOpenAI(items, step)
default:
if step.Type == gjson.String {
msg := []byte(`{"role":"","content":""}`)
msg, _ = sjson.SetBytes(msg, "role", defaultRole)
msg, _ = sjson.SetBytes(msg, "content", step.String())
*items = append(*items, msg)
}
}
}
func appendInteractionsMessageToOpenAI(items *[][]byte, step gjson.Result, role string) {
msg := []byte(`{"role":"","content":""}`)
msg, _ = sjson.SetBytes(msg, "role", role)
content := step.Get("content")
if content.Type == gjson.String {
msg, _ = sjson.SetBytes(msg, "content", content.String())
} else {
msg = appendInteractionsContentToOpenAIMessage(msg, content, role)
}
*items = append(*items, msg)
}
func appendInteractionsThoughtToOpenAI(items *[][]byte, step gjson.Result) {
msg := []byte(`{"role":"assistant","content":"","reasoning_content":""}`)
msg, _ = sjson.SetBytes(msg, "reasoning_content", interactionsText(step.Get("content")))
*items = append(*items, msg)
}
func appendInteractionsContentToOpenAIMessage(msg []byte, content gjson.Result, role string) []byte {
if !content.Exists() {
return msg
}
if content.Type == gjson.String {
msg, _ = sjson.SetBytes(msg, "content", content.String())
return msg
}
contentItems := make([][]byte, 0, 4)
textOnly := true
var textBuilder strings.Builder
appendPart := func(part gjson.Result) {
converted, ok := interactionsContentPartToOpenAI(part, role)
if !ok {
return
}
if gjson.GetBytes(converted, "type").String() == "text" {
textBuilder.WriteString(gjson.GetBytes(converted, "text").String())
} else {
textOnly = false
}
contentItems = append(contentItems, converted)
}
if content.IsArray() {
content.ForEach(func(_, part gjson.Result) bool {
appendPart(part)
return true
})
} else if content.IsObject() {
appendPart(content)
}
if len(contentItems) > 0 {
if textOnly {
msg, _ = sjson.SetBytes(msg, "content", textBuilder.String())
} else {
msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
}
}
return msg
}
func appendInteractionsFunctionCallToOpenAI(items *[][]byte, step gjson.Result) {
msg := []byte(`{"role":"assistant","content":"","tool_calls":[]}`)
toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":"{}"}}`)
callID := firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), "call_0")
toolCall, _ = sjson.SetBytes(toolCall, "id", callID)
toolCall, _ = sjson.SetBytes(toolCall, "function.name", step.Get("name").String())
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", jsonStringValue(step.Get("arguments"), "{}"))
msg = translatorcommon.SetRawArrayItems(msg, "tool_calls", [][]byte{toolCall})
*items = append(*items, msg)
}
func appendInteractionsFunctionResultToOpenAI(items *[][]byte, step gjson.Result) {
msg := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
msg, _ = sjson.SetBytes(msg, "tool_call_id", firstNonEmpty(step.Get("call_id").String(), step.Get("id").String()))
msg, _ = sjson.SetBytes(msg, "content", jsonStringValue(firstExisting(step.Get("result"), step.Get("output")), ""))
*items = append(*items, msg)
}
func copyInteractionsToolsToOpenAI(out []byte, root gjson.Result) []byte {
tools := root.Get("tools")
if !tools.Exists() || !tools.IsArray() {
return out
}
var toolItems [][]byte
tools.ForEach(func(_, tool gjson.Result) bool {
if converted, ok := openAIToolFromInteractionsTool(tool); ok {
toolItems = append(toolItems, converted)
}
if decls := firstExisting(tool.Get("function_declarations"), tool.Get("functionDeclarations")); decls.Exists() && decls.IsArray() {
decls.ForEach(func(_, decl gjson.Result) bool {
if converted, ok := openAIToolFromInteractionsTool(decl); ok {
toolItems = append(toolItems, converted)
}
return true
})
}
return true
})
if len(toolItems) > 0 {
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
}
return out
}
func copyInteractionsGenerationConfigToOpenAI(out []byte, root gjson.Result) []byte {
gen := root.Get("generation_config")
if !gen.Exists() {
gen = root.Get("generationConfig")
}
copyNumber(&out, "temperature", firstExisting(gen.Get("temperature"), root.Get("temperature")))
copyNumber(&out, "max_tokens", firstExisting(gen.Get("max_output_tokens"), gen.Get("maxOutputTokens"), root.Get("max_tokens"), root.Get("max_completion_tokens")))
copyNumber(&out, "top_p", firstExisting(gen.Get("top_p"), gen.Get("topP"), root.Get("top_p")))
copyNumber(&out, "top_k", firstExisting(gen.Get("top_k"), gen.Get("topK")))
copyNumber(&out, "n", firstExisting(gen.Get("candidate_count"), gen.Get("candidateCount"), root.Get("n")))
if stop := firstExisting(gen.Get("stop_sequences"), gen.Get("stopSequences"), root.Get("stop")); stop.Exists() {
out, _ = sjson.SetRawBytes(out, "stop", []byte(stop.Raw))
}
if toolChoice := firstExisting(gen.Get("tool_choice"), root.Get("tool_choice")); toolChoice.Exists() {
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(toolChoice.Raw))
}
if effort := interactionsReasoningEffort(root, gen); effort != "" {
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
}
if responseModalities := root.Get("response_modalities"); responseModalities.Exists() {
out, _ = sjson.SetRawBytes(out, "modalities", []byte(responseModalities.Raw))
}
return out
}
func copyInteractionsOpenAITopLevel(out []byte, root gjson.Result) []byte {
if format := root.Get("response_format"); format.Exists() {
out, _ = sjson.SetRawBytes(out, "response_format", []byte(format.Raw))
}
if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String {
out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
}
if previousInteractionID := firstNonEmpty(root.Get("previous_interaction_id").String(), root.Get("previous_response_id").String()); previousInteractionID != "" {
out, _ = sjson.SetBytes(out, "previous_response_id", previousInteractionID)
}
if environmentID := firstNonEmpty(root.Get("environment_id").String(), root.Get("environment.id").String()); environmentID != "" {
out, _ = sjson.SetBytes(out, "environment_id", environmentID)
}
if agentConfig := root.Get("agent_config"); agentConfig.Exists() {
out, _ = sjson.SetRawBytes(out, "agent_config", []byte(agentConfig.Raw))
}
for _, key := range []string{"parallel_tool_calls", "seed", "user"} {
if value := root.Get(key); value.Exists() {
out, _ = sjson.SetRawBytes(out, key, []byte(value.Raw))
}
}
return out
}
func interactionsContentPartToOpenAI(part gjson.Result, role string) ([]byte, bool) {
partType := part.Get("type").String()
if partType == "" && part.Get("text").Exists() {
partType = "text"
}
switch partType {
case "text":
out := []byte(`{"type":"text","text":""}`)
out, _ = sjson.SetBytes(out, "text", part.Get("text").String())
return out, true
case "image":
out := []byte(`{"type":"image_url","image_url":{"url":""}}`)
out, _ = sjson.SetBytes(out, "image_url.url", interactionsMediaDataURL(part, "application/octet-stream"))
return out, true
case "audio":
out := []byte(`{"type":"input_audio","input_audio":{"data":"","format":""}}`)
out, _ = sjson.SetBytes(out, "input_audio.data", part.Get("data").String())
out, _ = sjson.SetBytes(out, "input_audio.format", openAIInputAudioFormatFromMIME(part.Get("mime_type").String()))
return out, true
case "video":
out := []byte(`{"type":"video_url","video_url":{"url":""}}`)
out, _ = sjson.SetBytes(out, "video_url.url", interactionsMediaDataURL(part, "video/mp4"))
return out, true
case "document", "file":
out := []byte(`{"type":"file","file":{"filename":"","file_data":""}}`)
out, _ = sjson.SetBytes(out, "file.filename", firstNonEmpty(part.Get("filename").String(), openAIFileNameFromMIME(part.Get("mime_type").String())))
out, _ = sjson.SetBytes(out, "file.file_data", part.Get("data").String())
if url := firstNonEmpty(part.Get("file_url").String(), part.Get("url").String()); url != "" {
out, _ = sjson.DeleteBytes(out, "file.file_data")
out, _ = sjson.SetBytes(out, "file.file_url", url)
}
return out, true
default:
_ = role
}
return nil, false
}
func openAIToolFromInteractionsTool(tool gjson.Result) ([]byte, bool) {
name := firstNonEmpty(tool.Get("name").String(), tool.Get("function.name").String())
if name == "" {
return nil, false
}
out := []byte(`{"type":"function","function":{"name":""}}`)
out, _ = sjson.SetBytes(out, "function.name", name)
if desc := firstExisting(tool.Get("description"), tool.Get("function.description")); desc.Exists() {
out, _ = sjson.SetBytes(out, "function.description", desc.String())
}
if params := firstExisting(tool.Get("parameters"), tool.Get("function.parameters"), tool.Get("parametersJsonSchema")); params.Exists() {
out, _ = sjson.SetRawBytes(out, "function.parameters", []byte(params.Raw))
}
return out, true
}
func interactionsText(value gjson.Result) string {
if !value.Exists() {
return ""
}
if value.Type == gjson.String {
return value.String()
}
if text := value.Get("text"); text.Exists() {
return text.String()
}
for _, path := range []string{"content", "parts"} {
parts := value.Get(path)
if !parts.Exists() || !parts.IsArray() {
continue
}
var builder strings.Builder
parts.ForEach(func(_, part gjson.Result) bool {
builder.WriteString(firstNonEmpty(part.Get("text").String(), part.Get("content.text").String()))
return true
})
return builder.String()
}
return ""
}
func interactionsReasoningEffort(root, gen gjson.Result) string {
for _, value := range []gjson.Result{
gen.Get("reasoning_effort"),
gen.Get("thinking_level"),
gen.Get("thinkingLevel"),
gen.Get("thinking_config.thinking_level"),
gen.Get("thinkingConfig.thinkingLevel"),
root.Get("reasoning_effort"),
} {
if value.Exists() && value.Type == gjson.String {
return strings.ToLower(strings.TrimSpace(value.String()))
}
}
return ""
}
func interactionsMediaDataURL(part gjson.Result, fallbackMimeType string) string {
if url := firstNonEmpty(part.Get("image_url").String(), part.Get("file_data").String(), part.Get("url").String()); url != "" {
return url
}
data := part.Get("data").String()
if data == "" {
return ""
}
mimeType := firstNonEmpty(part.Get("mime_type").String(), fallbackMimeType)
return "data:" + mimeType + ";base64," + data
}
func openAIInputAudioFormatFromMIME(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case "audio/wav", "audio/wave", "audio/x-wav":
return "wav"
case "audio/flac":
return "flac"
case "audio/opus", "audio/ogg":
return "opus"
case "audio/pcm", "audio/l16":
return "pcm16"
default:
return "mp3"
}
}
func openAIFileNameFromMIME(mimeType string) string {
switch strings.ToLower(strings.TrimSpace(mimeType)) {
case "application/pdf":
return "document.pdf"
case "text/plain":
return "document.txt"
case "text/csv":
return "document.csv"
case "application/json":
return "document.json"
default:
if _, suffix, ok := strings.Cut(mimeType, "/"); ok && suffix != "" {
return fmt.Sprintf("document.%s", strings.ReplaceAll(suffix, "+", "."))
}
return "document.bin"
}
}
func copyNumber(out *[]byte, path string, value gjson.Result) {
if value.Exists() {
*out, _ = sjson.SetRawBytes(*out, path, []byte(value.Raw))
}
}
func jsonStringValue(value gjson.Result, fallback string) string {
if !value.Exists() {
return fallback
}
if value.Type == gjson.String {
return value.String()
}
return value.Raw
}
func firstExisting(values ...gjson.Result) gjson.Result {
for _, value := range values {
if value.Exists() {
return value
}
}
return gjson.Result{}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View file

@ -0,0 +1,158 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertInteractionsRequestToOpenAIPreservesExpressibleFields(t *testing.T) {
out := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","tool_choice":{"type":"function","function":{"name":"lookup"}},"response_modalities":["text","image"],"service_tier":"priority","input":"hi"}`), false)
if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tool_choice.function.name").String(); got != "lookup" {
t.Fatalf("tool_choice.function.name = %q, want lookup. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "modalities.0").String(); got != "text" {
t.Fatalf("modalities.0 = %q, want text. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "modalities.1").String(); got != "image" {
t.Fatalf("modalities.1 = %q, want image. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "service_tier").String(); got != "priority" {
t.Fatalf("service_tier = %q, want priority. Output: %s", got, string(out))
}
}
func TestConvertOpenAIRequestToInteractionsMapsMessagesToolsAndStream(t *testing.T) {
raw := []byte(`{"model":"gemini-3.1-flash-lite","stream":true,"messages":[{"role":"system","content":"be brief"},{"role":"user","content":"今天北京的天气怎么样?"}],"tools":[{"type":"function","function":{"name":"get_weather","description":"weather","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}}}],"tool_choice":"auto","max_completion_tokens":128}`)
out := ConvertOpenAIRequestToInteractions("gemini-3.1-flash-lite", raw, false)
if got := gjson.GetBytes(out, "model").String(); got != "gemini-3.1-flash-lite" {
t.Fatalf("model = %q, want gemini-3.1-flash-lite. Output: %s", got, string(out))
}
if !gjson.GetBytes(out, "stream").Bool() {
t.Fatalf("stream should be true. Output: %s", string(out))
}
if got := gjson.GetBytes(out, "system_instruction").String(); got != "be brief" {
t.Fatalf("system_instruction = %q, want be brief. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.type").String(); got != "user_input" {
t.Fatalf("input.0.type = %q, want user_input. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" {
t.Fatalf("input text = %q. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" {
t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "get_weather" {
t.Fatalf("tool name = %q, want get_weather. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tools.0.parameters.properties.location.type").String(); got != "string" {
t.Fatalf("tool schema missing. Output: %s", string(out))
}
if got := gjson.GetBytes(out, "generation_config.tool_choice").String(); got != "auto" {
t.Fatalf("tool_choice = %q, want auto. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "generation_config.max_output_tokens").Int(); got != 128 {
t.Fatalf("max_output_tokens = %d, want 128. Output: %s", got, string(out))
}
}
func TestConvertOpenAIRequestToInteractionsMapsToolCallsAndResults(t *testing.T) {
raw := []byte(`{"model":"gemini-3.1-flash-lite","messages":[{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"}]}`)
out := ConvertOpenAIRequestToInteractions("gemini-3.1-flash-lite", raw, false)
if got := gjson.GetBytes(out, "input.0.type").String(); got != "function_call" {
t.Fatalf("input.0.type = %q, want function_call. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.call_id").String(); got != "call_1" {
t.Fatalf("call_id = %q, want call_1. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.arguments.q").String(); got != "x" {
t.Fatalf("arguments.q = %q, want x. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.1.type").String(); got != "function_result" {
t.Fatalf("input.1.type = %q, want function_result. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.1.result").String(); got != "ok" {
t.Fatalf("result = %q, want ok. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIAcceptsImageContent(t *testing.T) {
out := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"image","mime_type":"image/png","data":"aGVsbG8="}]}]}`), false)
if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "image_url" {
t.Fatalf("content type = %q, want image_url. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.0.image_url.url").String(); got != "data:image/png;base64,aGVsbG8=" {
t.Fatalf("image url = %q, want data:image/png;base64,aGVsbG8=. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIPreservesNonImageMediaContent(t *testing.T) {
out := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="},{"type":"video","mime_type":"video/mp4","data":"AAAAIGZ0eXA="},{"type":"document","mime_type":"application/pdf","data":"JVBERi0="}]}]}`), false)
if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "input_audio" {
t.Fatalf("audio content type = %q, want input_audio. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.0.input_audio.format").String(); got != "wav" {
t.Fatalf("audio format = %q, want wav. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.1.type").String(); got != "video_url" {
t.Fatalf("video content type = %q, want video_url. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.0.content.2.type").String(); got != "file" {
t.Fatalf("document content type = %q, want file. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIWithToolMessagesDirect(t *testing.T) {
out := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}]}`), false)
if got := gjson.GetBytes(out, "messages.1.tool_calls.0.function.name").String(); got != "lookup" {
t.Fatalf("tool call name = %q, want lookup. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.1.tool_calls.0.function.arguments").String(); got != `{"q":"x"}` {
t.Fatalf("tool call arguments = %q, want JSON object string. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != "call_1" {
t.Fatalf("tool_call_id = %q, want call_1. Output: %s", got, string(out))
}
}
func TestConvertOpenAIRequestToInteractions_AntigravitySanitizesGenerationConfigAndSetsAgentConfig(t *testing.T) {
raw := []byte(`{
"model":"antigravity-preview-05-2026",
"messages":[{"role":"user","content":"search"}],
"max_tokens":1024,
"temperature":0.5,
"top_p":0.9,
"tools":[{"type":"function","function":{"name":"search","parameters":{"type":"object"}}}]
}`)
out := ConvertOpenAIRequestToInteractions("antigravity-preview-05-2026", raw, false)
// generation_config should not contain temperature, top_p, max_output_tokens
for _, knob := range []string{"temperature", "top_p", "top_k", "stop_sequences", "max_output_tokens"} {
if gjson.GetBytes(out, "generation_config."+knob).Exists() {
t.Fatalf("generation_config.%s should be stripped for antigravity model. Output: %s", knob, string(out))
}
}
if got := gjson.GetBytes(out, "agent_config.max_total_tokens").Int(); got != 1024 {
t.Fatalf("agent_config.max_total_tokens = %d, want 1024. Output: %s", got, string(out))
}
}
func TestConvertOpenAIRequestToInteractions_PreservesEnvironmentIDAndPreviousInteractionID(t *testing.T) {
raw := []byte(`{
"model":"antigravity-preview-05-2026",
"messages":[{"role":"user","content":"continue"}],
"previous_response_id":"v1_prev123",
"environment_id":"env_456"
}`)
out := ConvertOpenAIRequestToInteractions("antigravity-preview-05-2026", raw, false)
if got := gjson.GetBytes(out, "previous_interaction_id").String(); got != "v1_prev123" {
t.Fatalf("previous_interaction_id = %q, want v1_prev123. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "environment_id").String(); got != "env_456" {
t.Fatalf("environment_id = %q, want env_456. Output: %s", got, string(out))
}
}

View file

@ -0,0 +1,406 @@
package chat_completions
import (
"bytes"
"context"
"fmt"
"strings"
"time"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type openAIToInteractionsStreamState struct {
Created bool
StatusUpdated bool
Completed bool
Done bool
CurrentStepType string
CurrentStepID string
ToolCallIDs map[int]string
ToolCallNames map[int]string
ID string
StepIndex int
ActiveStepIndex int
ActiveStepOpen bool
Usage gjson.Result
}
func ConvertOpenAIResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
if param == nil {
var local any
param = &local
}
if *param == nil {
*param = &openAIToInteractionsStreamState{}
}
st := (*param).(*openAIToInteractionsStreamState)
if st.ToolCallIDs == nil {
st.ToolCallIDs = make(map[int]string)
}
if st.ToolCallNames == nil {
st.ToolCallNames = make(map[int]string)
}
return convertOpenAIChatStreamToInteractions(modelName, rawJSON, st)
}
func ConvertOpenAIResponseToInteractionsNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
root := gjson.ParseBytes(rawJSON)
out := []byte(`{"id":"","status":"completed","object":"interaction","model":"","steps":[]}`)
out, _ = sjson.SetBytes(out, "id", firstNonEmpty(root.Get("id").String(), fmt.Sprintf("interaction_%d", time.Now().UnixNano())))
out, _ = sjson.SetBytes(out, "model", firstNonEmpty(modelName, root.Get("model").String()))
choices := root.Get("choices")
var steps [][]byte
choices.ForEach(func(_, choice gjson.Result) bool {
message := choice.Get("message")
if reasoning := message.Get("reasoning_content"); reasoning.Exists() {
for _, text := range openAIReasoningTexts(reasoning) {
steps = append(steps, interactionsTextStep("thought", text))
}
}
if content := message.Get("content"); content.Exists() && content.String() != "" {
steps = append(steps, interactionsTextStep("model_output", content.String()))
}
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
if step, ok := openAIToolCallToInteractionsStep(toolCall); ok {
steps = append(steps, step)
}
return true
})
}
if finishReason := choice.Get("finish_reason"); finishReason.Exists() {
out, _ = sjson.SetBytes(out, "finish_reason", finishReason.String())
}
return true
})
if len(steps) > 0 {
out = translatorcommon.SetRawArrayItems(out, "steps", steps)
}
out = setInteractionsUsageFromOpenAIChat(out, "usage", root.Get("usage"))
return out
}
func convertOpenAIChatStreamToInteractions(modelName string, rawJSON []byte, st *openAIToInteractionsStreamState) [][]byte {
payload := openAIChatSSEPayload(rawJSON)
if len(payload) == 0 {
return nil
}
if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) {
out := make([][]byte, 0, 3)
out = appendInteractionsStepStop(out, st)
if !st.Completed {
out = appendInteractionsCompleted(out, st, modelName, gjson.Result{})
}
return appendInteractionsDone(out, st)
}
root := gjson.ParseBytes(payload)
if !root.Exists() {
return nil
}
if usage := root.Get("usage"); usage.Exists() {
st.Usage = usage
}
out := make([][]byte, 0)
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
if len(choices.Array()) == 0 {
if root.Get("usage").Exists() {
out = appendInteractionsStepStop(out, st)
out = appendInteractionsCompleted(out, st, modelName, root)
}
return out
}
choices.ForEach(func(_, choice gjson.Result) bool {
delta := choice.Get("delta")
if reasoning := delta.Get("reasoning_content"); reasoning.Exists() {
for _, text := range openAIReasoningTexts(reasoning) {
out = ensureInteractionsStep(out, st, modelName, "thought", root)
out = appendInteractionsTextDelta(out, st, text, true)
}
}
if content := delta.Get("content"); content.Exists() && content.String() != "" {
out = ensureInteractionsStep(out, st, modelName, "model_output", root)
out = appendInteractionsTextDelta(out, st, content.String(), false)
}
if toolCalls := delta.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
out = appendOpenAIToolCallDelta(out, st, modelName, root, toolCall)
return true
})
}
if finishReason := choice.Get("finish_reason"); finishReason.Exists() {
out = appendInteractionsStepStop(out, st)
}
return true
})
}
return out
}
func appendOpenAIToolCallDelta(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root, toolCall gjson.Result) [][]byte {
index := int(toolCall.Get("index").Int())
if id := toolCall.Get("id").String(); id != "" {
st.ToolCallIDs[index] = id
}
function := toolCall.Get("function")
if name := function.Get("name").String(); name != "" {
st.ToolCallNames[index] = name
}
stepID := firstNonEmpty(st.ToolCallIDs[index], fmt.Sprintf("call_%d", index))
stepName := st.ToolCallNames[index]
if st.CurrentStepType != "function_call" || st.CurrentStepID != stepID {
out = appendInteractionsStepStop(out, st)
step := []byte(`{"type":"function_call","id":"","call_id":"","name":"","arguments":{}}`)
step, _ = sjson.SetBytes(step, "id", stepID)
step, _ = sjson.SetBytes(step, "call_id", stepID)
step, _ = sjson.SetBytes(step, "name", stepName)
out = appendInteractionsCreated(out, st, modelName, root)
out = appendInteractionsStepStart(out, st, "function_call", gjson.ParseBytes(step))
}
if args := function.Get("arguments"); args.Exists() && args.String() != "" {
out = appendInteractionsArgumentsDelta(out, st, args.String())
}
return out
}
func appendInteractionsCreated(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root gjson.Result) [][]byte {
if st.Created {
return out
}
st.ID = firstNonEmpty(root.Get("id").String(), st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano()))
created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
created, _ = sjson.SetBytes(created, "interaction.id", st.ID)
created, _ = sjson.SetBytes(created, "interaction.model", firstNonEmpty(modelName, root.Get("model").String()))
out = append(out, translatorcommon.SSEEventData("interaction.created", created))
st.Created = true
return appendInteractionsStatusUpdate(out, st)
}
func appendInteractionsStatusUpdate(out [][]byte, st *openAIToInteractionsStreamState) [][]byte {
if st.StatusUpdated {
return out
}
statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`)
statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID)
out = append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate))
st.StatusUpdated = true
return out
}
func ensureInteractionsStep(out [][]byte, st *openAIToInteractionsStreamState, modelName, stepType string, step gjson.Result) [][]byte {
out = appendInteractionsCreated(out, st, modelName, step)
if st.ActiveStepOpen && st.CurrentStepType == stepType {
return out
}
out = appendInteractionsStepStop(out, st)
return appendInteractionsStepStart(out, st, stepType, step)
}
func appendInteractionsStepStart(out [][]byte, st *openAIToInteractionsStreamState, stepType string, step gjson.Result) [][]byte {
index := st.StepIndex
st.StepIndex++
st.ActiveStepIndex = index
st.CurrentStepType = stepType
st.ActiveStepOpen = true
payload := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
payload, _ = sjson.SetBytes(payload, "index", index)
payload, _ = sjson.SetBytes(payload, "step.type", stepType)
if stepType == "function_call" {
id := firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), st.CurrentStepID)
st.CurrentStepID = id
if id != "" {
payload, _ = sjson.SetBytes(payload, "step.id", id)
payload, _ = sjson.SetBytes(payload, "step.call_id", id)
}
payload, _ = sjson.SetBytes(payload, "step.name", step.Get("name").String())
payload, _ = sjson.SetRawBytes(payload, "step.arguments", []byte(`{}`))
} else {
st.CurrentStepID = ""
}
return append(out, translatorcommon.SSEEventData("step.start", payload))
}
func appendInteractionsTextDelta(out [][]byte, st *openAIToInteractionsStreamState, text string, thought bool) [][]byte {
if thought {
payload := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
payload, _ = sjson.SetBytes(payload, "delta.content.text", text)
return append(out, translatorcommon.SSEEventData("step.delta", payload))
}
payload := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
payload, _ = sjson.SetBytes(payload, "delta.text", text)
return append(out, translatorcommon.SSEEventData("step.delta", payload))
}
func appendInteractionsArgumentsDelta(out [][]byte, st *openAIToInteractionsStreamState, arguments string) [][]byte {
payload := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
payload, _ = sjson.SetBytes(payload, "delta.arguments", arguments)
return append(out, translatorcommon.SSEEventData("step.delta", payload))
}
func appendInteractionsStepStop(out [][]byte, st *openAIToInteractionsStreamState) [][]byte {
if !st.ActiveStepOpen {
return out
}
payload := []byte(`{"index":0,"event_type":"step.stop"}`)
payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
out = append(out, translatorcommon.SSEEventData("step.stop", payload))
st.ActiveStepOpen = false
st.CurrentStepType = ""
st.CurrentStepID = ""
return out
}
func appendInteractionsCompleted(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root gjson.Result) [][]byte {
if st.Completed {
return out
}
if !st.Created {
out = appendInteractionsCreated(out, st, modelName, root)
}
now := time.Now().UTC().Format(time.RFC3339)
payload := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
payload, _ = sjson.SetBytes(payload, "interaction.id", st.ID)
payload, _ = sjson.SetBytes(payload, "interaction.created", now)
payload, _ = sjson.SetBytes(payload, "interaction.updated", now)
payload, _ = sjson.SetBytes(payload, "interaction.model", firstNonEmpty(modelName, root.Get("model").String()))
usage := root.Get("usage")
if !usage.Exists() {
usage = st.Usage
}
payload = setInteractionsUsageFromOpenAIChat(payload, "interaction.usage", usage)
out = append(out, translatorcommon.SSEEventData("interaction.completed", payload))
st.Completed = true
return out
}
func appendInteractionsDone(out [][]byte, st *openAIToInteractionsStreamState) [][]byte {
if st.Done {
return out
}
out = append(out, translatorcommon.SSEEventData("done", []byte("[DONE]")))
st.Done = true
return out
}
func isOpenAIStreamDone(rawJSON []byte) bool {
return bytes.Equal(bytes.TrimSpace(openAIChatSSEPayload(rawJSON)), []byte("[DONE]"))
}
func openAIChatSSEPayload(rawJSON []byte) []byte {
trimmed := bytes.TrimSpace(rawJSON)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("[DONE]")) {
return trimmed
}
if bytes.HasPrefix(trimmed, []byte("data:")) {
return bytes.TrimSpace(trimmed[len("data:"):])
}
var dataLines [][]byte
for _, line := range bytes.Split(trimmed, []byte("\n")) {
line = bytes.TrimSpace(line)
if bytes.HasPrefix(line, []byte("data:")) {
dataLines = append(dataLines, bytes.TrimSpace(line[len("data:"):]))
}
}
if len(dataLines) > 0 {
return bytes.Join(dataLines, []byte("\n"))
}
return trimmed
}
func interactionsTextStep(stepType, text string) []byte {
step := []byte(`{"type":"","content":[{"type":"text","text":""}]}`)
step, _ = sjson.SetBytes(step, "type", stepType)
step, _ = sjson.SetBytes(step, "content.0.text", text)
return step
}
func openAIToolCallToInteractionsStep(toolCall gjson.Result) ([]byte, bool) {
if toolType := toolCall.Get("type").String(); toolType != "" && toolType != "function" {
return nil, false
}
function := toolCall.Get("function")
if !function.Exists() {
return nil, false
}
step := []byte(`{"type":"function_call","name":"","arguments":{}}`)
if id := toolCall.Get("id").String(); id != "" {
step, _ = sjson.SetBytes(step, "id", id)
step, _ = sjson.SetBytes(step, "call_id", id)
}
step, _ = sjson.SetBytes(step, "name", function.Get("name").String())
setRawJSONValue(&step, "arguments", function.Get("arguments"), []byte(`{}`))
return step, true
}
func setInteractionsUsageFromOpenAIChat(out []byte, path string, usage gjson.Result) []byte {
if !usage.Exists() {
return out
}
if value := usage.Get("prompt_tokens"); value.Exists() {
out, _ = sjson.SetBytes(out, path+".input_tokens", value.Int())
out, _ = sjson.SetBytes(out, path+".total_input_tokens", value.Int())
}
if value := usage.Get("completion_tokens"); value.Exists() {
out, _ = sjson.SetBytes(out, path+".output_tokens", value.Int())
out, _ = sjson.SetBytes(out, path+".total_output_tokens", value.Int())
}
if value := usage.Get("total_tokens"); value.Exists() {
out, _ = sjson.SetBytes(out, path+".total_tokens", value.Int())
}
if value := usage.Get("prompt_tokens_details.cached_tokens"); value.Exists() {
out, _ = sjson.SetBytes(out, path+".cached_tokens", value.Int())
out, _ = sjson.SetBytes(out, path+".total_cached_tokens", value.Int())
}
if value := usage.Get("completion_tokens_details.reasoning_tokens"); value.Exists() {
out, _ = sjson.SetBytes(out, path+".reasoning_tokens", value.Int())
out, _ = sjson.SetBytes(out, path+".total_thought_tokens", value.Int())
}
return out
}
func openAIReasoningTexts(reasoning gjson.Result) []string {
if reasoning.Type == gjson.String {
if reasoning.String() == "" {
return nil
}
return []string{reasoning.String()}
}
texts := make([]string, 0)
if reasoning.IsArray() {
reasoning.ForEach(func(_, item gjson.Result) bool {
if text := firstNonEmpty(item.Get("text").String(), item.Get("content").String()); text != "" {
texts = append(texts, text)
}
return true
})
}
return texts
}
func setRawJSONValue(out *[]byte, path string, value gjson.Result, fallback []byte) {
if !value.Exists() {
*out, _ = sjson.SetRawBytes(*out, path, fallback)
return
}
raw := strings.TrimSpace(value.String())
if value.Type == gjson.String && gjson.Valid(raw) {
*out, _ = sjson.SetRawBytes(*out, path, []byte(raw))
return
}
if value.Type == gjson.String {
*out, _ = sjson.SetBytes(*out, path, value.String())
return
}
*out, _ = sjson.SetRawBytes(*out, path, []byte(value.Raw))
}

View file

@ -0,0 +1,243 @@
package chat_completions
import (
"bytes"
"context"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIResponseToInteractionsStreamUsageOnlyTerminalChunk(t *testing.T) {
var param any
finishRaw := []byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-test","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`)
usageRaw := []byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-test","choices":[],"usage":{"prompt_tokens":3,"completion_tokens":4,"total_tokens":7}}`)
doneRaw := []byte(`data: [DONE]`)
finishOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, finishRaw, &param)
usageOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, usageRaw, &param)
doneOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, &param)
if got := countInteractionsEvents(finishOut, "interaction.completed"); got != 0 {
t.Fatalf("finish interaction.completed count = %d, want 0", got)
}
if got := countInteractionsEvents(usageOut, "interaction.completed"); got != 1 {
t.Fatalf("usage interaction.completed count = %d, want 1", got)
}
if got := countInteractionsEvents(doneOut, "interaction.completed"); got != 0 {
t.Fatalf("done interaction.completed count = %d, want 0", got)
}
if got := countInteractionsEvents(doneOut, "done"); got != 1 {
t.Fatalf("done event count = %d, want 1", got)
}
payload := findInteractionsEventPayload(usageOut, "interaction.completed")
if got := gjson.GetBytes(payload, "interaction.usage.total_input_tokens").Int(); got != 3 {
t.Fatalf("total_input_tokens = %d, want 3. Payload: %s", got, string(payload))
}
if got := gjson.GetBytes(payload, "interaction.usage.total_output_tokens").Int(); got != 4 {
t.Fatalf("total_output_tokens = %d, want 4. Payload: %s", got, string(payload))
}
if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 7 {
t.Fatalf("total_tokens = %d, want 7. Payload: %s", got, string(payload))
}
}
func TestConvertOpenAIResponseToInteractionsCompletesOnDoneWithoutUsage(t *testing.T) {
var param any
finishRaw := []byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-test","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`)
doneRaw := []byte(`data: [DONE]`)
finishOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, finishRaw, &param)
doneOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, &param)
if got := countInteractionsEvents(finishOut, "interaction.completed"); got != 0 {
t.Fatalf("finish interaction.completed count = %d, want 0", got)
}
if got := countInteractionsEvents(doneOut, "interaction.completed"); got != 1 {
t.Fatalf("done interaction.completed count = %d, want 1", got)
}
if got := countInteractionsEvents(doneOut, "done"); got != 1 {
t.Fatalf("done event count = %d, want 1", got)
}
}
func TestConvertOpenAIResponseToInteractionsStreamCreatedUsesChunkIdentity(t *testing.T) {
var param any
raw := []byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-test","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":null}]}`)
out := ConvertOpenAIResponseToInteractions(context.Background(), "", nil, nil, raw, &param)
payload := findInteractionsEventPayload(out, "interaction.created")
if got := gjson.GetBytes(payload, "interaction.id").String(); got != "chatcmpl_1" {
t.Fatalf("interaction.id = %q, want chatcmpl_1. Payload: %s", got, string(payload))
}
if got := gjson.GetBytes(payload, "interaction.model").String(); got != "gpt-test" {
t.Fatalf("interaction.model = %q, want gpt-test. Payload: %s", got, string(payload))
}
}
func TestConvertOpenAIResponseToInteractionsNonStreamDirectToolCall(t *testing.T) {
raw := []byte(`{"id":"chatcmpl_1","model":"gpt-test","choices":[{"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":2,"completion_tokens":3,"total_tokens":5}}`)
out := ConvertOpenAIResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "steps.0.type").String(); got != "function_call" {
t.Fatalf("step type = %q, want function_call. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "steps.0.call_id").String(); got != "call_1" {
t.Fatalf("call_id = %q, want call_1. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "steps.0.arguments.q").String(); got != "x" {
t.Fatalf("arguments.q = %q, want x. Output: %s", got, string(out))
}
}
func TestConvertInteractionsResponseToOpenAIStreamToolCall(t *testing.T) {
var param any
chunks := [][]byte{
[]byte(`data: {"event_type":"interaction.created","interaction":{"id":"i1","model":"gemini-3.1-flash-lite"}}`),
[]byte(`data: {"event_type":"step.start","index":0,"step":{"type":"function_call","id":"call_1","name":"get_weather","arguments":{}}}`),
[]byte(`data: {"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":"{\"location\":\"北京\"}"}}`),
[]byte(`data: {"event_type":"step.stop","index":0}`),
[]byte(`data: {"event_type":"interaction.completed","interaction":{"id":"i1","status":"requires_action","usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5}}}`),
}
var out [][]byte
for _, chunk := range chunks {
out = append(out, ConvertInteractionsResponseToOpenAI(context.Background(), "gemini-3.1-flash-lite", nil, nil, chunk, &param)...)
}
toolStart := findOpenAIChatChunk(out, "choices.0.delta.tool_calls.0.function.name")
if got := gjson.GetBytes(toolStart, "choices.0.delta.tool_calls.0.id").String(); got != "call_1" {
t.Fatalf("tool call id = %q, want call_1. Payload: %s", got, string(toolStart))
}
if got := gjson.GetBytes(toolStart, "choices.0.delta.tool_calls.0.function.name").String(); got != "get_weather" {
t.Fatalf("tool name = %q, want get_weather. Payload: %s", got, string(toolStart))
}
toolArgs := findOpenAIChatChunkValue(out, "choices.0.delta.tool_calls.0.function.arguments", `{"location":"北京"}`)
if got := gjson.GetBytes(toolArgs, "choices.0.delta.tool_calls.0.function.arguments").String(); got != `{"location":"北京"}` {
t.Fatalf("tool args = %q, want location JSON. Payload: %s", got, string(toolArgs))
}
completed := findOpenAIChatChunkValue(out, "choices.0.finish_reason", "tool_calls")
if got := gjson.GetBytes(completed, "choices.0.finish_reason").String(); got != "tool_calls" {
t.Fatalf("finish_reason = %q, want tool_calls. Payload: %s", got, string(completed))
}
if got := gjson.GetBytes(completed, "usage.prompt_tokens").Int(); got != 2 {
t.Fatalf("prompt_tokens = %d, want 2. Payload: %s", got, string(completed))
}
}
func TestConvertInteractionsResponseToOpenAIStreamFinishMetadataUsage(t *testing.T) {
var param any
out := ConvertInteractionsResponseToOpenAI(context.Background(), "gpt-test", nil, nil, []byte(`data: {"event_type":"finish","metadata":{"total_usage":{"total_input_tokens":2,"total_output_tokens":6,"total_thought_tokens":3,"total_cached_tokens":1,"total_tokens":11}}}`), &param)
completed := findOpenAIChatChunkValue(out, "choices.0.finish_reason", "stop")
if len(completed) == 0 {
t.Fatalf("completion chunk not found")
}
if got := gjson.GetBytes(completed, "usage.prompt_tokens").Int(); got != 2 {
t.Fatalf("prompt_tokens = %d, want 2. Payload: %s", got, string(completed))
}
if got := gjson.GetBytes(completed, "usage.completion_tokens").Int(); got != 6 {
t.Fatalf("completion_tokens = %d, want 6. Payload: %s", got, string(completed))
}
if got := gjson.GetBytes(completed, "usage.completion_tokens_details.reasoning_tokens").Int(); got != 3 {
t.Fatalf("reasoning_tokens = %d, want 3. Payload: %s", got, string(completed))
}
if got := gjson.GetBytes(completed, "usage.prompt_tokens_details.cached_tokens").Int(); got != 1 {
t.Fatalf("cached_tokens = %d, want 1. Payload: %s", got, string(completed))
}
if got := gjson.GetBytes(completed, "usage.total_tokens").Int(); got != 11 {
t.Fatalf("total_tokens = %d, want 11. Payload: %s", got, string(completed))
}
}
func TestConvertInteractionsResponseToOpenAINonStreamToolCall(t *testing.T) {
raw := []byte(`{"id":"i1","model":"gemini-3.1-flash-lite","steps":[{"type":"function_call","id":"call_1","name":"get_weather","arguments":{"location":"北京"}}],"usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5}}`)
out := ConvertInteractionsResponseToOpenAINonStream(context.Background(), "gemini-3.1-flash-lite", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.id").String(); got != "call_1" {
t.Fatalf("tool call id = %q, want call_1. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.name").String(); got != "get_weather" {
t.Fatalf("tool name = %q, want get_weather. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.arguments").String(); got != `{"location":"北京"}` {
t.Fatalf("tool args = %q, want location JSON. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "choices.0.finish_reason").String(); got != "tool_calls" {
t.Fatalf("finish_reason = %q, want tool_calls. Output: %s", got, string(out))
}
}
func TestConvertInteractionsResponseToOpenAINonStream_PreservesEnvironmentID(t *testing.T) {
raw := []byte(`{"id":"i1","model":"antigravity-preview-05-2026","environment_id":"env_chat123","steps":[{"type":"model_output","content":[{"type":"text","text":"hello"}]}],"usage":{"total_tokens":5}}`)
out := ConvertInteractionsResponseToOpenAINonStream(context.Background(), "antigravity-preview-05-2026", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "environment_id").String(); got != "env_chat123" {
t.Fatalf("environment_id = %q, want env_chat123. Output: %s", got, string(out))
}
}
func TestConvertInteractionsResponseToOpenAIStream_PreservesEnvironmentID(t *testing.T) {
var param any
chunk := []byte(`data: {"event_type":"interaction.created","interaction":{"id":"i1","model":"antigravity-preview-05-2026","environment_id":"env_chat_stream456"}}`)
out := ConvertInteractionsResponseToOpenAI(context.Background(), "antigravity-preview-05-2026", nil, nil, chunk, &param)
if len(out) == 0 {
t.Fatalf("no output chunks generated")
}
if got := gjson.GetBytes(out[0], "environment_id").String(); got != "env_chat_stream456" {
t.Fatalf("environment_id = %q, want env_chat_stream456. Chunk: %s", got, string(out[0]))
}
}
func findInteractionsEventPayload(events [][]byte, eventType string) []byte {
for _, event := range events {
payload := interactionsSSEPayload(event)
if interactionsEventName(event, payload) == eventType {
return payload
}
}
return nil
}
func countInteractionsEvents(events [][]byte, eventType string) int {
count := 0
for _, event := range events {
payload := interactionsSSEPayload(event)
if interactionsEventName(event, payload) == eventType {
count++
}
}
return count
}
func interactionsEventName(event, payload []byte) string {
if eventType := gjson.GetBytes(payload, "event_type").String(); eventType != "" {
return eventType
}
const prefix = "event: "
lineEnd := bytes.IndexByte(event, '\n')
if lineEnd < 0 || !bytes.HasPrefix(event, []byte(prefix)) {
return ""
}
return string(event[len(prefix):lineEnd])
}
func interactionsSSEPayload(event []byte) []byte {
const prefix = "\ndata: "
idx := bytes.Index(event, []byte(prefix))
if idx < 0 {
return nil
}
return event[idx+len(prefix):]
}
func findOpenAIChatChunk(chunks [][]byte, path string) []byte {
for _, chunk := range chunks {
if gjson.GetBytes(chunk, path).Exists() {
return chunk
}
}
return nil
}
func findOpenAIChatChunkValue(chunks [][]byte, path, want string) []byte {
for _, chunk := range chunks {
if gjson.GetBytes(chunk, path).String() == want {
return chunk
}
}
return nil
}

View file

@ -0,0 +1,33 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToInteractionsNormalizesFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-3.5-flash","messages":[{"role":"user","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out := ConvertOpenAIRequestToInteractions("gemini-3.5-flash", input, false)
document := gjson.GetBytes(out, "input.0.content.0")
if got := document.Get("mime_type").String(); got != "application/pdf" {
t.Fatalf("document.mime_type = %q, want application/pdf. Output: %s", got, out)
}
if got := document.Get("data").String(); got != "JVBERi0xLjQK" {
t.Fatalf("document.data = %q, want raw base64 payload. Output: %s", got, out)
}
}
func TestConvertOpenAIRequestToInteractionsPreservesRawFileDataWithMIMEType(t *testing.T) {
input := []byte(`{"model":"gemini-3.5-flash","messages":[{"role":"user","content":[{"type":"document","mime_type":"application/pdf","data":"JVBERi0xLjQK"}]}]}`)
out := ConvertOpenAIRequestToInteractions("gemini-3.5-flash", input, false)
document := gjson.GetBytes(out, "input.0.content.0")
if got := document.Get("mime_type").String(); got != "application/pdf" {
t.Fatalf("document.mime_type = %q, want application/pdf. Output: %s", got, out)
}
if got := document.Get("data").String(); got != "JVBERi0xLjQK" {
t.Fatalf("document.data = %q, want unchanged raw base64 payload. Output: %s", got, out)
}
}

View file

@ -0,0 +1,345 @@
package chat_completions
import (
"strings"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func ConvertOpenAIRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte {
root := gjson.ParseBytes(inputRawJSON)
out := []byte(`{"model":"","input":[]}`)
model := firstNonEmpty(modelName, root.Get("model").String())
out, _ = sjson.SetBytes(out, "model", model)
if streamValue, ok := openAIRequestStreamValue(root, stream); ok {
out, _ = sjson.SetBytes(out, "stream", streamValue)
}
if previousResponseID := firstNonEmpty(root.Get("previous_response_id").String(), root.Get("previous_interaction_id").String()); previousResponseID != "" {
out, _ = sjson.SetBytes(out, "previous_interaction_id", previousResponseID)
}
if environmentID := firstNonEmpty(root.Get("environment_id").String(), root.Get("environment.id").String()); environmentID != "" {
out, _ = sjson.SetBytes(out, "environment_id", environmentID)
}
if agentConfig := root.Get("agent_config"); agentConfig.Exists() {
out, _ = sjson.SetRawBytes(out, "agent_config", []byte(agentConfig.Raw))
}
out = appendOpenAIMessagesToInteractions(out, root.Get("messages"))
out = copyOpenAIChatGenerationConfigToInteractions(out, root, model)
out = appendOpenAIChatToolsToInteractions(out, root.Get("tools"))
return out
}
func openAIRequestStreamValue(root gjson.Result, stream bool) (bool, bool) {
if value := root.Get("stream"); value.Exists() {
return value.Bool(), true
}
if stream {
return true, true
}
return false, false
}
func appendOpenAIMessagesToInteractions(out []byte, messages gjson.Result) []byte {
if !messages.Exists() || !messages.IsArray() {
return out
}
inputItems := translatorcommon.NewRawArrayItems(messages.Get("#").Int())
var systemBuilder strings.Builder
messages.ForEach(func(_, message gjson.Result) bool {
role := strings.ToLower(strings.TrimSpace(message.Get("role").String()))
switch role {
case "system", "developer":
if text := openAIChatContentText(message.Get("content")); text != "" {
if systemBuilder.Len() > 0 {
systemBuilder.WriteByte('\n')
}
systemBuilder.WriteString(text)
}
default:
appendOpenAIMessageToInteractions(&inputItems, message)
}
return true
})
if systemBuilder.Len() > 0 {
out, _ = sjson.SetBytes(out, "system_instruction", systemBuilder.String())
}
out = translatorcommon.SetRawArrayItems(out, "input", inputItems)
return out
}
func appendOpenAIMessageToInteractions(items *[][]byte, message gjson.Result) {
role := strings.ToLower(strings.TrimSpace(message.Get("role").String()))
switch role {
case "assistant":
if reasoning := message.Get("reasoning_content"); reasoning.Exists() {
for _, text := range openAIReasoningTexts(reasoning) {
*items = append(*items, interactionsTextStep("thought", text))
}
}
if step, ok := openAIChatContentStep("model_output", message.Get("content")); ok {
*items = append(*items, step)
}
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
if step, ok := openAIToolCallToInteractionsStep(toolCall); ok {
*items = append(*items, step)
}
return true
})
}
case "tool", "function":
*items = append(*items, openAIToolResultToInteractions(message))
default:
if step, ok := openAIChatContentStep("user_input", message.Get("content")); ok {
*items = append(*items, step)
}
}
}
func openAIChatContentStep(stepType string, content gjson.Result) ([]byte, bool) {
contentItems := make([][]byte, 0, 4)
if content.Type == gjson.String {
if content.String() == "" {
return nil, false
}
part := []byte(`{"type":"text","text":""}`)
part, _ = sjson.SetBytes(part, "text", content.String())
contentItems = append(contentItems, part)
} else {
appendPart := func(part gjson.Result) {
if converted, ok := openAIChatContentPartToInteractions(part); ok {
contentItems = append(contentItems, converted)
}
}
if content.IsArray() {
content.ForEach(func(_, part gjson.Result) bool {
appendPart(part)
return true
})
} else if content.IsObject() {
appendPart(content)
}
}
if len(contentItems) == 0 {
return nil, false
}
step := []byte(`{"type":"","content":[]}`)
step, _ = sjson.SetBytes(step, "type", stepType)
step, _ = sjson.SetRawBytes(step, "content", translatorcommon.JoinRawArray(contentItems))
return step, true
}
func openAIChatContentPartToInteractions(part gjson.Result) ([]byte, bool) {
partType := strings.ToLower(strings.TrimSpace(part.Get("type").String()))
if partType == "" && part.Get("text").Exists() {
partType = "text"
}
switch partType {
case "text", "input_text", "output_text":
out := []byte(`{"type":"text","text":""}`)
out, _ = sjson.SetBytes(out, "text", part.Get("text").String())
return out, true
case "image_url", "input_image", "image":
return openAIChatImagePartToInteractions(part), true
case "input_audio", "audio":
out := []byte(`{"type":"audio","data":""}`)
audio := part.Get("input_audio")
data := firstNonEmpty(audio.Get("data").String(), part.Get("data").String())
if data == "" {
return nil, false
}
out, _ = sjson.SetBytes(out, "data", data)
if format := firstNonEmpty(audio.Get("format").String(), part.Get("format").String()); format != "" {
out, _ = sjson.SetBytes(out, "mime_type", openAIInputAudioMIMEType(format))
}
return out, true
case "file", "input_file", "document":
file := part.Get("file")
filename := firstNonEmpty(file.Get("filename").String(), part.Get("filename").String())
fallbackMIMEType := firstNonEmpty(file.Get("mime_type").String(), file.Get("mimeType").String(), part.Get("mime_type").String(), part.Get("mimeType").String())
fileData := firstNonEmpty(file.Get("file_data").String(), part.Get("file_data").String(), part.Get("data").String())
fileURL := firstNonEmpty(file.Get("file_url").String(), part.Get("file_url").String(), part.Get("url").String())
out := []byte(`{"type":"document"}`)
if filename != "" {
out, _ = sjson.SetBytes(out, "filename", filename)
}
hasContent := false
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, fallbackMIMEType, fileData); ok {
out, _ = sjson.SetBytes(out, "mime_type", mimeType)
out, _ = sjson.SetBytes(out, "data", data)
hasContent = true
}
if fileURL != "" {
out, _ = sjson.SetBytes(out, "file_url", fileURL)
hasContent = true
}
return out, hasContent
}
return nil, false
}
func openAIChatImagePartToInteractions(part gjson.Result) []byte {
out := []byte(`{"type":"image"}`)
imageURL := firstNonEmpty(part.Get("image_url.url").String(), part.Get("image_url").String(), part.Get("url").String())
if mimeType, data, ok := openAIChatParseDataURL(imageURL); ok {
out, _ = sjson.SetBytes(out, "mime_type", mimeType)
out, _ = sjson.SetBytes(out, "data", data)
return out
}
if data := part.Get("data").String(); data != "" {
out, _ = sjson.SetBytes(out, "data", data)
if mimeType := part.Get("mime_type").String(); mimeType != "" {
out, _ = sjson.SetBytes(out, "mime_type", mimeType)
}
return out
}
if imageURL != "" {
out, _ = sjson.SetBytes(out, "image_url", imageURL)
}
return out
}
func openAIToolResultToInteractions(message gjson.Result) []byte {
out := []byte(`{"type":"function_result","result":""}`)
if callID := firstNonEmpty(message.Get("tool_call_id").String(), message.Get("id").String()); callID != "" {
out, _ = sjson.SetBytes(out, "id", callID)
out, _ = sjson.SetBytes(out, "call_id", callID)
}
if name := message.Get("name").String(); name != "" {
out, _ = sjson.SetBytes(out, "name", name)
}
content := message.Get("content")
if content.Exists() && content.Type == gjson.String {
out, _ = sjson.SetBytes(out, "result", content.String())
} else if content.Exists() {
out, _ = sjson.SetRawBytes(out, "result", []byte(content.Raw))
}
return out
}
func isAntigravityModel(model string) bool {
return strings.Contains(strings.ToLower(model), "antigravity")
}
func copyOpenAIChatGenerationConfigToInteractions(out []byte, root gjson.Result, model string) []byte {
if isAntigravityModel(model) {
if maxOutputTokens := firstExisting(root.Get("max_completion_tokens"), root.Get("max_tokens"), root.Get("max_output_tokens")); maxOutputTokens.Exists() && !root.Get("agent_config.max_total_tokens").Exists() {
out, _ = sjson.SetBytes(out, "agent_config.max_total_tokens", maxOutputTokens.Int())
}
} else {
copyNumber(&out, "generation_config.max_output_tokens", firstExisting(root.Get("max_completion_tokens"), root.Get("max_tokens")))
copyNumber(&out, "generation_config.temperature", root.Get("temperature"))
copyNumber(&out, "generation_config.top_p", root.Get("top_p"))
copyNumber(&out, "generation_config.presence_penalty", root.Get("presence_penalty"))
copyNumber(&out, "generation_config.frequency_penalty", root.Get("frequency_penalty"))
copyNumber(&out, "generation_config.candidate_count", root.Get("n"))
if stop := root.Get("stop"); stop.Exists() {
out, _ = sjson.SetRawBytes(out, "generation_config.stop_sequences", []byte(stop.Raw))
}
}
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
out, _ = sjson.SetRawBytes(out, "generation_config.tool_choice", []byte(toolChoice.Raw))
}
if effort := root.Get("reasoning_effort"); effort.Exists() && effort.Type == gjson.String {
out, _ = sjson.SetBytes(out, "generation_config.thinking_level", strings.ToLower(strings.TrimSpace(effort.String())))
}
if responseFormat := root.Get("response_format"); responseFormat.Exists() {
out, _ = sjson.SetRawBytes(out, "response_format", []byte(responseFormat.Raw))
}
if modalities := root.Get("modalities"); modalities.Exists() {
out, _ = sjson.SetRawBytes(out, "response_modalities", []byte(modalities.Raw))
}
if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String {
out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
}
return out
}
func appendOpenAIChatToolsToInteractions(out []byte, tools gjson.Result) []byte {
if !tools.Exists() || !tools.IsArray() {
return out
}
var toolItems [][]byte
tools.ForEach(func(_, tool gjson.Result) bool {
if converted, ok := openAIChatToolToInteractions(tool); ok {
toolItems = append(toolItems, converted)
}
return true
})
if len(toolItems) > 0 {
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
}
return out
}
func openAIChatToolToInteractions(tool gjson.Result) ([]byte, bool) {
toolType := strings.ToLower(strings.TrimSpace(tool.Get("type").String()))
if toolType != "" && toolType != "function" {
return nil, false
}
name := firstNonEmpty(tool.Get("function.name").String(), tool.Get("name").String())
if name == "" {
return nil, false
}
out := []byte(`{"type":"function","name":""}`)
out, _ = sjson.SetBytes(out, "name", name)
if desc := firstExisting(tool.Get("function.description"), tool.Get("description")); desc.Exists() {
out, _ = sjson.SetBytes(out, "description", desc.String())
}
if parameters := firstExisting(tool.Get("function.parameters"), tool.Get("parameters")); parameters.Exists() {
out, _ = sjson.SetRawBytes(out, "parameters", []byte(parameters.Raw))
}
return out, true
}
func openAIChatContentText(content gjson.Result) string {
if content.Type == gjson.String {
return content.String()
}
if content.IsObject() {
return content.Get("text").String()
}
if !content.IsArray() {
return ""
}
var builder strings.Builder
content.ForEach(func(_, part gjson.Result) bool {
if text := part.Get("text").String(); text != "" {
builder.WriteString(text)
}
return true
})
return builder.String()
}
func openAIInputAudioMIMEType(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "wav":
return "audio/wav"
case "flac":
return "audio/flac"
case "opus":
return "audio/opus"
case "pcm16":
return "audio/pcm"
default:
return "audio/mpeg"
}
}
func openAIChatParseDataURL(value string) (string, string, bool) {
if !strings.HasPrefix(value, "data:") {
return "", "", false
}
meta, data, ok := strings.Cut(strings.TrimPrefix(value, "data:"), ",")
if !ok {
return "", "", false
}
mimeType, encoding, _ := strings.Cut(meta, ";")
if !strings.EqualFold(encoding, "base64") || strings.TrimSpace(mimeType) == "" || data == "" {
return "", "", false
}
return mimeType, data, true
}

View file

@ -0,0 +1,361 @@
package chat_completions
import (
"bytes"
"context"
"fmt"
"strings"
"time"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type interactionsToOpenAIChatStreamState struct {
ID string
Model string
EnvironmentID string
Created int64
Started bool
Completed bool
SawToolCall bool
StepTypes map[int]string
ToolIDs map[int]string
ToolNames map[int]string
ToolArguments map[int]*strings.Builder
TextByStepIndex map[int]*strings.Builder
}
func ConvertInteractionsResponseToOpenAI(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
if param == nil {
var local any
param = &local
}
if *param == nil {
*param = &interactionsToOpenAIChatStreamState{Model: modelName}
}
st := (*param).(*interactionsToOpenAIChatStreamState)
st.Model = firstNonEmpty(st.Model, modelName)
st.ensureMaps()
return convertInteractionsEventToOpenAIChat(modelName, rawJSON, st)
}
func ConvertInteractionsResponseToOpenAINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
root := gjson.ParseBytes(rawJSON)
interaction := root
if nested := root.Get("interaction"); nested.Exists() {
interaction = nested
}
out := []byte(`{"id":"","object":"chat.completion","created":0,"model":"","choices":[{"index":0,"message":{"role":"assistant","content":""},"finish_reason":"stop"}]}`)
out, _ = sjson.SetBytes(out, "id", firstNonEmpty(interaction.Get("id").String(), root.Get("id").String(), fmt.Sprintf("chatcmpl_%d", time.Now().UnixNano())))
out, _ = sjson.SetBytes(out, "created", time.Now().Unix())
out, _ = sjson.SetBytes(out, "model", firstNonEmpty(interaction.Get("model").String(), modelName))
steps := interaction.Get("steps")
if !steps.Exists() {
steps = root.Get("steps")
}
var textBuilder strings.Builder
var reasoningBuilder strings.Builder
sawToolCall := false
var toolCalls [][]byte
steps.ForEach(func(_, step gjson.Result) bool {
switch step.Get("type").String() {
case "model_output":
for _, text := range interactionsContentTextsForOpenAIChat(step.Get("content")) {
textBuilder.WriteString(text)
}
case "thought":
for _, text := range interactionsContentTextsForOpenAIChat(step.Get("content")) {
reasoningBuilder.WriteString(text)
}
case "function_call":
sawToolCall = true
toolCalls = append(toolCalls, openAIChatToolCallFromInteractions(step, gjson.Result{}))
}
return true
})
if textBuilder.Len() > 0 {
out, _ = sjson.SetBytes(out, "choices.0.message.content", textBuilder.String())
}
if reasoningBuilder.Len() > 0 {
out, _ = sjson.SetBytes(out, "choices.0.message.reasoning_content", reasoningBuilder.String())
}
if len(toolCalls) > 0 {
out = translatorcommon.SetRawArrayItems(out, "choices.0.message.tool_calls", toolCalls)
}
if sawToolCall {
out, _ = sjson.SetBytes(out, "choices.0.message.content", nil)
out, _ = sjson.SetBytes(out, "choices.0.finish_reason", "tool_calls")
}
if envID := firstNonEmpty(interaction.Get("environment_id").String(), root.Get("environment_id").String(), interaction.Get("environment.id").String(), root.Get("environment.id").String(), root.Get("interaction.environment_id").String()); envID != "" {
out, _ = sjson.SetBytes(out, "environment_id", envID)
}
out = setOpenAIChatUsageFromInteractions(out, "usage", translatorcommon.InteractionsUsage(root))
return out
}
func convertInteractionsEventToOpenAIChat(modelName string, rawJSON []byte, st *interactionsToOpenAIChatStreamState) [][]byte {
payload := openAIChatInteractionsPayload(rawJSON)
if len(payload) == 0 || bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) {
return nil
}
root := gjson.ParseBytes(payload)
if !root.Exists() {
return nil
}
switch root.Get("event_type").String() {
case "interaction.created":
interaction := root.Get("interaction")
st.ID = firstNonEmpty(interaction.Get("id").String(), st.ID)
st.Model = firstNonEmpty(interaction.Get("model").String(), st.Model, modelName)
if envID := firstNonEmpty(interaction.Get("environment_id").String(), root.Get("environment_id").String(), interaction.Get("environment.id").String(), root.Get("environment.id").String()); envID != "" {
st.EnvironmentID = envID
}
return ensureOpenAIChatStarted(nil, st)
case "step.start":
return interactionsStepStartToOpenAIChat(modelName, root, st)
case "step.delta":
return interactionsStepDeltaToOpenAIChat(modelName, root, st)
case "interaction.completed", "finish":
interaction := root.Get("interaction")
if envID := firstNonEmpty(interaction.Get("environment_id").String(), root.Get("environment_id").String(), interaction.Get("environment.id").String(), root.Get("environment.id").String()); envID != "" {
st.EnvironmentID = envID
}
return appendOpenAIChatCompleted(nil, root, st)
case "done":
return nil
}
return nil
}
func interactionsStepStartToOpenAIChat(modelName string, root gjson.Result, st *interactionsToOpenAIChatStreamState) [][]byte {
_ = modelName
out := ensureOpenAIChatStarted(nil, st)
index := int(root.Get("index").Int())
step := root.Get("step")
stepType := step.Get("type").String()
st.StepTypes[index] = stepType
switch stepType {
case "function_call":
st.SawToolCall = true
st.ToolIDs[index] = firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), fmt.Sprintf("call_%d", index))
st.ToolNames[index] = step.Get("name").String()
if st.ToolArguments[index] == nil {
st.ToolArguments[index] = &strings.Builder{}
}
if args := step.Get("arguments"); args.Exists() && strings.TrimSpace(args.Raw) != "{}" {
st.ToolArguments[index].WriteString(jsonStringValue(args, "{}"))
}
return append(out, openAIChatToolCallStartChunk(st, index))
default:
return out
}
}
func interactionsStepDeltaToOpenAIChat(modelName string, root gjson.Result, st *interactionsToOpenAIChatStreamState) [][]byte {
_ = modelName
index := int(root.Get("index").Int())
delta := root.Get("delta")
out := ensureOpenAIChatStarted(nil, st)
switch delta.Get("type").String() {
case "thought_summary":
text := firstNonEmpty(delta.Get("content.text").String(), delta.Get("text").String())
if text == "" {
return out
}
return append(out, openAIChatDeltaChunk(st, "reasoning_content", text))
case "arguments_delta":
args := delta.Get("arguments").String()
if st.ToolArguments[index] == nil {
st.ToolArguments[index] = &strings.Builder{}
}
st.ToolArguments[index].WriteString(args)
return append(out, openAIChatToolCallArgumentsChunk(st, index, args))
default:
text := delta.Get("text").String()
if text == "" {
return out
}
if st.TextByStepIndex[index] == nil {
st.TextByStepIndex[index] = &strings.Builder{}
}
st.TextByStepIndex[index].WriteString(text)
return append(out, openAIChatDeltaChunk(st, "content", text))
}
}
func ensureOpenAIChatStarted(out [][]byte, st *interactionsToOpenAIChatStreamState) [][]byte {
if st.Started {
return out
}
chunk := openAIChatBaseChunk(st)
chunk, _ = sjson.SetBytes(chunk, "choices.0.delta.role", "assistant")
st.Started = true
return append(out, chunk)
}
func appendOpenAIChatCompleted(out [][]byte, root gjson.Result, st *interactionsToOpenAIChatStreamState) [][]byte {
if st.Completed {
return out
}
out = ensureOpenAIChatStarted(out, st)
chunk := openAIChatBaseChunk(st)
finishReason := "stop"
if st.SawToolCall {
finishReason = "tool_calls"
}
chunk, _ = sjson.SetBytes(chunk, "choices.0.finish_reason", finishReason)
chunk = setOpenAIChatUsageFromInteractions(chunk, "usage", translatorcommon.InteractionsUsage(root))
st.Completed = true
return append(out, chunk)
}
func openAIChatBaseChunk(st *interactionsToOpenAIChatStreamState) []byte {
chunk := []byte(`{"id":"","object":"chat.completion.chunk","created":0,"model":"","choices":[{"index":0,"delta":{},"finish_reason":null}]}`)
chunk, _ = sjson.SetBytes(chunk, "id", firstNonEmpty(st.ID, fmt.Sprintf("chatcmpl_%d", time.Now().UnixNano())))
chunk, _ = sjson.SetBytes(chunk, "created", openAIChatCreated(st))
chunk, _ = sjson.SetBytes(chunk, "model", st.Model)
if st != nil && st.EnvironmentID != "" {
chunk, _ = sjson.SetBytes(chunk, "environment_id", st.EnvironmentID)
}
return chunk
}
func openAIChatDeltaChunk(st *interactionsToOpenAIChatStreamState, field, value string) []byte {
chunk := openAIChatBaseChunk(st)
chunk, _ = sjson.SetBytes(chunk, "choices.0.delta."+field, value)
return chunk
}
func openAIChatToolCallStartChunk(st *interactionsToOpenAIChatStreamState, index int) []byte {
chunk := openAIChatBaseChunk(st)
toolCall := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`)
toolCall, _ = sjson.SetBytes(toolCall, "index", index)
toolCall, _ = sjson.SetBytes(toolCall, "id", firstNonEmpty(st.ToolIDs[index], fmt.Sprintf("call_%d", index)))
toolCall, _ = sjson.SetBytes(toolCall, "function.name", st.ToolNames[index])
chunk, _ = sjson.SetRawBytes(chunk, "choices.0.delta.tool_calls.-1", toolCall)
return chunk
}
func openAIChatToolCallArgumentsChunk(st *interactionsToOpenAIChatStreamState, index int, arguments string) []byte {
chunk := openAIChatBaseChunk(st)
toolCall := []byte(`{"index":0,"function":{"arguments":""}}`)
toolCall, _ = sjson.SetBytes(toolCall, "index", index)
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments)
chunk, _ = sjson.SetRawBytes(chunk, "choices.0.delta.tool_calls.-1", toolCall)
return chunk
}
func openAIChatToolCallFromInteractions(step, fallbackArgs gjson.Result) []byte {
toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":"{}"}}`)
callID := firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), "call_0")
toolCall, _ = sjson.SetBytes(toolCall, "id", callID)
toolCall, _ = sjson.SetBytes(toolCall, "function.name", step.Get("name").String())
args := step.Get("arguments")
if !args.Exists() {
args = fallbackArgs
}
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", jsonStringValue(args, "{}"))
return toolCall
}
func setOpenAIChatUsageFromInteractions(out []byte, path string, usage gjson.Result) []byte {
if !usage.Exists() {
return out
}
if value, ok := interactionsUsageInt(usage, "input_tokens", "total_input_tokens"); ok {
out, _ = sjson.SetBytes(out, path+".prompt_tokens", value)
}
if value, ok := interactionsUsageInt(usage, "output_tokens", "total_output_tokens"); ok {
out, _ = sjson.SetBytes(out, path+".completion_tokens", value)
}
if value, ok := interactionsUsageInt(usage, "total_tokens"); ok {
out, _ = sjson.SetBytes(out, path+".total_tokens", value)
}
if value, ok := interactionsUsageInt(usage, "cached_tokens", "total_cached_tokens"); ok {
out, _ = sjson.SetBytes(out, path+".prompt_tokens_details.cached_tokens", value)
}
if value, ok := interactionsUsageInt(usage, "reasoning_tokens", "total_thought_tokens"); ok {
out, _ = sjson.SetBytes(out, path+".completion_tokens_details.reasoning_tokens", value)
}
return out
}
func interactionsUsageInt(root gjson.Result, paths ...string) (int64, bool) {
for _, path := range paths {
if value := root.Get(path); value.Exists() {
return value.Int(), true
}
}
return 0, false
}
func interactionsContentTextsForOpenAIChat(content gjson.Result) []string {
if !content.Exists() {
return nil
}
if content.Type == gjson.String {
return []string{content.String()}
}
var out []string
content.ForEach(func(_, part gjson.Result) bool {
if text := firstNonEmpty(part.Get("text").String(), part.Get("content.text").String()); text != "" {
out = append(out, text)
}
return true
})
return out
}
func openAIChatInteractionsPayload(rawJSON []byte) []byte {
trimmed := bytes.TrimSpace(rawJSON)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("[DONE]")) {
return trimmed
}
if bytes.HasPrefix(trimmed, []byte("data:")) {
return bytes.TrimSpace(trimmed[len("data:"):])
}
var dataLines [][]byte
for _, line := range bytes.Split(trimmed, []byte("\n")) {
line = bytes.TrimSpace(line)
if bytes.HasPrefix(line, []byte("data:")) {
dataLines = append(dataLines, bytes.TrimSpace(line[len("data:"):]))
}
}
if len(dataLines) > 0 {
return bytes.Join(dataLines, []byte("\n"))
}
return trimmed
}
func openAIChatCreated(st *interactionsToOpenAIChatStreamState) int64 {
if st.Created == 0 {
st.Created = time.Now().Unix()
}
return st.Created
}
func (st *interactionsToOpenAIChatStreamState) ensureMaps() {
if st.StepTypes == nil {
st.StepTypes = make(map[int]string)
}
if st.ToolIDs == nil {
st.ToolIDs = make(map[int]string)
}
if st.ToolNames == nil {
st.ToolNames = make(map[int]string)
}
if st.ToolArguments == nil {
st.ToolArguments = make(map[int]*strings.Builder)
}
if st.TextByStepIndex == nil {
st.TextByStepIndex = make(map[int]*strings.Builder)
}
}

View file

@ -0,0 +1,28 @@
package responses
import (
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
translator.Register(
OpenaiResponse,
Interactions,
ConvertOpenAIResponsesRequestToInteractions,
interfaces.TranslateResponse{
Stream: ConvertInteractionsResponseToOpenAIResponses,
NonStream: ConvertInteractionsResponseToOpenAIResponsesNonStream,
},
)
translator.Register(
Interactions,
OpenaiResponse,
ConvertInteractionsRequestToOpenAIResponses,
interfaces.TranslateResponse{
Stream: ConvertOpenAIResponsesResponseToInteractions,
NonStream: ConvertOpenAIResponsesResponseToInteractionsNonStream,
},
)
}

View file

@ -0,0 +1,722 @@
package responses
import (
"strings"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func ConvertOpenAIResponsesRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte {
root := gjson.ParseBytes(inputRawJSON)
out := []byte(`{"model":"","input":[]}`)
model := requestModel(modelName, root)
out, _ = sjson.SetBytes(out, "model", model)
if streamValue, ok := requestStreamValue(root, stream); ok {
out, _ = sjson.SetBytes(out, "stream", streamValue)
}
if instructions := root.Get("instructions"); instructions.Exists() {
out, _ = sjson.SetBytes(out, "system_instruction", responsesInstructionsText(instructions))
}
if previousResponseID := firstNonEmpty(root.Get("previous_response_id").String(), root.Get("previous_interaction_id").String()); previousResponseID != "" {
out, _ = sjson.SetBytes(out, "previous_interaction_id", previousResponseID)
}
if environmentID := firstNonEmpty(root.Get("environment_id").String(), root.Get("environment.id").String()); environmentID != "" {
out, _ = sjson.SetBytes(out, "environment_id", environmentID)
}
if agentConfig := root.Get("agent_config"); agentConfig.Exists() {
out, _ = sjson.SetRawBytes(out, "agent_config", []byte(agentConfig.Raw))
}
if input := root.Get("input"); input.Exists() {
out = setResponsesInputOnInteractions(out, input)
}
out = appendResponsesToolsToInteractions(out, root.Get("tools"))
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
out, _ = sjson.SetRawBytes(out, "generation_config.tool_choice", []byte(toolChoice.Raw))
}
if effort := root.Get("reasoning.effort"); effort.Exists() && effort.Type == gjson.String {
out, _ = sjson.SetBytes(out, "generation_config.thinking_level", strings.ToLower(strings.TrimSpace(effort.String())))
}
if summary := root.Get("reasoning.summary"); summary.Exists() && summary.Type == gjson.String {
out, _ = sjson.SetBytes(out, "generation_config.thinking_summaries", summary.String())
}
if format := root.Get("response_format"); format.Exists() {
out, _ = sjson.SetRawBytes(out, "response_format", []byte(format.Raw))
} else if format := root.Get("text.format"); format.Exists() {
out, _ = sjson.SetRawBytes(out, "response_format", []byte(format.Raw))
}
if isAntigravityModel(model) {
if maxOutputTokens := firstExisting(root.Get("max_output_tokens"), root.Get("max_tokens"), root.Get("max_completion_tokens")); maxOutputTokens.Exists() && !root.Get("agent_config.max_total_tokens").Exists() {
out, _ = sjson.SetBytes(out, "agent_config.max_total_tokens", maxOutputTokens.Int())
}
for _, knob := range []string{"temperature", "top_p", "top_k", "stop_sequences", "max_output_tokens", "presence_penalty", "frequency_penalty", "candidate_count"} {
out, _ = sjson.DeleteBytes(out, "generation_config."+knob)
}
}
return out
}
func ConvertInteractionsRequestToOpenAIResponses(modelName string, inputRawJSON []byte, stream bool) []byte {
root := gjson.ParseBytes(inputRawJSON)
out := []byte(`{"model":"","input":[]}`)
out, _ = sjson.SetBytes(out, "model", requestModel(modelName, root))
if stream || root.Get("stream").Bool() {
out, _ = sjson.SetBytes(out, "stream", true)
}
if instructions := interactionsSystemInstructionText(root); instructions != "" {
out, _ = sjson.SetBytes(out, "instructions", instructions)
}
if previousInteractionID := firstNonEmpty(root.Get("previous_interaction_id").String(), root.Get("previous_response_id").String()); previousInteractionID != "" {
out, _ = sjson.SetBytes(out, "previous_response_id", previousInteractionID)
}
if environmentID := firstNonEmpty(root.Get("environment_id").String(), root.Get("environment.id").String()); environmentID != "" {
out, _ = sjson.SetBytes(out, "environment_id", environmentID)
}
if agentConfig := root.Get("agent_config"); agentConfig.Exists() {
out, _ = sjson.SetRawBytes(out, "agent_config", []byte(agentConfig.Raw))
}
if input := root.Get("input"); input.Exists() {
out = setInteractionsInputOnResponses(out, input)
}
out = appendInteractionsToolsToResponses(out, root.Get("tools"))
if toolChoice := root.Get("generation_config.tool_choice"); toolChoice.Exists() {
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(toolChoice.Raw))
} else if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(toolChoice.Raw))
}
if effort := interactionsThinkingEffort(root); effort != "" {
out, _ = sjson.SetBytes(out, "reasoning.effort", effort)
}
if summary := root.Get("generation_config.thinking_summaries"); summary.Exists() && summary.Type == gjson.String {
out, _ = sjson.SetBytes(out, "reasoning.summary", summary.String())
}
if responseModalities := root.Get("response_modalities"); responseModalities.Exists() {
out, _ = sjson.SetRawBytes(out, "modalities", []byte(responseModalities.Raw))
}
if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String {
out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
}
if format := root.Get("response_format"); format.Exists() {
out, _ = sjson.SetRawBytes(out, "text.format", []byte(format.Raw))
}
return out
}
func requestModel(modelName string, root gjson.Result) string {
if strings.TrimSpace(modelName) != "" {
return modelName
}
return root.Get("model").String()
}
func requestStreamValue(root gjson.Result, stream bool) (bool, bool) {
if value := root.Get("stream"); value.Exists() {
return value.Bool(), true
}
if stream {
return true, true
}
return false, false
}
func responsesInstructionsText(instructions gjson.Result) string {
if instructions.Type == gjson.String {
return instructions.String()
}
if text := instructions.Get("text"); text.Exists() {
return text.String()
}
if parts := instructions.Get("content"); parts.Exists() && parts.IsArray() {
var builder strings.Builder
parts.ForEach(func(_, part gjson.Result) bool {
if text := part.Get("text").String(); text != "" {
builder.WriteString(text)
}
return true
})
return builder.String()
}
return instructions.String()
}
func interactionsSystemInstructionText(root gjson.Result) string {
sys := root.Get("system_instruction")
if !sys.Exists() {
return ""
}
if sys.Type == gjson.String {
return sys.String()
}
if text := sys.Get("text"); text.Exists() {
return text.String()
}
if parts := sys.Get("parts"); parts.Exists() && parts.IsArray() {
var builder strings.Builder
parts.ForEach(func(_, part gjson.Result) bool {
if text := part.Get("text").String(); text != "" {
builder.WriteString(text)
}
return true
})
return builder.String()
}
return ""
}
func interactionsThinkingEffort(root gjson.Result) string {
for _, path := range []string{
"generation_config.thinking_level",
"generation_config.thinkingConfig.thinkingLevel",
"generation_config.thinkingConfig.thinking_level",
"generation_config.thinking_config.thinking_level",
} {
if level := root.Get(path); level.Exists() && level.Type == gjson.String {
return strings.ToLower(strings.TrimSpace(level.String()))
}
}
return ""
}
func setResponsesInputOnInteractions(out []byte, input gjson.Result) []byte {
functionNamesByCallID := make(map[string]string)
items := make([][]byte, 0)
if input.Type == gjson.String {
items = append(items, interactionsTextStep("user_input", input.String()))
} else if input.IsArray() {
input.ForEach(func(_, item gjson.Result) bool {
if converted := responsesInputItemToInteractions(item, functionNamesByCallID); converted != nil {
items = append(items, converted)
}
return true
})
} else if input.IsObject() {
if converted := responsesInputItemToInteractions(input, functionNamesByCallID); converted != nil {
items = append(items, converted)
}
}
if len(items) > 0 {
out, _ = sjson.SetRawBytes(out, "input", translatorcommon.JoinRawArray(items))
}
return out
}
func responsesInputItemToInteractions(item gjson.Result, functionNamesByCallID map[string]string) []byte {
switch item.Get("type").String() {
case "message":
stepType := "user_input"
if role := item.Get("role").String(); role == "assistant" || role == "model" {
stepType = "model_output"
}
step := []byte(`{"type":"","content":[]}`)
step, _ = sjson.SetBytes(step, "type", stepType)
return appendResponsesContentToInteractions(step, item.Get("content"))
case "function_call":
callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String())
if callID != "" {
if name := item.Get("name").String(); name != "" {
functionNamesByCallID[callID] = name
}
}
return responsesFunctionCallToInteractions(item)
case "function_call_output":
return responsesFunctionOutputToInteractions(item, functionNamesByCallID)
case "input_text", "output_text", "text":
stepType := "user_input"
if item.Get("type").String() == "output_text" {
stepType = "model_output"
}
return interactionsTextStep(stepType, item.Get("text").String())
case "input_image", "output_image":
stepType := "user_input"
if item.Get("type").String() == "output_image" {
stepType = "model_output"
}
step := []byte(`{"type":"","content":[]}`)
step, _ = sjson.SetBytes(step, "type", stepType)
if part, ok := responsesContentPartToInteractions(item); ok {
step = translatorcommon.SetRawArrayItems(step, "content", [][]byte{part})
}
return step
default:
if content := item.Get("content"); content.Exists() {
step := []byte(`{"type":"user_input","content":[]}`)
return appendResponsesContentToInteractions(step, content)
}
}
return nil
}
func appendResponsesContentToInteractions(step []byte, content gjson.Result) []byte {
var contentItems [][]byte
if content.Type == gjson.String {
part := []byte(`{"type":"text","text":""}`)
part, _ = sjson.SetBytes(part, "text", content.String())
contentItems = append(contentItems, part)
} else if content.IsArray() {
content.ForEach(func(_, item gjson.Result) bool {
if part, ok := responsesContentPartToInteractions(item); ok {
contentItems = append(contentItems, part)
}
return true
})
} else if content.IsObject() {
if part, ok := responsesContentPartToInteractions(content); ok {
contentItems = append(contentItems, part)
}
}
if len(contentItems) > 0 {
step = translatorcommon.SetRawArrayItems(step, "content", contentItems)
}
return step
}
func responsesContentPartToInteractions(part gjson.Result) ([]byte, bool) {
switch part.Get("type").String() {
case "input_text", "output_text", "text":
out := []byte(`{"type":"text","text":""}`)
out, _ = sjson.SetBytes(out, "text", part.Get("text").String())
return out, true
case "input_image", "output_image":
return responsesImagePartToInteractions(part), true
}
if text := part.Get("text"); text.Exists() {
out := []byte(`{"type":"text","text":""}`)
out, _ = sjson.SetBytes(out, "text", text.String())
return out, true
}
return nil, false
}
func responsesImagePartToInteractions(part gjson.Result) []byte {
out := []byte(`{"type":"image"}`)
imageURL := firstNonEmpty(part.Get("image_url").String(), part.Get("url").String())
if mimeType, data, ok := parseDataURL(imageURL); ok {
out, _ = sjson.SetBytes(out, "mime_type", mimeType)
out, _ = sjson.SetBytes(out, "data", data)
return out
}
if data := part.Get("data").String(); data != "" {
out, _ = sjson.SetBytes(out, "data", data)
if mimeType := part.Get("mime_type").String(); mimeType != "" {
out, _ = sjson.SetBytes(out, "mime_type", mimeType)
}
return out
}
if imageURL != "" {
out, _ = sjson.SetBytes(out, "image_url", imageURL)
}
return out
}
func responsesFunctionCallToInteractions(item gjson.Result) []byte {
out := []byte(`{"type":"function_call","name":"","arguments":{}}`)
out, _ = sjson.SetBytes(out, "name", item.Get("name").String())
if callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String()); callID != "" {
out, _ = sjson.SetBytes(out, "call_id", callID)
}
setJSONValue(&out, "arguments", item.Get("arguments"), []byte(`{}`))
return out
}
func responsesFunctionOutputToInteractions(item gjson.Result, functionNamesByCallID map[string]string) []byte {
out := []byte(`{"type":"function_result","name":"","result":{}}`)
callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String())
if name := item.Get("name").String(); name != "" {
out, _ = sjson.SetBytes(out, "name", name)
} else if name := functionNamesByCallID[callID]; name != "" {
out, _ = sjson.SetBytes(out, "name", name)
}
if callID != "" {
out, _ = sjson.SetBytes(out, "call_id", callID)
}
result := item.Get("output")
if !result.Exists() {
result = item.Get("result")
}
setJSONValue(&out, "result", result, []byte(`{}`))
return out
}
func interactionsTextStep(stepType, text string) []byte {
step := []byte(`{"type":"","content":[{"type":"text","text":""}]}`)
step, _ = sjson.SetBytes(step, "type", stepType)
step, _ = sjson.SetBytes(step, "content.0.text", text)
return step
}
func appendResponsesToolsToInteractions(out []byte, tools gjson.Result) []byte {
if !tools.Exists() || !tools.IsArray() {
return out
}
var toolItems [][]byte
tools.ForEach(func(_, tool gjson.Result) bool {
switch tool.Get("type").String() {
case "function", "":
if converted, ok := functionToolToInteractions(tool); ok {
toolItems = append(toolItems, converted)
}
case "namespace":
declarationItems := make([][]byte, 0, 4)
children := tool.Get("children")
if !children.Exists() {
children = tool.Get("tools")
}
children.ForEach(func(_, child gjson.Result) bool {
if converted, ok := functionDeclarationFromTool(child); ok {
declarationItems = append(declarationItems, converted)
}
return true
})
if len(declarationItems) > 0 {
group := []byte(`{"function_declarations":[]}`)
group, _ = sjson.SetRawBytes(group, "function_declarations", translatorcommon.JoinRawArray(declarationItems))
toolItems = append(toolItems, group)
}
}
return true
})
if len(toolItems) > 0 {
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
}
return out
}
func functionToolToInteractions(tool gjson.Result) ([]byte, bool) {
name := firstNonEmpty(tool.Get("name").String(), tool.Get("function.name").String())
if name == "" {
return nil, false
}
out := []byte(`{"type":"function","name":""}`)
out, _ = sjson.SetBytes(out, "name", name)
copyOptionalString(&out, "description", firstExisting(tool.Get("description"), tool.Get("function.description")))
copyOptionalRaw(&out, "parameters", firstExisting(tool.Get("parameters"), tool.Get("function.parameters")))
return out, true
}
func functionDeclarationFromTool(tool gjson.Result) ([]byte, bool) {
name := firstNonEmpty(tool.Get("name").String(), tool.Get("function.name").String())
if name == "" {
return nil, false
}
out := []byte(`{"name":""}`)
out, _ = sjson.SetBytes(out, "name", name)
copyOptionalString(&out, "description", firstExisting(tool.Get("description"), tool.Get("function.description")))
copyOptionalRaw(&out, "parameters", firstExisting(tool.Get("parameters"), tool.Get("function.parameters")))
return out, true
}
func setInteractionsInputOnResponses(out []byte, input gjson.Result) []byte {
items := make([][]byte, 0)
if input.Type == gjson.String {
items = append(items, interactionsTextMessage(input.String()))
} else if input.IsArray() {
input.ForEach(func(_, item gjson.Result) bool {
if converted := interactionsInputItemToResponses(item); converted != nil {
items = append(items, converted)
}
return true
})
} else if input.IsObject() {
if converted := interactionsInputItemToResponses(input); converted != nil {
items = append(items, converted)
}
}
if len(items) > 0 {
out, _ = sjson.SetRawBytes(out, "input", translatorcommon.JoinRawArray(items))
}
return out
}
func interactionsTextMessage(text string) []byte {
item := []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}`)
item, _ = sjson.SetBytes(item, "content.0.text", text)
return item
}
func interactionsInputItemToResponses(item gjson.Result) []byte {
switch item.Get("type").String() {
case "user_input":
return interactionsMessageToResponses(item, "user")
case "model_output":
return interactionsMessageToResponses(item, "assistant")
case "thought":
return interactionsThoughtToResponses(item)
case "function_call":
return interactionsFunctionCallToResponses(item)
case "function_result":
return interactionsFunctionResultToResponses(item)
default:
if item.Type == gjson.String {
return interactionsTextMessage(item.String())
}
}
return nil
}
func interactionsMessageToResponses(item gjson.Result, role string) []byte {
var contentItems [][]byte
content := item.Get("content")
if content.Type == gjson.String {
partType := "input_text"
if role == "assistant" {
partType = "output_text"
}
part := []byte(`{"type":"","text":""}`)
part, _ = sjson.SetBytes(part, "type", partType)
part, _ = sjson.SetBytes(part, "text", content.String())
contentItems = append(contentItems, part)
} else {
content.ForEach(func(_, part gjson.Result) bool {
if converted, ok := interactionsContentPartToResponses(part, role); ok {
contentItems = append(contentItems, converted)
}
return true
})
}
out := []byte(`{"type":"message","role":"","content":[]}`)
out, _ = sjson.SetBytes(out, "role", role)
out = translatorcommon.SetRawArrayItems(out, "content", contentItems)
return out
}
func interactionsThoughtToResponses(item gjson.Result) []byte {
var summaryItems [][]byte
for _, text := range interactionsContentTexts(item.Get("content")) {
part := []byte(`{"type":"summary_text","text":""}`)
part, _ = sjson.SetBytes(part, "text", text)
summaryItems = append(summaryItems, part)
}
out := []byte(`{"type":"reasoning","summary":[]}`)
out = translatorcommon.SetRawArrayItems(out, "summary", summaryItems)
return out
}
func interactionsContentPartToResponses(part gjson.Result, role string) ([]byte, bool) {
partType := part.Get("type").String()
if partType == "" && part.Get("text").Exists() {
partType = "text"
}
switch partType {
case "text":
outType := "input_text"
if role == "assistant" {
outType = "output_text"
}
out := []byte(`{"type":"","text":""}`)
out, _ = sjson.SetBytes(out, "type", outType)
out, _ = sjson.SetBytes(out, "text", part.Get("text").String())
return out, true
case "image":
outType := "input_image"
if role == "assistant" {
outType = "output_image"
}
out := []byte(`{"type":""}`)
out, _ = sjson.SetBytes(out, "type", outType)
imageURL := interactionsMediaDataURL(part)
if imageURL != "" {
out, _ = sjson.SetBytes(out, "image_url", imageURL)
}
return out, true
case "audio":
out := []byte(`{"type":"output_text","text":""}`)
format := mediaFormat(part.Get("mime_type").String())
out, _ = sjson.SetBytes(out, "text", "Audio content: inline data (Format: "+format+")")
return out, true
case "video", "document":
outType := "input_file"
if role == "assistant" {
outType = "output_file"
}
out := []byte(`{"type":""}`)
out, _ = sjson.SetBytes(out, "type", outType)
if dataURL := interactionsMediaDataURL(part); dataURL != "" {
out, _ = sjson.SetBytes(out, "file_data", dataURL)
}
if filename := part.Get("filename").String(); filename != "" {
out, _ = sjson.SetBytes(out, "filename", filename)
}
return out, true
}
return nil, false
}
func interactionsFunctionCallToResponses(item gjson.Result) []byte {
out := []byte(`{"type":"function_call","call_id":"","name":"","arguments":"{}"}`)
if callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String()); callID != "" {
out, _ = sjson.SetBytes(out, "call_id", callID)
}
out, _ = sjson.SetBytes(out, "name", item.Get("name").String())
out, _ = sjson.SetBytes(out, "arguments", jsonStringValue(item.Get("arguments"), "{}"))
return out
}
func interactionsFunctionResultToResponses(item gjson.Result) []byte {
out := []byte(`{"type":"function_call_output","call_id":"","output":""}`)
if callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String()); callID != "" {
out, _ = sjson.SetBytes(out, "call_id", callID)
}
if name := item.Get("name").String(); name != "" {
out, _ = sjson.SetBytes(out, "name", name)
}
result := item.Get("result")
if !result.Exists() {
result = item.Get("output")
}
out, _ = sjson.SetBytes(out, "output", jsonStringValue(result, ""))
return out
}
func appendInteractionsToolsToResponses(out []byte, tools gjson.Result) []byte {
if !tools.Exists() || !tools.IsArray() {
return out
}
var toolItems [][]byte
tools.ForEach(func(_, tool gjson.Result) bool {
if converted, ok := responsesToolFromInteractionsTool(tool); ok {
toolItems = append(toolItems, converted)
}
if decls := tool.Get("function_declarations"); decls.Exists() && decls.IsArray() {
decls.ForEach(func(_, decl gjson.Result) bool {
if converted, ok := responsesToolFromInteractionsTool(decl); ok {
toolItems = append(toolItems, converted)
}
return true
})
}
return true
})
if len(toolItems) > 0 {
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
}
return out
}
func responsesToolFromInteractionsTool(tool gjson.Result) ([]byte, bool) {
name := firstNonEmpty(tool.Get("name").String(), tool.Get("function.name").String())
if name == "" {
return nil, false
}
out := []byte(`{"type":"function","name":""}`)
out, _ = sjson.SetBytes(out, "name", name)
copyOptionalString(&out, "description", firstExisting(tool.Get("description"), tool.Get("function.description")))
copyOptionalRaw(&out, "parameters", firstExisting(tool.Get("parameters"), tool.Get("function.parameters"), tool.Get("parametersJsonSchema")))
return out, true
}
func interactionsContentTexts(content gjson.Result) []string {
texts := make([]string, 0)
if content.Type == gjson.String {
return append(texts, content.String())
}
if content.IsArray() {
content.ForEach(func(_, part gjson.Result) bool {
if text := firstNonEmpty(part.Get("text").String(), part.Get("content.text").String()); text != "" {
texts = append(texts, text)
}
return true
})
}
return texts
}
func interactionsMediaDataURL(part gjson.Result) string {
if url := firstNonEmpty(part.Get("image_url").String(), part.Get("file_data").String(), part.Get("url").String()); url != "" {
return url
}
data := part.Get("data").String()
if data == "" {
return ""
}
mimeType := part.Get("mime_type").String()
if mimeType == "" {
mimeType = "application/octet-stream"
}
return "data:" + mimeType + ";base64," + data
}
func mediaFormat(mimeType string) string {
if mimeType == "" {
return "unknown"
}
if _, format, ok := strings.Cut(mimeType, "/"); ok && format != "" {
return format
}
return mimeType
}
func parseDataURL(value string) (string, string, bool) {
if !strings.HasPrefix(value, "data:") {
return "", "", false
}
header, data, ok := strings.Cut(strings.TrimPrefix(value, "data:"), ",")
if !ok {
return "", "", false
}
mimeType, _, _ := strings.Cut(header, ";")
if mimeType == "" {
mimeType = "application/octet-stream"
}
return mimeType, data, true
}
func setJSONValue(out *[]byte, path string, value gjson.Result, defaultRaw []byte) {
if !value.Exists() {
*out, _ = sjson.SetRawBytes(*out, path, defaultRaw)
return
}
if value.Type == gjson.String && gjson.Valid(value.String()) {
*out, _ = sjson.SetRawBytes(*out, path, []byte(value.String()))
return
}
if value.Type == gjson.String {
*out, _ = sjson.SetBytes(*out, path, value.String())
return
}
*out, _ = sjson.SetRawBytes(*out, path, []byte(value.Raw))
}
func jsonStringValue(value gjson.Result, fallback string) string {
if !value.Exists() {
return fallback
}
if value.Type == gjson.String {
return value.String()
}
return value.Raw
}
func copyOptionalString(out *[]byte, path string, value gjson.Result) {
if value.Exists() {
*out, _ = sjson.SetBytes(*out, path, value.String())
}
}
func copyOptionalRaw(out *[]byte, path string, value gjson.Result) {
if value.Exists() {
*out, _ = sjson.SetRawBytes(*out, path, []byte(value.Raw))
}
}
func isAntigravityModel(model string) bool {
return strings.Contains(strings.ToLower(model), "antigravity")
}
func firstExisting(values ...gjson.Result) gjson.Result {
for _, value := range values {
if value.Exists() {
return value
}
}
return gjson.Result{}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View file

@ -0,0 +1,347 @@
package responses
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIResponsesRequestToInteractions(t *testing.T) {
raw := []byte(`{
"model":"gpt-test",
"instructions":"be brief",
"input":[
{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"},{"type":"input_image","image_url":"data:image/png;base64,aGVsbG8="}]},
{"type":"function_call","name":"lookup","call_id":"call_1","arguments":"{\"q\":\"x\"}"},
{"type":"function_call_output","call_id":"call_1","output":{"ok":true}}
],
"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],
"tool_choice":"auto",
"reasoning":{"effort":"high","summary":"auto"},
"response_format":{"type":"json_object"},
"stream":true
}`)
out := ConvertOpenAIResponsesRequestToInteractions("gpt-test", raw, true)
if got := gjson.GetBytes(out, "input.0.type").String(); got != "user_input" {
t.Fatalf("input.0.type = %q, want user_input. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "text" {
t.Fatalf("content.0.type = %q, want text. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hi" {
t.Fatalf("input text = %q, want hi. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.content.1.mime_type").String(); got != "image/png" {
t.Fatalf("image mime_type = %q, want image/png. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "call_1" {
t.Fatalf("function call_id = %q, want call_1. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.2.type").String(); got != "function_result" {
t.Fatalf("function result type = %q, want function_result. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.2.name").String(); got != "lookup" {
t.Fatalf("function result name = %q, want lookup. Output: %s", got, string(out))
}
sys := gjson.GetBytes(out, "system_instruction")
if sys.Type != gjson.String {
t.Fatalf("system_instruction type = %v, want string. Output: %s", sys.Type, string(out))
}
if got := sys.String(); got != "be brief" {
t.Fatalf("system_instruction = %q, want be brief. Output: %s", got, string(out))
}
if gjson.GetBytes(out, "system_instruction.parts").Exists() {
t.Fatalf("system_instruction.parts should not be forwarded. Output: %s", string(out))
}
if got := gjson.GetBytes(out, "generation_config.thinking_level").String(); got != "high" {
t.Fatalf("thinking_level = %q, want high. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" {
t.Fatalf("tool name = %q, want lookup. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "generation_config.tool_choice").String(); got != "auto" {
t.Fatalf("tool_choice = %q, want auto. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "response_format.type").String(); got != "json_object" {
t.Fatalf("response_format.type = %q, want json_object. Output: %s", got, string(out))
}
}
func TestConvertOpenAIResponsesRequestToInteractionsPreservesRequestStream(t *testing.T) {
out := ConvertOpenAIResponsesRequestToInteractions("gpt-test", []byte(`{"model":"gpt-test","input":"hi","stream":true}`), false)
if got := gjson.GetBytes(out, "stream").Bool(); !got {
t.Fatalf("stream = %v, want true. Output: %s", got, string(out))
}
out = ConvertOpenAIResponsesRequestToInteractions("gpt-test", []byte(`{"model":"gpt-test","input":"hi","stream":false}`), true)
if got := gjson.GetBytes(out, "stream").Bool(); got {
t.Fatalf("stream = %v, want false. Output: %s", got, string(out))
}
}
func TestConvertOpenAIResponsesRequestToInteractionsPreservesPreviousResponseID(t *testing.T) {
out := ConvertOpenAIResponsesRequestToInteractions("gpt-test", []byte(`{"model":"gpt-test","input":"hi","previous_response_id":"resp_123"}`), false)
if got := gjson.GetBytes(out, "previous_interaction_id").String(); got != "resp_123" {
t.Fatalf("previous_interaction_id = %q, want resp_123. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIResponsesWithToolMessages(t *testing.T) {
raw := []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}]}`)
out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, false)
foundFunctionCall := false
foundFunctionOutput := false
gjson.GetBytes(out, "input").ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() == "function_call" {
foundFunctionCall = true
if item.Get("name").String() != "lookup" {
t.Fatalf("name = %q, want lookup", item.Get("name").String())
}
}
if item.Get("type").String() == "function_call_output" {
foundFunctionOutput = true
}
return true
})
if !foundFunctionCall {
t.Fatal("function_call input not found")
}
if !foundFunctionOutput {
t.Fatal("function_call_output input not found")
}
}
func TestConvertInteractionsRequestToOpenAIResponsesPreservesStringSystemAndThinkingConfig(t *testing.T) {
raw := []byte(`{"model":"gpt-test","system_instruction":"You are a helpful assistant.","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]}],"tools":[{"name":"lookup","type":"function","parameters":{"type":"object"}}],"generation_config":{"tool_choice":"auto","thinking_level":"high","thinking_summaries":"auto"},"stream":true}`)
out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, true)
if got := gjson.GetBytes(out, "instructions").String(); got != "You are a helpful assistant." {
t.Fatalf("instructions = %q, want system instruction. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tool_choice").String(); got != "auto" {
t.Fatalf("tool_choice = %q, want auto. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "high" {
t.Fatalf("reasoning.effort = %q, want high. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "reasoning.summary").String(); got != "auto" {
t.Fatalf("reasoning.summary = %q, want auto. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIResponsesPreservesInteractionStream(t *testing.T) {
out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","input":"hi","stream":true}`), false)
if got := gjson.GetBytes(out, "stream").Bool(); !got {
t.Fatalf("stream = %v, want true. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIResponsesPreservesPreviousInteractionID(t *testing.T) {
out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","input":"hi","previous_interaction_id":"interaction_123"}`), false)
if got := gjson.GetBytes(out, "previous_response_id").String(); got != "interaction_123" {
t.Fatalf("previous_response_id = %q, want interaction_123. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIResponsesPreservesToolCallID(t *testing.T) {
out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"function_call","name":"lookup","call_id":"call_gateway","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_gateway","result":{"ok":true}}]}`), false)
foundFunctionCall := false
foundFunctionOutput := false
gjson.GetBytes(out, "input").ForEach(func(_, item gjson.Result) bool {
switch item.Get("type").String() {
case "function_call":
foundFunctionCall = true
if got := item.Get("call_id").String(); got != "call_gateway" {
t.Fatalf("function_call call_id = %q, want call_gateway. Output: %s", got, string(out))
}
case "function_call_output":
foundFunctionOutput = true
if got := item.Get("call_id").String(); got != "call_gateway" {
t.Fatalf("function_call_output call_id = %q, want call_gateway. Output: %s", got, string(out))
}
}
return true
})
if !foundFunctionCall {
t.Fatal("function_call input not found")
}
if !foundFunctionOutput {
t.Fatal("function_call_output input not found")
}
}
func TestConvertInteractionsRequestToOpenAIResponsesConvertsSimpleTools(t *testing.T) {
out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","tools":[{"name":"lookup","description":"Find data","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}],"input":"hi"}`), false)
if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" {
t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" {
t.Fatalf("tools.0.name = %q, want lookup. Output: %s", got, string(out))
}
if gjson.GetBytes(out, "tools.0.function").Exists() {
t.Fatalf("tools.0.function should not be forwarded. Output: %s", string(out))
}
if got := gjson.GetBytes(out, "tools.0.parameters.properties.q.type").String(); got != "string" {
t.Fatalf("tools.0.parameters.properties.q.type = %q, want string. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIResponsesConvertsFunctionDeclarationsTools(t *testing.T) {
out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","tools":[{"function_declarations":[{"name":"lookup","description":"Find data","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}]}],"input":"hi"}`), false)
if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" {
t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" {
t.Fatalf("tools.0.name = %q, want lookup. Output: %s", got, string(out))
}
if gjson.GetBytes(out, "tools.0.function_declarations").Exists() {
t.Fatalf("tools.0.function_declarations should not be forwarded. Output: %s", string(out))
}
}
func TestConvertInteractionsRequestToOpenAIResponsesWithImageContent(t *testing.T) {
raw := []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"text","text":"describe"},{"type":"image","mime_type":"image/png","data":"aGVsbG8="}]}]}`)
out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, false)
if got := gjson.GetBytes(out, "input.0.content.1.type").String(); got != "input_image" {
t.Fatalf("content.1.type = %q, want input_image", got)
}
if got := gjson.GetBytes(out, "input.0.content.1.image_url").String(); got != "data:image/png;base64,aGVsbG8=" {
t.Fatalf("image_url = %q, want data URL", got)
}
}
func TestConvertInteractionsRequestToOpenAIResponsesPreservesNonImageMediaContent(t *testing.T) {
out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"model_output","content":[{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="},{"type":"video","mime_type":"video/mp4","data":"AAAAIGZ0eXA="},{"type":"document","mime_type":"application/pdf","data":"JVBERi0="}]}]}`), false)
if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "output_text" {
t.Fatalf("audio fallback type = %q, want output_text. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.content.1.type").String(); got != "output_file" {
t.Fatalf("video type = %q, want output_file. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "input.0.content.2.type").String(); got != "output_file" {
t.Fatalf("document type = %q, want output_file. Output: %s", got, string(out))
}
if gjson.GetBytes(out, "input.0.content.#(type==\"output_image\")").Exists() {
t.Fatalf("non-image media must not be converted to output_image. Output: %s", string(out))
}
}
func TestConvertInteractionsRequestToOpenAIResponsesWithAssistantTextContent(t *testing.T) {
raw := []byte(`{"model":"gpt-test","input":[{"type":"model_output","content":[{"type":"text","text":"hello"}]}]}`)
out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, false)
if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "output_text" {
t.Fatalf("content.0.type = %q, want output_text", got)
}
if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hello" {
t.Fatalf("content.0.text = %q, want hello", got)
}
}
func TestConvertInteractionsRequestToOpenAIResponsesWithUserObjectContent(t *testing.T) {
raw := []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]}]}`)
out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, false)
if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "input_text" {
t.Fatalf("content.0.type = %q, want input_text", got)
}
if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hi" {
t.Fatalf("content.0.text = %q, want hi", got)
}
}
func TestConvertInteractionsRequestToOpenAIResponsesWithStringFunctionArguments(t *testing.T) {
raw := []byte(`{"model":"gpt-test","input":[{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}]}`)
out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, false)
found := false
gjson.GetBytes(out, "input").ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() == "function_call" {
found = true
if item.Get("arguments").Type != gjson.String {
t.Fatalf("arguments should be string, got %v", item.Get("arguments").Type)
}
if got := item.Get("arguments").String(); got != `{"q":"x"}` {
t.Fatalf("arguments = %q, want {\"q\":\"x\"}", got)
}
}
return true
})
if !found {
t.Fatal("function_call input not found")
}
}
func TestConvertInteractionsRequestToOpenAIResponsesPreservesExpressibleFields(t *testing.T) {
out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","tool_choice":{"type":"function","function":{"name":"lookup"}},"response_modalities":["text","image"],"service_tier":"priority","store":true,"background":true,"webhook_config":{"url":"https://example.com"},"input":"hi"}`), false)
if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tool_choice.function.name").String(); got != "lookup" {
t.Fatalf("tool_choice.function.name = %q, want lookup. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "modalities.0").String(); got != "text" {
t.Fatalf("modalities.0 = %q, want text. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "modalities.1").String(); got != "image" {
t.Fatalf("modalities.1 = %q, want image. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "service_tier").String(); got != "priority" {
t.Fatalf("service_tier = %q, want priority. Output: %s", got, string(out))
}
for _, path := range []string{"store", "background", "webhook_config"} {
if gjson.GetBytes(out, path).Exists() {
t.Fatalf("%s should not be forwarded. Output: %s", path, string(out))
}
}
}
func TestConvertOpenAIResponsesRequestToInteractions_PreservesEnvironmentID(t *testing.T) {
out := ConvertOpenAIResponsesRequestToInteractions("gpt-test", []byte(`{"model":"gpt-test","input":"hi","previous_response_id":"resp_123","environment_id":"env_abc456"}`), false)
if got := gjson.GetBytes(out, "previous_interaction_id").String(); got != "resp_123" {
t.Fatalf("previous_interaction_id = %q, want resp_123. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "environment_id").String(); got != "env_abc456" {
t.Fatalf("environment_id = %q, want env_abc456. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIResponses_PreservesEnvironmentID(t *testing.T) {
out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","input":"hi","previous_interaction_id":"interaction_123","environment_id":"env_abc456"}`), false)
if got := gjson.GetBytes(out, "previous_response_id").String(); got != "interaction_123" {
t.Fatalf("previous_response_id = %q, want interaction_123. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "environment_id").String(); got != "env_abc456" {
t.Fatalf("environment_id = %q, want env_abc456. Output: %s", got, string(out))
}
}
func TestConvertOpenAIResponsesRequestToInteractions_AntigravitySanitizesGenerationConfigAndSetsAgentConfig(t *testing.T) {
raw := []byte(`{
"model":"antigravity-preview-05-2026",
"input":"Search the web",
"previous_response_id":"v1_Chd3...",
"environment_id":"env_789",
"max_output_tokens":2048,
"temperature":0.7,
"top_p":0.95,
"tools":[{"type":"function","name":"web_search","parameters":{"type":"object"}}]
}`)
out := ConvertOpenAIResponsesRequestToInteractions("antigravity-preview-05-2026", raw, false)
if got := gjson.GetBytes(out, "previous_interaction_id").String(); got != "v1_Chd3..." {
t.Fatalf("previous_interaction_id = %q, want v1_Chd3.... Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "environment_id").String(); got != "env_789" {
t.Fatalf("environment_id = %q, want env_789. Output: %s", got, string(out))
}
// temperature, top_p, max_output_tokens should be stripped from generation_config for Antigravity models
for _, knob := range []string{"temperature", "top_p", "top_k", "stop_sequences", "max_output_tokens"} {
if gjson.GetBytes(out, "generation_config."+knob).Exists() {
t.Fatalf("generation_config.%s should be stripped for antigravity model. Output: %s", knob, string(out))
}
}
// max_output_tokens should be mapped to agent_config.max_total_tokens
if got := gjson.GetBytes(out, "agent_config.max_total_tokens").Int(); got != 2048 {
t.Fatalf("agent_config.max_total_tokens = %d, want 2048. Output: %s", got, string(out))
}
}

View file

@ -0,0 +1,705 @@
package responses
import (
"bytes"
"context"
"encoding/base64"
"strings"
"testing"
"github.com/tidwall/gjson"
)
func TestConvertInteractionsResponseToOpenAIResponsesNonStream(t *testing.T) {
raw := []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`)
out := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, raw, nil)
if got := gjson.GetBytes(out, "output.0.content.0.text").String(); got != "ok" {
t.Fatalf("response text = %q, want ok. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 3 {
t.Fatalf("usage.total_tokens = %d, want 3. Output: %s", got, string(out))
}
}
func TestConvertInteractionsResponseToOpenAIResponsesStream(t *testing.T) {
var param any
var out [][]byte
for _, raw := range [][]byte{
[]byte(`event: interaction.created
data: {"interaction":{"id":"interaction_1","model":"source-model"},"event_type":"interaction.created"}
`),
[]byte(`event: step.delta
data: {"index":0,"delta":{"content":{"text":"thinking","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}
`),
[]byte(`event: step.delta
data: {"index":1,"delta":{"text":"I will call a tool.","type":"text"},"event_type":"step.delta"}
`),
[]byte(`event: step.start
data: {"index":2,"step":{"id":"call_1","type":"function_call","name":"get_weather","arguments":{}},"event_type":"step.start"}
`),
[]byte(`event: step.delta
data: {"index":2,"delta":{"arguments":"{\"location\":\"北京\"}","type":"arguments_delta"},"event_type":"step.delta"}
`),
[]byte(`event: step.stop
data: {"index":2,"event_type":"step.stop"}
`),
[]byte(`event: interaction.completed
data: {"interaction":{"id":"interaction_1","status":"completed","usage":{"total_tokens":399,"total_input_tokens":123,"total_cached_tokens":5,"total_output_tokens":36,"total_thought_tokens":240},"created":"2026-07-06T06:01:35Z","object":"interaction","model":"gpt-test"},"event_type":"interaction.completed"}
`),
[]byte(`event: done
data: [DONE]
`),
} {
out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, raw, &param)...)
}
if payload := findResponsesEventPayload(out, "response.output_text.delta"); gjson.GetBytes(payload, "delta").String() != "I will call a tool." {
t.Fatalf("output_text delta payload = %s", string(payload))
}
if payload := findResponsesEventPayload(out, "response.function_call_arguments.delta"); gjson.GetBytes(payload, "delta").String() != `{"location":"北京"}` {
t.Fatalf("function args delta payload = %s", string(payload))
}
argumentsDonePayload := findResponsesEventPayload(out, "response.function_call_arguments.done")
if got := gjson.GetBytes(argumentsDonePayload, "item_id").String(); got != "call_1" {
t.Fatalf("function args done item_id = %q, want call_1. Payload: %s", got, string(argumentsDonePayload))
}
if got := gjson.GetBytes(argumentsDonePayload, "arguments").String(); got != `{"location":"北京"}` {
t.Fatalf("function args done arguments = %q, want full arguments. Payload: %s", got, string(argumentsDonePayload))
}
createdPayload := findResponsesEventPayload(out, "response.created")
if got := gjson.GetBytes(createdPayload, "response.model").String(); got != "gpt-test" {
t.Fatalf("response.created models = %q, want gpt-test", got)
}
completedPayload := findResponsesEventPayload(out, "response.completed")
if got := gjson.GetBytes(completedPayload, "response.usage.total_tokens").Int(); got != 399 {
t.Fatalf("total_tokens = %d, want 399. Payload: %s", got, string(completedPayload))
}
if got := gjson.GetBytes(completedPayload, "response.usage.output_tokens_details.reasoning_tokens").Int(); got != 240 {
t.Fatalf("reasoning_tokens = %d, want 240. Payload: %s", got, string(completedPayload))
}
if got := strings.Join(responsesEventNames(out), ","); !strings.Contains(got, "response.completed") {
t.Fatalf("events = %s, want response.completed", got)
}
}
func TestConvertInteractionsResponseToOpenAIResponsesStreamFunctionCallStartArguments(t *testing.T) {
var param any
var out [][]byte
for _, raw := range [][]byte{
[]byte(`event: step.start
data: {"index":0,"step":{"id":"call_1","type":"function_call","name":"lookup","arguments":{"q":"x"}},"event_type":"step.start"}
`),
[]byte(`event: step.stop
data: {"index":0,"event_type":"step.stop"}
`),
} {
out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", nil, nil, raw, &param)...)
}
gotEvents := strings.Join(responsesEventNames(out), ",")
wantEvents := "response.output_item.added,response.function_call_arguments.delta,response.function_call_arguments.done,response.output_item.done"
if gotEvents != wantEvents {
t.Fatalf("events = %s, want %s", gotEvents, wantEvents)
}
if payload := findResponsesEventPayload(out, "response.function_call_arguments.delta"); gjson.GetBytes(payload, "delta").String() != `{"q":"x"}` {
t.Fatalf("function args delta = %s", string(payload))
}
if payload := findResponsesEventPayload(out, "response.function_call_arguments.done"); gjson.GetBytes(payload, "arguments").String() != `{"q":"x"}` {
t.Fatalf("function args done = %s", string(payload))
}
if payload := findResponsesEventPayload(out, "response.output_item.done"); gjson.GetBytes(payload, "item.arguments").String() != `{"q":"x"}` {
t.Fatalf("output item done = %s", string(payload))
}
}
func TestConvertInteractionsResponseToOpenAIResponsesStreamFunctionCallEmptyArguments(t *testing.T) {
var param any
var out [][]byte
for _, raw := range [][]byte{
[]byte(`event: step.start
data: {"index":0,"step":{"id":"call_1","type":"function_call","name":"lookup","arguments":{}},"event_type":"step.start"}
`),
[]byte(`event: step.stop
data: {"index":0,"event_type":"step.stop"}
`),
[]byte(`event: interaction.completed
data: {"interaction":{"id":"interaction_1","status":"completed","model":"gpt-test"},"event_type":"interaction.completed"}
`),
} {
out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", nil, nil, raw, &param)...)
}
gotEvents := strings.Join(responsesEventNames(out), ",")
wantEvents := "response.output_item.added,response.function_call_arguments.done,response.output_item.done,response.completed"
if gotEvents != wantEvents {
t.Fatalf("events = %s, want %s", gotEvents, wantEvents)
}
if payload := findResponsesEventPayload(out, "response.function_call_arguments.done"); gjson.GetBytes(payload, "arguments").String() != "{}" {
t.Fatalf("function args done = %s", string(payload))
}
if payload := findResponsesEventPayload(out, "response.output_item.done"); gjson.GetBytes(payload, "item.arguments").String() != "{}" {
t.Fatalf("output item done = %s", string(payload))
}
if payload := findResponsesEventPayload(out, "response.completed"); gjson.GetBytes(payload, "response.output.0.arguments").String() != "{}" {
t.Fatalf("completed output = %s", string(payload))
}
}
func TestConvertInteractionsResponseToOpenAIResponsesStreamFunctionCallEventsAreIdempotent(t *testing.T) {
var param any
var out [][]byte
for _, raw := range [][]byte{
[]byte(`event: step.start
data: {"index":0,"step":{"id":"call_1","type":"function_call","name":"lookup","arguments":{"q":"x"}},"event_type":"step.start"}
`),
[]byte(`event: step.start
data: {"index":0,"step":{"id":"call_1","type":"function_call","name":"lookup","arguments":{"q":"x"}},"event_type":"step.start"}
`),
[]byte(`event: step.stop
data: {"index":0,"event_type":"step.stop"}
`),
[]byte(`event: step.stop
data: {"index":0,"event_type":"step.stop"}
`),
} {
out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", nil, nil, raw, &param)...)
}
gotEvents := strings.Join(responsesEventNames(out), ",")
wantEvents := "response.output_item.added,response.function_call_arguments.delta,response.function_call_arguments.done,response.output_item.done"
if gotEvents != wantEvents {
t.Fatalf("events = %s, want %s", gotEvents, wantEvents)
}
}
func TestConvertInteractionsResponseToOpenAIResponsesStreamModelOutputDoneIncludesText(t *testing.T) {
var param any
var out [][]byte
for _, raw := range [][]byte{
[]byte(`event: step.start
data: {"index":0,"step":{"id":"msg_1","type":"model_output"},"event_type":"step.start"}
`),
[]byte(`event: step.delta
data: {"index":0,"delta":{"text":"hello","type":"text"},"event_type":"step.delta"}
`),
[]byte(`event: step.delta
data: {"index":0,"delta":{"text":" world","type":"text"},"event_type":"step.delta"}
`),
[]byte(`event: step.stop
data: {"index":0,"event_type":"step.stop"}
`),
} {
out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, raw, &param)...)
}
if payload := findResponsesEventPayload(out, "response.output_text.done"); gjson.GetBytes(payload, "text").String() != "hello world" {
t.Fatalf("output_text done payload = %s", string(payload))
}
if payload := findResponsesEventPayload(out, "response.content_part.done"); gjson.GetBytes(payload, "part.text").String() != "hello world" {
t.Fatalf("content_part done payload = %s", string(payload))
}
if payload := findResponsesEventPayload(out, "response.output_item.done"); gjson.GetBytes(payload, "item.content.0.text").String() != "hello world" {
t.Fatalf("output_item done payload = %s", string(payload))
}
}
func testGPTResponsesReasoningSignature() string {
payload := make([]byte, 1+8+16+16+32)
payload[0] = 0x80
payload[8] = 1
for i := 9; i < len(payload); i++ {
payload[i] = byte(i)
}
return base64.URLEncoding.EncodeToString(payload)
}
func TestConvertInteractionsResponseToOpenAIResponsesStreamPreservesThoughtSignature(t *testing.T) {
var param any
signature := testGPTResponsesReasoningSignature()
var out [][]byte
for _, raw := range [][]byte{
[]byte(`event: step.start
data: {"index":0,"step":{"type":"thought"},"event_type":"step.start"}
`),
[]byte(`event: step.delta
data: {"index":0,"delta":{"content":{"text":"thinking","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}
`),
[]byte(`event: step.delta
data: {"index":0,"delta":{"signature":"","type":"thought_signature"},"event_type":"step.delta"}
`),
[]byte(`event: step.delta
data: {"index":0,"delta":{"signature":"` + signature + `","type":"thought_signature"},"event_type":"step.delta"}
`),
[]byte(`event: step.stop
data: {"index":0,"event_type":"step.stop"}
`),
[]byte(`event: interaction.completed
data: {"interaction":{"id":"interaction_1","status":"completed","object":"interaction","model":"gpt-test"},"event_type":"interaction.completed"}
`),
} {
out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, raw, &param)...)
}
if got := strings.Join(responsesEventNames(out), ","); strings.Contains(got, "response.output_text.delta") {
t.Fatalf("events = %s, did not expect output_text delta for thought signature", got)
}
donePayload := findResponsesEventPayload(out, "response.output_item.done")
if got := gjson.GetBytes(donePayload, "item.encrypted_content").String(); got != signature {
t.Fatalf("done encrypted_content = %q, want %q. Payload: %s", got, signature, string(donePayload))
}
if got := gjson.GetBytes(donePayload, "item.summary.0.text").String(); got != "thinking" {
t.Fatalf("done summary = %q, want thinking. Payload: %s", got, string(donePayload))
}
completedPayload := findResponsesEventPayload(out, "response.completed")
if got := gjson.GetBytes(completedPayload, "response.output.0.encrypted_content").String(); got != signature {
t.Fatalf("completed encrypted_content = %q, want %q. Payload: %s", got, signature, string(completedPayload))
}
}
func TestConvertInteractionsResponseToOpenAIResponsesStreamDropsForeignThoughtSignature(t *testing.T) {
var param any
foreignSignature := "foreign-gemini-signature"
var out [][]byte
for _, raw := range [][]byte{
[]byte(`event: step.start
data: {"index":0,"step":{"type":"thought"},"event_type":"step.start"}
`),
[]byte(`event: step.delta
data: {"index":0,"delta":{"content":{"text":"thinking","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}
`),
[]byte(`event: step.delta
data: {"index":0,"delta":{"signature":"` + foreignSignature + `","type":"thought_signature"},"event_type":"step.delta"}
`),
[]byte(`event: step.stop
data: {"index":0,"event_type":"step.stop"}
`),
[]byte(`event: interaction.completed
data: {"interaction":{"id":"interaction_1","status":"completed","object":"interaction","model":"gpt-test"},"event_type":"interaction.completed"}
`),
} {
out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, raw, &param)...)
}
donePayload := findResponsesEventPayload(out, "response.output_item.done")
if got := gjson.GetBytes(donePayload, "item.encrypted_content").String(); got != "" {
t.Fatalf("done encrypted_content = %q, want empty for foreign signature. Payload: %s", got, string(donePayload))
}
if got := gjson.GetBytes(donePayload, "item.summary.0.text").String(); got != "thinking" {
t.Fatalf("done summary = %q, want thinking. Payload: %s", got, string(donePayload))
}
completedPayload := findResponsesEventPayload(out, "response.completed")
if got := gjson.GetBytes(completedPayload, "response.output.0.encrypted_content").String(); got != "" {
t.Fatalf("completed encrypted_content = %q, want empty for foreign signature. Payload: %s", got, string(completedPayload))
}
}
func TestConvertInteractionsResponseToOpenAIResponsesNonStreamThoughtSignature(t *testing.T) {
validSig := testGPTResponsesReasoningSignature()
rawValid := []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"thought","signature":"` + validSig + `","content":[{"type":"text","text":"thinking"}]}],"usage":{"total_tokens":1}}`)
outValid := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, rawValid, nil)
if got := gjson.GetBytes(outValid, "output.0.encrypted_content").String(); got != validSig {
t.Fatalf("valid encrypted_content = %q, want %q. Output: %s", got, validSig, string(outValid))
}
if got := gjson.GetBytes(outValid, "output.0.summary.0.text").String(); got != "thinking" {
t.Fatalf("summary = %q, want thinking. Output: %s", got, string(outValid))
}
rawForeign := []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"thought","thought_signature":"foreign-gemini-signature","content":[{"type":"text","text":"thinking"}]}],"usage":{"total_tokens":1}}`)
outForeign := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, rawForeign, nil)
if got := gjson.GetBytes(outForeign, "output.0.encrypted_content").String(); got != "" {
t.Fatalf("foreign encrypted_content = %q, want empty. Output: %s", got, string(outForeign))
}
if got := gjson.GetBytes(outForeign, "output.0.summary.0.text").String(); got != "thinking" {
t.Fatalf("summary = %q, want thinking. Output: %s", got, string(outForeign))
}
}
func TestConvertOpenAIResponsesResponseToInteractionsNonStreamFunctionCall(t *testing.T) {
raw := []byte(`{"id":"resp_1","output":[{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`)
out := ConvertOpenAIResponsesResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "steps.0.type").String(); got != "function_call" {
t.Fatalf("step type = %q, want function_call", got)
}
if got := gjson.GetBytes(out, "steps.0.name").String(); got != "lookup" {
t.Fatalf("name = %q, want lookup", got)
}
if got := gjson.GetBytes(out, "steps.0.call_id").String(); got != "call_1" {
t.Fatalf("call_id = %q, want call_1", got)
}
}
func TestConvertOpenAIResponsesResponseToInteractionsNonStreamFunctionCallStringArgs(t *testing.T) {
raw := []byte(`{"id":"resp_1","output":[{"type":"function_call","name":"lookup","call_id":"call_1","arguments":"{\"q\":\"x\"}"}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`)
out := ConvertOpenAIResponsesResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "steps.0.type").String(); got != "function_call" {
t.Fatalf("step type = %q, want function_call", got)
}
if got := gjson.GetBytes(out, "steps.0.arguments.q").String(); got != "x" {
t.Fatalf("arguments.q = %q, want x", got)
}
}
func TestConvertOpenAIResponsesResponseToInteractionsNonStreamUsageDetails(t *testing.T) {
raw := []byte(`{"id":"resp_1","output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":11,"output_tokens":13,"total_tokens":24,"input_tokens_details":{"cached_tokens":5},"output_tokens_details":{"reasoning_tokens":7}}}`)
out := ConvertOpenAIResponsesResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil)
if got := gjson.GetBytes(out, "id").String(); got != "resp_1" {
t.Fatalf("id = %q, want resp_1. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "usage.input_tokens").Int(); got != 11 {
t.Fatalf("usage.input_tokens = %d, want 11. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "usage.output_tokens").Int(); got != 13 {
t.Fatalf("usage.output_tokens = %d, want 13. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "usage.reasoning_tokens").Int(); got != 7 {
t.Fatalf("usage.reasoning_tokens = %d, want 7. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "usage.cached_tokens").Int(); got != 5 {
t.Fatalf("usage.cached_tokens = %d, want 5. Output: %s", got, string(out))
}
}
func TestConvertOpenAIResponsesResponseToInteractionsStreamFunctionCallCallID(t *testing.T) {
var param any
raw := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1","call_id":"call_stream_1","name":"lookup","arguments":"{\"q\":\"x\"}"}}`)
out := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, raw, &param)
payload := findInteractionsStepDeltaPayload(out)
if len(payload) == 0 {
t.Fatalf("step.delta payload not found")
}
startPayload := findInteractionsEventPayload(out, "step.start")
if got := gjson.GetBytes(startPayload, "step.id").String(); got != "call_stream_1" {
t.Fatalf("step.id = %q, want call_stream_1", got)
}
if got := gjson.GetBytes(payload, "delta.arguments").String(); got != `{"q":"x"}` {
t.Fatalf("delta.arguments = %q, want JSON string", got)
}
}
func TestConvertOpenAIResponsesResponseToInteractionsStreamSkipsDoneArgumentsAfterDelta(t *testing.T) {
var param any
deltaRaw := []byte(`{"type":"response.function_call_arguments.delta","output_index":0,"item_id":"fc_1","call_id":"call_1","delta":"{\"q\":\"x\"}"}`)
deltaOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, deltaRaw, &param)
payload := findInteractionsStepDeltaPayload(deltaOut)
if len(payload) == 0 {
t.Fatalf("delta step.delta payload not found")
}
if got := gjson.GetBytes(payload, "delta.arguments").String(); got != `{"q":"x"}` {
t.Fatalf("delta.arguments = %q, want JSON string. Payload: %s", got, string(payload))
}
doneRaw := []byte(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"x\"}"}}`)
doneOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, &param)
if got := countInteractionsEventType(doneOut, "step.delta"); got != 0 {
t.Fatalf("done step.delta count = %d, want 0", got)
}
if got := countInteractionsEventType(doneOut, "step.stop"); got != 1 {
t.Fatalf("done step.stop count = %d, want 1", got)
}
}
func TestConvertOpenAIResponsesResponseToInteractionsStreamSkipsDoneTextAfterDelta(t *testing.T) {
var param any
deltaRaw := []byte(`{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"hi"}`)
deltaOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, deltaRaw, &param)
payload := findInteractionsStepDeltaPayload(deltaOut)
if len(payload) == 0 {
t.Fatalf("delta step.delta payload not found")
}
if got := gjson.GetBytes(payload, "delta.text").String(); got != "hi" {
t.Fatalf("delta.text = %q, want hi. Payload: %s", got, string(payload))
}
doneRaw := []byte(`{"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_1","content":[{"type":"output_text","text":"hi"}]}}`)
doneOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, &param)
if got := countInteractionsEventType(doneOut, "step.delta"); got != 0 {
t.Fatalf("done step.delta count = %d, want 0", got)
}
}
func TestConvertOpenAIResponsesResponseToInteractionsStreamSkipsDoneTextAfterUnkeyedDelta(t *testing.T) {
var param any
deltaRaw := []byte(`{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"delta":"hi"}`)
deltaOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, deltaRaw, &param)
payload := findInteractionsStepDeltaPayload(deltaOut)
if len(payload) == 0 {
t.Fatalf("delta step.delta payload not found")
}
if got := gjson.GetBytes(payload, "delta.text").String(); got != "hi" {
t.Fatalf("delta.text = %q, want hi. Payload: %s", got, string(payload))
}
doneRaw := []byte(`{"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_1","content":[{"type":"output_text","text":"hi"}]}}`)
doneOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, &param)
if got := countInteractionsEventType(doneOut, "step.delta"); got != 0 {
t.Fatalf("done step.delta count = %d, want 0", got)
}
}
func TestConvertOpenAIResponsesResponseToInteractionsStreamCompletedOutputFallback(t *testing.T) {
var param any
raw := []byte(`{"type":"response.completed","response":{"output":[{"type":"message","id":"msg_1","content":[{"type":"output_text","text":"final"}]}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`)
out := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, raw, &param)
payload := findInteractionsStepDeltaPayload(out)
if len(payload) == 0 {
t.Fatalf("fallback step.delta payload not found")
}
if got := gjson.GetBytes(payload, "delta.text").String(); got != "final" {
t.Fatalf("delta.text = %q, want final. Payload: %s", got, string(payload))
}
if got := countInteractionsEventType(out, "interaction.completed"); got != 1 {
t.Fatalf("interaction.completed count = %d, want 1", got)
}
}
func TestConvertOpenAIResponsesResponseToInteractionsStreamEmitsDone(t *testing.T) {
var param any
completedRaw := []byte(`{"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`)
completedOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, completedRaw, &param)
doneOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, []byte(`data: [DONE]`), &param)
if got := countInteractionsEventType(completedOut, "interaction.completed"); got != 1 {
t.Fatalf("completed interaction.completed count = %d, want 1", got)
}
if got := countInteractionsEventType(completedOut, "done"); got != 1 {
t.Fatalf("completed done count = %d, want 1", got)
}
if got := countInteractionsEventType(doneOut, "interaction.completed"); got != 0 {
t.Fatalf("done interaction.completed count = %d, want 0", got)
}
if got := countInteractionsEventType(doneOut, "done"); got != 0 {
t.Fatalf("done event count = %d, want 0", got)
}
if payload := findInteractionsEventPayload(completedOut, "done"); string(payload) != "[DONE]" {
t.Fatalf("done payload = %q, want [DONE]", string(payload))
}
}
func TestConvertInteractionsResponseToOpenAIResponsesStreamFinishMetadataUsage(t *testing.T) {
var param any
out := ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", nil, nil, []byte(`data: {"event_type":"finish","metadata":{"total_usage":{"total_input_tokens":2,"total_output_tokens":6,"total_thought_tokens":3,"total_cached_tokens":1,"total_tokens":11}}}`), &param)
payload := findResponsesEventPayload(out, "response.completed")
if len(payload) == 0 {
t.Fatalf("response.completed payload not found")
}
if got := gjson.GetBytes(payload, "response.usage.input_tokens").Int(); got != 2 {
t.Fatalf("input_tokens = %d, want 2. Payload: %s", got, string(payload))
}
if got := gjson.GetBytes(payload, "response.usage.output_tokens").Int(); got != 6 {
t.Fatalf("output_tokens = %d, want 6. Payload: %s", got, string(payload))
}
if got := gjson.GetBytes(payload, "response.usage.output_tokens_details.reasoning_tokens").Int(); got != 3 {
t.Fatalf("reasoning_tokens = %d, want 3. Payload: %s", got, string(payload))
}
if got := gjson.GetBytes(payload, "response.usage.input_tokens_details.cached_tokens").Int(); got != 1 {
t.Fatalf("cached_tokens = %d, want 1. Payload: %s", got, string(payload))
}
if got := gjson.GetBytes(payload, "response.usage.total_tokens").Int(); got != 11 {
t.Fatalf("total_tokens = %d, want 11. Payload: %s", got, string(payload))
}
}
func TestConvertOpenAIResponsesResponseToInteractionsStreamCreatedThenDelta(t *testing.T) {
var param any
var out [][]byte
for _, raw := range [][]byte{
[]byte(`{"type":"response.created","response":{"id":"resp_1","model":"gpt-test"}}`),
[]byte(`{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"hi"}`),
} {
out = append(out, ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, raw, &param)...)
}
got := strings.Join(interactionsEventNames(out), ",")
want := "interaction.created,interaction.status_update,step.start,step.delta"
if got != want {
t.Fatalf("events = %s, want %s", got, want)
}
payload := findInteractionsEventPayload(out, "interaction.status_update")
if gotID := gjson.GetBytes(payload, "interaction_id").String(); gotID != "resp_1" {
t.Fatalf("interaction_id = %q, want resp_1. Payload: %s", gotID, string(payload))
}
}
func TestConvertOpenAIResponsesResponseToInteractionsStreamCompletesAfterSteps(t *testing.T) {
var param any
var out [][]byte
for _, raw := range [][]byte{
[]byte(`{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"我将调用工具。"}`),
[]byte(`{"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"weather\"}"}}`),
[]byte(`{"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`),
} {
out = append(out, ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, raw, &param)...)
}
got := strings.Join(interactionsEventNames(out), ",")
want := "interaction.created,interaction.status_update,step.start,step.delta,step.stop,step.start,step.delta,step.stop,interaction.completed,done"
if got != want {
t.Fatalf("events = %s, want %s", got, want)
}
completedPayload := findInteractionsEventPayload(out, "interaction.completed")
if gotTokens := gjson.GetBytes(completedPayload, "interaction.usage.total_tokens").Int(); gotTokens != 3 {
t.Fatalf("total_tokens = %d, want 3. Payload: %s", gotTokens, string(completedPayload))
}
}
func TestConvertOpenAIResponsesResponseToInteractionsStreamSkipsCompletedTextAfterUnkeyedDelta(t *testing.T) {
var param any
deltaRaw := []byte(`{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"delta":"final"}`)
deltaOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, deltaRaw, &param)
payload := findInteractionsStepDeltaPayload(deltaOut)
if len(payload) == 0 {
t.Fatalf("delta step.delta payload not found")
}
if got := gjson.GetBytes(payload, "delta.text").String(); got != "final" {
t.Fatalf("delta.text = %q, want final. Payload: %s", got, string(payload))
}
raw := []byte(`{"type":"response.completed","response":{"output":[{"type":"message","id":"msg_1","content":[{"type":"output_text","text":"final"}]}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`)
out := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, raw, &param)
if got := countInteractionsEventType(out, "step.delta"); got != 0 {
t.Fatalf("completed step.delta count = %d, want 0", got)
}
if got := countInteractionsEventType(out, "interaction.completed"); got != 1 {
t.Fatalf("interaction.completed count = %d, want 1", got)
}
}
func findInteractionsStepDeltaPayload(events [][]byte) []byte {
return findInteractionsEventPayload(events, "step.delta")
}
func findInteractionsEventPayload(events [][]byte, eventType string) []byte {
for _, event := range events {
payload := ssePayload(event)
if interactionsEventName(event, payload) == eventType {
return payload
}
}
return nil
}
func ssePayload(event []byte) []byte {
const prefix = "\ndata: "
idx := bytes.Index(event, []byte(prefix))
if idx < 0 {
return nil
}
return event[idx+len(prefix):]
}
func countInteractionsEventType(events [][]byte, eventType string) int {
count := 0
for _, event := range events {
payload := ssePayload(event)
if interactionsEventName(event, payload) == eventType {
count++
}
}
return count
}
func interactionsEventNames(events [][]byte) []string {
names := make([]string, 0, len(events))
for _, event := range events {
payload := ssePayload(event)
if name := interactionsEventName(event, payload); name != "" {
names = append(names, name)
}
}
return names
}
func interactionsEventName(event, payload []byte) string {
if eventType := gjson.GetBytes(payload, "event_type").String(); eventType != "" {
return eventType
}
const prefix = "event: "
lineEnd := bytes.IndexByte(event, '\n')
if lineEnd < 0 || !bytes.HasPrefix(event, []byte(prefix)) {
return ""
}
return string(event[len(prefix):lineEnd])
}
func findResponsesEventPayload(events [][]byte, eventType string) []byte {
for _, event := range events {
payload := ssePayload(event)
if gjson.GetBytes(payload, "type").String() == eventType {
return payload
}
}
return nil
}
func responsesEventNames(events [][]byte) []string {
names := make([]string, 0, len(events))
for _, event := range events {
payload := ssePayload(event)
if name := gjson.GetBytes(payload, "type").String(); name != "" {
names = append(names, name)
}
}
return names
}
func TestConvertInteractionsResponseToOpenAIResponsesNonStream_PreservesEnvironmentID(t *testing.T) {
raw := []byte(`{"id":"interaction_1","object":"interaction","environment_id":"env_abc123","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`)
out := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "antigravity-preview-05-2026", []byte(`{"model":"antigravity-preview-05-2026"}`), nil, raw, nil)
if got := gjson.GetBytes(out, "environment_id").String(); got != "env_abc123" {
t.Fatalf("environment_id = %q, want env_abc123. Output: %s", got, string(out))
}
}
func TestConvertInteractionsResponseToOpenAIResponsesStream_PreservesEnvironmentID(t *testing.T) {
var param any
var out [][]byte
rawEvents := [][]byte{
[]byte("event: interaction.created\ndata: {\"interaction\":{\"id\":\"interaction_1\",\"environment_id\":\"env_stream123\",\"model\":\"antigravity-preview-05-2026\"},\"event_type\":\"interaction.created\"}\n\n"),
[]byte("event: interaction.completed\ndata: {\"interaction\":{\"id\":\"interaction_1\",\"environment_id\":\"env_stream123\",\"status\":\"completed\"},\"event_type\":\"interaction.completed\"}\n\n"),
[]byte("event: done\ndata: [DONE]\n\n"),
}
for _, raw := range rawEvents {
out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "antigravity-preview-05-2026", []byte(`{"model":"antigravity-preview-05-2026"}`), nil, raw, &param)...)
}
createdPayload := findResponsesEventPayload(out, "response.created")
if got := gjson.GetBytes(createdPayload, "response.environment_id").String(); got != "env_stream123" {
t.Fatalf("response.created environment_id = %q, want env_stream123. Payload: %s", got, string(createdPayload))
}
completedPayload := findResponsesEventPayload(out, "response.completed")
if got := gjson.GetBytes(completedPayload, "response.environment_id").String(); got != "env_stream123" {
t.Fatalf("response.completed environment_id = %q, want env_stream123. Payload: %s", got, string(completedPayload))
}
}

View file

@ -0,0 +1,19 @@
package chat_completions
import (
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
translator.Register(
OpenAI,
OpenAI,
ConvertOpenAIRequestToOpenAI,
interfaces.TranslateResponse{
Stream: ConvertOpenAIResponseToOpenAI,
NonStream: ConvertOpenAIResponseToOpenAINonStream,
},
)
}

View file

@ -0,0 +1,36 @@
// Package openai provides request translation functionality for OpenAI to OpenAI API compatibility.
// It converts OpenAI Chat Completions requests into OpenAI-compatible JSON using gjson/sjson only.
package chat_completions
import (
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertOpenAIRequestToOpenAI converts an OpenAI Chat Completions request (raw JSON)
// into a complete OpenAI request JSON. All JSON construction uses sjson and lookups use gjson.
//
// Parameters:
// - modelName: The name of the model to use for the request
// - rawJSON: The raw JSON request data from the OpenAI API
// - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)
//
// Returns:
// - []byte: The transformed request data in OpenAI API format
func ConvertOpenAIRequestToOpenAI(modelName string, inputRawJSON []byte, _ bool) []byte {
currentModel := gjson.GetBytes(inputRawJSON, "model")
if currentModel.Type == gjson.String && currentModel.String() == modelName {
return inputRawJSON
}
// Update the "model" field in the JSON payload with the provided modelName
// The sjson.SetBytes function returns a new byte slice with the updated JSON.
updatedJSON, err := sjson.SetBytes(inputRawJSON, "model", modelName)
if err != nil {
// If there's an error, return the original JSON or handle the error appropriately.
// For now, we'll return the original, but in a real scenario, logging or a more robust error
// handling mechanism would be needed.
return inputRawJSON
}
return updatedJSON
}

View file

@ -0,0 +1,27 @@
package chat_completions
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToOpenAIReusesMatchingModelPayload(t *testing.T) {
input := []byte(`{"model":"gpt-test","messages":[{"role":"user","content":"hello"}]}`)
output := ConvertOpenAIRequestToOpenAI("gpt-test", input, false)
if &output[0] != &input[0] {
t.Fatal("matching model caused a payload copy")
}
}
func TestConvertOpenAIRequestToOpenAIUpdatesDifferentModel(t *testing.T) {
input := []byte(`{"model":"old-model","messages":[]}`)
output := ConvertOpenAIRequestToOpenAI("new-model", input, false)
if model := gjson.GetBytes(output, "model").String(); model != "new-model" {
t.Fatalf("model = %q, want new-model", model)
}
}

View file

@ -0,0 +1,53 @@
// Package chat_completions provides passthrough response translation for OpenAI Chat Completions.
// It normalizes OpenAI-compatible SSE lines by stripping the "data:" prefix and dropping "[DONE]".
package chat_completions
import (
"bytes"
"context"
)
// ConvertOpenAIResponseToOpenAI normalizes a single chunk of an OpenAI-compatible streaming response.
// If the chunk is an SSE "data:" line, the prefix is stripped and the remaining JSON payload is returned.
// The "[DONE]" marker yields no output.
//
// Parameters:
// - ctx: The context for the request, used for cancellation and timeout handling
// - modelName: The name of the model being used for the response (unused in current implementation)
// - rawJSON: The raw JSON response from the OpenAI API
// - param: A pointer to a parameter object for maintaining state between calls
//
// Returns:
// - [][]byte: A slice of JSON payload chunks in OpenAI format.
func ConvertOpenAIResponseToOpenAI(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
if param != nil {
if done, ok := (*param).(bool); ok && done {
// Drop any chunks that arrive after the terminal [DONE] marker.
return [][]byte{}
}
}
if bytes.HasPrefix(rawJSON, []byte("data:")) {
rawJSON = bytes.TrimSpace(rawJSON[5:])
}
if bytes.Equal(rawJSON, []byte("[DONE]")) {
if param != nil {
*param = true
}
return [][]byte{}
}
return [][]byte{rawJSON}
}
// ConvertOpenAIResponseToOpenAINonStream passes through a non-streaming OpenAI response.
//
// Parameters:
// - ctx: The context for the request, used for cancellation and timeout handling
// - modelName: The name of the model being used for the response
// - rawJSON: The raw JSON response from the OpenAI API
// - param: A pointer to a parameter object for the conversion
//
// Returns:
// - []byte: The OpenAI-compatible JSON response.
func ConvertOpenAIResponseToOpenAINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
return rawJSON
}

View file

@ -0,0 +1,38 @@
package chat_completions
import (
"bytes"
"context"
"testing"
)
func TestConvertOpenAIResponseToOpenAIDropsChunksAfterDone(t *testing.T) {
var param any
ctx := context.Background()
first := ConvertOpenAIResponseToOpenAI(ctx, "m", nil, nil, []byte(`data: {"id":"x","choices":[]}`), &param)
if len(first) != 1 || !bytes.Contains(first[0], []byte(`"id":"x"`)) {
t.Fatalf("first chunk = %v", first)
}
done := ConvertOpenAIResponseToOpenAI(ctx, "m", nil, nil, []byte("data: [DONE]"), &param)
if len(done) != 0 {
t.Fatalf("DONE should yield no output, got %v", done)
}
if doneFlag, ok := param.(bool); !ok || !doneFlag {
t.Fatalf("param after DONE = %#v, want true", param)
}
trailing := ConvertOpenAIResponseToOpenAI(ctx, "m", nil, nil, []byte(`data: {"choices":[],"cost":"0"}`), &param)
if len(trailing) != 0 {
t.Fatalf("post-DONE chunk should be dropped, got %v", trailing)
}
}
func TestConvertOpenAIResponseToOpenAIPassthroughWithoutDone(t *testing.T) {
var param any
out := ConvertOpenAIResponseToOpenAI(context.Background(), "m", nil, nil, []byte(`{"id":"y"}`), &param)
if len(out) != 1 || !bytes.Equal(out[0], []byte(`{"id":"y"}`)) {
t.Fatalf("out = %v", out)
}
}

View file

@ -0,0 +1,19 @@
package responses
import (
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
translator.Register(
OpenaiResponse,
OpenAI,
ConvertOpenAIResponsesRequestToOpenAIChatCompletions,
interfaces.TranslateResponse{
Stream: ConvertOpenAIChatCompletionsResponseToOpenAIResponses,
NonStream: ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream,
},
)
}

View file

@ -0,0 +1,560 @@
package responses
import (
"strings"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertOpenAIResponsesRequestToOpenAIChatCompletions converts OpenAI responses format to OpenAI chat completions format.
// It transforms the OpenAI responses API format (with instructions and input array) into the standard
// OpenAI chat completions format (with messages array and system content).
//
// The conversion handles:
// 1. Model name and streaming configuration
// 2. Instructions to system message conversion
// 3. Input array to messages array transformation
// 4. Tool definitions and tool choice conversion
// 5. Function calls and function results handling
// 6. Generation parameters mapping (max_tokens, reasoning, etc.)
//
// Parameters:
// - modelName: The name of the model to use for the request
// - rawJSON: The raw JSON request data in OpenAI responses format
// - stream: A boolean indicating if the request is for a streaming response
//
// Returns:
// - []byte: The transformed request data in OpenAI chat completions format
func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := inputRawJSON
// Base OpenAI chat completions template with default values
out := []byte(`{"model":"","messages":[],"stream":false}`)
root := gjson.ParseBytes(rawJSON)
messages := make([][]byte, 0)
appendMessage := func(message []byte) {
messages = append(messages, message)
}
// Set model name
out, _ = sjson.SetBytes(out, "model", modelName)
// Set stream configuration
out, _ = sjson.SetBytes(out, "stream", stream)
// Map Responses text format to Chat Completions response format.
if textFormat := root.Get("text.format"); textFormat.Exists() {
if responseFormat := convertResponsesTextFormatToChatResponseFormat(textFormat); len(responseFormat) > 0 {
out, _ = sjson.SetRawBytes(out, "response_format", responseFormat)
}
}
// Map generation parameters from responses format to chat completions format
if maxTokens := root.Get("max_output_tokens"); maxTokens.Exists() {
out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
}
// Convert instructions to system message
if instructions := root.Get("instructions"); instructions.Exists() {
systemMessage := []byte(`{"role":"system","content":""}`)
systemMessage, _ = sjson.SetBytes(systemMessage, "content", instructions.String())
appendMessage(systemMessage)
}
// Convert input array to messages
if input := root.Get("input"); input.Exists() && input.IsArray() {
inputItems := input.Array()
outputCallIDs := make(map[string]struct{})
for _, item := range inputItems {
itemType := item.Get("type").String()
if itemType != "function_call_output" && itemType != "custom_tool_call_output" {
continue
}
callID := strings.TrimSpace(item.Get("call_id").String())
if callID == "" {
continue
}
outputCallIDs[callID] = struct{}{}
}
pendingToolCalls := make([]interface{}, 0)
pendingToolCallIDs := make([]string, 0)
pendingReasoningContent := ""
awaitingToolOutputs := make(map[string]struct{})
deferredMessages := make([][]byte, 0)
mergeableAssistantIndex := -1
takePendingReasoningContent := func() string {
reasoningContent := pendingReasoningContent
pendingReasoningContent = ""
return reasoningContent
}
flushPendingToolCalls := func() {
if len(pendingToolCalls) == 0 {
return
}
reasoningContent := takePendingReasoningContent()
mergedIntoAssistant := false
if mergeableAssistantIndex >= 0 && mergeableAssistantIndex == len(messages)-1 {
assistantMessage := gjson.ParseBytes(messages[mergeableAssistantIndex])
if assistantMessage.Get("role").String() == "assistant" && !assistantMessage.Get("tool_calls").Exists() {
updatedMessage, _ := sjson.SetBytes(messages[mergeableAssistantIndex], "tool_calls", pendingToolCalls)
combinedReasoning := combineOpenAIResponsesReasoning(assistantMessage.Get("reasoning_content").String(), reasoningContent)
if combinedReasoning != "" {
updatedMessage, _ = sjson.SetBytes(updatedMessage, "reasoning_content", combinedReasoning)
}
messages[mergeableAssistantIndex] = updatedMessage
mergedIntoAssistant = true
}
}
if !mergedIntoAssistant {
assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`)
assistantMessage, _ = sjson.SetBytes(assistantMessage, "tool_calls", pendingToolCalls)
if reasoningContent != "" {
assistantMessage, _ = sjson.SetBytes(assistantMessage, "reasoning_content", reasoningContent)
}
appendMessage(assistantMessage)
}
for _, id := range pendingToolCallIDs {
if strings.TrimSpace(id) == "" {
continue
}
awaitingToolOutputs[id] = struct{}{}
}
pendingToolCalls = pendingToolCalls[:0]
pendingToolCallIDs = pendingToolCallIDs[:0]
mergeableAssistantIndex = -1
}
flushDeferredMessages := func() {
for _, message := range deferredMessages {
appendMessage(message)
}
deferredMessages = deferredMessages[:0]
}
hasAwaitingToolOutput := func() bool {
for id := range awaitingToolOutputs {
if _, ok := outputCallIDs[id]; ok {
return true
}
}
return false
}
appendRegularMessage := func(message []byte) int {
// Keep tool-call adjacency strict for providers that require
// assistant(tool_calls) -> tool(tool_call_id) with no message in between.
if hasAwaitingToolOutput() {
deferredMessages = append(deferredMessages, message)
return -1
}
appendMessage(message)
return len(messages) - 1
}
appendPendingReasoningMessage := func() {
reasoningContent := takePendingReasoningContent()
if reasoningContent == "" {
return
}
message := []byte(`{"role":"assistant","content":"","reasoning_content":""}`)
message, _ = sjson.SetBytes(message, "reasoning_content", reasoningContent)
appendRegularMessage(message)
}
for _, item := range inputItems {
itemType := item.Get("type").String()
if itemType == "" && item.Get("role").String() != "" {
itemType = "message"
}
if itemType != "function_call" && itemType != "custom_tool_call" {
flushPendingToolCalls()
}
switch itemType {
case "message", "":
// Handle regular message conversion
role := item.Get("role").String()
if role == "developer" {
role = "user"
}
mergeableAssistantIndex = -1
if role != "assistant" {
appendPendingReasoningMessage()
}
message := []byte(`{"role":"","content":[]}`)
message, _ = sjson.SetBytes(message, "role", role)
if content := item.Get("content"); content.Exists() && content.IsArray() {
var contentItems [][]byte
content.ForEach(func(_, contentItem gjson.Result) bool {
contentType := contentItem.Get("type").String()
if contentType == "" {
contentType = "input_text"
}
switch contentType {
case "input_text", "output_text":
text := contentItem.Get("text").String()
contentPart := []byte(`{"type":"text","text":""}`)
contentPart, _ = sjson.SetBytes(contentPart, "text", text)
contentItems = append(contentItems, contentPart)
case "input_image":
imageURL := contentItem.Get("image_url").String()
contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`)
contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", imageURL)
if detail, ok := normalizeChatImageDetail(contentItem.Get("detail")); ok && detail != "" {
contentPart, _ = sjson.SetBytes(contentPart, "image_url.detail", detail)
}
contentItems = append(contentItems, contentPart)
}
return true
})
message = translatorcommon.SetRawArrayItems(message, "content", contentItems)
} else if content.Type == gjson.String {
message, _ = sjson.SetBytes(message, "content", content.String())
}
if role == "assistant" {
reasoningContent := combineOpenAIResponsesReasoning(takePendingReasoningContent(), item.Get("reasoning_content").String())
if reasoningContent != "" {
message, _ = sjson.SetBytes(message, "reasoning_content", reasoningContent)
}
}
messageIndex := appendRegularMessage(message)
if role == "assistant" {
mergeableAssistantIndex = messageIndex
}
case "reasoning":
reasoningContent := collectOpenAIResponsesReasoningContent(item)
pendingReasoningContent = combineOpenAIResponsesReasoning(pendingReasoningContent, reasoningContent)
case "function_call":
pendingReasoningContent = combineOpenAIResponsesReasoning(pendingReasoningContent, item.Get("reasoning_content").String())
// Buffer consecutive function calls and emit them as one assistant message.
toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
if callId := item.Get("call_id"); callId.Exists() {
toolCall, _ = sjson.SetBytes(toolCall, "id", callId.String())
}
if name := item.Get("name"); name.Exists() {
functionName := name.String()
if namespace := strings.TrimSpace(item.Get("namespace").String()); namespace != "" {
functionName = qualifyResponsesNamespaceToolName(namespace, functionName)
}
toolCall, _ = sjson.SetBytes(toolCall, "function.name", functionName)
}
if arguments := item.Get("arguments"); arguments.Exists() {
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments.String())
}
pendingToolCalls = append(pendingToolCalls, gjson.ParseBytes(toolCall).Value())
if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
pendingToolCallIDs = append(pendingToolCallIDs, callID)
}
case "function_call_output":
mergeableAssistantIndex = -1
// Handle function call output conversion to tool message
toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
callID := ""
if callId := item.Get("call_id"); callId.Exists() {
callID = strings.TrimSpace(callId.String())
toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callID)
}
if output := item.Get("output"); output.Exists() {
toolMessage = setFunctionCallOutputContent(toolMessage, output)
}
appendMessage(toolMessage)
if callID != "" {
delete(awaitingToolOutputs, callID)
}
if len(awaitingToolOutputs) == 0 && len(deferredMessages) > 0 {
flushDeferredMessages()
}
case "custom_tool_call":
pendingReasoningContent = combineOpenAIResponsesReasoning(pendingReasoningContent, item.Get("reasoning_content").String())
// Codex freeform tool call replay: wrap the raw input so it
// matches the {"input": string} function shape used when
// converting custom tool definitions.
toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
toolCall, _ = sjson.SetBytes(toolCall, "id", item.Get("call_id").String())
toolCall, _ = sjson.SetBytes(toolCall, "function.name", item.Get("name").String())
wrappedArgs, _ := sjson.SetBytes([]byte(`{"input":""}`), "input", item.Get("input").String())
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", string(wrappedArgs))
pendingToolCalls = append(pendingToolCalls, gjson.ParseBytes(toolCall).Value())
if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
pendingToolCallIDs = append(pendingToolCallIDs, callID)
}
case "custom_tool_call_output":
mergeableAssistantIndex = -1
toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
callID := strings.TrimSpace(item.Get("call_id").String())
toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callID)
if output := item.Get("output"); output.Exists() {
toolMessage = setCustomToolCallOutputContent(toolMessage, output)
}
appendMessage(toolMessage)
if callID != "" {
delete(awaitingToolOutputs, callID)
}
if len(awaitingToolOutputs) == 0 && len(deferredMessages) > 0 {
flushDeferredMessages()
}
default:
mergeableAssistantIndex = -1
}
}
flushPendingToolCalls()
appendPendingReasoningMessage()
flushDeferredMessages()
} else if input.Type == gjson.String {
msg := []byte(`{}`)
msg, _ = sjson.SetBytes(msg, "role", "user")
msg, _ = sjson.SetBytes(msg, "content", input.String())
appendMessage(msg)
}
if len(messages) > 0 {
out, _ = sjson.SetRawBytes(out, "messages", translatorcommon.JoinRawArray(messages))
}
// Convert tools from responses format to chat completions format.
// Codex Desktop (Responses Lite) delivers tool definitions through an
// "additional_tools" input item instead of the top-level "tools" field,
// so merge both sources.
var chatCompletionsTools []interface{}
for _, chatTool := range mergeResponsesRequestChatTools(root) {
chatCompletionsTools = append(chatCompletionsTools, gjson.ParseBytes(chatTool).Value())
}
if len(chatCompletionsTools) > 0 {
out, _ = sjson.SetBytes(out, "tools", chatCompletionsTools)
if parallelToolCalls := root.Get("parallel_tool_calls"); parallelToolCalls.Exists() {
out, _ = sjson.SetBytes(out, "parallel_tool_calls", parallelToolCalls.Bool())
}
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(toolChoice.Raw))
}
}
if reasoningEffort := root.Get("reasoning.effort"); reasoningEffort.Exists() {
effort := strings.ToLower(strings.TrimSpace(reasoningEffort.String()))
if effort != "" {
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
}
}
return out
}
func convertResponsesTextFormatToChatResponseFormat(textFormat gjson.Result) []byte {
formatType := textFormat.Get("type").String()
switch formatType {
case "text", "json_object":
responseFormat := []byte(`{"type":""}`)
responseFormat, _ = sjson.SetBytes(responseFormat, "type", formatType)
return responseFormat
case "json_schema":
responseFormat := []byte(`{"type":"json_schema","json_schema":{}}`)
for _, field := range []string{"name", "description", "strict"} {
if value := textFormat.Get(field); value.Exists() {
responseFormat, _ = sjson.SetBytes(responseFormat, "json_schema."+field, value.Value())
}
}
if schema := textFormat.Get("schema"); schema.Exists() {
responseFormat, _ = sjson.SetRawBytes(responseFormat, "json_schema.schema", []byte(schema.Raw))
}
return responseFormat
default:
return nil
}
}
func setFunctionCallOutputContent(toolMessage []byte, output gjson.Result) []byte {
structuredContent := output
if output.Type == gjson.String {
if !gjson.Valid(output.String()) {
toolMessage, _ = sjson.SetBytes(toolMessage, "content", output.String())
return toolMessage
}
structuredContent = gjson.Parse(output.String())
}
if hasChatToolOutputImagePart(structuredContent) {
contentItems := make([][]byte, 0, len(structuredContent.Array()))
for _, item := range structuredContent.Array() {
contentItems = append(contentItems, chatToolOutputContentPart(item))
}
return translatorcommon.SetRawArrayItems(toolMessage, "content", contentItems)
}
toolMessage, _ = sjson.SetBytes(toolMessage, "content", output.String())
return toolMessage
}
func setCustomToolCallOutputContent(toolMessage []byte, output gjson.Result) []byte {
structuredContent := output
if output.Type == gjson.String && gjson.Valid(output.String()) {
structuredContent = gjson.Parse(output.String())
}
if hasChatToolOutputImagePart(structuredContent) {
return setFunctionCallOutputContent(toolMessage, output)
}
toolMessage, _ = sjson.SetBytes(toolMessage, "content", responsesToolOutputText(output))
return toolMessage
}
func chatToolOutputContentPart(item gjson.Result) []byte {
itemType := item.Get("type").String()
switch itemType {
case "text", "input_text", "output_text":
part := []byte(`{"type":"text","text":""}`)
part, _ = sjson.SetBytes(part, "text", item.Get("text").String())
return part
case "image_url", "input_image":
imageURL, detail, ok := chatToolOutputImageFields(item)
if !ok {
return chatToolOutputFallbackPart(item)
}
part := []byte(`{"type":"image_url","image_url":{"url":""}}`)
part, _ = sjson.SetBytes(part, "image_url.url", imageURL)
if detail != "" {
part, _ = sjson.SetBytes(part, "image_url.detail", detail)
}
return part
default:
return chatToolOutputFallbackPart(item)
}
}
func hasChatToolOutputImagePart(content gjson.Result) bool {
if !content.IsArray() {
return false
}
hasImage := false
for _, item := range content.Array() {
itemType := item.Get("type")
if itemType.Type != gjson.String {
continue
}
switch itemType.String() {
case "text", "input_text", "output_text":
if item.Get("text").Type != gjson.String {
return false
}
case "image_url", "input_image":
if _, _, ok := chatToolOutputImageFields(item); !ok {
return false
}
hasImage = true
}
}
return hasImage
}
func chatToolOutputImageFields(item gjson.Result) (imageURL, detail string, ok bool) {
var imageURLValue gjson.Result
var detailValue gjson.Result
switch item.Get("type").String() {
case "image_url":
imageURLValue = item.Get("image_url.url")
detailValue = item.Get("image_url.detail")
case "input_image":
imageURLValue = item.Get("image_url")
detailValue = item.Get("detail")
default:
return "", "", false
}
if imageURLValue.Type != gjson.String {
return "", "", false
}
imageURL = strings.TrimSpace(imageURLValue.String())
if imageURL == "" {
return "", "", false
}
detail, ok = normalizeChatImageDetail(detailValue)
if !ok {
return "", "", false
}
return imageURL, detail, true
}
func normalizeChatImageDetail(detailValue gjson.Result) (string, bool) {
if !detailValue.Exists() {
return "", true
}
if detailValue.Type != gjson.String {
return "", false
}
normalizedDetail := strings.ToLower(strings.TrimSpace(detailValue.String()))
switch normalizedDetail {
case "auto", "low", "high":
return normalizedDetail, true
case "original":
// Chat Completions does not support Codex's original detail value.
return "high", true
default:
return "", true
}
}
func chatToolOutputFallbackPart(item gjson.Result) []byte {
text := item.Raw
if item.Type == gjson.String || text == "" {
text = item.String()
}
part := []byte(`{"type":"text","text":""}`)
part, _ = sjson.SetBytes(part, "text", text)
return part
}
func collectOpenAIResponsesReasoningContent(item gjson.Result) string {
var reasoningText strings.Builder
if summary := item.Get("summary"); summary.Exists() && summary.IsArray() {
summary.ForEach(func(_, summaryItem gjson.Result) bool {
if summaryItem.Get("type").String() != "summary_text" {
return true
}
reasoningText.WriteString(summaryItem.Get("text").String())
return true
})
}
if reasoningText.Len() == 0 {
return "[reasoning unavailable]"
}
return reasoningText.String()
}
func combineOpenAIResponsesReasoning(existing, incoming string) string {
existingTrimmed := strings.TrimSpace(existing)
incomingTrimmed := strings.TrimSpace(incoming)
switch {
case existingTrimmed == "":
return incoming
case incomingTrimmed == "":
return existing
case existingTrimmed == "[reasoning unavailable]":
return incoming
case incomingTrimmed == "[reasoning unavailable]", existingTrimmed == incomingTrimmed:
return existing
default:
return existing + "\n\n" + incoming
}
}

View file

@ -0,0 +1,996 @@
package responses
import (
"bytes"
"context"
"fmt"
"sort"
"strings"
"sync/atomic"
"time"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type oaiToResponsesStateReasoning struct {
ReasoningID string
ReasoningData string
OutputIndex int
}
type oaiToResponsesState struct {
Seq int
ResponseID string
Created int64
Started bool
CompletedEmitted bool
ReasoningID string
ReasoningIndex int
// aggregation buffers for response.output
// Per-output message text buffers by index
MsgTextBuf map[int]*strings.Builder
ReasoningBuf strings.Builder
Reasonings []oaiToResponsesStateReasoning
FuncArgsBuf map[string]*strings.Builder
FuncNames map[string]string
FuncCallIDs map[string]string
FuncOutputIx map[string]int
FuncArgsSent map[string]int
MsgOutputIx map[int]int
NextOutputIx int
// message item state per output index
MsgItemAdded map[int]bool // whether response.output_item.added emitted for message
MsgContentAdded map[int]bool // whether response.content_part.added emitted for message
MsgItemDone map[int]bool // whether message done events were emitted
// function item state
FuncItemAdded map[string]bool
FuncItemCustom map[string]bool
FuncArgsDone map[string]bool
FuncItemDone map[string]bool
// names of freeform ("custom") tools from the original request; calls to
// these are emitted as custom_tool_call items instead of function_call
CustomToolNames map[string]struct{}
FinishReason string
// usage aggregation
PromptTokens int64
CachedTokens int64
CompletionTokens int64
TotalTokens int64
ReasoningTokens int64
UsageSeen bool
}
// responseIDCounter provides a process-wide unique counter for synthesized response identifiers.
var responseIDCounter uint64
func emitRespEvent(event string, payload []byte) []byte {
return translatorcommon.SSEEventData(event, payload)
}
func incompleteByFinishReason(reason string) ([]byte, bool) {
switch reason {
case "length", "max_tokens":
return []byte(`{"reason":"max_output_tokens"}`), true
case "content_filter":
return []byte(`{"reason":"content_filter"}`), true
default:
return nil, false
}
}
func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte, nextSeq func() int) []byte {
eventType := "response.completed"
status := "completed"
incompleteDetails, isIncomplete := incompleteByFinishReason(st.FinishReason)
if isIncomplete {
eventType = "response.incomplete"
status = "incomplete"
}
completed := []byte(`{"type":"","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"","background":false,"error":null}}`)
completed, _ = sjson.SetBytes(completed, "type", eventType)
completed, _ = sjson.SetBytes(completed, "sequence_number", nextSeq())
completed, _ = sjson.SetBytes(completed, "response.id", st.ResponseID)
completed, _ = sjson.SetBytes(completed, "response.created_at", st.Created)
completed, _ = sjson.SetBytes(completed, "response.status", status)
if len(incompleteDetails) > 0 {
completed, _ = sjson.SetRawBytes(completed, "response.incomplete_details", incompleteDetails)
}
// Inject original request fields into response as per docs/response.completed.json
if requestRawJSON != nil {
req := gjson.ParseBytes(requestRawJSON)
if v := req.Get("instructions"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.instructions", v.String())
}
if v := req.Get("max_output_tokens"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.max_output_tokens", v.Int())
}
if v := req.Get("max_tool_calls"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.max_tool_calls", v.Int())
}
if v := req.Get("model"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.model", v.String())
}
if v := req.Get("parallel_tool_calls"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.parallel_tool_calls", v.Bool())
}
if v := req.Get("previous_response_id"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.previous_response_id", v.String())
}
if v := req.Get("prompt_cache_key"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.prompt_cache_key", v.String())
}
if v := req.Get("reasoning"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.reasoning", v.Value())
}
if v := req.Get("safety_identifier"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.safety_identifier", v.String())
}
if v := req.Get("service_tier"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.service_tier", v.String())
}
if v := req.Get("store"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.store", v.Bool())
}
if v := req.Get("temperature"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.temperature", v.Float())
}
if v := req.Get("text"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.text", v.Value())
}
if v := req.Get("tool_choice"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.tool_choice", v.Value())
}
if v := req.Get("tools"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.tools", v.Value())
}
if v := req.Get("top_logprobs"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.top_logprobs", v.Int())
}
if v := req.Get("top_p"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.top_p", v.Float())
}
if v := req.Get("truncation"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.truncation", v.String())
}
if v := req.Get("user"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.user", v.Value())
}
if v := req.Get("metadata"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.metadata", v.Value())
}
}
type completedOutputItem struct {
index int
raw []byte
}
outputItems := make([]completedOutputItem, 0, len(st.Reasonings)+len(st.MsgItemAdded)+len(st.FuncArgsBuf))
if len(st.Reasonings) > 0 {
for _, r := range st.Reasonings {
item := []byte(`{"id":"","type":"reasoning","summary":[{"type":"summary_text","text":""}]}`)
item, _ = sjson.SetBytes(item, "id", r.ReasoningID)
item, _ = sjson.SetBytes(item, "summary.0.text", r.ReasoningData)
outputItems = append(outputItems, completedOutputItem{index: r.OutputIndex, raw: item})
}
}
if len(st.MsgItemAdded) > 0 {
for i := range st.MsgItemAdded {
txt := ""
if b := st.MsgTextBuf[i]; b != nil {
txt = b.String()
}
msgStatus := "completed"
if _, isInc := incompleteByFinishReason(st.FinishReason); isInc {
msgStatus = "incomplete"
}
item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))
item, _ = sjson.SetBytes(item, "status", msgStatus)
item, _ = sjson.SetBytes(item, "content.0.text", txt)
outputItems = append(outputItems, completedOutputItem{index: st.MsgOutputIx[i], raw: item})
}
}
if len(st.FuncArgsBuf) > 0 {
for key := range st.FuncArgsBuf {
if !st.FuncItemDone[key] {
continue
}
args := ""
if b := st.FuncArgsBuf[key]; b != nil {
args = b.String()
}
callID := st.FuncCallIDs[key]
name := st.FuncNames[key]
toolStatus := "completed"
if _, isInc := incompleteByFinishReason(st.FinishReason); isInc {
toolStatus = "incomplete"
}
if st.FuncItemCustom[key] {
item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID))
item, _ = sjson.SetBytes(item, "status", toolStatus)
item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(args))
item, _ = sjson.SetBytes(item, "call_id", callID)
item = applyResponsesFunctionCallNamespaceFields(item, requestRawJSON, name, "")
outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item})
continue
}
item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
item, _ = sjson.SetBytes(item, "status", toolStatus)
item, _ = sjson.SetBytes(item, "arguments", args)
item, _ = sjson.SetBytes(item, "call_id", callID)
item = applyResponsesFunctionCallNamespaceFields(item, requestRawJSON, name, "")
outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item})
}
}
sort.Slice(outputItems, func(i, j int) bool { return outputItems[i].index < outputItems[j].index })
outputs := make([][]byte, 0, len(outputItems))
for _, item := range outputItems {
outputs = append(outputs, item.raw)
}
if len(outputs) > 0 {
completed, _ = sjson.SetRawBytes(completed, "response.output", translatorcommon.JoinRawArray(outputs))
}
if st.UsageSeen {
completed, _ = sjson.SetBytes(completed, "response.usage.input_tokens", st.PromptTokens)
completed, _ = sjson.SetBytes(completed, "response.usage.input_tokens_details.cached_tokens", st.CachedTokens)
completed, _ = sjson.SetBytes(completed, "response.usage.output_tokens", st.CompletionTokens)
if st.ReasoningTokens > 0 {
completed, _ = sjson.SetBytes(completed, "response.usage.output_tokens_details.reasoning_tokens", st.ReasoningTokens)
}
total := st.TotalTokens
if total == 0 {
total = st.PromptTokens + st.CompletionTokens
}
completed, _ = sjson.SetBytes(completed, "response.usage.total_tokens", total)
}
return emitRespEvent(eventType, completed)
}
// ConvertOpenAIChatCompletionsResponseToOpenAIResponses converts OpenAI Chat Completions streaming chunks
// to OpenAI Responses SSE events (response.*).
func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
if *param == nil {
*param = &oaiToResponsesState{
FuncArgsBuf: make(map[string]*strings.Builder),
FuncNames: make(map[string]string),
FuncCallIDs: make(map[string]string),
FuncOutputIx: make(map[string]int),
FuncArgsSent: make(map[string]int),
MsgOutputIx: make(map[int]int),
MsgTextBuf: make(map[int]*strings.Builder),
MsgItemAdded: make(map[int]bool),
MsgContentAdded: make(map[int]bool),
MsgItemDone: make(map[int]bool),
FuncItemAdded: make(map[string]bool),
FuncItemCustom: make(map[string]bool),
FuncArgsDone: make(map[string]bool),
FuncItemDone: make(map[string]bool),
Reasonings: make([]oaiToResponsesStateReasoning, 0),
}
}
st := (*param).(*oaiToResponsesState)
if bytes.HasPrefix(rawJSON, []byte("data:")) {
rawJSON = bytes.TrimSpace(rawJSON[5:])
}
rawJSON = bytes.TrimSpace(rawJSON)
if len(rawJSON) == 0 {
return [][]byte{}
}
requestForNamespace := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
isDone := bytes.Equal(rawJSON, []byte("[DONE]"))
if isDone && (!st.Started || st.CompletedEmitted) {
return [][]byte{}
}
root := gjson.ParseBytes(rawJSON)
if !isDone {
obj := root.Get("object")
if obj.Exists() && obj.String() != "" && obj.String() != "chat.completion.chunk" {
return [][]byte{}
}
if !root.Get("choices").Exists() || !root.Get("choices").IsArray() {
return [][]byte{}
}
}
if usage := root.Get("usage"); usage.Exists() {
if v := usage.Get("prompt_tokens"); v.Exists() {
st.PromptTokens = v.Int()
st.UsageSeen = true
}
if v := usage.Get("prompt_tokens_details.cached_tokens"); v.Exists() {
st.CachedTokens = v.Int()
st.UsageSeen = true
}
if v := usage.Get("completion_tokens"); v.Exists() {
st.CompletionTokens = v.Int()
st.UsageSeen = true
} else if v := usage.Get("output_tokens"); v.Exists() {
st.CompletionTokens = v.Int()
st.UsageSeen = true
}
if v := usage.Get("output_tokens_details.reasoning_tokens"); v.Exists() {
st.ReasoningTokens = v.Int()
st.UsageSeen = true
} else if v := usage.Get("completion_tokens_details.reasoning_tokens"); v.Exists() {
st.ReasoningTokens = v.Int()
st.UsageSeen = true
}
if v := usage.Get("total_tokens"); v.Exists() {
st.TotalTokens = v.Int()
st.UsageSeen = true
}
}
nextSeq := func() int { st.Seq++; return st.Seq }
allocOutputIndex := func() int {
ix := st.NextOutputIx
st.NextOutputIx++
return ix
}
toolStateKey := func(outputIndex, toolIndex int) string { return fmt.Sprintf("%d:%d", outputIndex, toolIndex) }
var out [][]byte
emitToolItem := func(key string, force bool) {
if st.FuncItemAdded[key] {
return
}
callID := st.FuncCallIDs[key]
name := st.FuncNames[key]
if !force && (callID == "" || name == "") {
return
}
if name == "" {
if customToolName, ok := responsesSingleCustomToolName(requestForNamespace); ok {
name = customToolName
st.FuncNames[key] = customToolName
}
}
if callID == "" {
callID = fmt.Sprintf("call_%s_%s", st.ResponseID, strings.ReplaceAll(key, ":", "_"))
st.FuncCallIDs[key] = callID
}
outputIndex := st.FuncOutputIx[key]
_, isCustomTool := st.CustomToolNames[name]
st.FuncItemCustom[key] = isCustomTool
if isCustomTool {
o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`)
o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
o, _ = sjson.SetBytes(o, "output_index", outputIndex)
o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("ctc_%s", callID))
o, _ = sjson.SetBytes(o, "item.call_id", callID)
o = applyResponsesFunctionCallNamespaceFields(o, requestForNamespace, name, "item")
out = append(out, emitRespEvent("response.output_item.added", o))
} else {
o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
o, _ = sjson.SetBytes(o, "output_index", outputIndex)
o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", callID))
o, _ = sjson.SetBytes(o, "item.call_id", callID)
o = applyResponsesFunctionCallNamespaceFields(o, requestForNamespace, name, "item")
out = append(out, emitRespEvent("response.output_item.added", o))
}
st.FuncItemAdded[key] = true
}
emitPendingFunctionArgs := func(key string) {
if !st.FuncItemAdded[key] || st.FuncItemCustom[key] {
return
}
argsBuf := st.FuncArgsBuf[key]
if argsBuf == nil || argsBuf.Len() <= st.FuncArgsSent[key] {
return
}
args := argsBuf.String()
delta := args[st.FuncArgsSent[key]:]
callID := st.FuncCallIDs[key]
ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq())
ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", callID))
ad, _ = sjson.SetBytes(ad, "output_index", st.FuncOutputIx[key])
ad, _ = sjson.SetBytes(ad, "delta", delta)
out = append(out, emitRespEvent("response.function_call_arguments.delta", ad))
st.FuncArgsSent[key] = len(args)
}
if !st.Started {
st.ResponseID = root.Get("id").String()
st.Created = root.Get("created").Int()
// reset aggregation state for a new streaming response
st.MsgTextBuf = make(map[int]*strings.Builder)
st.ReasoningBuf.Reset()
st.ReasoningID = ""
st.ReasoningIndex = 0
st.FuncArgsBuf = make(map[string]*strings.Builder)
st.FuncNames = make(map[string]string)
st.FuncCallIDs = make(map[string]string)
st.FuncOutputIx = make(map[string]int)
st.FuncArgsSent = make(map[string]int)
st.MsgOutputIx = make(map[int]int)
st.NextOutputIx = 0
st.MsgItemAdded = make(map[int]bool)
st.MsgContentAdded = make(map[int]bool)
st.MsgItemDone = make(map[int]bool)
st.FuncItemAdded = make(map[string]bool)
st.FuncItemCustom = make(map[string]bool)
st.FuncArgsDone = make(map[string]bool)
st.FuncItemDone = make(map[string]bool)
st.CustomToolNames = responsesCustomToolNames(requestForNamespace)
st.PromptTokens = 0
st.CachedTokens = 0
st.CompletionTokens = 0
st.TotalTokens = 0
st.ReasoningTokens = 0
st.FinishReason = ""
st.UsageSeen = false
st.CompletedEmitted = false
// response.created
created := []byte(`{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"output":[]}}`)
created, _ = sjson.SetBytes(created, "sequence_number", nextSeq())
created, _ = sjson.SetBytes(created, "response.id", st.ResponseID)
created, _ = sjson.SetBytes(created, "response.created_at", st.Created)
requestModelName := translatorcommon.RequestModelName(originalRequestRawJSON, requestRawJSON)
if requestModelName == "" {
requestModelName = modelName
}
if requestModelName != "" {
created, _ = sjson.SetBytes(created, "response.model", requestModelName)
}
out = append(out, emitRespEvent("response.created", created))
inprog := []byte(`{"type":"response.in_progress","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","output":[]}}`)
inprog, _ = sjson.SetBytes(inprog, "sequence_number", nextSeq())
inprog, _ = sjson.SetBytes(inprog, "response.id", st.ResponseID)
inprog, _ = sjson.SetBytes(inprog, "response.created_at", st.Created)
if requestModelName != "" {
inprog, _ = sjson.SetBytes(inprog, "response.model", requestModelName)
}
out = append(out, emitRespEvent("response.in_progress", inprog))
st.Started = true
}
stopReasoning := func(text string) {
// Emit reasoning done events
textDone := []byte(`{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`)
textDone, _ = sjson.SetBytes(textDone, "sequence_number", nextSeq())
textDone, _ = sjson.SetBytes(textDone, "item_id", st.ReasoningID)
textDone, _ = sjson.SetBytes(textDone, "output_index", st.ReasoningIndex)
textDone, _ = sjson.SetBytes(textDone, "text", text)
out = append(out, emitRespEvent("response.reasoning_summary_text.done", textDone))
partDone := []byte(`{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
partDone, _ = sjson.SetBytes(partDone, "item_id", st.ReasoningID)
partDone, _ = sjson.SetBytes(partDone, "output_index", st.ReasoningIndex)
partDone, _ = sjson.SetBytes(partDone, "part.text", text)
out = append(out, emitRespEvent("response.reasoning_summary_part.done", partDone))
outputItemDone := []byte(`{"type":"response.output_item.done","item":{"id":"","type":"reasoning","encrypted_content":"","summary":[{"type":"summary_text","text":""}]},"output_index":0,"sequence_number":0}`)
outputItemDone, _ = sjson.SetBytes(outputItemDone, "sequence_number", nextSeq())
outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.id", st.ReasoningID)
outputItemDone, _ = sjson.SetBytes(outputItemDone, "output_index", st.ReasoningIndex)
outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.summary.0.text", text)
out = append(out, emitRespEvent("response.output_item.done", outputItemDone))
st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text, OutputIndex: st.ReasoningIndex})
st.ReasoningID = ""
}
emitMessageItemDone := func(idx int) {
if !st.MsgItemAdded[idx] || st.MsgItemDone[idx] {
return
}
msgOutputIndex := st.MsgOutputIx[idx]
fullText := ""
if b := st.MsgTextBuf[idx]; b != nil {
fullText = b.String()
}
done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())
done, _ = sjson.SetBytes(done, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
done, _ = sjson.SetBytes(done, "output_index", msgOutputIndex)
done, _ = sjson.SetBytes(done, "content_index", 0)
done, _ = sjson.SetBytes(done, "text", fullText)
out = append(out, emitRespEvent("response.output_text.done", done))
partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
partDone, _ = sjson.SetBytes(partDone, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
partDone, _ = sjson.SetBytes(partDone, "output_index", msgOutputIndex)
partDone, _ = sjson.SetBytes(partDone, "content_index", 0)
partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)
out = append(out, emitRespEvent("response.content_part.done", partDone))
msgStatus := "completed"
if _, isInc := incompleteByFinishReason(st.FinishReason); isInc {
msgStatus = "incomplete"
}
itemDone := []byte(`{"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.SetBytes(itemDone, "sequence_number", nextSeq())
itemDone, _ = sjson.SetBytes(itemDone, "output_index", msgOutputIndex)
itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
itemDone, _ = sjson.SetBytes(itemDone, "item.status", msgStatus)
itemDone, _ = sjson.SetBytes(itemDone, "item.content.0.text", fullText)
out = append(out, emitRespEvent("response.output_item.done", itemDone))
st.MsgItemDone[idx] = true
}
finalizeOpenItems := func() {
if len(st.MsgItemAdded) > 0 {
idxs := make([]int, 0, len(st.MsgItemAdded))
for idx := range st.MsgItemAdded {
idxs = append(idxs, idx)
}
sort.Slice(idxs, func(i, j int) bool { return st.MsgOutputIx[idxs[i]] < st.MsgOutputIx[idxs[j]] })
for _, idx := range idxs {
emitMessageItemDone(idx)
}
}
if st.ReasoningID != "" {
stopReasoning(st.ReasoningBuf.String())
st.ReasoningBuf.Reset()
}
if len(st.FuncArgsBuf) == 0 {
return
}
keys := make([]string, 0, len(st.FuncArgsBuf))
for key := range st.FuncArgsBuf {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
left := st.FuncOutputIx[keys[i]]
right := st.FuncOutputIx[keys[j]]
return left < right || (left == right && keys[i] < keys[j])
})
for _, key := range keys {
if st.FuncItemDone[key] {
continue
}
b := st.FuncArgsBuf[key]
hasArgs := b != nil && b.Len() > 0
_, isIncomplete := incompleteByFinishReason(st.FinishReason)
isExplicitToolFinish := st.FinishReason == "tool_calls" || st.FinishReason == "stop"
// If stream ended without finish_reason:
// If no arguments or partial/invalid JSON arguments were received, do not synthesize empty arguments
// or complete the in-flight tool call item as successfully completed.
if st.FinishReason == "" && (!hasArgs || !gjson.Valid(b.String())) {
continue
}
emitToolItem(key, true)
emitPendingFunctionArgs(key)
callID := st.FuncCallIDs[key]
if callID == "" || st.FuncItemDone[key] {
continue
}
outputIndex := st.FuncOutputIx[key]
toolStatus := "completed"
args := "{}"
if hasArgs {
args = b.String()
} else if isIncomplete || !isExplicitToolFinish {
args = ""
}
if isIncomplete {
toolStatus = "incomplete"
}
if st.FuncItemCustom[key] {
input := unwrapCustomToolInput(args)
inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq())
inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", callID))
inputDone, _ = sjson.SetBytes(inputDone, "output_index", outputIndex)
inputDone, _ = sjson.SetBytes(inputDone, "input", input)
out = append(out, emitRespEvent("response.custom_tool_call_input.done", inputDone))
itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`)
itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)
itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("ctc_%s", callID))
itemDone, _ = sjson.SetBytes(itemDone, "item.status", toolStatus)
itemDone, _ = sjson.SetBytes(itemDone, "item.input", input)
itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID)
itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForNamespace, st.FuncNames[key], "item")
out = append(out, emitRespEvent("response.output_item.done", itemDone))
st.FuncItemDone[key] = true
st.FuncArgsDone[key] = true
continue
}
fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq())
fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", callID))
fcDone, _ = sjson.SetBytes(fcDone, "output_index", outputIndex)
fcDone, _ = sjson.SetBytes(fcDone, "arguments", args)
out = append(out, emitRespEvent("response.function_call_arguments.done", fcDone))
itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)
itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", callID))
itemDone, _ = sjson.SetBytes(itemDone, "item.status", toolStatus)
itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args)
itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID)
itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForNamespace, st.FuncNames[key], "item")
out = append(out, emitRespEvent("response.output_item.done", itemDone))
st.FuncItemDone[key] = true
st.FuncArgsDone[key] = true
}
}
if isDone {
finalizeOpenItems()
hasActiveUnfinishedTool := false
for key := range st.FuncItemAdded {
if !st.FuncItemDone[key] {
hasActiveUnfinishedTool = true
break
}
}
if hasActiveUnfinishedTool {
return out
}
if len(st.MsgItemAdded) == 0 && len(st.FuncItemAdded) == 0 {
return out
}
st.CompletedEmitted = true
out = append(out, buildResponsesCompletedEvent(st, requestForNamespace, nextSeq))
return out
}
// choices[].delta content / tool_calls / reasoning_content
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
choices.ForEach(func(_, choice gjson.Result) bool {
idx := int(choice.Get("index").Int())
delta := choice.Get("delta")
if delta.Exists() {
if c := delta.Get("content"); c.Exists() && c.String() != "" {
// Ensure the message item and its first content part are announced before any text deltas
if st.ReasoningID != "" {
stopReasoning(st.ReasoningBuf.String())
st.ReasoningBuf.Reset()
}
if _, exists := st.MsgOutputIx[idx]; !exists {
st.MsgOutputIx[idx] = allocOutputIndex()
}
msgOutputIndex := st.MsgOutputIx[idx]
if !st.MsgItemAdded[idx] {
item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
item, _ = sjson.SetBytes(item, "output_index", msgOutputIndex)
item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
out = append(out, emitRespEvent("response.output_item.added", item))
st.MsgItemAdded[idx] = true
}
if !st.MsgContentAdded[idx] {
part := []byte(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
part, _ = sjson.SetBytes(part, "sequence_number", nextSeq())
part, _ = sjson.SetBytes(part, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
part, _ = sjson.SetBytes(part, "output_index", msgOutputIndex)
part, _ = sjson.SetBytes(part, "content_index", 0)
out = append(out, emitRespEvent("response.content_part.added", part))
st.MsgContentAdded[idx] = true
}
msg := []byte(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`)
msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
msg, _ = sjson.SetBytes(msg, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
msg, _ = sjson.SetBytes(msg, "output_index", msgOutputIndex)
msg, _ = sjson.SetBytes(msg, "content_index", 0)
msg, _ = sjson.SetBytes(msg, "delta", c.String())
out = append(out, emitRespEvent("response.output_text.delta", msg))
// aggregate for response.output
if st.MsgTextBuf[idx] == nil {
st.MsgTextBuf[idx] = &strings.Builder{}
}
st.MsgTextBuf[idx].WriteString(c.String())
}
// reasoning_content (OpenAI reasoning incremental text)
rc := delta.Get("reasoning_content")
if !rc.Exists() || rc.String() == "" {
rc = delta.Get("reasoning")
}
if rc.Exists() && rc.String() != "" {
// On first appearance, add reasoning item and part
if st.ReasoningID == "" {
st.ReasoningID = fmt.Sprintf("rs_%s_%d", st.ResponseID, idx)
st.ReasoningIndex = allocOutputIndex()
item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[]}}`)
item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
item, _ = sjson.SetBytes(item, "output_index", st.ReasoningIndex)
item, _ = sjson.SetBytes(item, "item.id", st.ReasoningID)
out = append(out, emitRespEvent("response.output_item.added", item))
part := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
part, _ = sjson.SetBytes(part, "sequence_number", nextSeq())
part, _ = sjson.SetBytes(part, "item_id", st.ReasoningID)
part, _ = sjson.SetBytes(part, "output_index", st.ReasoningIndex)
out = append(out, emitRespEvent("response.reasoning_summary_part.added", part))
}
// Append incremental text to reasoning buffer
st.ReasoningBuf.WriteString(rc.String())
msg := []byte(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`)
msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
msg, _ = sjson.SetBytes(msg, "item_id", st.ReasoningID)
msg, _ = sjson.SetBytes(msg, "output_index", st.ReasoningIndex)
msg, _ = sjson.SetBytes(msg, "delta", rc.String())
out = append(out, emitRespEvent("response.reasoning_summary_text.delta", msg))
}
// tool calls
if tcs := delta.Get("tool_calls"); tcs.Exists() && tcs.IsArray() {
if st.ReasoningID != "" {
stopReasoning(st.ReasoningBuf.String())
st.ReasoningBuf.Reset()
}
// Before emitting any function events, if a message is open for this index,
// close its text/content to match Codex expected ordering.
emitMessageItemDone(idx)
tcs.ForEach(func(_, tc gjson.Result) bool {
toolIndex := int(tc.Get("index").Int())
key := toolStateKey(idx, toolIndex)
if st.FuncArgsBuf[key] == nil {
st.FuncArgsBuf[key] = &strings.Builder{}
st.FuncOutputIx[key] = allocOutputIndex()
}
if newCallID := tc.Get("id").String(); newCallID != "" && st.FuncCallIDs[key] == "" {
st.FuncCallIDs[key] = newCallID
}
nameChunk := tc.Get("function.name").String()
if nameChunk != "" && !st.FuncItemAdded[key] {
st.FuncNames[key] = nameChunk
}
if args := tc.Get("function.arguments"); args.Exists() && args.String() != "" {
st.FuncArgsBuf[key].WriteString(args.String())
}
emitToolItem(key, false)
emitPendingFunctionArgs(key)
return true
})
}
}
// finish_reason triggers item-level finalization. response.completed is
// deferred until the terminal [DONE] marker so late usage-only chunks can
// still populate response.usage.
if fr := choice.Get("finish_reason"); fr.Exists() && fr.String() != "" {
st.FinishReason = fr.String()
finalizeOpenItems()
}
return true
})
}
return out
}
// ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream builds a single Responses JSON
// from a non-streaming OpenAI Chat Completions response.
func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
root := gjson.ParseBytes(rawJSON)
requestForNamespace := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
finishReason := root.Get("choices.0.finish_reason").String()
incompleteDetails, isIncomplete := incompleteByFinishReason(finishReason)
respStatus := "completed"
if isIncomplete {
respStatus = "incomplete"
}
// Basic response scaffold
resp := []byte(`{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null}`)
resp, _ = sjson.SetBytes(resp, "status", respStatus)
if isIncomplete {
resp, _ = sjson.SetRawBytes(resp, "incomplete_details", incompleteDetails)
}
// id: use provider id if present, otherwise synthesize
id := root.Get("id").String()
if id == "" {
id = fmt.Sprintf("resp_%x_%d", time.Now().UnixNano(), atomic.AddUint64(&responseIDCounter, 1))
}
resp, _ = sjson.SetBytes(resp, "id", id)
// created_at: map from chat.completion created
created := root.Get("created").Int()
if created == 0 {
created = time.Now().Unix()
}
resp, _ = sjson.SetBytes(resp, "created_at", created)
// Echo request fields when available (aligns with streaming path behavior)
if len(requestRawJSON) > 0 {
req := gjson.ParseBytes(requestRawJSON)
if v := req.Get("instructions"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "instructions", v.String())
}
if v := req.Get("max_output_tokens"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "max_output_tokens", v.Int())
} else {
// Also support max_tokens from chat completion style
if v = req.Get("max_tokens"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "max_output_tokens", v.Int())
}
}
if v := req.Get("max_tool_calls"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "max_tool_calls", v.Int())
}
if v := req.Get("model"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "model", v.String())
} else if v = root.Get("model"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "model", v.String())
}
if v := req.Get("parallel_tool_calls"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "parallel_tool_calls", v.Bool())
}
if v := req.Get("previous_response_id"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "previous_response_id", v.String())
}
if v := req.Get("prompt_cache_key"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "prompt_cache_key", v.String())
}
if v := req.Get("reasoning"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "reasoning", v.Value())
}
if v := req.Get("safety_identifier"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "safety_identifier", v.String())
}
if v := req.Get("service_tier"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "service_tier", v.String())
}
if v := req.Get("store"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "store", v.Bool())
}
if v := req.Get("temperature"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "temperature", v.Float())
}
if v := req.Get("text"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "text", v.Value())
}
if v := req.Get("tool_choice"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "tool_choice", v.Value())
}
if v := req.Get("tools"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "tools", v.Value())
}
if v := req.Get("top_logprobs"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "top_logprobs", v.Int())
}
if v := req.Get("top_p"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "top_p", v.Float())
}
if v := req.Get("truncation"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "truncation", v.String())
}
if v := req.Get("user"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "user", v.Value())
}
if v := req.Get("metadata"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "metadata", v.Value())
}
} else if v := root.Get("model"); v.Exists() {
// Fallback model from response
resp, _ = sjson.SetBytes(resp, "model", v.String())
}
// Build output list from choices[...]
var outputItems [][]byte
// Detect and capture reasoning content if present (with fallback to reasoning)
rc := gjson.GetBytes(rawJSON, "choices.0.message.reasoning_content")
if !rc.Exists() || rc.String() == "" {
rc = gjson.GetBytes(rawJSON, "choices.0.message.reasoning")
}
rcText := rc.String()
includeReasoning := rcText != ""
if !includeReasoning && len(requestRawJSON) > 0 {
includeReasoning = gjson.GetBytes(requestRawJSON, "reasoning").Exists()
}
if includeReasoning {
rid := id
if strings.HasPrefix(rid, "resp_") {
rid = strings.TrimPrefix(rid, "resp_")
}
// Prefer summary_text from reasoning_content; encrypted_content is optional
reasoningItem := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`)
reasoningItem, _ = sjson.SetBytes(reasoningItem, "id", fmt.Sprintf("rs_%s", rid))
if rcText != "" {
reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.type", "summary_text")
reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.text", rcText)
}
outputItems = append(outputItems, reasoningItem)
}
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
choices.ForEach(func(_, choice gjson.Result) bool {
msg := choice.Get("message")
if msg.Exists() {
// Text message part
if c := msg.Get("content"); c.Exists() && c.String() != "" {
itemStatus := "completed"
if isIncomplete {
itemStatus = "incomplete"
}
item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("msg_%s_%d", id, int(choice.Get("index").Int())))
item, _ = sjson.SetBytes(item, "status", itemStatus)
item, _ = sjson.SetBytes(item, "content.0.text", c.String())
outputItems = append(outputItems, item)
}
// Function/tool calls
if tcs := msg.Get("tool_calls"); tcs.Exists() && tcs.IsArray() {
customToolNames := responsesCustomToolNames(requestForNamespace)
tcs.ForEach(func(tcIndex, tc gjson.Result) bool {
callID := tc.Get("id").String()
if callID == "" {
// Providers may omit tool_call ids; synthesize one so the
// function_call item stays usable for Codex round-trips.
callID = fmt.Sprintf("call_%s_%d_%d", id, choice.Get("index").Int(), tcIndex.Int())
}
name := tc.Get("function.name").String()
args := tc.Get("function.arguments").String()
toolStatus := "completed"
if isIncomplete {
toolStatus = "incomplete"
}
if _, isCustomTool := customToolNames[name]; isCustomTool {
item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID))
item, _ = sjson.SetBytes(item, "status", toolStatus)
item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(args))
item, _ = sjson.SetBytes(item, "call_id", callID)
item = applyResponsesFunctionCallNamespaceFields(item, requestForNamespace, name, "")
outputItems = append(outputItems, item)
return true
}
item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
item, _ = sjson.SetBytes(item, "status", toolStatus)
item, _ = sjson.SetBytes(item, "arguments", args)
item, _ = sjson.SetBytes(item, "call_id", callID)
item = applyResponsesFunctionCallNamespaceFields(item, requestForNamespace, name, "")
outputItems = append(outputItems, item)
return true
})
}
}
return true
})
}
if len(outputItems) > 0 {
resp, _ = sjson.SetRawBytes(resp, "output", translatorcommon.JoinRawArray(outputItems))
}
// usage mapping
if usage := root.Get("usage"); usage.Exists() {
// Map common tokens
if usage.Get("prompt_tokens").Exists() || usage.Get("completion_tokens").Exists() || usage.Get("total_tokens").Exists() {
resp, _ = sjson.SetBytes(resp, "usage.input_tokens", usage.Get("prompt_tokens").Int())
if d := usage.Get("prompt_tokens_details.cached_tokens"); d.Exists() {
resp, _ = sjson.SetBytes(resp, "usage.input_tokens_details.cached_tokens", d.Int())
}
resp, _ = sjson.SetBytes(resp, "usage.output_tokens", usage.Get("completion_tokens").Int())
// Reasoning tokens not available in Chat Completions; set only if present under output_tokens_details
if d := usage.Get("output_tokens_details.reasoning_tokens"); d.Exists() {
resp, _ = sjson.SetBytes(resp, "usage.output_tokens_details.reasoning_tokens", d.Int())
}
resp, _ = sjson.SetBytes(resp, "usage.total_tokens", usage.Get("total_tokens").Int())
} else {
// Fallback to raw usage object if structure differs
resp, _ = sjson.SetBytes(resp, "usage", usage.Value())
}
}
return resp
}

View file

@ -0,0 +1,326 @@
package responses
import (
"strings"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// responsesToolDeclaration is one Responses tool declaration paired with the
// Chat Completions function name it produces. Namespace children carry both
// their declared name and the owning namespace, so reverse translation can
// restore the split identity.
type responsesToolDeclaration struct {
tool gjson.Result
chatName string
localName string
namespace string
custom bool
}
// walkResponsesToolDeclarations visits the tool declarations of a Responses
// request in one canonical order: the top-level "tools" field first, then
// Codex Desktop (Responses Lite) "additional_tools" input items, namespace
// children in declaration order. Declarations that produce no Chat Completions
// tool are skipped. Visiting stops early once visit returns false.
//
// Request conversion, reverse name resolution and freeform tool classification
// all traverse through here, so they cannot disagree about which declaration
// backs a given Chat Completions tool name.
func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDeclaration) bool) {
proceed := true
emit := func(tool gjson.Result, namespaceName string) {
if !proceed {
return
}
var custom bool
switch strings.TrimSpace(tool.Get("type").String()) {
case "", "function":
case "custom":
custom = true
default:
return
}
localName := responsesToolName(tool)
if localName == "" {
return
}
proceed = visit(responsesToolDeclaration{
tool: tool,
chatName: qualifyResponsesNamespaceToolName(namespaceName, localName),
localName: localName,
namespace: namespaceName,
custom: custom,
})
}
scan := func(tools gjson.Result) {
if !proceed || !tools.Exists() || !tools.IsArray() {
return
}
tools.ForEach(func(_, tool gjson.Result) bool {
if strings.TrimSpace(tool.Get("type").String()) == "namespace" {
if children := tool.Get("tools"); children.Exists() && children.IsArray() {
namespaceName := strings.TrimSpace(tool.Get("name").String())
children.ForEach(func(_, child gjson.Result) bool {
emit(child, namespaceName)
return proceed
})
}
return proceed
}
emit(tool, "")
return proceed
})
}
scan(root.Get("tools"))
if input := root.Get("input"); input.Exists() && input.IsArray() {
input.ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() == "additional_tools" {
scan(item.Get("tools"))
}
return proceed
})
}
}
// mergeResponsesRequestChatTools converts every tool declaration in a Responses
// request into Chat Completions form, merging the top-level "tools" field with
// Codex Desktop (Responses Lite) "additional_tools" input items.
//
// Codex clients may deliver the same tool through both channels, and namespace
// qualification can collapse distinct declarations onto one Chat Completions
// name, so entries are deduplicated by function name. The first occurrence
// wins, which keeps the top-level "tools" definition authoritative over the
// "additional_tools" copy. Chat Completions requires tool names to be unique;
// strict upstreams reject the whole request otherwise.
func mergeResponsesRequestChatTools(root gjson.Result) [][]byte {
var merged [][]byte
seenToolNames := make(map[string]struct{})
walkResponsesToolDeclarations(root, func(declaration responsesToolDeclaration) bool {
if _, duplicate := seenToolNames[declaration.chatName]; duplicate {
return true
}
convert := convertResponsesFunctionToolToOpenAIChat
if declaration.custom {
convert = convertResponsesCustomToolToOpenAIChat
}
if chatTool, ok := convert(declaration.tool, declaration.chatName); ok {
seenToolNames[declaration.chatName] = struct{}{}
merged = append(merged, chatTool)
}
return true
})
return merged
}
// convertResponsesCustomToolToOpenAIChat maps a Responses freeform ("custom")
// tool onto a Chat Completions function tool with a single freeform "input"
// string, mirroring the function-based shape Codex uses for apply_patch.
func convertResponsesCustomToolToOpenAIChat(tool gjson.Result, overrideName string) ([]byte, bool) {
name := strings.TrimSpace(overrideName)
if name == "" {
name = responsesToolName(tool)
}
if name == "" {
return nil, false
}
chatTool := []byte(`{"type":"function","function":{"name":"","description":"","parameters":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}}}`)
chatTool, _ = sjson.SetBytes(chatTool, "function.name", name)
if description := responsesToolDescription(tool); description != "" {
chatTool, _ = sjson.SetBytes(chatTool, "function.description", description)
}
return chatTool, true
}
func convertResponsesFunctionToolToOpenAIChat(tool gjson.Result, overrideName string) ([]byte, bool) {
name := strings.TrimSpace(overrideName)
if name == "" {
name = responsesToolName(tool)
}
if name == "" {
return nil, false
}
chatTool := []byte(`{"type":"function","function":{"name":"","description":"","parameters":{}}}`)
chatTool, _ = sjson.SetBytes(chatTool, "function.name", name)
if description := responsesToolDescription(tool); description != "" {
chatTool, _ = sjson.SetBytes(chatTool, "function.description", description)
}
if parameters := responsesToolParameters(tool); parameters.Exists() {
chatTool, _ = sjson.SetRawBytes(chatTool, "function.parameters", []byte(parameters.Raw))
}
return chatTool, true
}
func responsesToolName(tool gjson.Result) string {
if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
return name
}
return strings.TrimSpace(tool.Get("function.name").String())
}
func responsesToolDescription(tool gjson.Result) string {
if description := tool.Get("description").String(); description != "" {
return description
}
return tool.Get("function.description").String()
}
func responsesToolParameters(tool gjson.Result) gjson.Result {
for _, path := range []string{
"parameters",
"parametersJsonSchema",
"input_schema",
"function.parameters",
"function.parametersJsonSchema",
} {
if parameters := tool.Get(path); parameters.Exists() {
return parameters
}
}
return gjson.Result{}
}
// responsesToolOutputText flattens a tool output value that may be a plain
// string or an array of content parts ({"type":"input_text","text":...}) into
// a single text payload for a Chat Completions tool message.
func responsesToolOutputText(output gjson.Result) string {
if output.Type == gjson.String {
return output.String()
}
if output.IsArray() {
var b strings.Builder
output.ForEach(func(_, part gjson.Result) bool {
if part.Type == gjson.String {
b.WriteString(part.String())
return true
}
if text := part.Get("text"); text.Exists() {
b.WriteString(text.String())
}
return true
})
return b.String()
}
if output.Exists() {
return output.Raw
}
return ""
}
// responsesCustomToolNames collects the Chat Completions names of the freeform
// ("custom") tools that survive the merge, so response translation only unwraps
// freeform arguments for calls whose winning declaration really was freeform.
//
// Declaration types may differ across the two delivery channels: a top-level
// function and an "additional_tools" custom tool can flatten to the same name.
// Classification therefore follows the same first-wins rule as the merge —
// a discarded custom declaration must not turn a surviving ordinary function
// into a custom_tool_call.
func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} {
names := make(map[string]struct{})
seenToolNames := make(map[string]struct{})
walkResponsesToolDeclarations(gjson.ParseBytes(requestRawJSON), func(declaration responsesToolDeclaration) bool {
if _, duplicate := seenToolNames[declaration.chatName]; duplicate {
return true
}
seenToolNames[declaration.chatName] = struct{}{}
if declaration.custom {
names[declaration.chatName] = struct{}{}
}
return true
})
return names
}
func responsesSingleCustomToolName(requestRawJSON []byte) (string, bool) {
customToolNames := responsesCustomToolNames(requestRawJSON)
if len(customToolNames) != 1 {
return "", false
}
// Count the tools actually emitted, which are deduplicated by name, so a
// tool delivered through both "tools" and "additional_tools" still counts
// once and freeform unwrapping stays enabled.
toolCount := len(mergeResponsesRequestChatTools(gjson.ParseBytes(requestRawJSON)))
for name := range customToolNames {
return name, toolCount == 1
}
return "", false
}
// unwrapCustomToolInput extracts the freeform input from the {"input": "..."}
// function-call arguments produced for a converted custom tool; it falls back
// to the raw arguments when the wrapper is absent.
func unwrapCustomToolInput(arguments string) string {
if v := gjson.Get(arguments, "input"); v.Exists() {
if v.Type == gjson.String {
return v.String()
}
return v.Raw
}
return arguments
}
func qualifyResponsesNamespaceToolName(namespaceName, childName string) string {
childName = strings.TrimSpace(childName)
if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") {
return childName
}
if strings.HasPrefix(childName, namespaceName) {
return childName
}
if strings.HasSuffix(namespaceName, "__") {
return namespaceName + childName
}
return namespaceName + "__" + childName
}
// resolveResponsesQualifiedToolIdentity maps an emitted Chat Completions
// function name back to the Responses declaration that produced it.
//
// Declarations are walked in the same order mergeResponsesRequestChatTools
// uses, and the first one producing the name wins, so reverse translation
// reports the identity of the declaration that actually survived the merge. A
// flat top-level tool named "editor__apply_patch" therefore stays flat even
// when a later namespace declares a child qualifying to the same name.
func resolveResponsesQualifiedToolIdentity(root gjson.Result, qualifiedName string) (name, namespace string, found bool) {
walkResponsesToolDeclarations(root, func(declaration responsesToolDeclaration) bool {
if declaration.chatName != qualifiedName {
return true
}
name, namespace, found = declaration.localName, declaration.namespace, true
return false
})
return name, namespace, found
}
func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, qualifiedName string) (name, namespace string) {
qualifiedName = strings.TrimSpace(qualifiedName)
if qualifiedName == "" {
return "", ""
}
if resolvedName, resolvedNamespace, ok := resolveResponsesQualifiedToolIdentity(gjson.ParseBytes(requestRawJSON), qualifiedName); ok {
return resolvedName, resolvedNamespace
}
return qualifiedName, ""
}
func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte {
if len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) {
return originalRequestRawJSON
}
if len(requestRawJSON) > 0 && gjson.ValidBytes(requestRawJSON) {
return requestRawJSON
}
return nil
}
func applyResponsesFunctionCallNamespaceFields(item []byte, requestRawJSON []byte, qualifiedName string, itemPath string) []byte {
name, namespace := splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON, qualifiedName)
return translatorcommon.SetResponsesToolCallIdentity(item, name, namespace, itemPath)
}