Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
20
backend/internal/translator/openai/claude/init.go
Normal file
20
backend/internal/translator/openai/claude/init.go
Normal 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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),
|
||||
¶mAny,
|
||||
)...)
|
||||
}
|
||||
emitted = append(emitted, ConvertOpenAIResponseToClaude(
|
||||
context.Background(),
|
||||
"",
|
||||
[]byte(originalReq),
|
||||
nil,
|
||||
[]byte("data: [DONE]"),
|
||||
¶mAny,
|
||||
)...)
|
||||
|
||||
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}]}`),
|
||||
¶m,
|
||||
)
|
||||
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}]}`),
|
||||
¶m,
|
||||
)
|
||||
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")
|
||||
}
|
||||
}
|
||||
20
backend/internal/translator/openai/gemini/init.go
Normal file
20
backend/internal/translator/openai/gemini/init.go
Normal 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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\"}"}}]}}]}`), ¶m)
|
||||
out := ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, []byte(`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`), ¶m)
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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, ¶m)
|
||||
usageOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, usageRaw, ¶m)
|
||||
doneOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, ¶m)
|
||||
|
||||
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, ¶m)
|
||||
doneOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, ¶m)
|
||||
|
||||
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, ¶m)
|
||||
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, ¶m)...)
|
||||
}
|
||||
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}}}`), ¶m)
|
||||
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, ¶m)
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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, ¶m)...)
|
||||
}
|
||||
|
||||
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, ¶m)...)
|
||||
}
|
||||
|
||||
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, ¶m)...)
|
||||
}
|
||||
|
||||
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, ¶m)...)
|
||||
}
|
||||
|
||||
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, ¶m)...)
|
||||
}
|
||||
|
||||
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, ¶m)...)
|
||||
}
|
||||
|
||||
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, ¶m)...)
|
||||
}
|
||||
|
||||
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, ¶m)
|
||||
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, ¶m)
|
||||
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, ¶m)
|
||||
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, ¶m)
|
||||
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, ¶m)
|
||||
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, ¶m)
|
||||
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, ¶m)
|
||||
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, ¶m)
|
||||
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, ¶m)
|
||||
doneOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, []byte(`data: [DONE]`), ¶m)
|
||||
|
||||
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}}}`), ¶m)
|
||||
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, ¶m)...)
|
||||
}
|
||||
|
||||
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, ¶m)...)
|
||||
}
|
||||
|
||||
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, ¶m)
|
||||
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, ¶m)
|
||||
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, ¶m)...)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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":[]}`), ¶m)
|
||||
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]"), ¶m)
|
||||
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"}`), ¶m)
|
||||
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"}`), ¶m)
|
||||
if len(out) != 1 || !bytes.Equal(out[0], []byte(`{"id":"y"}`)) {
|
||||
t.Fatalf("out = %v", out)
|
||||
}
|
||||
}
|
||||
19
backend/internal/translator/openai/openai/responses/init.go
Normal file
19
backend/internal/translator/openai/openai/responses/init.go
Normal 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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in a new issue