Merge branch 'router-for-me:main' into my-fix

This commit is contained in:
AhDEV
2026-05-06 16:41:14 +08:00
committed by GitHub
81 changed files with 4470 additions and 1903 deletions
@@ -25,10 +25,19 @@ type ConvertAnthropicResponseToOpenAIParams struct {
CreatedAt int64
ResponseID string
FinishReason string
Usage claudeUsageTokens
// Tool calls accumulator for streaming
ToolCallsAccumulator map[int]*ToolCallAccumulator
}
type claudeUsageTokens struct {
InputTokens int64
OutputTokens int64
CacheCreationInputTokens int64
CacheReadInputTokens int64
HasUsage bool
}
// ToolCallAccumulator holds the state for accumulating tool call data
type ToolCallAccumulator struct {
ID string
@@ -36,15 +45,30 @@ type ToolCallAccumulator struct {
Arguments strings.Builder
}
func calculateClaudeUsageTokens(usage gjson.Result) (promptTokens, completionTokens, totalTokens, cachedTokens int64) {
inputTokens := usage.Get("input_tokens").Int()
completionTokens = usage.Get("output_tokens").Int()
cachedTokens = usage.Get("cache_read_input_tokens").Int()
cacheCreationInputTokens := usage.Get("cache_creation_input_tokens").Int()
func (u *claudeUsageTokens) Merge(usage gjson.Result) {
if !usage.Exists() {
return
}
u.HasUsage = true
if inputTokens := usage.Get("input_tokens"); inputTokens.Exists() {
u.InputTokens = inputTokens.Int()
}
if outputTokens := usage.Get("output_tokens"); outputTokens.Exists() {
u.OutputTokens = outputTokens.Int()
}
if cacheCreationInputTokens := usage.Get("cache_creation_input_tokens"); cacheCreationInputTokens.Exists() {
u.CacheCreationInputTokens = cacheCreationInputTokens.Int()
}
if cacheReadInputTokens := usage.Get("cache_read_input_tokens"); cacheReadInputTokens.Exists() {
u.CacheReadInputTokens = cacheReadInputTokens.Int()
}
}
promptTokens = inputTokens + cacheCreationInputTokens + cachedTokens
func (u claudeUsageTokens) OpenAIUsage() (promptTokens, completionTokens, totalTokens, cachedTokens int64) {
cachedTokens = u.CacheReadInputTokens
promptTokens = u.InputTokens + u.CacheCreationInputTokens + cachedTokens
completionTokens = u.OutputTokens
totalTokens = promptTokens + completionTokens
return promptTokens, completionTokens, totalTokens, cachedTokens
}
@@ -112,6 +136,7 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator == nil {
(*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
}
(*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.Merge(message.Get("usage"))
}
return [][]byte{template}
@@ -215,7 +240,8 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
// Handle usage information for token counts
if usage := root.Get("usage"); usage.Exists() {
promptTokens, completionTokens, totalTokens, cachedTokens := calculateClaudeUsageTokens(usage)
(*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.Merge(usage)
promptTokens, completionTokens, totalTokens, cachedTokens := (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.OpenAIUsage()
template, _ = sjson.SetBytes(template, "usage.prompt_tokens", promptTokens)
template, _ = sjson.SetBytes(template, "usage.completion_tokens", completionTokens)
template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokens)
@@ -296,6 +322,7 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
var stopReason string
var contentParts []string
var reasoningParts []string
usageTokens := claudeUsageTokens{}
toolCallsAccumulator := make(map[int]*ToolCallAccumulator)
for _, chunk := range chunks {
@@ -309,6 +336,7 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
messageID = message.Get("id").String()
model = message.Get("model").String()
createdAt = time.Now().Unix()
usageTokens.Merge(message.Get("usage"))
}
case "content_block_start":
@@ -371,15 +399,19 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
}
}
if usage := root.Get("usage"); usage.Exists() {
promptTokens, completionTokens, totalTokens, cachedTokens := calculateClaudeUsageTokens(usage)
out, _ = sjson.SetBytes(out, "usage.prompt_tokens", promptTokens)
out, _ = sjson.SetBytes(out, "usage.completion_tokens", completionTokens)
out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens)
out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_tokens", cachedTokens)
usageTokens.Merge(usage)
}
}
}
if usageTokens.HasUsage {
promptTokens, completionTokens, totalTokens, cachedTokens := usageTokens.OpenAIUsage()
out, _ = sjson.SetBytes(out, "usage.prompt_tokens", promptTokens)
out, _ = sjson.SetBytes(out, "usage.completion_tokens", completionTokens)
out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens)
out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_tokens", cachedTokens)
}
// Set basic response fields including message ID, creation time, and model
out, _ = sjson.SetBytes(out, "id", messageID)
out, _ = sjson.SetBytes(out, "created", createdAt)
@@ -37,6 +37,44 @@ func TestConvertClaudeResponseToOpenAI_StreamUsageIncludesCachedTokens(t *testin
}
}
func TestConvertClaudeResponseToOpenAI_StreamUsageMergesMessageStartUsage(t *testing.T) {
ctx := context.Background()
var param any
ConvertClaudeResponseToOpenAI(
ctx,
"claude-opus-4-6",
nil,
nil,
[]byte(`data: {"type":"message_start","message":{"id":"msg_123","model":"claude-opus-4-6","usage":{"input_tokens":13,"output_tokens":1,"cache_read_input_tokens":22000,"cache_creation_input_tokens":31}}}`),
&param,
)
out := ConvertClaudeResponseToOpenAI(
ctx,
"claude-opus-4-6",
nil,
nil,
[]byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":4}}`),
&param,
)
if len(out) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(out))
}
if gotPromptTokens := gjson.GetBytes(out[0], "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens)
}
if gotCompletionTokens := gjson.GetBytes(out[0], "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens)
}
if gotTotalTokens := gjson.GetBytes(out[0], "usage.total_tokens").Int(); gotTotalTokens != 22048 {
t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens)
}
if gotCachedTokens := gjson.GetBytes(out[0], "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
}
}
func TestConvertClaudeResponseToOpenAINonStream_UsageIncludesCachedTokens(t *testing.T) {
rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\"}}\n" +
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":13,\"output_tokens\":4,\"cache_read_input_tokens\":22000,\"cache_creation_input_tokens\":31}}\n")
@@ -56,3 +94,23 @@ func TestConvertClaudeResponseToOpenAINonStream_UsageIncludesCachedTokens(t *tes
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
}
}
func TestConvertClaudeResponseToOpenAINonStream_UsageMergesMessageStartUsage(t *testing.T) {
rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\",\"usage\":{\"input_tokens\":13,\"output_tokens\":1,\"cache_read_input_tokens\":22000,\"cache_creation_input_tokens\":31}}}\n" +
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":4}}\n")
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
if gotPromptTokens := gjson.GetBytes(out, "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens)
}
if gotCompletionTokens := gjson.GetBytes(out, "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens)
}
if gotTotalTokens := gjson.GetBytes(out, "usage.total_tokens").Int(); gotTotalTokens != 22048 {
t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens)
}
if gotCachedTokens := gjson.GetBytes(out, "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
}
}
@@ -339,25 +339,21 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
})
}
includedToolNames := map[string]struct{}{}
toolNameMap := map[string]string{}
// tools mapping: parameters -> input_schema
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
toolsJSON := []byte("[]")
tools.ForEach(func(_, tool gjson.Result) bool {
tJSON := []byte(`{"name":"","description":"","input_schema":{}}`)
if n := tool.Get("name"); n.Exists() {
tJSON, _ = sjson.SetBytes(tJSON, "name", n.String())
convertedTools := convertResponsesToolToClaudeTools(tool, toolNameMap)
for _, tJSON := range convertedTools {
toolName := gjson.GetBytes(tJSON, "name").String()
if toolName != "" {
includedToolNames[toolName] = struct{}{}
}
toolsJSON, _ = sjson.SetRawBytes(toolsJSON, "-1", tJSON)
}
if d := tool.Get("description"); d.Exists() {
tJSON, _ = sjson.SetBytes(tJSON, "description", d.String())
}
if params := tool.Get("parameters"); params.Exists() {
tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", []byte(params.Raw))
} else if params = tool.Get("parametersJsonSchema"); params.Exists() {
tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", []byte(params.Raw))
}
toolsJSON, _ = sjson.SetRawBytes(toolsJSON, "-1", tJSON)
return true
})
if parsedTools := gjson.ParseBytes(toolsJSON); parsedTools.IsArray() && len(parsedTools.Array()) > 0 {
@@ -375,14 +371,24 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
case "none":
// Leave unset; implies no tools
case "required":
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
if len(includedToolNames) > 0 {
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
}
}
case gjson.JSON:
if toolChoice.Get("type").String() == "function" {
fn := toolChoice.Get("function.name").String()
toolChoiceJSON := []byte(`{"name":"","type":"tool"}`)
toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", fn)
out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
if fn == "" {
fn = toolChoice.Get("name").String()
}
if mappedName := toolNameMap[fn]; mappedName != "" {
fn = mappedName
}
if _, ok := includedToolNames[fn]; ok {
toolChoiceJSON := []byte(`{"name":"","type":"tool"}`)
toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", fn)
out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
}
}
default:
@@ -391,3 +397,167 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
return out
}
func convertResponsesToolToClaudeTools(tool gjson.Result, toolNameMap map[string]string) [][]byte {
toolType := strings.TrimSpace(tool.Get("type").String())
switch toolType {
case "", "function":
if tJSON, ok := convertResponsesFunctionToolToClaude(tool, ""); ok {
return [][]byte{tJSON}
}
case "namespace":
return convertResponsesNamespaceToolToClaude(tool, toolNameMap)
case "web_search":
if tJSON, ok := convertResponsesWebSearchToolToClaude(tool); ok {
if name := gjson.GetBytes(tJSON, "name").String(); name != "" {
toolNameMap[name] = name
}
return [][]byte{tJSON}
}
default:
if isUnsupportedOpenAIBuiltinToolType(toolType) {
return nil
}
if tool.Get("name").String() != "" {
return [][]byte{[]byte(tool.Raw)}
}
}
return nil
}
func convertResponsesNamespaceToolToClaude(tool gjson.Result, toolNameMap map[string]string) [][]byte {
namespaceName := strings.TrimSpace(tool.Get("name").String())
children := tool.Get("tools")
if !children.Exists() || !children.IsArray() {
return nil
}
var out [][]byte
children.ForEach(func(_, child gjson.Result) bool {
childName := responsesToolName(child)
qualifiedName := qualifyResponsesNamespaceToolName(namespaceName, childName)
if tJSON, ok := convertResponsesFunctionToolToClaude(child, qualifiedName); ok {
out = append(out, tJSON)
toolNameMap[qualifiedName] = qualifiedName
if childName != "" {
toolNameMap[childName] = qualifiedName
}
}
return true
})
return out
}
func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) {
name := strings.TrimSpace(overrideName)
if name == "" {
name = responsesToolName(tool)
}
if name == "" {
return nil, false
}
tJSON := []byte(`{"name":"","description":"","input_schema":{}}`)
tJSON, _ = sjson.SetBytes(tJSON, "name", name)
if d := responsesToolDescription(tool); d != "" {
tJSON, _ = sjson.SetBytes(tJSON, "description", d)
}
tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", normalizeClaudeToolInputSchema(responsesToolParameters(tool)))
return tJSON, true
}
func convertResponsesWebSearchToolToClaude(tool gjson.Result) ([]byte, bool) {
if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() {
return nil, false
}
name := strings.TrimSpace(tool.Get("name").String())
if name == "" {
name = "web_search"
}
tJSON := []byte(`{"type":"web_search_20250305","name":""}`)
tJSON, _ = sjson.SetBytes(tJSON, "name", name)
if maxUses := tool.Get("max_uses"); maxUses.Exists() {
tJSON, _ = sjson.SetBytes(tJSON, "max_uses", maxUses.Int())
}
if allowedDomains := tool.Get("filters.allowed_domains"); allowedDomains.Exists() && allowedDomains.IsArray() {
tJSON, _ = sjson.SetRawBytes(tJSON, "allowed_domains", []byte(allowedDomains.Raw))
}
if userLocation := tool.Get("user_location"); userLocation.Exists() && userLocation.IsObject() {
tJSON, _ = sjson.SetRawBytes(tJSON, "user_location", []byte(userLocation.Raw))
}
return tJSON, 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{}
}
func normalizeClaudeToolInputSchema(parameters gjson.Result) []byte {
raw := strings.TrimSpace(parameters.Raw)
if raw == "" || raw == "null" || !gjson.Valid(raw) {
return []byte(`{"type":"object","properties":{}}`)
}
result := gjson.Parse(raw)
if !result.IsObject() {
return []byte(`{"type":"object","properties":{}}`)
}
schema := []byte(raw)
schemaType := result.Get("type").String()
if schemaType == "" {
schema, _ = sjson.SetBytes(schema, "type", "object")
schemaType = "object"
}
if schemaType == "object" && !result.Get("properties").Exists() {
schema, _ = sjson.SetRawBytes(schema, "properties", []byte(`{}`))
}
return schema
}
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
}
func isUnsupportedOpenAIBuiltinToolType(toolType string) bool {
switch toolType {
case "image_generation", "file_search", "code_interpreter", "computer_use_preview":
return true
default:
return false
}
}
@@ -26,7 +26,8 @@ type claudeToResponsesState struct {
FuncNames map[int]string // index -> function name
FuncCallIDs map[int]string // index -> call id
// message text aggregation
TextBuf strings.Builder
TextBuf strings.Builder
CurrentTextBuf strings.Builder
// reasoning state
ReasoningActive bool
ReasoningItemID string
@@ -80,6 +81,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
st.CreatedAt = time.Now().Unix()
// Reset per-message aggregation state
st.TextBuf.Reset()
st.CurrentTextBuf.Reset()
st.ReasoningBuf.Reset()
st.ReasoningActive = false
st.InTextBlock = false
@@ -128,6 +130,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
if typ == "text" {
// open message item + content part
st.InTextBlock = true
st.CurrentTextBuf.Reset()
st.CurrentMsgID = fmt.Sprintf("msg_%s_0", st.ResponseID)
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())
@@ -189,6 +192,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
out = append(out, emitEvent("response.output_text.delta", msg))
// aggregate text for response.output
st.TextBuf.WriteString(t.String())
st.CurrentTextBuf.WriteString(t.String())
}
} else if dt == "input_json_delta" {
idx := int(root.Get("index").Int())
@@ -220,17 +224,21 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
case "content_block_stop":
idx := int(root.Get("index").Int())
if st.InTextBlock {
fullText := st.CurrentTextBuf.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", st.CurrentMsgID)
done, _ = sjson.SetBytes(done, "text", fullText)
out = append(out, emitEvent("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", st.CurrentMsgID)
partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)
out = append(out, emitEvent("response.content_part.done", partDone))
final := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","text":""}],"role":"assistant"}}`)
final, _ = sjson.SetBytes(final, "sequence_number", nextSeq())
final, _ = sjson.SetBytes(final, "item.id", st.CurrentMsgID)
final, _ = sjson.SetBytes(final, "item.content.0.text", fullText)
out = append(out, emitEvent("response.output_item.done", final))
st.InTextBlock = false
} else if st.InFuncBlock {
@@ -0,0 +1,78 @@
package geminiCLI
import (
"testing"
"github.com/tidwall/gjson"
)
func TestConvertGeminiCLIRequestToCodex_PreservesSchemaPropertyNamedType(t *testing.T) {
input := []byte(`{
"request": {
"tools": [
{
"functionDeclarations": [
{
"name": "ask_user",
"description": "Ask the user one or more questions.",
"parametersJsonSchema": {
"type": "object",
"properties": {
"questions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"header": {
"type": "string"
},
"type": {
"default": "choice",
"description": "Question type.",
"enum": [
"choice",
"text",
"yesno"
],
"type": "string"
}
},
"required": [
"question",
"header",
"type"
]
}
}
},
"required": [
"questions"
]
}
}
]
}
]
}
}`)
out := ConvertGeminiCLIRequestToCodex("gpt-5.2", input, true)
tool := gjson.GetBytes(out, "tools.0")
if got := tool.Get("type").String(); got != "function" {
t.Fatalf("expected tool type %q, got %q; output=%s", "function", got, string(out))
}
typeProperty := tool.Get("parameters.properties.questions.items.properties.type")
if !typeProperty.IsObject() {
t.Fatalf("expected schema property named type to stay an object; output=%s", string(out))
}
if got := typeProperty.Get("type").String(); got != "string" {
t.Fatalf("expected schema property type %q, got %q; output=%s", "string", got, string(out))
}
if got := typeProperty.Get("default").String(); got != "choice" {
t.Fatalf("expected default %q, got %q; output=%s", "choice", got, string(out))
}
if got := typeProperty.Get("enum.2").String(); got != "yesno" {
t.Fatalf("expected enum value %q, got %q; output=%s", "yesno", got, string(out))
}
}
@@ -284,7 +284,11 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
util.Walk(toolsResult, "", "type", &pathsToLower)
for _, p := range pathsToLower {
fullPath := fmt.Sprintf("tools.%s", p)
out, _ = sjson.SetBytes(out, fullPath, strings.ToLower(gjson.GetBytes(out, fullPath).String()))
typeValue := gjson.GetBytes(out, fullPath)
if typeValue.Type != gjson.String {
continue
}
out, _ = sjson.SetBytes(out, fullPath, strings.ToLower(typeValue.String()))
}
return out
@@ -121,13 +121,13 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
case "tool":
// Handle tool response messages as top-level function_call_output objects
toolCallID := m.Get("tool_call_id").String()
content := m.Get("content").String()
content := m.Get("content")
// Create function_call_output object
funcOutput := []byte(`{}`)
funcOutput, _ = sjson.SetBytes(funcOutput, "type", "function_call_output")
funcOutput, _ = sjson.SetBytes(funcOutput, "call_id", toolCallID)
funcOutput, _ = sjson.SetBytes(funcOutput, "output", content)
funcOutput = setToolCallOutputContent(funcOutput, content)
out, _ = sjson.SetRawBytes(out, "input.-1", funcOutput)
default:
@@ -359,6 +359,91 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
return out
}
func setToolCallOutputContent(funcOutput []byte, content gjson.Result) []byte {
switch {
case content.Type == gjson.String:
funcOutput, _ = sjson.SetBytes(funcOutput, "output", content.String())
case content.IsArray():
output := []byte(`[]`)
for _, item := range content.Array() {
output = appendToolOutputContentPart(output, item)
}
funcOutput, _ = sjson.SetRawBytes(funcOutput, "output", output)
default:
fallbackOutput := content.Raw
if fallbackOutput == "" {
fallbackOutput = content.String()
}
funcOutput, _ = sjson.SetBytes(funcOutput, "output", fallbackOutput)
}
return funcOutput
}
func appendToolOutputContentPart(output []byte, item gjson.Result) []byte {
switch item.Get("type").String() {
case "text":
part := []byte(`{}`)
part, _ = sjson.SetBytes(part, "type", "input_text")
part, _ = sjson.SetBytes(part, "text", item.Get("text").String())
output, _ = sjson.SetRawBytes(output, "-1", part)
case "image_url":
imageURL := item.Get("image_url.url").String()
fileID := item.Get("image_url.file_id").String()
if imageURL == "" && fileID == "" {
return appendToolOutputFallbackPart(output, item)
}
part := []byte(`{}`)
part, _ = sjson.SetBytes(part, "type", "input_image")
if imageURL != "" {
part, _ = sjson.SetBytes(part, "image_url", imageURL)
}
if fileID != "" {
part, _ = sjson.SetBytes(part, "file_id", fileID)
}
if detail := item.Get("image_url.detail").String(); detail != "" {
part, _ = sjson.SetBytes(part, "detail", detail)
}
output, _ = sjson.SetRawBytes(output, "-1", part)
case "file":
fileID := item.Get("file.file_id").String()
fileData := item.Get("file.file_data").String()
fileURL := item.Get("file.file_url").String()
if fileID == "" && fileData == "" && fileURL == "" {
return appendToolOutputFallbackPart(output, item)
}
part := []byte(`{}`)
part, _ = sjson.SetBytes(part, "type", "input_file")
if fileID != "" {
part, _ = sjson.SetBytes(part, "file_id", fileID)
}
if fileData != "" {
part, _ = sjson.SetBytes(part, "file_data", fileData)
}
if fileURL != "" {
part, _ = sjson.SetBytes(part, "file_url", fileURL)
}
if filename := item.Get("file.filename").String(); filename != "" {
part, _ = sjson.SetBytes(part, "filename", filename)
}
output, _ = sjson.SetRawBytes(output, "-1", part)
default:
output = appendToolOutputFallbackPart(output, item)
}
return output
}
func appendToolOutputFallbackPart(output []byte, item gjson.Result) []byte {
text := item.Raw
if text == "" {
text = item.String()
}
part := []byte(`{}`)
part, _ = sjson.SetBytes(part, "type", "input_text")
part, _ = sjson.SetBytes(part, "text", text)
output, _ = sjson.SetRawBytes(output, "-1", part)
return output
}
// shortenNameIfNeeded applies the simple shortening rule for a single name.
// If the name length exceeds 64, it will try to preserve the "mcp__" prefix and last segment.
// Otherwise it truncates to 64 characters.
@@ -176,6 +176,182 @@ func TestToolCallWithContent(t *testing.T) {
}
}
func TestToolCallOutputWithMultimodalContent(t *testing.T) {
input := []byte(`{
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "Show me the generated result."},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_output_1",
"type": "function",
"function": {"name": "render_output", "arguments": "{}"}
}
]
},
{
"role": "tool",
"tool_call_id": "call_output_1",
"content": [
{"type":"text","text":"Rendered result attached."},
{"type":"image_url","image_url":{"url":"https://example.com/generated.png","detail":"high"}},
{"type":"image_url","image_url":{"file_id":"file-img-123"}},
{"type":"file","file":{"file_id":"file-doc-123","filename":"doc.pdf"}},
{"type":"file","file":{"file_data":"SGVsbG8=","filename":"inline.txt"}},
{"type":"file","file":{"file_url":"https://example.com/report.pdf","filename":"report.pdf"}}
]
}
],
"tools": [
{
"type": "function",
"function": {"name": "render_output", "description": "Render output", "parameters": {"type": "object", "properties": {}}}
}
]
}`)
out := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
output := gjson.Get(result, "input.2.output")
if !output.IsArray() {
t.Fatalf("expected tool output to be an array, got: %s", output.Raw)
}
parts := output.Array()
if len(parts) != 6 {
t.Fatalf("expected 6 output parts, got %d: %s", len(parts), output.Raw)
}
if parts[0].Get("type").String() != "input_text" || parts[0].Get("text").String() != "Rendered result attached." {
t.Fatalf("part 0: expected input_text with rendered text, got %s", parts[0].Raw)
}
if parts[1].Get("type").String() != "input_image" {
t.Fatalf("part 1: expected input_image, got %s", parts[1].Raw)
}
if parts[1].Get("image_url").String() != "https://example.com/generated.png" {
t.Errorf("part 1: unexpected image_url %s", parts[1].Get("image_url").String())
}
if parts[1].Get("detail").String() != "high" {
t.Errorf("part 1: unexpected detail %s", parts[1].Get("detail").String())
}
if parts[2].Get("type").String() != "input_image" || parts[2].Get("file_id").String() != "file-img-123" {
t.Fatalf("part 2: expected file_id-backed input_image, got %s", parts[2].Raw)
}
if parts[3].Get("type").String() != "input_file" || parts[3].Get("file_id").String() != "file-doc-123" {
t.Fatalf("part 3: expected file_id-backed input_file, got %s", parts[3].Raw)
}
if parts[3].Get("filename").String() != "doc.pdf" {
t.Errorf("part 3: unexpected filename %s", parts[3].Get("filename").String())
}
if parts[4].Get("type").String() != "input_file" || parts[4].Get("file_data").String() != "SGVsbG8=" {
t.Fatalf("part 4: expected file_data-backed input_file, got %s", parts[4].Raw)
}
if parts[5].Get("type").String() != "input_file" || parts[5].Get("file_url").String() != "https://example.com/report.pdf" {
t.Fatalf("part 5: expected file_url-backed input_file, got %s", parts[5].Raw)
}
}
func TestToolCallOutputFallsBackForInvalidStructuredParts(t *testing.T) {
input := []byte(`{
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "Check tool output."},
{
"role": "assistant",
"content": null,
"tool_calls": [
{"id": "call_invalid_parts", "type": "function", "function": {"name": "inspect", "arguments": "{}"}}
]
},
{
"role": "tool",
"tool_call_id": "call_invalid_parts",
"content": [
{"type":"image_url","image_url":{"detail":"low"}},
{"type":"file","file":{"filename":"orphan.txt"}},
{"type":"unknown_type","foo":"bar","nested":{"a":1}}
]
}
],
"tools": [
{"type": "function", "function": {"name": "inspect", "description": "Inspect", "parameters": {"type": "object", "properties": {}}}}
]
}`)
out := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
parts := gjson.Get(result, "input.2.output").Array()
if len(parts) != 3 {
t.Fatalf("expected 3 output parts, got %d: %s", len(parts), gjson.Get(result, "input.2.output").Raw)
}
expectedFallbacks := []string{
`{"type":"image_url","image_url":{"detail":"low"}}`,
`{"type":"file","file":{"filename":"orphan.txt"}}`,
`{"type":"unknown_type","foo":"bar","nested":{"a":1}}`,
}
for i, expectedFallback := range expectedFallbacks {
if parts[i].Get("type").String() != "input_text" {
t.Fatalf("part %d: expected input_text fallback, got %s", i, parts[i].Raw)
}
if parts[i].Get("text").String() != expectedFallback {
t.Fatalf("part %d: expected fallback %s, got %s", i, expectedFallback, parts[i].Get("text").String())
}
}
}
func TestToolCallOutputWithNonStringJSONContent(t *testing.T) {
tests := []struct {
name string
content string
expectedOutput string
}{
{name: "null", content: `null`, expectedOutput: `null`},
{name: "object", content: `{"status":"ok","count":2}`, expectedOutput: `{"status":"ok","count":2}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := []byte(`{
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "Check tool output."},
{
"role": "assistant",
"content": null,
"tool_calls": [
{"id": "call_json", "type": "function", "function": {"name": "inspect", "arguments": "{}"}}
]
},
{
"role": "tool",
"tool_call_id": "call_json",
"content": ` + tt.content + `
}
],
"tools": [
{"type": "function", "function": {"name": "inspect", "description": "Inspect", "parameters": {"type": "object", "properties": {}}}}
]
}`)
out := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
output := gjson.Get(result, "input.2.output")
if !output.Exists() {
t.Fatalf("expected output field to exist: %s", gjson.Get(result, "input.2").Raw)
}
if output.String() != tt.expectedOutput {
t.Fatalf("expected output %s, got %s", tt.expectedOutput, output.String())
}
})
}
}
// Parallel tool calls: assistant invokes 3 tools at once, all call_ids
// and outputs must be translated and paired correctly.
func TestMultipleToolCalls(t *testing.T) {
@@ -236,7 +236,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
// Handle function name
if function := toolCall.Get("function"); function.Exists() {
if name := function.Get("name"); name.Exists() {
if name := function.Get("name"); name.Exists() && name.String() != "" {
accumulator.Name = util.MapToolName(param.ToolNameMap, name.String())
stopThinkingContentBlock(param, &results)
@@ -0,0 +1,41 @@
package claude
import (
"bytes"
"context"
"testing"
)
func TestConvertOpenAIResponseToClaude_StreamIgnoresNullToolNameDelta(t *testing.T) {
originalRequest := []byte(`{"stream":true}`)
var param any
firstChunks := ConvertOpenAIResponseToClaude(
context.Background(),
"test-model",
originalRequest,
nil,
[]byte(`data: {"id":"chatcmpl_1","model":"test-model","created":1,"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"read_file","arguments":""}}]},"finish_reason":null}]}`),
&param,
)
firstOutput := bytes.Join(firstChunks, nil)
if !bytes.Contains(firstOutput, []byte(`"name":"read_file"`)) {
t.Fatalf("expected first chunk to start read_file tool block, got %s", string(firstOutput))
}
secondChunks := ConvertOpenAIResponseToClaude(
context.Background(),
"test-model",
originalRequest,
nil,
[]byte(`data: {"id":"chatcmpl_1","model":"test-model","created":1,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":null,"arguments":"{\"path\":\"/tmp/a\"}"}}]},"finish_reason":null}]}`),
&param,
)
secondOutput := bytes.Join(secondChunks, nil)
if bytes.Contains(secondOutput, []byte(`content_block_start`)) {
t.Fatalf("did not expect null tool name delta to start a new content block, got %s", string(secondOutput))
}
if bytes.Contains(secondOutput, []byte(`"name":""`)) {
t.Fatalf("did not expect null tool name delta to emit an empty tool name, got %s", string(secondOutput))
}
}