diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index 69701c60..616e8645 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -35,8 +35,16 @@ import ( // // Returns: // - []byte: The transformed request data in Claude Code API format -func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertGeminiRequestToClaude(modelName, inputRawJSON, stream) + +} + +// convertGeminiRequestToClaude also reports a user turn that was left empty +// because its only attachment has no Claude equivalent. +func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { rawJSON := inputRawJSON + var drops translatorcommon.UserTurnDrops userID := translatorcommon.DeriveClaudeUserID(rawJSON) @@ -106,6 +114,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream mi := registry.LookupModelInfo(modelName, "claude") supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0 supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax)) + supportsDynamic := supportsAdaptive && mi.Thinking.DynamicAllowed // MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid // validation errors since validate treats same-provider unsupported levels as errors. @@ -116,15 +125,21 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream if thinkingLevel.Exists() { level := strings.ToLower(strings.TrimSpace(thinkingLevel.String())) if supportsAdaptive { - switch level { - case "": - case "none": + switch { + case level == "": + case level == "none": out, _ = sjson.SetBytes(out, "thinking.type", "disabled") out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") out, _ = sjson.DeleteBytes(out, "output_config.effort") + case level == "auto" && supportsDynamic: + out, _ = sjson.SetBytes(out, "thinking.type", "adaptive") + out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") + out, _ = sjson.DeleteBytes(out, "output_config.effort") default: - if mapped, ok := thinking.MapToClaudeEffort(level, supportsMax); ok { - level = mapped + if !thinking.HasLevel(mi.Thinking.Levels, level) { + if mapped, ok := thinking.MapToClaudeEffort(level, supportsMax); ok { + level = mapped + } } out, _ = sjson.SetBytes(out, "thinking.type", "adaptive") out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") @@ -154,16 +169,22 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream if thinkingBudget.Exists() { budget := int(thinkingBudget.Int()) if supportsAdaptive { - switch budget { - case 0: + switch { + case budget == 0: out, _ = sjson.SetBytes(out, "thinking.type", "disabled") out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") out, _ = sjson.DeleteBytes(out, "output_config.effort") + case budget == -1 && supportsDynamic: + out, _ = sjson.SetBytes(out, "thinking.type", "adaptive") + out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") + out, _ = sjson.DeleteBytes(out, "output_config.effort") default: level, ok := thinking.ConvertBudgetToLevel(budget) if ok { - if mapped, okM := thinking.MapToClaudeEffort(level, supportsMax); okM { - level = mapped + if !thinking.HasLevel(mi.Thinking.Levels, level) { + if mapped, okM := thinking.MapToClaudeEffort(level, supportsMax); okM { + level = mapped + } } out, _ = sjson.SetBytes(out, "thinking.type", "adaptive") out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") @@ -189,7 +210,11 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream } // System instruction conversion to Claude Code format - if sysInstr := root.Get("system_instruction"); sysInstr.Exists() { + sysInstr := root.Get("systemInstruction") + if !sysInstr.Exists() { + sysInstr = root.Get("system_instruction") + } + if sysInstr.Exists() { if parts := sysInstr.Get("parts"); parts.Exists() && parts.IsArray() { var systemText strings.Builder parts.ForEach(func(_, part gjson.Result) bool { @@ -232,6 +257,8 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream } contentItems := make([][]byte, 0, 4) + // Counts what this turn sends; whitespace-only text is forwarded but never sendable. + sendable := 0 if parts := content.Get("parts"); parts.Exists() && parts.IsArray() { parts.ForEach(func(_, part gjson.Result) bool { if translatorcommon.IsGeminiThoughtPart(part) { @@ -243,6 +270,9 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream textContent := []byte(`{"type":"text","text":""}`) textContent, _ = sjson.SetBytes(textContent, "text", text.String()) contentItems = append(contentItems, textContent) + if strings.TrimSpace(text.String()) != "" { + sendable++ + } return true } @@ -266,6 +296,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(args.Raw)) } contentItems = append(contentItems, toolUse) + sendable++ return true } @@ -297,13 +328,19 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream toolResult, _ = sjson.SetBytes(toolResult, "content", response.Raw) } contentItems = append(contentItems, toolResult) + sendable++ return true } - // Inline data conversion to Claude Code content format + // Inline data conversion to Claude Code content format. Only model content + // keeps a placeholder for media Claude cannot read; a user attachment is + // never replaced by text, so it is recorded as dropped instead. if inlineData := geminiClaudeInlineData(part); inlineData.Exists() { - if contentPart, ok := claudeContentPartFromGeminiInlineData(inlineData); ok { + if contentPart, ok := claudeContentPartFromGeminiInlineData(inlineData, role == "assistant"); ok { contentItems = append(contentItems, contentPart) + sendable++ + } else if role != "assistant" { + drops.Drop("inlineData") } return true } @@ -312,6 +349,9 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream if fileData := geminiClaudeFileData(part); fileData.Exists() { if contentPart, ok := claudeContentPartFromGeminiFileData(fileData); ok { contentItems = append(contentItems, contentPart) + sendable++ + } else if role != "assistant" { + drops.Drop("fileData") } return true } @@ -319,6 +359,9 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream return true }) } + if role != "assistant" { + drops.EndTurn(sendable) + } // Only add message if it has content. if len(contentItems) > 0 { @@ -381,7 +424,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream // Stream setting configuration out, _ = sjson.SetBytes(out, "stream", stream) - return thinking.ApplyTranslatedSummaryToClaude(out, rawJSON, "gemini", modelName) + return thinking.ApplyTranslatedSummaryToClaude(out, rawJSON, "gemini", modelName), drops.Err() } func normalizeClaudeToolSchema(parameters gjson.Result) []byte { @@ -457,7 +500,10 @@ func geminiClaudeFileData(part gjson.Result) gjson.Result { return part.Get("file_data") } -func claudeContentPartFromGeminiInlineData(inlineData gjson.Result) ([]byte, bool) { +// claudeContentPartFromGeminiInlineData maps inline bytes onto a Claude block. +// Media that Claude cannot read becomes placeholder text only when +// keepPlaceholder is set; otherwise it reports false so the caller can refuse it. +func claudeContentPartFromGeminiInlineData(inlineData gjson.Result, keepPlaceholder bool) ([]byte, bool) { mimeType := inlineData.Get("mimeType").String() if mimeType == "" { mimeType = inlineData.Get("mime_type").String() @@ -479,6 +525,9 @@ func claudeContentPartFromGeminiInlineData(inlineData gjson.Result) ([]byte, boo documentContent, _ = sjson.SetBytes(documentContent, "source.data", data) return documentContent, true default: + if !keepPlaceholder { + return nil, false + } return claudeTextContentPart(fmt.Sprintf("Media content: inline data (Type: %s)", mimeType)), true } } diff --git a/internal/translator/claude/interactions/interactions_claude_request.go b/internal/translator/claude/interactions/interactions_claude_request.go index 5f815edf..556b9a76 100644 --- a/internal/translator/claude/interactions/interactions_claude_request.go +++ b/internal/translator/claude/interactions/interactions_claude_request.go @@ -11,7 +11,15 @@ import ( "github.com/tidwall/sjson" ) -func ConvertInteractionsRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertInteractionsRequestToClaude(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertInteractionsRequestToClaude(modelName, inputRawJSON, stream) + +} + +// convertInteractionsRequestToClaude also reports a user turn that was left empty +// because its only media part has no Claude equivalent. +func convertInteractionsRequestToClaude(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + var run interactionsClaudeUserRun root := gjson.ParseBytes(inputRawJSON) out := []byte(`{"model":"","max_tokens":32000,"messages":[]}`) out, _ = sjson.SetBytes(out, "model", modelName) @@ -21,10 +29,34 @@ func ConvertInteractionsRequestToClaude(modelName string, inputRawJSON []byte, s out = copyInteractionsSystemToClaude(out, root) out = copyInteractionsGenerationConfigToClaude(out, root) messageAccumulator := translatorcommon.NewClaudeMessageAccumulator(int(root.Get("input.#").Int())) - appendInteractionsInputToClaudeMessages(messageAccumulator, root.Get("input")) + appendInteractionsInputToClaudeMessages(messageAccumulator, root.Get("input"), &run) + run.end() out = translatorcommon.SetRawArrayItems(out, "messages", messageAccumulator.Messages()) out = copyInteractionsToolsToClaude(out, root) - return out + return out, run.drops.Err() +} + +// interactionsClaudeUserRun follows the consecutive user content that the message +// accumulator merges into one Claude user message. A media part Claude cannot +// carry is refused only when that whole turn is left with nothing to send, so +// text or a tool result in a neighbouring step still keeps the turn alive. +type interactionsClaudeUserRun struct { + drops translatorcommon.UserTurnDrops + sendable int +} + +func (r *interactionsClaudeUserRun) add() { + r.sendable++ +} + +func (r *interactionsClaudeUserRun) drop(partType string) { + r.drops.Drop(partType) +} + +// end closes the current user turn; call it when a non-user message starts or the input ends. +func (r *interactionsClaudeUserRun) end() { + r.drops.EndTurn(r.sendable) + r.sendable = 0 } func copyInteractionsSystemToClaude(out []byte, root gjson.Result) []byte { @@ -125,34 +157,35 @@ func setClaudeThinkingFromLevel(out []byte, level string) []byte { return out } -func appendInteractionsInputToClaudeMessages(accumulator *translatorcommon.ClaudeMessageAccumulator, input gjson.Result) { +func appendInteractionsInputToClaudeMessages(accumulator *translatorcommon.ClaudeMessageAccumulator, input gjson.Result, run *interactionsClaudeUserRun) { if !input.Exists() { return } if input.Type == gjson.String { step := []byte(`{"type":"user_input","content":[{"type":"text","text":""}]}`) step, _ = sjson.SetBytes(step, "content.0.text", input.String()) - appendInteractionsStepToClaude(accumulator, gjson.ParseBytes(step), "user") + appendInteractionsStepToClaude(accumulator, gjson.ParseBytes(step), "user", false, run) return } if input.IsObject() { - appendInteractionsInputItemToClaude(accumulator, input) + appendInteractionsInputItemToClaude(accumulator, input, run) return } input.ForEach(func(_, step gjson.Result) bool { - appendInteractionsInputItemToClaude(accumulator, step) + appendInteractionsInputItemToClaude(accumulator, step, run) return true }) } -func appendInteractionsInputItemToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result) { +func appendInteractionsInputItemToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result, run *interactionsClaudeUserRun) { if step.Get("steps").IsArray() { defaultRole := "user" if role := step.Get("role").String(); role == "model" || role == "assistant" { defaultRole = "assistant" } + instruction := translatorcommon.IsInteractionsInstructionStep(step, false) step.Get("steps").ForEach(func(_, nestedStep gjson.Result) bool { - appendInteractionsStepToClaude(accumulator, nestedStep, defaultRole) + appendInteractionsStepToClaude(accumulator, nestedStep, defaultRole, instruction, run) return true }) return @@ -163,48 +196,72 @@ func appendInteractionsInputItemToClaude(accumulator *translatorcommon.ClaudeMes wrapped, _ = sjson.SetBytes(wrapped, "type", "model_output") } wrapped, _ = sjson.SetRawBytes(wrapped, "content", []byte(step.Get("parts").Raw)) - appendInteractionsStepToClaude(accumulator, gjson.ParseBytes(wrapped), "user") + appendInteractionsStepToClaude(accumulator, gjson.ParseBytes(wrapped), "user", translatorcommon.IsInteractionsInstructionStep(step, false), run) return } stepType := step.Get("type").String() switch stepType { case "function_call": - appendInteractionsFunctionCallToClaude(accumulator, step) + appendInteractionsFunctionCallToClaude(accumulator, step, run) case "function_result": - appendInteractionsFunctionResultToClaude(accumulator, step) + appendInteractionsFunctionResultToClaude(accumulator, step, run) case "model_output", "thought": - appendInteractionsStepToClaude(accumulator, step, "assistant") + appendInteractionsStepToClaude(accumulator, step, "assistant", false, run) default: - appendInteractionsStepToClaude(accumulator, step, "user") + appendInteractionsStepToClaude(accumulator, step, "user", false, run) } } -func appendInteractionsStepToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result, defaultRole string) { +// appendInteractionsStepToClaude adds one step. instruction says the step sits in +// a developer or system wrapper. Developer and system content is sent as user +// content, but it is not the user's own turn: it closes the open user turn and +// never counts toward keeping an emptied one alive. +func appendInteractionsStepToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result, defaultRole string, instruction bool, run *interactionsClaudeUserRun) { role := defaultRole if stepRole := step.Get("role").String(); stepRole == "user" || stepRole == "assistant" { role = stepRole } + userContent := role == "user" && !translatorcommon.IsInteractionsInstructionStep(step, instruction) contentItems := make([][]byte, 0, 4) + appendPart := func(part gjson.Result) { + converted := interactionsContentToClaude(part, role) + if len(converted) == 0 { + if userContent { + if droppedType := interactionsClaudeDroppedPart(part); droppedType != "" { + run.drop(droppedType) + } + } + return + } + contentItems = append(contentItems, converted) + if userContent && interactionsClaudePartIsSendable(converted) { + run.add() + } + } stepContent := step.Get("content") if stepContent.Type == gjson.String { part := []byte(`{"type":"text","text":""}`) part, _ = sjson.SetBytes(part, "text", stepContent.String()) - contentItems = append(contentItems, part) + appendPart(gjson.ParseBytes(part)) } else if stepContent.IsArray() { stepContent.ForEach(func(_, part gjson.Result) bool { - if converted := interactionsContentToClaude(part, role); len(converted) > 0 { - contentItems = append(contentItems, converted) - } + appendPart(part) return true }) } else if text := step.Get("text"); text.Exists() { part := []byte(`{"type":"text","text":""}`) part, _ = sjson.SetBytes(part, "text", text.String()) - contentItems = append(contentItems, part) + appendPart(gjson.ParseBytes(part)) + } else if interactionsClaudeDroppedMedia(step) != "" { + // A bare media content object stands for a step of its own. + appendPart(step) } if len(contentItems) == 0 { return } + if !userContent { + run.end() + } msg := []byte(`{"role":"","content":[]}`) msg, _ = sjson.SetBytes(msg, "role", role) msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems)) @@ -240,7 +297,10 @@ func interactionsContentToClaude(part gjson.Result, role string) []byte { textPart, _ = sjson.SetBytes(textPart, "text", text) return textPart } - if part.Get("data").String() != "" || part.Get("file_data").String() != "" { + // A user attachment Claude cannot carry is never replaced by placeholder text; + // the caller records it as dropped. Assistant and tool result content only + // echoes earlier output, so it keeps the placeholder. + if role != "user" && (part.Get("data").String() != "" || part.Get("file_data").String() != "") { textPart := []byte(`{"type":"text","text":""}`) textPart, _ = sjson.SetBytes(textPart, "text", fmt.Sprintf("[%s content omitted]", partType)) return textPart @@ -249,7 +309,7 @@ func interactionsContentToClaude(part gjson.Result, role string) []byte { return nil } -func appendInteractionsFunctionCallToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result) { +func appendInteractionsFunctionCallToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result, run *interactionsClaudeUserRun) { toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) toolUse, _ = sjson.SetBytes(toolUse, "id", interactionsClaudeToolID(step)) toolUse, _ = sjson.SetBytes(toolUse, "name", util.SanitizeClaudeFunctionName(step.Get("name").String())) @@ -262,10 +322,11 @@ func appendInteractionsFunctionCallToClaude(accumulator *translatorcommon.Claude } msg := []byte(`{"role":"assistant","content":[]}`) msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray([][]byte{toolUse})) + run.end() accumulator.Append(msg) } -func appendInteractionsFunctionResultToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result) { +func appendInteractionsFunctionResultToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result, run *interactionsClaudeUserRun) { toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`) toolResult, _ = sjson.SetBytes(toolResult, "tool_use_id", interactionsClaudeToolID(step)) if isError := step.Get("is_error"); isError.Exists() && isError.Bool() { @@ -279,7 +340,7 @@ func appendInteractionsFunctionResultToClaude(accumulator *translatorcommon.Clau case result.IsArray(): contentItems := make([][]byte, 0, 4) result.ForEach(func(_, part gjson.Result) bool { - if converted := interactionsContentToClaude(part, "user"); len(converted) > 0 { + if converted := interactionsContentToClaude(part, "tool_result"); len(converted) > 0 { contentItems = append(contentItems, converted) } return true @@ -292,6 +353,8 @@ func appendInteractionsFunctionResultToClaude(accumulator *translatorcommon.Clau } msg := []byte(`{"role":"user","content":[]}`) msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray([][]byte{toolResult})) + // A tool result is content the model reads, so it keeps the surrounding user turn. + run.add() accumulator.Append(msg) } @@ -433,6 +496,9 @@ func interactionsClaudeText(value gjson.Result) string { return "" } +// interactionsClaudeMediaPart maps an Interactions image or document onto a Claude +// block. Inline bytes become a base64 source and an http(s) uri becomes a url +// source. It reports false when the part carries neither. func interactionsClaudeMediaPart(part gjson.Result, claudeType string) ([]byte, bool) { mimeType := firstClaudeInteractionsExisting(part, "mime_type", "mimeType", "media_type", "mediaType").String() data := firstClaudeInteractionsExisting(part, "data", "file_data", "fileData").String() @@ -444,14 +510,54 @@ func interactionsClaudeMediaPart(part gjson.Result, claudeType string) ([]byte, data = source.Get("data").String() } } - if mimeType == "" || data == "" { - return nil, false + if mimeType != "" && data != "" { + out := []byte(`{"type":"","source":{"type":"base64","media_type":"","data":""}}`) + out, _ = sjson.SetBytes(out, "type", claudeType) + out, _ = sjson.SetBytes(out, "source.media_type", mimeType) + out, _ = sjson.SetBytes(out, "source.data", data) + return out, true + } + for _, path := range []string{"uri", "file_uri", "fileUri", "url"} { + if uri := strings.TrimSpace(part.Get(path).String()); translatorcommon.IsHTTPURL(uri) { + out := []byte(`{"type":"","source":{"type":"url","url":""}}`) + out, _ = sjson.SetBytes(out, "type", claudeType) + out, _ = sjson.SetBytes(out, "source.url", uri) + return out, true + } + } + return nil, false +} + +// interactionsClaudeDroppedMedia names the media type of a part that +// interactionsContentToClaude left out, or returns "" when the part is not media. +func interactionsClaudeDroppedMedia(part gjson.Result) string { + switch partType := part.Get("type").String(); partType { + case "image", "audio", "video", "document": + return partType + case "file": + return "document" + } + return "" +} + +// interactionsClaudeDroppedPart names the type of a user part that +// interactionsContentToClaude left out: media, or any other part that carries +// bytes. It returns "" when the part holds nothing to report. +func interactionsClaudeDroppedPart(part gjson.Result) string { + if mediaType := interactionsClaudeDroppedMedia(part); mediaType != "" { + return mediaType + } + if part.Get("data").String() != "" || part.Get("file_data").String() != "" { + return translatorcommon.InteractionsAttachmentType(part) } - out := []byte(`{"type":"","source":{"type":"base64","media_type":"","data":""}}`) - out, _ = sjson.SetBytes(out, "type", claudeType) - out, _ = sjson.SetBytes(out, "source.media_type", mimeType) - out, _ = sjson.SetBytes(out, "source.data", data) - return out, true + return "" +} + +// interactionsClaudePartIsSendable reports whether a converted Claude block gives +// the model something to read. Blank text does not. +func interactionsClaudePartIsSendable(converted []byte) bool { + block := gjson.ParseBytes(converted) + return block.Get("type").String() != "text" || strings.TrimSpace(block.Get("text").String()) != "" } func firstClaudeInteractionsExisting(root gjson.Result, paths ...string) gjson.Result { diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index bb607095..b9d87664 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -16,6 +16,11 @@ import ( "github.com/tidwall/sjson" ) +const ( + defaultClaudeChatMaxTokens = 32000 + defaultFableChatMaxTokens = 64000 +) + // ConvertOpenAIRequestToClaude parses and transforms an OpenAI Chat Completions API request into Claude Code 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 Claude Code API. @@ -33,24 +38,27 @@ import ( // // Returns: // - []byte: The transformed request data in Claude Code API format -func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { return convertOpenAIRequestToClaude(modelName, inputRawJSON, stream, false) + } // ConvertOpenAIRequestToClaudeWithCompat preserves assistant reasoning content // as an unsigned thinking block for configured compatibility endpoints. -func ConvertOpenAIRequestToClaudeWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertOpenAIRequestToClaudeWithCompat(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { return convertOpenAIRequestToClaude(modelName, inputRawJSON, stream, true) } -func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream, preserveEmptyThinkingBlocks bool) []byte { +func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream, preserveEmptyThinkingBlocks bool) ([]byte, error) { rawJSON := inputRawJSON + var drops common.UserTurnDrops userID := common.DeriveClaudeUserID(rawJSON) // Base Claude Code API template with default max_tokens value out := []byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`) out, _ = sjson.SetBytes(out, "metadata.user_id", userID) + out, _ = sjson.SetBytes(out, "max_tokens", defaultClaudeChatMaxTokensForModel(modelName)) root := gjson.ParseBytes(rawJSON) @@ -76,8 +84,10 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream, out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") out, _ = sjson.DeleteBytes(out, "output_config.effort") default: - if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok { - effort = mapped + if !thinking.HasLevel(mi.Thinking.Levels, effort) { + if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok { + effort = mapped + } } out, _ = sjson.SetBytes(out, "thinking.type", "adaptive") out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") @@ -166,7 +176,7 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream, // instructions from the upstream request. case "system", "developer": systemStart := len(systemBlocks) - if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" { + if contentResult.Exists() && contentResult.Type == gjson.String && strings.TrimSpace(contentResult.String()) != "" { textPart := []byte(`{"type":"text","text":""}`) textPart, _ = sjson.SetBytes(textPart, "text", contentResult.String()) textPart = common.AttachCacheControl(textPart, message) @@ -174,20 +184,28 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream, } else if contentResult.Exists() && contentResult.IsArray() { contentResult.ForEach(func(_, part gjson.Result) bool { if part.Get("type").String() == "text" { + txt := part.Get("text").String() + if strings.TrimSpace(txt) == "" { + if part.Get("cache_control").Exists() && len(systemBlocks) > systemStart { + prevIdx := len(systemBlocks) - 1 + if !gjson.GetBytes(systemBlocks[prevIdx], "cache_control").Exists() { + systemBlocks[prevIdx] = common.AttachCacheControl(systemBlocks[prevIdx], part) + } + } + return true + } textPart := []byte(`{"type":"text","text":""}`) - textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String()) + textPart, _ = sjson.SetBytes(textPart, "text", txt) textPart = common.AttachCacheControl(textPart, part) systemBlocks = append(systemBlocks, textPart) } return true }) // Message-level cache_control applies to the last system block from this message. - if message.Get("cache_control").Exists() { - if len(systemBlocks) > systemStart { - lastIdx := len(systemBlocks) - 1 - if !gjson.GetBytes(systemBlocks[lastIdx], "cache_control").Exists() { - systemBlocks[lastIdx] = common.AttachCacheControl(systemBlocks[lastIdx], message) - } + if message.Get("cache_control").Exists() && len(systemBlocks) > systemStart { + lastIdx := len(systemBlocks) - 1 + if !gjson.GetBytes(systemBlocks[lastIdx], "cache_control").Exists() { + systemBlocks[lastIdx] = common.AttachCacheControl(systemBlocks[lastIdx], message) } } } @@ -202,20 +220,32 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream, } // Handle content based on its type - if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" { + if contentResult.Exists() && contentResult.Type == gjson.String && strings.TrimSpace(contentResult.String()) != "" { part := []byte(`{"type":"text","text":""}`) part, _ = sjson.SetBytes(part, "text", contentResult.String()) contentBlocks = append(contentBlocks, part) } else if contentResult.Exists() && contentResult.IsArray() { contentResult.ForEach(func(_, part gjson.Result) bool { - claudePart := convertOpenAIContentPartToClaudePart(part) - if claudePart != "" { + if claudePart := convertOpenAIContentPartToClaudePart(part); claudePart != "" { contentBlocks = append(contentBlocks, []byte(claudePart)) + } else if partType := part.Get("type").String(); partType == "text" { + if part.Get("cache_control").Exists() && len(contentBlocks) > 0 { + prevIdx := len(contentBlocks) - 1 + if !gjson.GetBytes(contentBlocks[prevIdx], "cache_control").Exists() { + contentBlocks[prevIdx] = common.AttachCacheControl(contentBlocks[prevIdx], part) + } + } + } else if role == "user" && (partType == "file" || partType == "input_audio") { + drops.Drop(partType) } return true }) } + if role == "user" { + drops.EndTurn(len(contentBlocks)) + } + // Handle tool calls (for assistant messages) if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() && role == "assistant" { toolCalls.ForEach(func(_, toolCall gjson.Result) bool { @@ -305,6 +335,9 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream, systemBlocks = append(systemBlocks, systemBlock) } + // Decided per user turn, so neither a system prompt nor the blank turn added below hides an emptied turn. + nothingLeftErr := drops.Err() + // Preserve a minimal conversational turn for system-only inputs. // Claude payloads with top-level system instructions but no messages are risky for downstream validation. if len(messageBlocks) == 0 && len(systemBlocks) > 0 { @@ -458,14 +491,18 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream, } } - return thinking.ApplyTranslatedSummaryToClaude(out, rawJSON, "openai", modelName) + return thinking.ApplyTranslatedSummaryToClaude(out, rawJSON, "openai", modelName), nothingLeftErr } func convertOpenAIContentPartToClaudePartRaw(part gjson.Result) []byte { switch part.Get("type").String() { case "text": + txt := part.Get("text").String() + if strings.TrimSpace(txt) == "" { + return nil + } textPart := []byte(`{"type":"text","text":""}`) - textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String()) + textPart, _ = sjson.SetBytes(textPart, "text", txt) return textPart case "image_url": @@ -536,8 +573,13 @@ func convertOpenAIToolResultContent(content gjson.Result) (string, bool) { if content.IsArray() { claudeParts := make([][]byte, 0, 4) + droppedEmptyText := false content.ForEach(func(_, part gjson.Result) bool { if part.Type == gjson.String { + if strings.TrimSpace(part.String()) == "" { + droppedEmptyText = true + return true + } textPart := []byte(`{"type":"text","text":""}`) textPart, _ = sjson.SetBytes(textPart, "text", part.String()) claudeParts = append(claudeParts, textPart) @@ -546,6 +588,8 @@ func convertOpenAIToolResultContent(content gjson.Result) (string, bool) { if claudePart := convertOpenAIContentPartToClaudePartRaw(part); len(claudePart) > 0 { claudeParts = append(claudeParts, claudePart) + } else if part.Get("type").String() == "text" { + droppedEmptyText = true } return true }) @@ -553,6 +597,9 @@ func convertOpenAIToolResultContent(content gjson.Result) (string, bool) { if len(claudeParts) > 0 || len(content.Array()) == 0 { return string(common.JoinRawArray(claudeParts)), true } + if droppedEmptyText { + return "", false + } return content.Raw, false } @@ -567,10 +614,29 @@ func convertOpenAIToolResultContent(content gjson.Result) (string, bool) { return content.Raw, false } -// firstExisting returns the first result that exists, or an empty result. +func defaultClaudeChatMaxTokensForModel(modelName string) int { + normalized := strings.ToLower(strings.TrimSpace(modelName)) + maxTokens := defaultClaudeChatMaxTokens + if strings.Contains(normalized, "fable") { + maxTokens = defaultFableChatMaxTokens + } + info := registry.LookupModelInfo(modelName, "claude") + if info == nil || info.MaxCompletionTokens <= 0 { + return maxTokens + } + // Fable keeps its conservative omitted-field ceiling. Every other registered + // model uses its output limit, including when that limit is above the + // historical 32000 default. + if strings.Contains(normalized, "fable") && info.MaxCompletionTokens >= maxTokens { + return maxTokens + } + return info.MaxCompletionTokens +} + +// firstExisting returns the first non-null result that exists, or an empty result. func firstExisting(values ...gjson.Result) gjson.Result { for _, value := range values { - if value.Exists() { + if value.Exists() && value.Type != gjson.Null { return value } } diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_response.go b/internal/translator/claude/openai/chat-completions/claude_openai_response.go index 88876fe1..bec2e46c 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_response.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_response.go @@ -12,6 +12,7 @@ import ( "strings" "time" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -37,6 +38,8 @@ type claudeUsageTokens struct { OutputTokens int64 CacheCreationInputTokens int64 CacheReadInputTokens int64 + ThinkingTokens int64 + HasThinkingTokens bool HasUsage bool } @@ -65,6 +68,21 @@ func (u *claudeUsageTokens) Merge(usage gjson.Result) { if cacheReadInputTokens := usage.Get("cache_read_input_tokens"); cacheReadInputTokens.Exists() { u.CacheReadInputTokens = cacheReadInputTokens.Int() } + if thinkingTokens := usage.Get("output_tokens_details.thinking_tokens"); thinkingTokens.Exists() { + u.ThinkingTokens = thinkingTokens.Int() + u.HasThinkingTokens = true + } +} + +// applyReasoningTokens maps Claude's output_tokens_details.thinking_tokens to OpenAI's +// completion_tokens_details.reasoning_tokens. Both are a subset of the output token count. +// The field is left out when upstream did not report it, so clients never see a false 0. +func (u claudeUsageTokens) applyReasoningTokens(out []byte) []byte { + if !u.HasThinkingTokens { + return out + } + out, _ = sjson.SetBytes(out, "usage.completion_tokens_details.reasoning_tokens", u.ThinkingTokens) + return out } func (u claudeUsageTokens) OpenAIUsage() (promptTokens, completionTokens, totalTokens, cachedTokens, cachedCreationTokens int64) { @@ -257,6 +275,7 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokens) template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_creation_tokens", cachedCreationTokens) template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cache_write_tokens", cachedCreationTokens) + template = (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.applyReasoningTokens(template) } return [][]byte{template} @@ -282,6 +301,7 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original usageTemplate, _ = sjson.SetBytes(usageTemplate, "usage.prompt_tokens_details.cached_tokens", cachedTokens) usageTemplate, _ = sjson.SetBytes(usageTemplate, "usage.prompt_tokens_details.cached_creation_tokens", cachedCreationTokens) usageTemplate, _ = sjson.SetBytes(usageTemplate, "usage.prompt_tokens_details.cache_write_tokens", cachedCreationTokens) + usageTemplate = p.Usage.applyReasoningTokens(usageTemplate) return [][]byte{usageTemplate} } return [][]byte{} @@ -313,7 +333,7 @@ func mapAnthropicStopReasonToOpenAI(anthropicReason string) string { return "stop" case "tool_use": return "tool_calls" - case "max_tokens": + case "max_tokens", "model_context_window_exceeded": return "length" case "stop_sequence": return "stop" @@ -338,6 +358,7 @@ func mapAnthropicStopReasonToOpenAI(anthropicReason string) string { // Returns: // - []byte: An OpenAI-compatible JSON response containing all message content and metadata func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { + rawJSON, _ = translatorcommon.ClaudeMessagesJSONToSSE(rawJSON) chunks := make([][]byte, 0) lines := bytes.Split(rawJSON, []byte("\n")) @@ -447,6 +468,7 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_tokens", cachedTokens) out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_creation_tokens", cachedCreationTokens) out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cache_write_tokens", cachedCreationTokens) + out = usageTokens.applyReasoningTokens(out) } // Set basic response fields including message ID, creation time, and model @@ -494,9 +516,9 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina out, _ = sjson.SetBytes(out, argumentsPath, arguments) toolCallsCount++ } - if toolCallsCount > 0 { + if finishReason := mapAnthropicStopReasonToOpenAI(stopReason); toolCallsCount > 0 && finishReason != "length" { out, _ = sjson.SetBytes(out, "choices.0.finish_reason", "tool_calls") - } else if finishReason := mapAnthropicStopReasonToOpenAI(stopReason); finishReason != "stop" { + } else if finishReason != "stop" { out, _ = sjson.SetBytes(out, "choices.0.finish_reason", finishReason) } } else if finishReason := mapAnthropicStopReasonToOpenAI(stopReason); finishReason != "stop" { diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 9c880b7c..9d2c43b7 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -1,6 +1,7 @@ package responses import ( + "context" "strconv" "strings" "unicode/utf16" @@ -13,6 +14,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v8/internal/util" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -33,18 +35,36 @@ const ( // - top-level tools and input[].additional_tools -> Claude tools[].input_schema // - max_output_tokens -> max_tokens // - stream passthrough via parameter -func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte { - return convertOpenAIResponsesRequestToClaude(modelName, inputRawJSON, stream, false) +func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertOpenAIResponsesRequestToClaude(modelName, inputRawJSON, stream, false, ClientToolSearchEnabled()) + } // ConvertOpenAIResponsesRequestToClaudeWithCompat preserves reasoning items // whose encrypted content is empty for configured compatibility endpoints. -func ConvertOpenAIResponsesRequestToClaudeWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte { - return convertOpenAIResponsesRequestToClaude(modelName, inputRawJSON, stream, true) +func ConvertOpenAIResponsesRequestToClaudeWithCompat(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertOpenAIResponsesRequestToClaude(modelName, inputRawJSON, stream, true, ClientToolSearchEnabled()) +} + +// ConvertOpenAIResponsesRequestEnvelopeToClaude translates with the +// upstream.claude.tool-search value pinned in ctx, if any. +func ConvertOpenAIResponsesRequestEnvelopeToClaude(ctx context.Context, req sdktranslator.RequestEnvelope) sdktranslator.RequestEnvelope { + req.Body, req.Err = convertOpenAIResponsesRequestToClaude(req.Model, req.Body, req.Stream, false, ClientToolSearchEnabledFor(ctx)) + return req +} + +// ConvertOpenAIResponsesRequestToClaudeWithCompatContext is +// ConvertOpenAIResponsesRequestToClaudeWithCompat with the +// upstream.claude.tool-search value pinned in ctx, if any. +func ConvertOpenAIResponsesRequestToClaudeWithCompatContext(ctx context.Context, modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertOpenAIResponsesRequestToClaude(modelName, inputRawJSON, stream, true, ClientToolSearchEnabledFor(ctx)) } -func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream, preserveEmptyThinkingBlocks bool) []byte { +// toolSearch carries upstream.claude.tool-search for the whole translation so +// a config reload cannot mix the two behaviors within one request. +func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream, preserveEmptyThinkingBlocks, toolSearch bool) ([]byte, error) { rawJSON := normalizeCodexAgentMessages(inputRawJSON) + var drops common.UserTurnDrops userID := common.DeriveClaudeUserID(rawJSON) @@ -77,8 +97,10 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") out, _ = sjson.DeleteBytes(out, "output_config.effort") default: - if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok { - effort = mapped + if !thinking.HasLevel(mi.Thinking.Levels, effort) { + if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok { + effort = mapped + } } out, _ = sjson.SetBytes(out, "thinking.type", "adaptive") out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") @@ -137,7 +159,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte messageBlocks := common.NewRawArrayItems(messageCapacity) systemBlocks := make([][]byte, 0, 4) appendSystemText := func(text string, cacheSource gjson.Result) { - if text == "" { + if strings.TrimSpace(text) == "" { return } block := []byte(`{"type":"text","text":""}`) @@ -163,7 +185,17 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte content.ForEach(func(_, part gjson.Result) bool { switch part.Get("type").String() { case "input_text", "output_text", "text": - appendSystemText(part.Get("text").String(), part) + txt := part.Get("text").String() + if strings.TrimSpace(txt) == "" { + if part.Get("cache_control").Exists() && len(systemBlocks) > startIdx { + prevIdx := len(systemBlocks) - 1 + if !gjson.GetBytes(systemBlocks[prevIdx], "cache_control").Exists() { + systemBlocks[prevIdx] = common.AttachCacheControl(systemBlocks[prevIdx], part) + } + } + return true + } + appendSystemText(txt, part) default: if block := responsesSystemUnsupportedBlock(part); len(block) > 0 { systemBlocks = append(systemBlocks, block) @@ -191,7 +223,11 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte appendSystemText(formatInstruction, gjson.Result{}) } - names := buildClaudeToolNames(root) + names := buildClaudeToolNames(root, toolSearch) + winners := responsesToolWinners(root, toolSearch) + // Native tool search declares discovered tools as deferred and replays + // search results as tool_reference blocks, which keeps the cached prefix. + nativeToolSearch := responsesClientToolSearchWins(responsesToolDescriptors(root, toolSearch)) // input array processing var pendingRole string @@ -286,7 +322,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte if input := root.Get("input"); input.Exists() { if input.IsArray() { inputItems = common.NormalizeResponsesToolCallOutputs(input.Array()) - } else if input.Type == gjson.String { + } else if input.Type == gjson.String && strings.TrimSpace(input.String()) != "" { contentPart := []byte(`{"type":"text","text":""}`) contentPart, _ = sjson.SetBytes(contentPart, "text", input.String()) appendParts("user", contentPart) @@ -306,8 +342,9 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte emittedToolResults := map[string]struct{}{} emittedRawToolUses := map[string]struct{}{} unmappedItemTypes := map[string]int{} + webSearchHistory := scanResponsesWebSearchHistory(inputItems) - for _, item := range inputItems { + for itemIndex, item := range inputItems { // System-level items already became top-level system blocks. if isResponsesSystemLevelRole(item.Get("role").String()) { continue @@ -321,6 +358,16 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte // Determine role and construct Claude-compatible content parts. var role string var partsJSON [][]byte + var droppedPartType string + attachCacheControlToPrecedingPart := func(src gjson.Result) { + if !src.Get("cache_control").Exists() || len(partsJSON) == 0 { + return + } + prevIdx := len(partsJSON) - 1 + if !gjson.GetBytes(partsJSON[prevIdx], "cache_control").Exists() { + partsJSON[prevIdx] = common.AttachCacheControl(partsJSON[prevIdx], src) + } + } if parts := item.Get("content"); parts.Exists() && parts.IsArray() { parts.ForEach(func(_, part gjson.Result) bool { ptype := part.Get("type").String() @@ -328,11 +375,15 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte case "input_text", "output_text": if t := part.Get("text"); t.Exists() { txt := t.String() - contentPart := []byte(`{"type":"text","text":""}`) - contentPart, _ = sjson.SetBytes(contentPart, "text", txt) - contentPart = attachClaudeCitations(contentPart, part.Get("annotations")) - contentPart = common.AttachCacheControl(contentPart, part) - partsJSON = append(partsJSON, contentPart) + if strings.TrimSpace(txt) != "" { + contentPart := []byte(`{"type":"text","text":""}`) + contentPart, _ = sjson.SetBytes(contentPart, "text", txt) + contentPart = attachClaudeCitations(contentPart, part.Get("annotations")) + contentPart = common.AttachCacheControl(contentPart, part) + partsJSON = append(partsJSON, contentPart) + } else { + attachCacheControlToPrecedingPart(part) + } } if ptype == "input_text" { role = "user" @@ -341,11 +392,16 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte } case "refusal": // Claude has no refusal block; the text keeps the turn intact. - if t := part.Get("refusal"); t.Exists() && t.String() != "" { - contentPart := []byte(`{"type":"text","text":""}`) - contentPart, _ = sjson.SetBytes(contentPart, "text", t.String()) - contentPart = common.AttachCacheControl(contentPart, part) - partsJSON = append(partsJSON, contentPart) + if t := part.Get("refusal"); t.Exists() { + txt := t.String() + if strings.TrimSpace(txt) != "" { + contentPart := []byte(`{"type":"text","text":""}`) + contentPart, _ = sjson.SetBytes(contentPart, "text", txt) + contentPart = common.AttachCacheControl(contentPart, part) + partsJSON = append(partsJSON, contentPart) + } else { + attachCacheControlToPrecedingPart(part) + } } role = "assistant" case "input_image": @@ -406,11 +462,18 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte if role == "" { role = "user" } + } else if droppedPartType == "" { + droppedPartType = ptype + } + case "input_audio": + // Claude has no audio block, so the part cannot be sent. + if droppedPartType == "" { + droppedPartType = ptype } } return true }) - } else if parts.Type == gjson.String && parts.String() != "" { + } else if parts.Type == gjson.String && strings.TrimSpace(parts.String()) != "" { contentPart := []byte(`{"type":"text","text":""}`) contentPart, _ = sjson.SetBytes(contentPart, "text", parts.String()) partsJSON = append(partsJSON, contentPart) @@ -427,6 +490,13 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte } } + if role == "user" { + if droppedPartType != "" { + drops.Drop(droppedPartType) + } + drops.EndTurn(len(partsJSON)) + } + if len(partsJSON) > 0 { lastIdx := len(partsJSON) - 1 if !gjson.GetBytes(partsJSON[lastIdx], "cache_control").Exists() { @@ -438,7 +508,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte case "web_search_call": // Rebuild the Claude server-side search pair so the replayed turn // still shows the search and its hits. - if blocks := convertResponsesWebSearchCallToClaudeBlocks(item); len(blocks) > 0 { + if blocks := webSearchHistory.claudeBlocks(itemIndex, item); len(blocks) > 0 { appendParts("assistant", blocks...) } @@ -490,12 +560,13 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte } emittedToolResults[rawID] = struct{}{} } - output := item.Get("output") + outputItem := item if rawID != "" { if lastItem, exists := lastToolResult[rawID]; exists { - output = lastItem.Get("output") + outputItem = lastItem } } + output := outputItem.Get("output") // Standalone outputs (no call_id, or one that never paired with a // function_call in this input, e.g. the create_thread delegation seed) // have no tool_use to attach to. Claude rejects orphan tool_result @@ -509,10 +580,52 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte callID := util.SanitizeClaudeToolID(rawID) toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`) toolResult, _ = sjson.SetBytes(toolResult, "tool_use_id", callID) + // Responses reports an unfinished tool output as status + // "incomplete"; Claude's only in-band equivalent is is_error + // on tool_result, so carry the signal over instead of dropping it. + if strings.EqualFold(strings.TrimSpace(outputItem.Get("status").String()), "incomplete") { + toolResult, _ = sjson.SetBytes(toolResult, "is_error", true) + } toolResult = applyResponsesToolResultContent(toolResult, output) appendParts("user", toolResult) + case "tool_search_call": + if !toolSearch { + unmappedItemTypes[typ]++ + continue + } + toolUse, rawCallID := convertResponsesToolSearchCallToClaude(item, names.claudeName(responsesToolSearchType)) + if toolUse == nil { + unmappedItemTypes[typ]++ + continue + } + emittedRawToolUses[rawCallID] = struct{}{} + appendToolUse(toolUse) + + case "tool_search_output": + if !toolSearch { + unmappedItemTypes[typ]++ + continue + } + rawID := strings.TrimSpace(item.Get("call_id").String()) + if rawID != "" { + if _, exists := emittedToolResults[rawID]; exists { + continue + } + emittedToolResults[rawID] = struct{}{} + } + if _, paired := emittedRawToolUses[rawID]; rawID == "" || !paired { + text := []byte(`{"type":"text","text":""}`) + text, _ = sjson.SetBytes(text, "text", responsesToolSearchOutputText(item, names)) + appendParts("user", text) + continue + } + toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`) + toolResult, _ = sjson.SetBytes(toolResult, "tool_use_id", util.SanitizeClaudeToolID(rawID)) + toolResult, _ = sjson.SetRawBytes(toolResult, "content", responsesToolSearchOutputContent(item, names, winners, nativeToolSearch)) + appendParts("user", toolResult) + default: // Reachability guard: Claude only ever receives the item types this // switch handles. A new one means the client gained a capability @@ -560,8 +673,10 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte // one winner for each final name, while keeping the original order for the // tools that survive conversion. var toolItems [][]byte - winners := responsesToolWinners(root) - for _, descriptor := range responsesToolDescriptors(root) { + // The deferred anchor goes between declared and discovered tools, so each + // discovery only appends to tools[]. + anchorIndex := -1 + for _, descriptor := range responsesToolDescriptors(root, toolSearch) { winner, ok := winners[descriptor.name] if !ok || winner.order != descriptor.order { continue @@ -571,6 +686,14 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte if !ok { continue } + if nativeToolSearch && descriptor.sourcePriority == responsesDiscoveredToolPriority { + if anchorIndex < 0 { + anchorIndex = len(toolItems) + } + // Anthropic rejects cache_control on deferred tools. + tJSON, _ = sjson.DeleteBytes(tJSON, "cache_control") + tJSON, _ = sjson.SetBytes(tJSON, "defer_loading", true) + } toolName := gjson.GetBytes(tJSON, "name").String() if toolName != "" { includedToolNames[descriptor.name] = struct{}{} @@ -578,12 +701,22 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte } toolItems = append(toolItems, tJSON) } - toolNameMap = responsesToolNameMap(root, includedToolNames) + if nativeToolSearch { + if anchorIndex < 0 { + anchorIndex = len(toolItems) + } + if anchor, ok := responsesDeferredAnchorTool(toolItems); ok { + toolItems = append(toolItems[:anchorIndex], append([][]byte{anchor}, toolItems[anchorIndex:]...)...) + } + } + toolNameMap = responsesToolNameMap(root, includedToolNames, toolSearch) if len(toolItems) > 0 { out, _ = sjson.SetRawBytes(out, "tools", common.JoinRawArray(toolItems)) } // Map tool_choice similar to Chat Completions translator (optional in docs, safe to handle) + toolChoiceNone := false + forcedToolFilteredOut := false if toolChoice := root.Get("tool_choice"); toolChoice.Exists() { switch toolChoice.Type { case gjson.String: @@ -592,14 +725,19 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`)) case "none": // Leave unset; implies no tools + toolChoiceNone = true case "required": if len(includedToolNames) > 0 { out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`)) + } else { + forcedToolFilteredOut = true } } case gjson.JSON: - choiceType := toolChoice.Get("type").String() - if choiceType == "function" || choiceType == "custom" { + choiceType := strings.TrimSpace(toolChoice.Get("type").String()) + if choiceType == "none" { + toolChoiceNone = true + } else if choiceType == "function" || choiceType == "custom" { fn := toolChoice.Get("function.name").String() if fn == "" { fn = toolChoice.Get("custom.name").String() @@ -624,6 +762,12 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte toolChoiceJSON := []byte(`{"name":"","type":"tool"}`) toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", names.claudeName(fn)) out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON) + } else { + forcedToolFilteredOut = true + } + } else if choiceType != "" && choiceType != "auto" { + if _, ok := includedToolNames[choiceType]; !ok { + forcedToolFilteredOut = true } } default: @@ -631,18 +775,40 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte } } - return thinking.ApplyTranslatedSummaryToClaude(out, rawJSON, "openai-response", modelName) + // parallel_tool_calls=false maps to Anthropic's disable_parallel_tool_use, + // mirroring the Chat Completions translator. It is not added when the + // effective choice is none, when no tool survived conversion, or when a + // forced tool was filtered out (no tool_choice was produced for it). + if parallelToolCalls := root.Get("parallel_tool_calls"); parallelToolCalls.Type == gjson.False && !toolChoiceNone && !forcedToolFilteredOut && len(toolItems) > 0 { + if choice := gjson.GetBytes(out, "tool_choice"); choice.Exists() { + if choice.Get("type").String() != "none" { + out, _ = sjson.SetBytes(out, "tool_choice.disable_parallel_tool_use", true) + } + } else { + out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto","disable_parallel_tool_use":true}`)) + } + } + + return thinking.ApplyTranslatedSummaryToClaude(out, rawJSON, "openai-response", modelName), drops.Err() } func defaultClaudeResponsesMaxTokensForModel(modelName string) int { + normalized := strings.ToLower(strings.TrimSpace(modelName)) maxTokens := defaultClaudeResponsesMaxTokens - if strings.Contains(strings.ToLower(strings.TrimSpace(modelName)), "fable") { + if strings.Contains(normalized, "fable") { maxTokens = defaultFableResponsesMaxTokens } - if info := registry.LookupModelInfo(modelName, "claude"); info != nil && info.MaxCompletionTokens > 0 && info.MaxCompletionTokens < maxTokens { - return info.MaxCompletionTokens + info := registry.LookupModelInfo(modelName, "claude") + if info == nil || info.MaxCompletionTokens <= 0 { + return maxTokens } - return maxTokens + // Fable keeps its conservative omitted-field ceiling. Every other registered + // model uses its output limit, including when that limit is above the + // historical 32000 default. + if strings.Contains(normalized, "fable") && info.MaxCompletionTokens >= maxTokens { + return maxTokens + } + return info.MaxCompletionTokens } // isResponsesSystemLevelRole reports whether an input item carries system-level @@ -659,7 +825,9 @@ func isResponsesSystemLevelRole(role string) bool { // dropUnsupportedClaudeAssistantPrefill removes a trailing assistant message for // Claude model families that reject assistant message prefill (e.g., Fable, -// Opus 5, Sonnet 4.6). +// Opus 5, Sonnet 4.6, Haiku 5.5), except when the message ends on a server-tool +// block (server_tool_use or web_search_tool_result) from a pause_turn response +// that the continuation must send back. func dropUnsupportedClaudeAssistantPrefill(modelName string, messages [][]byte) [][]byte { if !claudeModelRejectsAssistantPrefill(modelName) || len(messages) == 0 { return messages @@ -668,9 +836,29 @@ func dropUnsupportedClaudeAssistantPrefill(modelName string, messages [][]byte) if !strings.EqualFold(strings.TrimSpace(last.Get("role").String()), "assistant") { return messages } + if claudeAssistantMessageEndsWithPausedServerTool(last) { + return messages + } return messages[:len(messages)-1] } +func claudeAssistantMessageEndsWithPausedServerTool(message gjson.Result) bool { + content := message.Get("content") + if !content.IsArray() { + return false + } + parts := content.Array() + if len(parts) == 0 { + return false + } + switch strings.ToLower(strings.TrimSpace(parts[len(parts)-1].Get("type").String())) { + case "server_tool_use", "web_search_tool_result": + return true + default: + return false + } +} + // stripTrailingClaudeThinkingBlocks removes trailing thinking and // redacted_thinking blocks from the final assistant message. Anthropic rejects // requests whose final assistant content block is thinking. If no content @@ -727,12 +915,39 @@ func stripTrailingClaudeThinkingBlocks(messages [][]byte) [][]byte { // disallows trailing assistant prefill in its conversation history. func claudeModelRejectsAssistantPrefill(modelName string) bool { normalized := strings.ToLower(strings.TrimSpace(modelName)) - for _, family := range []string{"fable", "opus-5", "sonnet-4-6"} { - if strings.Contains(normalized, family) { - return true + // Provider namespaces are not part of the model family. + if index := strings.LastIndexByte(normalized, '/'); index >= 0 { + normalized = normalized[index+1:] + } + normalized = strings.TrimPrefix(normalized, "claude-") + tokens := strings.Split(strings.ReplaceAll(normalized, ".", "-"), "-") + if tokens[0] == "fable" { + return true + } + if len(tokens) < 2 || (tokens[0] != "opus" && tokens[0] != "sonnet" && tokens[0] != "haiku") { + return false + } + parseVersion := func(token string) int { + // Eight-digit snapshot dates must never be treated as versions. + if token == "" || len(token) >= 8 { + return -1 + } + for _, digit := range token { + if digit < '0' || digit > '9' { + return -1 + } + } + version, errAtoi := strconv.Atoi(token) + if errAtoi != nil { + return -1 } + return version } - return false + major := parseVersion(tokens[1]) + if major >= 5 { + return true + } + return tokens[0] == "sonnet" && major == 4 && len(tokens) > 2 && parseVersion(tokens[2]) >= 6 } // responsesSystemUnsupportedBlock represents a system-level content part that @@ -855,8 +1070,13 @@ func applyResponsesToolResultContent(toolResult []byte, output gjson.Result) []b var partsJSON [][]byte hasImage := false hasFile := false + droppedEmptyPart := false output.ForEach(func(_, part gjson.Result) bool { if partJSON := convertResponsesContentPartToClaude(part); len(partJSON) > 0 { + if !contentPartHasVisibleContent(partJSON) { + droppedEmptyPart = true + return true + } partsJSON = append(partsJSON, partJSON) partType := gjson.ParseBytes(partJSON).Get("type").String() if partType == "image" { @@ -869,6 +1089,10 @@ func applyResponsesToolResultContent(toolResult []byte, output gjson.Result) []b return true }) if len(partsJSON) == 0 { + if droppedEmptyPart { + toolResult, _ = sjson.SetBytes(toolResult, "content", "") + return toolResult + } toolResult, _ = sjson.SetBytes(toolResult, "content", output.Raw) return toolResult } @@ -1261,6 +1485,9 @@ func convertResponsesToolDescriptorToClaude(descriptor responsesToolDescriptor, if overrideName == "" && !descriptor.direct { overrideName = descriptor.name } + if descriptor.clientSearch { + return convertResponsesToolSearchToolToClaude(descriptor.tool, claudeName) + } switch descriptor.toolType { case "function": return convertResponsesFunctionToolToClaude(descriptor.tool, overrideName) @@ -1279,12 +1506,16 @@ func convertResponsesToolDescriptorToClaude(descriptor responsesToolDescriptor, } } +// responsesDiscoveredToolPriority ranks tools loaded by a tool_search_output +// after every declared tool, so they append to tools[] in discovery order. +const responsesDiscoveredToolPriority = 2 + type responsesToolSource struct { tools gjson.Result - priority int // Top-level tools use 0; all additional_tools sources use 1. + priority int // Top-level tools use 0, additional_tools 1, tool_search_output results 2. } -func responsesToolSources(root gjson.Result) []responsesToolSource { +func responsesToolSources(root gjson.Result, toolSearch bool) []responsesToolSource { var sources []responsesToolSource appendSource := func(tools gjson.Result, priority int) { if tools.Exists() && tools.IsArray() { @@ -1300,6 +1531,14 @@ func responsesToolSources(root gjson.Result) []responsesToolSource { } return true }) + if toolSearch { + input.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "tool_search_output" { + appendSource(item.Get("tools"), responsesDiscoveredToolPriority) + } + return true + }) + } } return sources } @@ -1313,11 +1552,47 @@ type responsesToolDescriptor struct { sourcePriority int direct bool order int + // clientSearch marks the Codex client tool_search declaration, which only + // exists while upstream.claude.tool-search is on. + clientSearch bool } -func responsesToolDescriptors(root gjson.Result) []responsesToolDescriptor { +func responsesToolDescriptors(root gjson.Result, toolSearch bool) []responsesToolDescriptor { var descriptors []responsesToolDescriptor - appendDescriptor := func(tool gjson.Result, name, childName, namespaceName, toolType string, sourcePriority int, direct bool) { + for _, source := range responsesToolSources(root, toolSearch) { + descriptors = appendResponsesToolDescriptors(descriptors, source.tools, source.priority, toolSearch) + } + if !responsesClientToolSearchWins(descriptors) { + return descriptors + } + // With client tool search, declared defer_loading tools stay hidden until + // a tool_search_output in history loads them. + kept := descriptors[:0] + for _, descriptor := range descriptors { + if descriptor.sourcePriority != responsesDiscoveredToolPriority && descriptor.tool.Get("defer_loading").Bool() { + continue + } + descriptor.order = len(kept) + kept = append(kept, descriptor) + } + return kept +} + +// responsesClientToolSearchWins reports whether the client tool_search +// declaration owns the tool_search name. A same-named function that wins +// instead leaves no search tool, so deferred tools must stay declared. +func responsesClientToolSearchWins(descriptors []responsesToolDescriptor) bool { + var winner *responsesToolDescriptor + for i := range descriptors { + if descriptors[i].name == responsesToolSearchType && (winner == nil || responsesToolDescriptorPrecedes(descriptors[i], *winner)) { + winner = &descriptors[i] + } + } + return winner != nil && winner.clientSearch +} + +func appendResponsesToolDescriptors(descriptors []responsesToolDescriptor, tools gjson.Result, sourcePriority int, toolSearch bool) []responsesToolDescriptor { + appendDescriptor := func(tool gjson.Result, name, childName, namespaceName, toolType string, direct bool) { if name == "" { return } @@ -1332,7 +1607,7 @@ func responsesToolDescriptors(root gjson.Result) []responsesToolDescriptor { order: len(descriptors), }) } - appendNamespaceChildren := func(namespaceTool gjson.Result, sourcePriority int) { + appendNamespaceChildren := func(namespaceTool gjson.Result) { namespaceName := strings.TrimSpace(namespaceTool.Get("name").String()) children := namespaceTool.Get("tools") if !children.Exists() || !children.IsArray() { @@ -1346,41 +1621,52 @@ func responsesToolDescriptors(root gjson.Result) []responsesToolDescriptor { qualifiedName := qualifyResponsesNamespaceToolName(namespaceName, childName) switch strings.TrimSpace(child.Get("type").String()) { case "", "function": - appendDescriptor(child, qualifiedName, childName, namespaceName, "function", sourcePriority, false) + appendDescriptor(child, qualifiedName, childName, namespaceName, "function", false) case "custom": - appendDescriptor(child, qualifiedName, childName, namespaceName, "custom", sourcePriority, false) + appendDescriptor(child, qualifiedName, childName, namespaceName, "custom", false) } return true }) } - for _, source := range responsesToolSources(root) { - source.tools.ForEach(func(_, tool gjson.Result) bool { - toolType := strings.TrimSpace(tool.Get("type").String()) - switch toolType { - case "", "function": - appendDescriptor(tool, responsesToolName(tool), "", "", "function", source.priority, true) - case "custom": - appendDescriptor(tool, responsesToolName(tool), "", "", "custom", source.priority, true) - case "namespace": - appendNamespaceChildren(tool, source.priority) - case "web_search": - if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() { - return true - } - name := strings.TrimSpace(tool.Get("name").String()) - if name == "" { - name = "web_search" - } - appendDescriptor(tool, name, "", "", "web_search", source.priority, true) - default: - if isUnsupportedOpenAIBuiltinToolType(toolType) { - return true - } - appendDescriptor(tool, strings.TrimSpace(tool.Get("name").String()), "", "", toolType, source.priority, true) + tools.ForEach(func(_, tool gjson.Result) bool { + toolType := strings.TrimSpace(tool.Get("type").String()) + switch toolType { + case "", "function": + appendDescriptor(tool, responsesToolName(tool), "", "", "function", true) + case "custom": + appendDescriptor(tool, responsesToolName(tool), "", "", "custom", true) + case "namespace": + appendNamespaceChildren(tool) + case responsesToolSearchType: + if !toolSearch { + // Without tool search support a declaration is a generic tool, + // as it was before tool_search was translated. + appendDescriptor(tool, strings.TrimSpace(tool.Get("name").String()), "", "", toolType, true) + return true } - return true - }) - } + // Hosted search cannot run on Claude, and the raw declaration must + // never reach Claude, so only client search is kept. + if isResponsesClientToolSearch(tool) { + appendDescriptor(tool, responsesToolSearchType, "", "", responsesToolSearchType, true) + descriptors[len(descriptors)-1].clientSearch = true + } + case "web_search": + if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() { + return true + } + name := strings.TrimSpace(tool.Get("name").String()) + if name == "" { + name = "web_search" + } + appendDescriptor(tool, name, "", "", "web_search", true) + default: + if isUnsupportedOpenAIBuiltinToolType(toolType) { + return true + } + appendDescriptor(tool, strings.TrimSpace(tool.Get("name").String()), "", "", toolType, true) + } + return true + }) return descriptors } @@ -1396,9 +1682,9 @@ func responsesToolDescriptorPrecedes(left, right responsesToolDescriptor) bool { return left.order < right.order } -func responsesToolWinners(root gjson.Result) map[string]responsesToolDescriptor { +func responsesToolWinners(root gjson.Result, toolSearch bool) map[string]responsesToolDescriptor { winners := map[string]responsesToolDescriptor{} - for _, descriptor := range responsesToolDescriptors(root) { + for _, descriptor := range responsesToolDescriptors(root, toolSearch) { current, exists := winners[descriptor.name] if !exists || responsesToolDescriptorPrecedes(descriptor, current) { winners[descriptor.name] = descriptor @@ -1407,10 +1693,10 @@ func responsesToolWinners(root gjson.Result) map[string]responsesToolDescriptor return winners } -func responsesToolNameMap(root gjson.Result, acceptedToolNames map[string]struct{}) map[string]string { +func responsesToolNameMap(root gjson.Result, acceptedToolNames map[string]struct{}, toolSearch bool) map[string]string { toolNameMap := map[string]string{} - descriptors := responsesToolDescriptors(root) - winners := responsesToolWinners(root) + descriptors := responsesToolDescriptors(root, toolSearch) + winners := responsesToolWinners(root, toolSearch) // Direct tool names are canonical aliases and must win over namespace // child aliases, regardless of declaration order. @@ -1446,8 +1732,9 @@ func responsesToolNameMap(root gjson.Result, acceptedToolNames map[string]struct func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} { names := make(map[string]struct{}) root := gjson.ParseBytes(requestRawJSON) - winners := responsesToolWinners(root) - toolNames := buildClaudeToolNamesWithWinners(root, winners) + toolSearch := ClientToolSearchEnabled() + winners := responsesToolWinners(root, toolSearch) + toolNames := buildClaudeToolNamesWithWinners(root, winners, toolSearch) for name, descriptor := range winners { if descriptor.toolType == "custom" { names[name] = struct{}{} @@ -1644,15 +1931,15 @@ func qualifyResponsesNamespaceToolName(namespaceName, childName string) string { return namespaceName + "__" + childName } -func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, qualifiedName string) (name, namespace string) { +func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, qualifiedName string, toolSearch bool) (name, namespace string) { qualifiedName = strings.TrimSpace(qualifiedName) if qualifiedName == "" { return "", "" } root := gjson.ParseBytes(requestRawJSON) - winners := responsesToolWinners(root) - toolNames := buildClaudeToolNamesWithWinners(root, winners) + winners := responsesToolWinners(root, toolSearch) + toolNames := buildClaudeToolNamesWithWinners(root, winners, toolSearch) identity := toolNames.identity(qualifiedName) descriptor, ok := winners[identity] diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go index 1a8a6e69..e8544176 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -17,7 +17,13 @@ import ( type claudeToResponsesState struct { translatorcommon.ApplyPatchErrorState - ApplyPatchCalls map[string]*translatorcommon.ApplyPatchCallState + ApplyPatchCalls map[string]*translatorcommon.ApplyPatchCallState + // ToolSearch is upstream.claude.tool-search as pinned for this response. + ToolSearch bool + // RequestEcho holds the request fields echoed into the completed + // response, extracted once in the constructor so the request body + // itself is neither retained nor re-scanned per chunk. + RequestEcho responsesRequestEcho ToolWinners map[string]responsesToolDescriptor ToolNames claudeToolNames FuncItemAdded map[int]bool @@ -165,17 +171,36 @@ func (u claudeResponsesUsageTokens) OpenAIResponsesUsage() (inputTokens, outputT func claudeResponsesIncompleteDetails(stopReason string) ([]byte, bool) { switch strings.ToLower(strings.TrimSpace(stopReason)) { - case "max_tokens": + case "max_tokens", "model_context_window_exceeded": return []byte(`{"reason":"max_output_tokens"}`), true - case "pause_turn": - // Anthropic's server-tool iteration pause is unfinished, not a token limit: - // https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons#pause_turn - // Responses has no matching incomplete reason, so keep its nullable details null. - return []byte(`null`), true + case "refusal": + // Safety classifiers stop the turn with a normal 200. Responses clients + // treat content_filter as a retryable refusal instead of a finished answer: + // https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons#refusal + return []byte(`{"reason":"content_filter"}`), true } return nil, false } +// claudeStopReasonPauseTurn reports Anthropic's server-tool iteration pause. +// The turn is unfinished but valid: the client continues it by sending the +// paused assistant content back unchanged, so Responses ends the response as +// completed with end_turn=false instead of an error: +// https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons#pause_turn +func claudeStopReasonPauseTurn(stopReason string) bool { + return strings.EqualFold(strings.TrimSpace(stopReason), "pause_turn") +} + +// claudeResponsesWebSearchStatus keeps a paused or tool_use-deferred search +// that never received its result in_progress, so the next request can send it +// back without a result. +func claudeResponsesWebSearchStatus(stopReason, status string, hasResults bool) string { + if !hasResults && (claudeStopReasonPauseTurn(stopReason) || strings.EqualFold(strings.TrimSpace(stopReason), "tool_use")) { + return "in_progress" + } + return status +} + func claudeResponsesOutputStatus(stopReason string) string { if _, incomplete := claudeResponsesIncompleteDetails(stopReason); incomplete { return "incomplete" @@ -201,8 +226,222 @@ func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte { return nil } -func applyResponsesFunctionCallNamespaceFields(item []byte, requestRawJSON []byte, qualifiedName string, itemPath string) []byte { - name, namespace := splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON, qualifiedName) +// responsesRequestEcho carries the typed values of the Responses request +// fields echoed into response objects. Extraction walks the top level of +// the request once; apply writes the fields in the same fixed order and +// with the same typed conversions as the previous per-field lookups, so +// the emitted JSON is unchanged. +type responsesRequestEcho struct { + instructions string + hasInstructions bool + maxOutputTokens int64 + hasMaxOutputTokens bool + maxToolCalls int64 + hasMaxToolCalls bool + model string + hasModel bool + parallelToolCalls bool + hasParallelToolCalls bool + previousResponseID string + hasPreviousResponseID bool + promptCacheKey string + hasPromptCacheKey bool + reasoning any + hasReasoning bool + safetyIdentifier string + hasSafetyIdentifier bool + serviceTier string + hasServiceTier bool + store bool + hasStore bool + temperature float64 + hasTemperature bool + text any + hasText bool + toolChoice any + hasToolChoice bool + tools any + hasTools bool + topLogprobs int64 + hasTopLogprobs bool + topP float64 + hasTopP bool + truncation string + hasTruncation bool + user any + hasUser bool + metadata any + hasMetadata bool +} + +func extractResponsesRequestEcho(root gjson.Result) responsesRequestEcho { + var e responsesRequestEcho + root.ForEach(func(key, value gjson.Result) bool { + switch key.String() { + case "instructions": + if !e.hasInstructions { + e.instructions, e.hasInstructions = value.String(), true + } + case "max_output_tokens": + if !e.hasMaxOutputTokens { + e.maxOutputTokens, e.hasMaxOutputTokens = value.Int(), true + } + case "max_tool_calls": + if !e.hasMaxToolCalls { + e.maxToolCalls, e.hasMaxToolCalls = value.Int(), true + } + case "model": + if !e.hasModel { + e.model, e.hasModel = value.String(), true + } + case "parallel_tool_calls": + if !e.hasParallelToolCalls { + e.parallelToolCalls, e.hasParallelToolCalls = value.Bool(), true + } + case "previous_response_id": + if !e.hasPreviousResponseID { + e.previousResponseID, e.hasPreviousResponseID = value.String(), true + } + case "prompt_cache_key": + if !e.hasPromptCacheKey { + e.promptCacheKey, e.hasPromptCacheKey = value.String(), true + } + case "reasoning": + if !e.hasReasoning { + e.reasoning, e.hasReasoning = value.Value(), true + } + case "safety_identifier": + if !e.hasSafetyIdentifier { + e.safetyIdentifier, e.hasSafetyIdentifier = value.String(), true + } + case "service_tier": + if !e.hasServiceTier { + e.serviceTier, e.hasServiceTier = value.String(), true + } + case "store": + if !e.hasStore { + e.store, e.hasStore = value.Bool(), true + } + case "temperature": + if !e.hasTemperature { + e.temperature, e.hasTemperature = value.Float(), true + } + case "text": + if !e.hasText { + e.text, e.hasText = value.Value(), true + } + case "tool_choice": + if !e.hasToolChoice { + e.toolChoice, e.hasToolChoice = value.Value(), true + } + case "tools": + if !e.hasTools { + e.tools, e.hasTools = value.Value(), true + } + case "top_logprobs": + if !e.hasTopLogprobs { + e.topLogprobs, e.hasTopLogprobs = value.Int(), true + } + case "top_p": + if !e.hasTopP { + e.topP, e.hasTopP = value.Float(), true + } + case "truncation": + if !e.hasTruncation { + e.truncation, e.hasTruncation = value.String(), true + } + case "user": + if !e.hasUser { + e.user, e.hasUser = value.Value(), true + } + case "metadata": + if !e.hasMetadata { + e.metadata, e.hasMetadata = value.Value(), true + } + } + return true + }) + return e +} + +func (e responsesRequestEcho) apply(dst []byte, prefix string) []byte { + if e.hasInstructions { + dst, _ = sjson.SetBytes(dst, prefix+"instructions", e.instructions) + } + if e.hasMaxOutputTokens { + dst, _ = sjson.SetBytes(dst, prefix+"max_output_tokens", e.maxOutputTokens) + } + if e.hasMaxToolCalls { + dst, _ = sjson.SetBytes(dst, prefix+"max_tool_calls", e.maxToolCalls) + } + if e.hasModel { + dst, _ = sjson.SetBytes(dst, prefix+"model", e.model) + } + if e.hasParallelToolCalls { + dst, _ = sjson.SetBytes(dst, prefix+"parallel_tool_calls", e.parallelToolCalls) + } + if e.hasPreviousResponseID { + dst, _ = sjson.SetBytes(dst, prefix+"previous_response_id", e.previousResponseID) + } + if e.hasPromptCacheKey { + dst, _ = sjson.SetBytes(dst, prefix+"prompt_cache_key", e.promptCacheKey) + } + if e.hasReasoning { + dst, _ = sjson.SetBytes(dst, prefix+"reasoning", e.reasoning) + } + if e.hasSafetyIdentifier { + dst, _ = sjson.SetBytes(dst, prefix+"safety_identifier", e.safetyIdentifier) + } + if e.hasServiceTier { + dst, _ = sjson.SetBytes(dst, prefix+"service_tier", e.serviceTier) + } + if e.hasStore { + dst, _ = sjson.SetBytes(dst, prefix+"store", e.store) + } + if e.hasTemperature { + dst, _ = sjson.SetBytes(dst, prefix+"temperature", e.temperature) + } + if e.hasText { + dst, _ = sjson.SetBytes(dst, prefix+"text", e.text) + } + if e.hasToolChoice { + dst, _ = sjson.SetBytes(dst, prefix+"tool_choice", e.toolChoice) + } + if e.hasTools { + dst, _ = sjson.SetBytes(dst, prefix+"tools", e.tools) + } + if e.hasTopLogprobs { + dst, _ = sjson.SetBytes(dst, prefix+"top_logprobs", e.topLogprobs) + } + if e.hasTopP { + dst, _ = sjson.SetBytes(dst, prefix+"top_p", e.topP) + } + if e.hasTruncation { + dst, _ = sjson.SetBytes(dst, prefix+"truncation", e.truncation) + } + if e.hasUser { + dst, _ = sjson.SetBytes(dst, prefix+"user", e.user) + } + if e.hasMetadata { + dst, _ = sjson.SetBytes(dst, prefix+"metadata", e.metadata) + } + return dst +} + +// applyFunctionCallNamespaceFields resolves a function call's (name, +// namespace) from the winners and tool names built once for this stream, +// matching splitResponsesQualifiedFunctionCallFromRequest without +// re-parsing the request on every tool call. +func (st *claudeToResponsesState) applyFunctionCallNamespaceFields(item []byte, qualifiedName string, itemPath string) []byte { + qualifiedName = strings.TrimSpace(qualifiedName) + name, namespace := qualifiedName, "" + if qualifiedName != "" { + identity := st.ToolNames.identity(qualifiedName) + name = identity + if descriptor, ok := st.ToolWinners[identity]; ok && !descriptor.direct { + name, namespace = descriptor.childName, descriptor.namespace + } + } return translatorcommon.SetResponsesToolCallIdentity(item, name, namespace, itemPath) } @@ -280,6 +519,12 @@ func (st *claudeToResponsesState) isApplyPatch(name string) bool { return ok && d.toolType == "custom" && applypatch.IsCustomTool(d.tool) } +// isToolSearch reports whether a Claude tool name maps to the client +// tool_search declaration. +func (st *claudeToResponsesState) isToolSearch(name string) bool { + return st.ToolWinners[st.ToolNames.identity(name)].clientSearch +} + // validateApplyPatchSnapshots permits equivalent JSON spellings but never // silently replaces one complete patch input with another. func validateApplyPatchSnapshots(previous, current string) error { @@ -316,7 +561,7 @@ func (st *claudeToResponsesState) failToolInput(err error, nextSeq func() int) [ return [][]byte{emitEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ResponseID, nextSeq()))} } -func (st *claudeToResponsesState) emitFuncItem(idx int, requestJSON []byte, force bool, nextSeq func() int) [][]byte { +func (st *claudeToResponsesState) emitFuncItem(idx int, force bool, nextSeq func() int) [][]byte { if st.FuncItemAdded[idx] || st.ToolInputError() != nil { return nil } @@ -347,6 +592,10 @@ func (st *claudeToResponsesState) emitFuncItem(idx int, requestJSON []byte, forc d := st.ToolWinners[st.ToolNames.identity(name)] st.FuncCustom[idx] = d.toolType == "custom" outputIndex := st.functionOutputIndex(idx) + if st.isToolSearch(name) { + st.FuncItemAdded[idx] = true + return [][]byte{toolSearchCallEvent("response.output_item.added", nextSeq(), outputIndex, buildResponsesToolSearchCallItem(callID, "in_progress", ""))} + } item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`) itemID := fmt.Sprintf("fc_%s", callID) if st.FuncCustom[idx] { @@ -364,7 +613,7 @@ func (st *claudeToResponsesState) emitFuncItem(idx int, requestJSON []byte, forc } item, _ = sjson.SetBytes(item, "item.id", itemID) item, _ = sjson.SetBytes(item, "item.call_id", callID) - item = applyResponsesFunctionCallNamespaceFields(item, requestJSON, name, "item") + item = st.applyFunctionCallNamespaceFields(item, name, "item") item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) item, _ = sjson.SetBytes(item, "output_index", outputIndex) st.FuncItemAdded[idx] = true @@ -372,7 +621,7 @@ func (st *claudeToResponsesState) emitFuncItem(idx int, requestJSON []byte, forc } func (st *claudeToResponsesState) emitPendingFuncArgs(idx int, nextSeq func() int) [][]byte { - if !st.FuncItemAdded[idx] || st.ToolInputError() != nil { + if !st.FuncItemAdded[idx] || st.ToolInputError() != nil || st.isToolSearch(st.FuncNames[idx]) { return nil } buf := st.FuncArgsBuf[idx] @@ -401,11 +650,11 @@ func (st *claudeToResponsesState) emitPendingFuncArgs(idx int, nextSeq func() in return [][]byte{emitEvent("response.function_call_arguments.delta", msg)} } -func (st *claudeToResponsesState) finalizeFuncItem(idx int, requestForToolMetadata []byte, status string, nextSeq func() int) [][]byte { +func (st *claudeToResponsesState) finalizeFuncItem(idx int, status string, nextSeq func() int) [][]byte { if st.FuncItemDone[idx] || st.ToolInputError() != nil { return nil } - out := st.emitFuncItem(idx, requestForToolMetadata, true, nextSeq) + out := st.emitFuncItem(idx, true, nextSeq) out = append(out, st.emitPendingFuncArgs(idx, nextSeq)...) if st.ToolInputError() != nil { return out @@ -426,15 +675,23 @@ func (st *claudeToResponsesState) finalizeFuncItem(idx int, requestForToolMetada args = buf.String() } } - if !st.FuncCustom[idx] && args == "" && status == "completed" { - args = "{}" - } callID := st.FuncCallIDs[idx] if callID == "" { callID = st.CurrentFCID } name := st.FuncNames[idx] + if st.isToolSearch(name) { + status = toolSearchCallStatus(status, args) + st.FuncItemStatus[idx] = status + out = append(out, toolSearchCallEvent("response.output_item.done", nextSeq(), outputIndex, buildResponsesToolSearchCallItem(callID, status, args))) + st.InFuncBlock = false + return out + } + if !st.FuncCustom[idx] && args == "" && status == "completed" { + args = "{}" + } + if st.FuncCustom[idx] { input := "" patchCall := st.ApplyPatchCalls[fmt.Sprint(idx)] @@ -474,7 +731,7 @@ func (st *claudeToResponsesState) finalizeFuncItem(idx int, requestForToolMetada itemDone, _ = sjson.SetBytes(itemDone, "item.status", status) itemDone, _ = sjson.SetBytes(itemDone, "item.input", input) itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID) - itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForToolMetadata, name, "item") + itemDone = st.applyFunctionCallNamespaceFields(itemDone, name, "item") out = append(out, emitEvent("response.output_item.done", itemDone)) } else { if !st.FuncArgsDone[idx] { @@ -497,7 +754,7 @@ func (st *claudeToResponsesState) finalizeFuncItem(idx int, requestForToolMetada itemDone, _ = sjson.SetBytes(itemDone, "item.status", status) itemDone, _ = translatorcommon.SetStringWithoutHTMLEscape(itemDone, "item.arguments", args) itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID) - itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForToolMetadata, name, "item") + itemDone = st.applyFunctionCallNamespaceFields(itemDone, name, "item") out = append(out, emitEvent("response.output_item.done", itemDone)) } st.InFuncBlock = false @@ -613,12 +870,14 @@ func (st *claudeToResponsesState) finalizeAssistantMessage(nextSeq func() int) [ return out } -func newClaudeToResponsesState(requestJSON []byte) *claudeToResponsesState { +func newClaudeToResponsesState(requestJSON []byte, toolSearch bool) *claudeToResponsesState { root := gjson.ParseBytes(requestJSON) - winners := responsesToolWinners(root) + winners := responsesToolWinners(root, toolSearch) return &claudeToResponsesState{ + ToolSearch: toolSearch, + RequestEcho: extractResponsesRequestEcho(root), ToolWinners: winners, - ToolNames: buildClaudeToolNamesWithWinners(root, winners), + ToolNames: buildClaudeToolNamesWithWinners(root, winners, toolSearch), ApplyPatchCalls: make(map[string]*translatorcommon.ApplyPatchCallState), FuncItemAdded: make(map[int]bool), FuncArgsSent: make(map[int]int), @@ -641,7 +900,7 @@ func newClaudeToResponsesState(requestJSON []byte) *claudeToResponsesState { // ConvertClaudeResponseToOpenAIResponses converts Claude SSE to OpenAI Responses SSE events. func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { if *param == nil { - *param = newClaudeToResponsesState(pickRequestJSON(originalRequestRawJSON, requestRawJSON)) + *param = newClaudeToResponsesState(pickRequestJSON(originalRequestRawJSON, requestRawJSON), ClientToolSearchEnabledFor(ctx)) } st := (*param).(*claudeToResponsesState) if st.ToolInputError() != nil || st.CompletedEmitted { @@ -654,7 +913,6 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } rawJSON = bytes.TrimSpace(rawJSON[5:]) root := gjson.ParseBytes(rawJSON) - requestForToolMetadata := pickRequestJSON(originalRequestRawJSON, requestRawJSON) ev := root.Get("type").String() var out [][]byte @@ -750,7 +1008,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin if (st.isApplyPatch(st.FuncNames[prevIdx]) || st.FuncNames[prevIdx] == "") && !st.FuncBlockStopped[prevIdx] { continue } - out = append(out, st.finalizeFuncItem(prevIdx, requestForToolMetadata, "completed", nextSeq)...) + out = append(out, st.finalizeFuncItem(prevIdx, "completed", nextSeq)...) if st.ToolInputError() != nil { return out } @@ -837,7 +1095,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin return append(out, st.failToolInput(errSnapshot, nextSeq)...) } } - out = append(out, st.emitFuncItem(idx, requestForToolMetadata, false, nextSeq)...) + out = append(out, st.emitFuncItem(idx, false, nextSeq)...) out = append(out, st.emitPendingFuncArgs(idx, nextSeq)...) } else if typ == "server_tool_use" { if name := cb.Get("name").String(); name != claudeWebSearchToolName { @@ -856,8 +1114,20 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin // A result block carries its full content up front and has no deltas. // Results are stored here and the item is closed when the next block // starts or at message_stop so the final stop_reason is respected. - if item := st.WebSearchByToolID[cb.Get("tool_use_id").String()]; item != nil { + toolUseID := cb.Get("tool_use_id").String() + if item := st.WebSearchByToolID[toolUseID]; item != nil { + item.Results = claudeWebSearchResultsToResponses(cb.Get("content")) + } else if toolUseID != "" { + // A continuation after pause_turn delivers the result of a search + // whose server_tool_use arrived in the previous response. Surface it + // under the same id so the client keeps the result in its history. + item := st.startWebSearch(idx, toolUseID) item.Results = claudeWebSearchResultsToResponses(cb.Get("content")) + added := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"web_search_call","status":"in_progress","action":{"type":"search","query":""}}}`) + added, _ = sjson.SetBytes(added, "sequence_number", nextSeq()) + added, _ = sjson.SetBytes(added, "output_index", item.OutputIndex) + added, _ = sjson.SetBytes(added, "item.id", responsesWebSearchCallID(item.ToolUseID)) + out = append(out, emitEvent("response.output_item.added", added)) } else { log.Debugf("claude->responses: web_search_tool_result without matching server_tool_use at block %d", idx) } @@ -964,7 +1234,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin out = append(out, st.finalizeAssistantMessage(nextSeq)...) for idx := range st.FuncCallIDs { if !st.FuncItemDone[idx] { - out = append(out, st.finalizeFuncItem(idx, requestForToolMetadata, toolStatus, nextSeq)...) + out = append(out, st.finalizeFuncItem(idx, toolStatus, nextSeq)...) if st.ToolInputError() != nil { return out } @@ -972,7 +1242,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } for _, item := range st.WebSearchItems { if !item.Emitted { - out = append(out, st.finalizeWebSearchWithStatus(item, toolStatus, nextSeq)...) + out = append(out, st.finalizeWebSearchWithStatus(item, claudeResponsesWebSearchStatus(st.StopReason, toolStatus, len(item.Results) > 0), nextSeq)...) } } @@ -986,72 +1256,12 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin if len(incompleteDetails) > 0 { completed, _ = sjson.SetRawBytes(completed, "response.incomplete_details", incompleteDetails) } + if claudeStopReasonPauseTurn(st.StopReason) { + completed, _ = sjson.SetBytes(completed, "response.end_turn", false) + } // Inject original request fields into response as per docs/response.completed.json - reqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON) - if len(reqBytes) > 0 { - req := gjson.ParseBytes(reqBytes) - 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()) - } - } + completed = st.RequestEcho.apply(completed, "response.") // Build response.output from aggregated state outputsWrapper := []byte(`{"arr":[]}`) @@ -1123,7 +1333,9 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin if callID == "" && st.CurrentFCID != "" { callID = st.CurrentFCID } - if st.FuncCustom[idx] { + if st.isToolSearch(name) { + outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), buildResponsesToolSearchCallItem(callID, status, args)) + } else if st.FuncCustom[idx] { 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", status) @@ -1135,7 +1347,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } item, _ = sjson.SetBytes(item, "input", input) item, _ = sjson.SetBytes(item, "call_id", callID) - item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, name, "") + item = st.applyFunctionCallNamespaceFields(item, name, "") outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), item) } else { item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) @@ -1143,7 +1355,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin item, _ = sjson.SetBytes(item, "status", status) item, _ = translatorcommon.SetStringWithoutHTMLEscape(item, "arguments", args) item, _ = sjson.SetBytes(item, "call_id", callID) - item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, name, "") + item = st.applyFunctionCallNamespaceFields(item, name, "") outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), item) } } @@ -1176,7 +1388,8 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } // ConvertClaudeResponseToOpenAIResponsesNonStream aggregates Claude SSE into a single OpenAI Responses JSON. -func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte { +func ConvertClaudeResponseToOpenAIResponsesNonStream(ctx context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte { + rawJSON, nativeModel := translatorcommon.ClaudeMessagesJSONToSSE(rawJSON) // Aggregate Claude SSE lines into a single OpenAI Responses JSON (non-stream) // We follow the same aggregation logic as the streaming variant but produce // one final object matching docs/out.json structure. @@ -1202,7 +1415,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string } reqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON) - st := newClaudeToResponsesState(reqBytes) + st := newClaudeToResponsesState(reqBytes, ClientToolSearchEnabledFor(ctx)) if param != nil { *param = st } @@ -1299,7 +1512,9 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string activeMessageItem = item case "tool_use": itemType := "function_call" - if d := st.ToolWinners[st.ToolNames.identity(cb.Get("name").String())]; d.toolType == "custom" { + if st.isToolSearch(cb.Get("name").String()) { + itemType = "tool_search_call" + } else if st.ToolWinners[st.ToolNames.identity(cb.Get("name").String())].toolType == "custom" { itemType = "custom_tool_call" } item := blockToItem[idx] @@ -1335,9 +1550,12 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string return []byte(gjson.GetBytes(translatorcommon.ApplyPatchFailure(responseID, 0), "response").Raw) } } - if item.itemType == "custom_tool_call" { + switch item.itemType { + case "custom_tool_call": item.id = fmt.Sprintf("ctc_%s", item.callID) - } else { + case "tool_search_call": + item.id = responsesToolSearchIDPrefix + item.callID + default: item.id = fmt.Sprintf("fc_%s", item.callID) } case "server_tool_use": @@ -1357,8 +1575,17 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string item.args.WriteString(input.Raw) } case "web_search_tool_result": - if item := webSearchByToolID[cb.Get("tool_use_id").String()]; item != nil { + toolUseID := cb.Get("tool_use_id").String() + if item := webSearchByToolID[toolUseID]; item != nil { + item.results = claudeWebSearchResultsToResponses(cb.Get("content")) + } else if toolUseID != "" { + // Continuation after pause_turn: the search started in the + // previous response; keep its result under the same id. + item := newOutputItem("web_search_call", idx) + item.id = responsesWebSearchCallID(toolUseID) + item.callID = toolUseID item.results = claudeWebSearchResultsToResponses(cb.Get("content")) + webSearchByToolID[toolUseID] = item } else { log.Debugf("claude->responses: web_search_tool_result without matching server_tool_use at block %d", idx) } @@ -1384,7 +1611,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string } } case "input_json_delta": - if item != nil && (item.itemType == "function_call" || item.itemType == "custom_tool_call" || item.itemType == "web_search_call") { + if item != nil && (item.itemType == "function_call" || item.itemType == "custom_tool_call" || item.itemType == "tool_search_call" || item.itemType == "web_search_call") { if pj := d.Get("partial_json"); pj.Exists() { item.args.WriteString(pj.String()) } @@ -1432,70 +1659,17 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string if len(incompleteDetails) > 0 { out, _ = sjson.SetRawBytes(out, "incomplete_details", incompleteDetails) } + if claudeStopReasonPauseTurn(stopReason) { + out, _ = sjson.SetBytes(out, "end_turn", false) + } // Inject request echo fields as top-level (similar to streaming variant) - if len(reqBytes) > 0 { - req := gjson.ParseBytes(reqBytes) - if v := req.Get("instructions"); v.Exists() { - out, _ = sjson.SetBytes(out, "instructions", v.String()) - } - if v := req.Get("max_output_tokens"); v.Exists() { - out, _ = sjson.SetBytes(out, "max_output_tokens", v.Int()) - } - if v := req.Get("max_tool_calls"); v.Exists() { - out, _ = sjson.SetBytes(out, "max_tool_calls", v.Int()) - } - if v := req.Get("model"); v.Exists() { - out, _ = sjson.SetBytes(out, "model", v.String()) - } - if v := req.Get("parallel_tool_calls"); v.Exists() { - out, _ = sjson.SetBytes(out, "parallel_tool_calls", v.Bool()) - } - if v := req.Get("previous_response_id"); v.Exists() { - out, _ = sjson.SetBytes(out, "previous_response_id", v.String()) - } - if v := req.Get("prompt_cache_key"); v.Exists() { - out, _ = sjson.SetBytes(out, "prompt_cache_key", v.String()) - } - if v := req.Get("reasoning"); v.Exists() { - out, _ = sjson.SetBytes(out, "reasoning", v.Value()) - } - if v := req.Get("safety_identifier"); v.Exists() { - out, _ = sjson.SetBytes(out, "safety_identifier", v.String()) - } - if v := req.Get("service_tier"); v.Exists() { - out, _ = sjson.SetBytes(out, "service_tier", v.String()) - } - if v := req.Get("store"); v.Exists() { - out, _ = sjson.SetBytes(out, "store", v.Bool()) - } - if v := req.Get("temperature"); v.Exists() { - out, _ = sjson.SetBytes(out, "temperature", v.Float()) - } - if v := req.Get("text"); v.Exists() { - out, _ = sjson.SetBytes(out, "text", v.Value()) - } - if v := req.Get("tool_choice"); v.Exists() { - out, _ = sjson.SetBytes(out, "tool_choice", v.Value()) - } - if v := req.Get("tools"); v.Exists() { - out, _ = sjson.SetBytes(out, "tools", v.Value()) - } - if v := req.Get("top_logprobs"); v.Exists() { - out, _ = sjson.SetBytes(out, "top_logprobs", v.Int()) - } - if v := req.Get("top_p"); v.Exists() { - out, _ = sjson.SetBytes(out, "top_p", v.Float()) - } - if v := req.Get("truncation"); v.Exists() { - out, _ = sjson.SetBytes(out, "truncation", v.String()) - } - if v := req.Get("user"); v.Exists() { - out, _ = sjson.SetBytes(out, "user", v.Value()) - } - if v := req.Get("metadata"); v.Exists() { - out, _ = sjson.SetBytes(out, "metadata", v.Value()) - } + out = st.RequestEcho.apply(out, "") + + // A native response's model is authoritative; otherwise retain request echo + // behavior, including the existing SSE path. + if nativeModel != "" { + out, _ = sjson.SetBytes(out, "model", nativeModel) } // Build output array in the order of the original content blocks. @@ -1517,7 +1691,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string item, _ = sjson.SetRawBytes(item, "summary", translatorcommon.JoinRawArray([][]byte{summary})) case "web_search_call": item = buildResponsesWebSearchCallItem(outputItem.callID, claudeWebSearchQuery(outputItem.args.String()), outputItem.results) - item, _ = sjson.SetBytes(item, "status", itemStatus) + item, _ = sjson.SetBytes(item, "status", claudeResponsesWebSearchStatus(stopReason, itemStatus, len(outputItem.results) > 0)) case "message": item = []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`) item, _ = sjson.SetBytes(item, "id", outputItem.id) @@ -1526,6 +1700,9 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string if len(outputItem.annotations) > 0 { item, _ = sjson.SetBytes(item, "content.0.annotations", outputItem.annotations) } + case "tool_search_call": + args := outputItem.args.String() + item = buildResponsesToolSearchCallItem(outputItem.callID, toolSearchCallStatus(itemStatus, args), args) case "function_call", "custom_tool_call": if outputItem.itemType == "custom_tool_call" { item = []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) @@ -1549,7 +1726,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string } item, _ = sjson.SetBytes(item, "input", input) item, _ = sjson.SetBytes(item, "call_id", outputItem.callID) - item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, outputItem.name, "") + item = st.applyFunctionCallNamespaceFields(item, outputItem.name, "") break } args := outputItem.args.String() @@ -1561,7 +1738,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string item, _ = sjson.SetBytes(item, "status", itemStatus) item, _ = translatorcommon.SetStringWithoutHTMLEscape(item, "arguments", args) item, _ = sjson.SetBytes(item, "call_id", outputItem.callID) - item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, outputItem.name, "") + item = st.applyFunctionCallNamespaceFields(item, outputItem.name, "") } if len(item) > 0 { outputs = append(outputs, item) diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_tool_names.go b/internal/translator/claude/openai/responses/claude_openai-responses_tool_names.go index b7a6b332..12a3e953 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_tool_names.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_tool_names.go @@ -22,15 +22,15 @@ type claudeToolNames struct { fromClaude map[string]string } -func buildClaudeToolNames(root gjson.Result) claudeToolNames { - return buildClaudeToolNamesWithWinners(root, responsesToolWinners(root)) +func buildClaudeToolNames(root gjson.Result, toolSearch bool) claudeToolNames { + return buildClaudeToolNamesWithWinners(root, responsesToolWinners(root, toolSearch), toolSearch) } -func buildClaudeToolNamesWithWinners(root gjson.Result, winners map[string]responsesToolDescriptor) claudeToolNames { +func buildClaudeToolNamesWithWinners(root gjson.Result, winners map[string]responsesToolDescriptor, toolSearch bool) claudeToolNames { m := claudeToolNames{toClaude: map[string]string{}, fromClaude: map[string]string{}} taken := map[string]bool{} var declared []string - for _, d := range responsesToolDescriptors(root) { + for _, d := range responsesToolDescriptors(root, toolSearch) { if w, ok := winners[d.name]; !ok || w.order != d.order { continue } diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_tool_search.go b/internal/translator/claude/openai/responses/claude_openai-responses_tool_search.go new file mode 100644 index 00000000..3c47af15 --- /dev/null +++ b/internal/translator/claude/openai/responses/claude_openai-responses_tool_search.go @@ -0,0 +1,222 @@ +package responses + +import ( + "context" + "strings" + "sync/atomic" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" + "github.com/router-for-me/CLIProxyAPI/v8/internal/util" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// Codex declares client-executed tool search as {"type":"tool_search", +// "execution":"client"} and keeps deferred tools out of tools[]. The model +// calls it through a tool_search_call item, Codex runs the search locally and +// replays a tool_search_output item whose tools[] become callable from then on. +// Claude has no counterpart, so the declaration becomes an ordinary function +// tool, the call and output become a tool_use/tool_result pair, and every tool +// discovered in history is declared on later requests. +// +// With native tool search, discovered tools are declared with defer_loading +// and referenced through tool_reference blocks. Anthropic keeps the prompt +// cache when deferred tools are added, but not when a request goes from no +// deferred tool to one, so a constant deferred anchor is declared from the +// first request on and discoveries only append after it. +const ( + // responsesToolSearchType is both the declaration type and the tool name, + // as Codex names the tool after its type. + responsesToolSearchType = "tool_search" + // responsesToolSearchIDPrefix mirrors Codex's item id prefix for tool_search_call. + responsesToolSearchIDPrefix = "tsc_" + // responsesDeferredAnchorName names the constant deferred tool declared with + // native tool search; nothing ever references it. + responsesDeferredAnchorName = "tool_search_deferred_anchor" +) + +// clientToolSearchEnabled mirrors upstream.claude.tool-search. While it is off +// the translator keeps the behavior that predates tool_search support: +// tool_search declarations are generic tools and tool_search_call and +// tool_search_output items are unmapped. +var clientToolSearchEnabled atomic.Bool + +// SetClientToolSearchEnabled applies upstream.claude.tool-search. +func SetClientToolSearchEnabled(enabled bool) { + clientToolSearchEnabled.Store(enabled) +} + +// ClientToolSearchEnabled reports whether upstream.claude.tool-search is on. +func ClientToolSearchEnabled() bool { + return clientToolSearchEnabled.Load() +} + +type clientToolSearchContextKey struct{} + +// WithClientToolSearch pins upstream.claude.tool-search for one request, so +// its fallback decision, request translation and response translation agree +// even if the configuration is reloaded while it runs. +func WithClientToolSearch(ctx context.Context, enabled bool) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, clientToolSearchContextKey{}, enabled) +} + +// ClientToolSearchEnabledFor returns the value pinned in ctx, or the current +// upstream.claude.tool-search when ctx carries none. +func ClientToolSearchEnabledFor(ctx context.Context) bool { + if ctx != nil { + if enabled, ok := ctx.Value(clientToolSearchContextKey{}).(bool); ok { + return enabled + } + } + return ClientToolSearchEnabled() +} + +const claudeToolSearchDefaultSchema = `{"type":"object","properties":{"query":{"type":"string","description":"Search query for deferred tools."},"limit":{"type":"number","description":"Maximum number of tools to return."}},"required":["query"],"additionalProperties":false}` + +const responsesDeferredAnchorJSON = `{"name":"` + responsesDeferredAnchorName + `","description":"Placeholder that is never loaded; tool search returns the real tools.","input_schema":{"type":"object","properties":{}},"defer_loading":true}` + +// responsesDeferredAnchorTool returns the deferred anchor declaration, unless a +// converted tool already carries its Claude name (names are normalized during +// conversion, so the check runs on the final tools[]). On a clash the request +// keeps the client's tool and simply has no anchor. +func responsesDeferredAnchorTool(toolItems [][]byte) ([]byte, bool) { + for _, tool := range toolItems { + if gjson.GetBytes(tool, "name").String() == responsesDeferredAnchorName { + return nil, false + } + } + return []byte(responsesDeferredAnchorJSON), true +} + +// isResponsesClientToolSearch reports whether a tool declaration asks the +// client to execute tool search. Hosted (server) search cannot run on Claude. +func isResponsesClientToolSearch(tool gjson.Result) bool { + return strings.TrimSpace(tool.Get("type").String()) == responsesToolSearchType && + strings.TrimSpace(tool.Get("execution").String()) == "client" +} + +// convertResponsesToolSearchToolToClaude keeps the client's declared schema +// but guarantees the required query field Codex parses. +func convertResponsesToolSearchToolToClaude(tool gjson.Result, claudeName string) ([]byte, bool) { + schema := []byte(claudeToolSearchDefaultSchema) + if parameters := tool.Get("parameters"); parameters.IsObject() { + schema = util.NormalizeClaudeToolInputSchema([]byte(parameters.Raw)) + } + if gjson.GetBytes(schema, "properties.query.type").String() != "string" { + schema, _ = sjson.SetRawBytes(schema, "properties.query", []byte(`{"type":"string","description":"Search query for deferred tools."}`)) + } + if !gjson.GetBytes(schema, `required.#(=="query")`).Exists() { + schema, _ = sjson.SetBytes(schema, "required.-1", "query") + } + + tJSON := []byte(`{"name":"","description":"","input_schema":{}}`) + tJSON, _ = sjson.SetBytes(tJSON, "name", claudeName) + tJSON, _ = sjson.SetBytes(tJSON, "description", responsesToolDescription(tool)) + tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", schema) + return tJSON, true +} + +// convertResponsesToolSearchCallToClaude replays a client tool_search_call as +// an assistant tool_use and returns it with its raw call id. Hosted calls have +// no call id and return nil. +func convertResponsesToolSearchCallToClaude(item gjson.Result, claudeName string) ([]byte, string) { + callID := strings.TrimSpace(item.Get("call_id").String()) + if callID == "" || strings.TrimSpace(item.Get("execution").String()) != "client" { + return nil, "" + } + toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) + toolUse, _ = sjson.SetBytes(toolUse, "id", util.SanitizeClaudeToolID(callID)) + toolUse, _ = sjson.SetBytes(toolUse, "name", claudeName) + arguments := item.Get("arguments") + if arguments.Type == gjson.String { + arguments = gjson.Parse(arguments.String()) + } + if arguments.IsObject() { + toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(arguments.Raw)) + } + return toolUse, callID +} + +// responsesToolSearchOutputText summarizes discovered tools for the model. +// Their full definitions are declared in tools[], so names suffice here. +func responsesToolSearchOutputText(item gjson.Result, names claudeToolNames) string { + var discovered []string + for _, descriptor := range appendResponsesToolDescriptors(nil, item.Get("tools"), responsesDiscoveredToolPriority, true) { + discovered = append(discovered, names.claudeName(descriptor.name)) + } + if len(discovered) == 0 { + return "No matching tools were found." + } + return "Tools now available to call: " + strings.Join(discovered, ", ") + "." +} + +// responsesToolSearchOutputContent returns the raw tool_result content for a +// paired tool_search_output. With native tool search it lists a tool_reference +// for each tool that only loads through search, so Claude expands the deferred +// definition at this point in the conversation. Tools that are already declared +// directly need no reference; when none qualify, the text summary is used. +func responsesToolSearchOutputContent(item gjson.Result, names claudeToolNames, winners map[string]responsesToolDescriptor, nativeReferences bool) []byte { + if nativeReferences { + var references [][]byte + seen := make(map[string]struct{}) + for _, descriptor := range appendResponsesToolDescriptors(nil, item.Get("tools"), responsesDiscoveredToolPriority, true) { + winner, ok := winners[descriptor.name] + if !ok || winner.sourcePriority != responsesDiscoveredToolPriority || (winner.toolType != "function" && winner.toolType != "custom") { + continue + } + claudeName := names.claudeName(descriptor.name) + if _, duplicate := seen[claudeName]; duplicate { + continue + } + seen[claudeName] = struct{}{} + reference, _ := sjson.SetBytes([]byte(`{"type":"tool_reference","tool_name":""}`), "tool_name", claudeName) + references = append(references, reference) + } + if len(references) > 0 { + return common.JoinRawArray(references) + } + } + text, _ := sjson.SetBytes([]byte(`{"text":""}`), "text", responsesToolSearchOutputText(item, names)) + return []byte(gjson.GetBytes(text, "text").Raw) +} + +// toolSearchCallStatus downgrades a completed call whose arguments are not a +// JSON object, so a truncated search is never presented as a finished call. +func toolSearchCallStatus(status, arguments string) string { + if status == "completed" && !isJSONObject(arguments) { + return "incomplete" + } + return status +} + +func isJSONObject(raw string) bool { + return gjson.Valid(raw) && gjson.Parse(raw).IsObject() +} + +// buildResponsesToolSearchCallItem renders the tool_search_call item Codex +// parses: call_id, execution "client" and an arguments object. +func buildResponsesToolSearchCallItem(callID, status, arguments string) []byte { + item := []byte(`{"id":"","type":"tool_search_call","status":"","call_id":"","execution":"client","arguments":{}}`) + item, _ = sjson.SetBytes(item, "id", responsesToolSearchIDPrefix+callID) + item, _ = sjson.SetBytes(item, "status", status) + item, _ = sjson.SetBytes(item, "call_id", callID) + if isJSONObject(arguments) { + item, _ = sjson.SetRawBytes(item, "arguments", []byte(arguments)) + } + return item +} + +// toolSearchCallEvent wraps a tool_search_call item in an output_item event. +// Responses has no argument delta events for it; Codex dispatches the search +// from output_item.done. +func toolSearchCallEvent(event string, sequence, outputIndex int, item []byte) []byte { + payload := []byte(`{"type":"","sequence_number":0,"output_index":0,"item":{}}`) + payload, _ = sjson.SetBytes(payload, "type", event) + payload, _ = sjson.SetBytes(payload, "sequence_number", sequence) + payload, _ = sjson.SetBytes(payload, "output_index", outputIndex) + payload, _ = sjson.SetRawBytes(payload, "item", item) + return emitEvent(event, payload) +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_web_search.go b/internal/translator/claude/openai/responses/claude_openai-responses_web_search.go index 0c7c5add..8566d8a0 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_web_search.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_web_search.go @@ -108,6 +108,13 @@ func buildResponsesWebSearchCallItem(claudeToolUseID, query string, results []by // buildResponsesWebSearchCallItem: it rebuilds the Claude block pair so a // replayed turn still shows that the search happened and what it returned. func convertResponsesWebSearchCallToClaudeBlocks(item gjson.Result) [][]byte { + return convertResponsesWebSearchCallToClaudeBlocksWithQuery(item, "", true) +} + +// convertResponsesWebSearchCallToClaudeBlocksWithQuery rebuilds the server_tool_use +// block, using fallbackQuery when the item carries none, and the result block +// only when withResult is set. +func convertResponsesWebSearchCallToClaudeBlocksWithQuery(item gjson.Result, fallbackQuery string, withResult bool) [][]byte { toolUseID := claudeWebSearchToolUseID(strings.TrimSpace(item.Get("id").String())) if toolUseID == "" { return nil @@ -116,9 +123,16 @@ func convertResponsesWebSearchCallToClaudeBlocks(item gjson.Result) [][]byte { use := []byte(`{"type":"server_tool_use","id":"","name":"","input":{}}`) use, _ = sjson.SetBytes(use, "id", toolUseID) use, _ = sjson.SetBytes(use, "name", claudeWebSearchToolName) - if query := responsesWebSearchCallQuery(item); query != "" { + query := responsesWebSearchCallQuery(item) + if query == "" { + query = fallbackQuery + } + if query != "" { use, _ = sjson.SetBytes(use, "input.query", query) } + if !withResult { + return [][]byte{use} + } result := []byte(`{"type":"web_search_tool_result","tool_use_id":"","content":[]}`) result, _ = sjson.SetBytes(result, "tool_use_id", toolUseID) @@ -128,6 +142,94 @@ func convertResponsesWebSearchCallToClaudeBlocks(item gjson.Result) [][]byte { return [][]byte{use, result} } +// responsesWebSearchHistory indexes web_search_call items of one request so a +// search paused by pause_turn or deferred by tool_use replays faithfully: +// - while the open search turn is the tail of the history, the search is sent +// back as a server_tool_use without a result, unchanged, as Anthropic requires; +// - when the continuation delivered the result under the same id within the +// same assistant run (no user turn in between), the open item is skipped and +// the continuation item emits the full pair, keeping the query recorded by +// the open item. +// +// Every other web_search_call, including repeated ids across separate turns, +// keeps its previous one-pair-per-item replay. +type responsesWebSearchHistory struct { + // boundaryAfter[i] reports whether a user turn follows index i. + boundaryAfter []bool + // skip marks paused items that a later continuation item replaces. + skip map[int]bool + // pausedQuery carries the paused item's query to its continuation item. + pausedQuery map[int]string +} + +func responsesWebSearchItemIsPaused(item gjson.Result) bool { + return item.Get("type").String() == "web_search_call" && + strings.EqualFold(strings.TrimSpace(item.Get("status").String()), "in_progress") && + !item.Get("results").Exists() +} + +// responsesItemIsTurnBoundary reports a user turn. Tool outputs do not end the +// assistant turn: Anthropic runs a deferred search right after them. +func responsesItemIsTurnBoundary(item gjson.Result) bool { + typ := item.Get("type").String() + role := item.Get("role").String() + if typ == "" && role != "" { + typ = "message" + } + return typ == "message" && role != "assistant" +} + +func scanResponsesWebSearchHistory(items []gjson.Result) responsesWebSearchHistory { + history := responsesWebSearchHistory{ + boundaryAfter: make([]bool, len(items)), + skip: map[int]bool{}, + pausedQuery: map[int]string{}, + } + boundary := false + for index := len(items) - 1; index >= 0; index-- { + history.boundaryAfter[index] = boundary + if responsesItemIsTurnBoundary(items[index]) { + boundary = true + } + } + for index, item := range items { + if !responsesWebSearchItemIsPaused(item) { + continue + } + id := strings.TrimSpace(item.Get("id").String()) + if id == "" { + continue + } + for next := index + 1; next < len(items); next++ { + if responsesItemIsTurnBoundary(items[next]) { + break + } + if items[next].Get("type").String() == "web_search_call" && strings.TrimSpace(items[next].Get("id").String()) == id { + history.skip[index] = true + query := responsesWebSearchCallQuery(item) + if previous, ok := history.pausedQuery[index]; ok && query == "" { + query = previous + } + history.pausedQuery[next] = query + break + } + } + } + return history +} + +func (h responsesWebSearchHistory) claudeBlocks(index int, item gjson.Result) [][]byte { + if h.skip[index] { + return nil + } + query := h.pausedQuery[index] + tail := index >= len(h.boundaryAfter) || !h.boundaryAfter[index] + if responsesWebSearchItemIsPaused(item) && tail { + return convertResponsesWebSearchCallToClaudeBlocksWithQuery(item, query, false) + } + return convertResponsesWebSearchCallToClaudeBlocksWithQuery(item, query, true) +} + // responsesWebSearchCallQuery reads the query from a Responses web_search_call, // tolerating the `queries` array and the `open_page` action shape that OpenAI // clients emit. diff --git a/internal/translator/claude/openai/responses/init.go b/internal/translator/claude/openai/responses/init.go index 0ab246d4..eeaaab91 100644 --- a/internal/translator/claude/openai/responses/init.go +++ b/internal/translator/claude/openai/responses/init.go @@ -4,16 +4,24 @@ import ( . "github.com/router-for-me/CLIProxyAPI/v8/internal/constant" "github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/translator" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" ) func init() { translator.Register( OpenaiResponse, Claude, - ConvertOpenAIResponsesRequestToClaude, + nil, interfaces.TranslateResponse{ Stream: ConvertClaudeResponseToOpenAIResponses, NonStream: ConvertClaudeResponseToOpenAIResponsesNonStream, }, ) + // The envelope form receives the request context, which carries the + // upstream.claude.tool-search value the executor pinned for the request. + sdktranslator.RegisterRequestEnvelope( + sdktranslator.FormatOpenAIResponse, + sdktranslator.FormatClaude, + ConvertOpenAIResponsesRequestEnvelopeToClaude, + ) } diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index dd0823a4..e178d8a1 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -6,6 +6,7 @@ package claude import ( + "encoding/base64" "strings" sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature" @@ -19,18 +20,22 @@ import ( // 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 { +func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { return convertClaudeRequestToOpenAI(modelName, inputRawJSON, stream, false) + } // ConvertClaudeRequestToOpenAIWithCompat preserves assistant thinking text // for configured compatibility endpoints. -func ConvertClaudeRequestToOpenAIWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertClaudeRequestToOpenAIWithCompat(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { return convertClaudeRequestToOpenAI(modelName, inputRawJSON, stream, true) } -func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool, preserveThinkingBlocks bool) []byte { - rawJSON := inputRawJSON +func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool, preserveThinkingBlocks bool) ([]byte, error) { + // The internal commentary marker must be restored as assistant text before + // any thinking handling, so it is never dropped or mapped to reasoning. + rawJSON, _ := sigcompat.RestoreCodexCommentaryMarkers(inputRawJSON) + var drops translatorcommon.UserTurnDrops // Base OpenAI Chat Completions API template out := []byte(`{"model":"","messages":[]}`) @@ -207,9 +212,11 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream case "redacted_thinking": // Explicitly ignore redacted_thinking - never map to reasoning_content (AC2) - case "text", "image": + case "text", "image", "document", "container_upload": if contentItem, ok := convertClaudeContentPart(part); ok { contentItems = append(contentItems, []byte(contentItem)) + } else if role == "user" && partType != "text" { + drops.Drop(partType) } case "tool_use": @@ -263,6 +270,9 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream hasReasoning := reasoningContent != "" hasToolCalls := len(toolCalls) > 0 hasToolResults := len(toolResults) > 0 + if role == "user" { + drops.EndTurn(len(contentItems) + len(toolResults)) + } // Flush pending system reminders before new content if no tool_results responded to preceding calls if precedingToolCallsPending && !hasToolResults && len(pendingSystemReminders) > 0 { @@ -422,7 +432,7 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream out, _ = sjson.SetBytes(out, "user", user.String()) } - return out + return out, drops.Err() } func normalizeObjectSchemaProperties(schema any) any { @@ -557,11 +567,42 @@ func convertClaudeContentPart(part gjson.Result) (string, bool) { return string(imageContent), true + case "document", "container_upload": + return convertClaudeFilePartToOpenAI(part) + default: return "", false } } +// convertClaudeFilePartToOpenAI emits an OpenAI file part for inline base64 bytes. +// A file id carries none, so it stays unconverted and the caller reports it. +func convertClaudeFilePartToOpenAI(part gjson.Result) (string, bool) { + source := part.Get("source") + mimeType := source.Get("media_type").String() + + var data []byte + if source.Get("type").String() == "base64" { + decoded, errDecode := base64.StdEncoding.DecodeString(strings.TrimSpace(source.Get("data").String())) + if errDecode != nil { + return "", false + } + data = decoded + } + + if len(data) == 0 { + return "", false + } + if mimeType == "" { + mimeType = "application/octet-stream" + } + + fileContent := []byte(`{"type":"file","file":{"filename":"","file_data":""}}`) + fileContent, _ = sjson.SetBytes(fileContent, "file.filename", part.Get("filename").String()) + fileContent, _ = sjson.SetBytes(fileContent, "file.file_data", "data:"+mimeType+";base64,"+base64.StdEncoding.EncodeToString(data)) + return string(fileContent), true +} + // toolResultImagePlaceholder keeps the OpenAI tool message non-empty when a Claude // tool_result carried nothing but images. const toolResultImagePlaceholder = "[Tool returned image content; the images follow in the next user message.]" diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go index a7fdad25..7cdf707b 100644 --- a/internal/translator/openai/claude/openai_claude_response.go +++ b/internal/translator/openai/claude/openai_claude_response.go @@ -53,6 +53,9 @@ type ConvertOpenAIResponseToAnthropicParams struct { MessageStopSent bool // Tool call content block index mapping ToolCallBlockIndexes map[int]int + // OmittedToolCallIndexes maps a chunk-local tool_calls array position to the + // active accumulator key when the upstream omits tool_calls[].index. + OmittedToolCallIndexes map[int]int // Index assigned to text content block TextContentBlockIndex int // Index assigned to thinking content block @@ -68,6 +71,14 @@ type ConvertOpenAIResponseToAnthropicParams struct { UsageOutputTokens int64 UsageCachedTokens int64 UsageCacheWriteTokens int64 + // ToolNameMapResolved tracks whether ToolNameMap has been resolved from + // the original request once per stream. + ToolNameMapResolved bool + // StreamResolved and StreamRequested cache whether the original request + // asked for streaming, resolved once per stream so large request bodies + // are not re-parsed on every chunk. + StreamResolved bool + StreamRequested bool } // InterleavedContentChunk stores a buffered chunk of text or thinking arriving while a tool call is open @@ -131,21 +142,28 @@ func ConvertOpenAIResponseToClaude(_ context.Context, _ string, originalRequestR } rawJSON = bytes.TrimSpace(rawJSON[5:]) - if (*param).(*ConvertOpenAIResponseToAnthropicParams).ToolNameMap == nil { - (*param).(*ConvertOpenAIResponseToAnthropicParams).ToolNameMap = util.ToolNameMapFromClaudeRequest(originalRequestRawJSON) + state := (*param).(*ConvertOpenAIResponseToAnthropicParams) + if !state.ToolNameMapResolved { + if state.ToolNameMap == nil { + state.ToolNameMap = util.ToolNameMapFromClaudeRequest(originalRequestRawJSON) + } + state.ToolNameMapResolved = true } // Check if this is the [DONE] marker if bytes.Equal(bytes.TrimSpace(rawJSON), []byte("[DONE]")) { - return convertOpenAIDoneToAnthropic((*param).(*ConvertOpenAIResponseToAnthropicParams)) + return convertOpenAIDoneToAnthropic(state) } - streamResult := gjson.GetBytes(originalRequestRawJSON, "stream") - if !streamResult.Exists() || (streamResult.Exists() && streamResult.Type == gjson.False) { + if !state.StreamResolved { + streamResult := gjson.GetBytes(originalRequestRawJSON, "stream") + state.StreamRequested = streamResult.Exists() && streamResult.Type != gjson.False + state.StreamResolved = true + } + if !state.StreamRequested { return convertOpenAINonStreamingToAnthropic(rawJSON) - } else { - return convertOpenAIStreamingChunkToAnthropic(rawJSON, (*param).(*ConvertOpenAIResponseToAnthropicParams)) } + return convertOpenAIStreamingChunkToAnthropic(rawJSON, state) } func hasValidToolCallArguments(param *ConvertOpenAIResponseToAnthropicParams) bool { @@ -313,9 +331,33 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI } toolCalls.ForEach(func(arrayIndex, toolCall gjson.Result) bool { + var incomingID string + if id := toolCall.Get("id"); id.Exists() && id.Type == gjson.String { + incomingID = id.String() + } + index := int(toolCall.Get("index").Int()) if !toolCall.Get("index").Exists() { - index = int(arrayIndex.Int()) + pos := int(arrayIndex.Int()) + index = pos + if param.OmittedToolCallIndexes != nil { + if mapped, ok := param.OmittedToolCallIndexes[pos]; ok { + index = mapped + } + } + if existing, exists := param.ToolCallsAccumulator[index]; exists && existing != nil && incomingID != "" && existing.ID != "" && existing.ID != incomingID { + nextIndex := index + 1 + for k := range param.ToolCallsAccumulator { + if k >= nextIndex { + nextIndex = k + 1 + } + } + index = nextIndex + if param.OmittedToolCallIndexes == nil { + param.OmittedToolCallIndexes = make(map[int]int) + } + param.OmittedToolCallIndexes[pos] = index + } } // Initialize accumulator if needed @@ -328,10 +370,8 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI // 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 - } + if incomingID != "" && !accumulator.StartEmitted { + accumulator.ID = incomingID } // Handle function name and arguments @@ -404,12 +444,14 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI } // Emit message_delta and message_stop only when generation is finished: - // 1. Upstream provided a finish_reason, or - // 2. Upstream sent a trailing usage-only chunk (choices array is empty or absent) after content/tools started. + // - Upstream sent a trailing usage-only chunk (choices array is empty or absent) after content/tools started, or + // - Upstream sent [DONE] (handled in convertOpenAIDoneToAnthropic). + // Content chunks with finish_reason and preliminary usage must not emit message_delta early, + // because authoritative token counts (such as cached_tokens) can arrive in the trailing usage-only chunk. isTrailingUsageChunk := hasUsage && !root.Get("choices.0").Exists() && (param.FinishReason != "" || param.SawToolCall || param.TextContentBlockStarted || param.ThinkingContentBlockStarted || param.ContentAccumulator.Len() > 0 || len(param.InterleavedContentChunks) > 0) - if !param.MessageDeltaSent && (param.FinishReason != "" || isTrailingUsageChunk) && hasUsage { + if !param.MessageDeltaSent && isTrailingUsageChunk { finalizeOpenAIAnthropicContentBlocks(param, &results) emitAnthropicMessageDelta(param, &results, param.UsageInputTokens, param.UsageOutputTokens, param.UsageCachedTokens, param.UsageCacheWriteTokens) emitMessageStopIfNeeded(param, &results) @@ -463,8 +505,10 @@ func convertOpenAINonStreamingToAnthropic(rawJSON []byte) [][]byte { } // Handle tool calls + hasToolCall := false if toolCalls := choice.Get("message.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", toolCall.Get("function.name").String()) @@ -491,8 +535,8 @@ func convertOpenAINonStreamingToAnthropic(rawJSON []byte) [][]byte { } // Set stop reason - if finishReason := choice.Get("finish_reason"); finishReason.Exists() { - out, _ = sjson.SetBytes(out, "stop_reason", mapOpenAIFinishReasonToAnthropic(finishReason.String())) + if finishReason := choice.Get("finish_reason"); finishReason.Exists() || hasToolCall { + out, _ = sjson.SetBytes(out, "stop_reason", mapOpenAINonStreamFinishReasonToAnthropic(finishReason.String(), hasToolCall)) } } @@ -530,6 +574,13 @@ func mapOpenAIFinishReasonToAnthropic(openAIReason string) string { } } +func mapOpenAINonStreamFinishReasonToAnthropic(openAIReason string, hasToolCall bool) string { + if hasToolCall && openAIReason != "length" && openAIReason != "content_filter" { + return "tool_use" + } + return mapOpenAIFinishReasonToAnthropic(openAIReason) +} + func (p *ConvertOpenAIResponseToAnthropicParams) toolContentBlockIndex(openAIToolIndex int) int { if idx, ok := p.ToolCallBlockIndexes[openAIToolIndex]; ok { return idx @@ -795,16 +846,12 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina out, _ = sjson.SetBytes(out, "model", root.Get("model").String()) hasToolCall := false - stopReasonSet := false + finishReason := "" 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 - } + finishReason = choice.Get("finish_reason").String() if message := choice.Get("message"); message.Exists() { if contentResult := message.Get("content"); contentResult.Exists() { @@ -938,13 +985,7 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina } } - if !stopReasonSet { - if hasToolCall { - out, _ = sjson.SetBytes(out, "stop_reason", "tool_use") - } else { - out, _ = sjson.SetBytes(out, "stop_reason", "end_turn") - } - } + out, _ = sjson.SetBytes(out, "stop_reason", mapOpenAINonStreamFinishReasonToAnthropic(finishReason, hasToolCall)) return out } diff --git a/internal/translator/openai/gemini/openai_gemini_request.go b/internal/translator/openai/gemini/openai_gemini_request.go index 33f002a9..a714adf1 100644 --- a/internal/translator/openai/gemini/openai_gemini_request.go +++ b/internal/translator/openai/gemini/openai_gemini_request.go @@ -20,7 +20,7 @@ import ( // 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 { +func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { rawJSON := inputRawJSON // Base OpenAI Chat Completions API template out := []byte(`{"model":"","messages":[]}`) @@ -243,6 +243,9 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream } else { toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", "{}") } + if thoughtSig := geminiFunctionCallThoughtSignature(part); thoughtSig != "" { + toolCall, _ = sjson.SetBytes(toolCall, "extra_content.google.thought_signature", thoughtSig) + } toolCallItems = append(toolCallItems, toolCall) } @@ -369,7 +372,7 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream } } - return out + return out, nil } func deterministicToolCallID(kind string, msgIdx, partIdx int, name, payload string) string { @@ -377,6 +380,21 @@ func deterministicToolCallID(kind string, msgIdx, partIdx int, name, payload str return "call_" + hex.EncodeToString(sum[:12]) } +func geminiFunctionCallThoughtSignature(part gjson.Result) string { + for _, path := range []string{ + "thoughtSignature", + "thought_signature", + "functionCall.thoughtSignature", + "functionCall.thought_signature", + "extra_content.google.thought_signature", + } { + if sig := part.Get(path); sig.Exists() && sig.String() != "" { + return sig.String() + } + } + return "" +} + func explicitGeminiToolID(node gjson.Result) string { if id := strings.TrimSpace(node.Get("id").String()); id != "" { return id diff --git a/internal/translator/openai/gemini/openai_gemini_response.go b/internal/translator/openai/gemini/openai_gemini_response.go index e1308b60..557c6334 100644 --- a/internal/translator/openai/gemini/openai_gemini_response.go +++ b/internal/translator/openai/gemini/openai_gemini_response.go @@ -9,6 +9,7 @@ import ( "bytes" "context" "fmt" + "sort" "strconv" "strings" @@ -29,9 +30,10 @@ type ConvertOpenAIResponseToGeminiParams struct { // ToolCallAccumulator holds the state for accumulating tool call data type ToolCallAccumulator struct { - ID string - Name string - Arguments strings.Builder + ID string + Name string + Arguments strings.Builder + ThoughtSignature string } // ConvertOpenAIResponseToGemini converts OpenAI Chat Completions streaming response format to Gemini API format. @@ -144,7 +146,10 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR if len(chunkOutputs) > 0 { results = append(results, chunkOutputs...) - return true + finishReason := choice.Get("finish_reason") + if !delta.Get("tool_calls").Exists() && (finishReason.Type != gjson.String || finishReason.String() == "") { + return true + } } // Handle tool calls delta @@ -154,6 +159,7 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR toolID := toolCall.Get("id").String() toolType := toolCall.Get("type").String() function := toolCall.Get("function") + thoughtSig := openAIToolCallThoughtSignature(toolCall) // Skip non-function tool calls explicitly marked as other types. if toolType != "" && toolType != "function" { @@ -161,7 +167,7 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR } // OpenAI streaming deltas may omit the type field while still carrying function data. - if !function.Exists() { + if !function.Exists() && thoughtSig == "" { return true } @@ -193,11 +199,17 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR acc.Arguments.WriteString(functionArgs) } + if thoughtSig != "" { + acc.ThoughtSignature = thoughtSig + } + return true }) - // Don't output anything for tool call deltas - wait for completion - return true + if finishReason := choice.Get("finish_reason"); finishReason.Type != gjson.String || finishReason.String() == "" { + // Don't output anything for tool call deltas - wait for completion + return true + } } // Handle finish reason @@ -207,8 +219,15 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR // If we have accumulated tool calls, output them now if len((*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator) > 0 { + toolIndices := make([]int, 0, len((*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator)) + for toolIndex := range (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator { + toolIndices = append(toolIndices, toolIndex) + } + sort.Ints(toolIndices) + partIndex := 0 - for _, accumulator := range (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator { + for _, toolIndex := range toolIndices { + accumulator := (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator[toolIndex] 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) @@ -217,6 +236,10 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR } template, _ = sjson.SetBytes(template, namePath, accumulator.Name) template, _ = sjson.SetRawBytes(template, argsPath, []byte(parseArgsToObjectRaw(accumulator.Arguments.String()))) + if accumulator.ThoughtSignature != "" { + sigPath := fmt.Sprintf("candidates.0.content.parts.%d.thoughtSignature", partIndex) + template, _ = sjson.SetBytes(template, sigPath, accumulator.ThoughtSignature) + } partIndex++ } @@ -242,6 +265,20 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR return [][]byte{} } +func openAIToolCallThoughtSignature(toolCall gjson.Result) string { + for _, path := range []string{ + "extra_content.google.thought_signature", + "function.extra_content.google.thought_signature", + "thoughtSignature", + "thought_signature", + } { + if sig := toolCall.Get(path); sig.Exists() && sig.String() != "" { + return sig.String() + } + } + return "" +} + // mapOpenAIFinishReasonToGemini maps OpenAI finish reasons to Gemini finish reasons func mapOpenAIFinishReasonToGemini(openAIReason string) string { switch openAIReason { @@ -598,6 +635,9 @@ func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, origina } part, _ = sjson.SetBytes(part, "functionCall.name", functionName) part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(parseArgsToObjectRaw(functionArgs))) + if thoughtSig := openAIToolCallThoughtSignature(toolCall); thoughtSig != "" { + part, _ = sjson.SetBytes(part, "thoughtSignature", thoughtSig) + } allParts[partIndex] = part partIndex++ } diff --git a/internal/translator/openai/interactions/chat-completions/interactions_openai_request.go b/internal/translator/openai/interactions/chat-completions/interactions_openai_request.go index fc25189b..501b037d 100644 --- a/internal/translator/openai/interactions/chat-completions/interactions_openai_request.go +++ b/internal/translator/openai/interactions/chat-completions/interactions_openai_request.go @@ -9,7 +9,7 @@ import ( "github.com/tidwall/sjson" ) -func ConvertInteractionsRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertInteractionsRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { root := gjson.ParseBytes(inputRawJSON) out := []byte(`{"model":"","messages":[]}`) model := firstNonEmpty(modelName, root.Get("model").String()) @@ -29,7 +29,7 @@ func ConvertInteractionsRequestToOpenAI(modelName string, inputRawJSON []byte, s out = copyInteractionsToolsToOpenAI(out, root, forAntigravity) out = copyInteractionsGenerationConfigToOpenAI(out, root) out = copyInteractionsOpenAITopLevel(out, root) - return out + return out, nil } func appendInteractionsSystemToOpenAI(items *[][]byte, root gjson.Result) { diff --git a/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go b/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go index 6bea8ca3..5a938fa7 100644 --- a/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go +++ b/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go @@ -8,7 +8,14 @@ import ( "github.com/tidwall/sjson" ) -func ConvertOpenAIRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertOpenAIRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertOpenAIRequestToInteractions(modelName, inputRawJSON, stream) + +} + +// convertOpenAIRequestToInteractions also reports a file or audio part that +// Interactions cannot receive when it leaves a user turn with nothing to send. +func convertOpenAIRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { root := gjson.ParseBytes(inputRawJSON) out := []byte(`{"model":"","input":[]}`) model := firstNonEmpty(modelName, root.Get("model").String()) @@ -26,10 +33,10 @@ func ConvertOpenAIRequestToInteractions(modelName string, inputRawJSON []byte, s out, _ = sjson.SetRawBytes(out, "agent_config", []byte(agentConfig.Raw)) } forAntigravity := isAntigravityModel(model) - out = appendOpenAIMessagesToInteractions(out, root.Get("messages"), forAntigravity) + out, errMessages := appendOpenAIMessagesToInteractions(out, root.Get("messages"), forAntigravity) out = copyOpenAIChatGenerationConfigToInteractions(out, root, model) out = appendOpenAIChatToolsToInteractions(out, root.Get("tools"), forAntigravity) - return out + return out, errMessages } func openAIRequestStreamValue(root gjson.Result, stream bool) (bool, bool) { @@ -42,10 +49,11 @@ func openAIRequestStreamValue(root gjson.Result, stream bool) (bool, bool) { return false, false } -func appendOpenAIMessagesToInteractions(out []byte, messages gjson.Result, forAntigravity bool) []byte { +func appendOpenAIMessagesToInteractions(out []byte, messages gjson.Result, forAntigravity bool) ([]byte, error) { if !messages.Exists() || !messages.IsArray() { - return out + return out, nil } + var drops translatorcommon.UserTurnDrops inputItems := translatorcommon.NewRawArrayItems(messages.Get("#").Int()) var systemBuilder strings.Builder toolNamesByID := make(map[string]string) @@ -60,7 +68,7 @@ func appendOpenAIMessagesToInteractions(out []byte, messages gjson.Result, forAn systemBuilder.WriteString(text) } default: - appendOpenAIMessageToInteractions(&inputItems, message, forAntigravity, toolNamesByID) + appendOpenAIMessageToInteractions(&inputItems, message, forAntigravity, toolNamesByID, &drops) } return true }) @@ -68,10 +76,10 @@ func appendOpenAIMessagesToInteractions(out []byte, messages gjson.Result, forAn out, _ = sjson.SetBytes(out, "system_instruction", systemBuilder.String()) } out = translatorcommon.SetRawArrayItems(out, "input", inputItems) - return out + return out, drops.Err() } -func appendOpenAIMessageToInteractions(items *[][]byte, message gjson.Result, forAntigravity bool, toolNamesByID map[string]string) { +func appendOpenAIMessageToInteractions(items *[][]byte, message gjson.Result, forAntigravity bool, toolNamesByID map[string]string, drops *translatorcommon.UserTurnDrops) { role := strings.ToLower(strings.TrimSpace(message.Get("role").String())) switch role { case "assistant": @@ -80,7 +88,7 @@ func appendOpenAIMessageToInteractions(items *[][]byte, message gjson.Result, fo *items = append(*items, interactionsTextStep("thought", text)) } } - if step, ok := openAIChatContentStep("model_output", message.Get("content")); ok { + if step, ok := openAIChatContentStep("model_output", message.Get("content"), nil); ok { *items = append(*items, step) } if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() { @@ -99,14 +107,22 @@ func appendOpenAIMessageToInteractions(items *[][]byte, message gjson.Result, fo case "tool", "function": *items = append(*items, openAIToolResultToInteractions(message, forAntigravity, toolNamesByID)) default: - if step, ok := openAIChatContentStep("user_input", message.Get("content")); ok { + if step, ok := openAIChatContentStep("user_input", message.Get("content"), drops); ok { *items = append(*items, step) } } } -func openAIChatContentStep(stepType string, content gjson.Result) ([]byte, bool) { +// openAIChatContentStep converts one message content into an Interactions step. +// A non-nil drops closes the user turn: an attachment that cannot be sent is +// recorded, and text or any other sendable part beside it keeps the turn alive. +// An empty text part is forwarded but does not count as sendable. +func openAIChatContentStep(stepType string, content gjson.Result, drops *translatorcommon.UserTurnDrops) ([]byte, bool) { contentItems := make([][]byte, 0, 4) + sendable := 0 + if drops != nil { + defer func() { drops.EndTurn(sendable) }() + } if content.Type == gjson.String { if content.String() == "" { return nil, false @@ -114,10 +130,19 @@ func openAIChatContentStep(stepType string, content gjson.Result) ([]byte, bool) part := []byte(`{"type":"text","text":""}`) part, _ = sjson.SetBytes(part, "text", content.String()) contentItems = append(contentItems, part) + sendable++ } else { appendPart := func(part gjson.Result) { - if converted, ok := openAIChatContentPartToInteractions(part); ok { - contentItems = append(contentItems, converted) + converted, ok := openAIChatContentPartToInteractions(part) + if !ok { + if partType := openAIChatPartType(part); drops != nil && isOpenAIChatAttachmentType(partType) { + drops.Drop(partType) + } + return + } + contentItems = append(contentItems, converted) + if convertedType := gjson.GetBytes(converted, "type").String(); convertedType != "text" || gjson.GetBytes(converted, "text").String() != "" { + sendable++ } } if content.IsArray() { @@ -138,11 +163,26 @@ func openAIChatContentStep(stepType string, content gjson.Result) ([]byte, bool) return step, true } -func openAIChatContentPartToInteractions(part gjson.Result) ([]byte, bool) { +func openAIChatPartType(part gjson.Result) string { partType := strings.ToLower(strings.TrimSpace(part.Get("type").String())) if partType == "" && part.Get("text").Exists() { partType = "text" } + return partType +} + +// isOpenAIChatAttachmentType reports the part types whose loss must be refused +// when they leave a user turn empty, instead of being skipped silently. +func isOpenAIChatAttachmentType(partType string) bool { + switch partType { + case "file", "input_file", "document", "input_audio", "audio": + return true + } + return false +} + +func openAIChatContentPartToInteractions(part gjson.Result) ([]byte, bool) { + partType := openAIChatPartType(part) switch partType { case "text", "input_text", "output_text": out := []byte(`{"type":"text","text":""}`) diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go index 2ab284e1..63d35f86 100644 --- a/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go +++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go @@ -10,7 +10,15 @@ import ( "github.com/tidwall/sjson" ) -func ConvertOpenAIResponsesRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertOpenAIResponsesRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertOpenAIResponsesRequestToInteractions(modelName, inputRawJSON, stream) + +} + +// convertOpenAIResponsesRequestToInteractions also reports a file, audio or video +// part Interactions cannot receive when it leaves a user turn with nothing to send. +func convertOpenAIResponsesRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + var drops translatorcommon.UserTurnDrops root := gjson.ParseBytes(inputRawJSON) out := []byte(`{"model":"","input":[]}`) model := requestModel(modelName, root) @@ -33,7 +41,7 @@ func ConvertOpenAIResponsesRequestToInteractions(modelName string, inputRawJSON forAntigravity := isAntigravityModel(model) forDevin := isDevinModel(model) || !forAntigravity if input := root.Get("input"); input.Exists() { - out = setResponsesInputOnInteractions(out, input, forAntigravity) + out = setResponsesInputOnInteractions(out, input, forAntigravity, &drops) } out = appendResponsesToolsToInteractions(out, root, forAntigravity, forDevin) if toolChoice := root.Get("tool_choice"); toolChoice.Exists() { @@ -104,10 +112,10 @@ func ConvertOpenAIResponsesRequestToInteractions(modelName string, inputRawJSON out, _ = sjson.SetRawBytes(out, "generation_config.stop_sequences", []byte(stop.Raw)) } } - return out + return out, drops.Err() } -func ConvertInteractionsRequestToOpenAIResponses(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertInteractionsRequestToOpenAIResponses(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { root := gjson.ParseBytes(inputRawJSON) out := []byte(`{"model":"","input":[]}`) model := requestModel(modelName, root) @@ -152,7 +160,7 @@ func ConvertInteractionsRequestToOpenAIResponses(modelName string, inputRawJSON if format := root.Get("response_format"); format.Exists() { out, _ = sjson.SetRawBytes(out, "text.format", []byte(format.Raw)) } - return out + return out, nil } func requestModel(modelName string, root gjson.Result) string { @@ -230,20 +238,20 @@ func interactionsThinkingEffort(root gjson.Result) string { return "" } -func setResponsesInputOnInteractions(out []byte, input gjson.Result, forAntigravity bool) []byte { +func setResponsesInputOnInteractions(out []byte, input gjson.Result, forAntigravity bool, drops *translatorcommon.UserTurnDrops) []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, forAntigravity); converted != nil { + if converted := responsesInputItemToInteractions(item, functionNamesByCallID, forAntigravity, drops); converted != nil { items = append(items, converted) } return true }) } else if input.IsObject() { - if converted := responsesInputItemToInteractions(input, functionNamesByCallID, forAntigravity); converted != nil { + if converted := responsesInputItemToInteractions(input, functionNamesByCallID, forAntigravity, drops); converted != nil { items = append(items, converted) } } @@ -253,16 +261,29 @@ func setResponsesInputOnInteractions(out []byte, input gjson.Result, forAntigrav return out } -func responsesInputItemToInteractions(item gjson.Result, functionNamesByCallID map[string]string, forAntigravity bool) []byte { +// responsesUserTurnDrops returns drops when the item is a real user turn. A +// system or developer message is also sent as a user_input step, but it is not +// the user's own turn and must never be refused or hide an emptied one. +func responsesUserTurnDrops(role string, drops *translatorcommon.UserTurnDrops) *translatorcommon.UserTurnDrops { + if role == "" || role == "user" { + return drops + } + return nil +} + +func responsesInputItemToInteractions(item gjson.Result, functionNamesByCallID map[string]string, forAntigravity bool, drops *translatorcommon.UserTurnDrops) []byte { switch item.Get("type").String() { case "message": stepType := "user_input" - if role := item.Get("role").String(); role == "assistant" || role == "model" { + role := item.Get("role").String() + turnDrops := responsesUserTurnDrops(role, drops) + if role == "assistant" || role == "model" { stepType = "model_output" + turnDrops = nil } step := []byte(`{"type":"","content":[]}`) step, _ = sjson.SetBytes(step, "type", stepType) - return appendResponsesContentToInteractions(step, item.Get("content")) + return appendResponsesContentToInteractions(step, item.Get("content"), turnDrops) case "function_call": callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String()) name := item.Get("name").String() @@ -291,43 +312,63 @@ func responsesInputItemToInteractions(item gjson.Result, functionNamesByCallID m stepType = "model_output" } return interactionsTextStep(stepType, item.Get("text").String()) - case "input_image", "output_image": + case "input_image", "output_image", "input_file", "input_audio", "input_video": stepType := "user_input" + var turnDrops *translatorcommon.UserTurnDrops if item.Get("type").String() == "output_image" { stepType = "model_output" + } else { + turnDrops = drops } 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 + return appendResponsesContentToInteractions(step, item, turnDrops) default: if content := item.Get("content"); content.Exists() { step := []byte(`{"type":"user_input","content":[]}`) - return appendResponsesContentToInteractions(step, content) + return appendResponsesContentToInteractions(step, content, responsesUserTurnDrops(item.Get("role").String(), drops)) } } return nil } -func appendResponsesContentToInteractions(step []byte, content gjson.Result) []byte { +// appendResponsesContentToInteractions converts one message content into step +// content. A non-nil drops closes the user turn: an attachment that cannot be +// sent is recorded, and text or any other sendable part beside it keeps the turn +// alive. An empty text part is forwarded but does not count as sendable. +func appendResponsesContentToInteractions(step []byte, content gjson.Result, drops *translatorcommon.UserTurnDrops) []byte { var contentItems [][]byte + sendable := 0 + appendPart := func(item gjson.Result) { + part, ok := responsesContentPartToInteractions(item) + if !ok { + if partType := item.Get("type").String(); drops != nil && isResponsesUnsendableAttachmentType(partType) { + drops.Drop(partType) + } + return + } + contentItems = append(contentItems, part) + if gjson.GetBytes(part, "type").String() != "text" || gjson.GetBytes(part, "text").String() != "" { + sendable++ + } + } if content.Type == gjson.String { part := []byte(`{"type":"text","text":""}`) part, _ = sjson.SetBytes(part, "text", content.String()) contentItems = append(contentItems, part) + if content.String() != "" { + sendable++ + } } else if content.IsArray() { content.ForEach(func(_, item gjson.Result) bool { - if part, ok := responsesContentPartToInteractions(item); ok { - contentItems = append(contentItems, part) - } + appendPart(item) return true }) } else if content.IsObject() { - if part, ok := responsesContentPartToInteractions(content); ok { - contentItems = append(contentItems, part) - } + appendPart(content) + } + if drops != nil { + drops.EndTurn(sendable) } if len(contentItems) > 0 { step = translatorcommon.SetRawArrayItems(step, "content", contentItems) @@ -335,6 +376,17 @@ func appendResponsesContentToInteractions(step []byte, content gjson.Result) []b return step } +// isResponsesUnsendableAttachmentType reports the Responses attachment types +// whose loss must be refused when they leave a user turn empty. A file id, +// a part without bytes and a video have no Interactions counterpart. +func isResponsesUnsendableAttachmentType(partType string) bool { + switch partType { + case "input_file", "input_audio", "input_video": + return true + } + return false +} + func responsesContentPartToInteractions(part gjson.Result) ([]byte, bool) { switch part.Get("type").String() { case "input_text", "output_text", "text": @@ -343,6 +395,10 @@ func responsesContentPartToInteractions(part gjson.Result) ([]byte, bool) { return out, true case "input_image", "output_image": return responsesImagePartToInteractions(part), true + case "input_file": + return responsesFilePartToInteractions(part) + case "input_audio": + return responsesAudioPartToInteractions(part) } if text := part.Get("text"); text.Exists() { out := []byte(`{"type":"text","text":""}`) @@ -352,6 +408,60 @@ func responsesContentPartToInteractions(part gjson.Result) ([]byte, bool) { return nil, false } +// responsesFilePartToInteractions maps an input_file onto an Interactions +// document. Interactions takes inline bytes or a url; a bare file_id names a file +// only the OpenAI side can resolve, so it reports false. +func responsesFilePartToInteractions(part gjson.Result) ([]byte, bool) { + filename := part.Get("filename").String() + fallbackMIMEType := firstNonEmpty(part.Get("mime_type").String(), part.Get("mimeType").String()) + out := []byte(`{"type":"document"}`) + if filename != "" { + out, _ = sjson.SetBytes(out, "filename", filename) + } + hasContent := false + if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, fallbackMIMEType, part.Get("file_data").String()); ok { + out, _ = sjson.SetBytes(out, "mime_type", mimeType) + out, _ = sjson.SetBytes(out, "data", data) + hasContent = true + } + if fileURL := part.Get("file_url").String(); fileURL != "" { + out, _ = sjson.SetBytes(out, "file_url", fileURL) + hasContent = true + } + return out, hasContent +} + +// responsesAudioPartToInteractions maps an input_audio with inline bytes onto an +// Interactions audio part. +func responsesAudioPartToInteractions(part gjson.Result) ([]byte, bool) { + audio := part.Get("input_audio") + data := firstNonEmpty(audio.Get("data").String(), part.Get("data").String()) + if data == "" { + return nil, false + } + out := []byte(`{"type":"audio","data":""}`) + out, _ = sjson.SetBytes(out, "data", data) + if format := firstNonEmpty(audio.Get("format").String(), part.Get("format").String()); format != "" { + out, _ = sjson.SetBytes(out, "mime_type", responsesInputAudioMIMEType(format)) + } + return out, true +} + +func responsesInputAudioMIMEType(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 responsesImagePartToInteractions(part gjson.Result) []byte { out := []byte(`{"type":"image"}`) imageURL := firstNonEmpty(part.Get("image_url").String(), part.Get("url").String()) diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go index 491a7f20..4ce1e08f 100644 --- a/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go +++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/google/uuid" applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" "github.com/router-for-me/CLIProxyAPI/v8/internal/signature" translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" @@ -19,6 +20,7 @@ import ( type interactionsToResponsesStreamState struct { translatorcommon.ApplyPatchErrorState ID string + ItemIDPrefix string EnvironmentID string FunctionCalls map[int]*interactionsFunctionCallState ItemIDs map[int]string @@ -86,9 +88,12 @@ func ConvertInteractionsResponseToOpenAIResponses(ctx context.Context, modelName param = &local } if *param == nil { - *param = &interactionsToResponsesStreamState{} + *param = &interactionsToResponsesStreamState{ItemIDPrefix: uuid.NewString()} } st := (*param).(*interactionsToResponsesStreamState) + if st.ItemIDPrefix == "" { + st.ItemIDPrefix = uuid.NewString() + } st.ForAntigravity = isAntigravityModel(modelName) if st.FunctionCalls == nil { st.FunctionCalls = make(map[int]*interactionsFunctionCallState) @@ -400,7 +405,7 @@ func interactionsStepStartToResponses(modelName string, root gjson.Result, st *i if call := st.FunctionCalls[index]; call != nil && (call.PatchCall != nil || st.ToolIdentityMap[call.RawName].ApplyPatch || (call.RawName == "" && interactionsHasPatchBridge(st))) { return interactionsUpdateFunctionCall(index, step, st, true) } - itemID := firstNonEmpty(step.Get("id").String(), step.Get("call_id").String(), fmt.Sprintf("item_%d", index)) + itemID := firstNonEmpty(step.Get("id").String(), step.Get("call_id").String(), interactionsFallbackItemID(st, index)) if stepType == "function_call" { return interactionsUpdateFunctionCall(index, step, st, true) } @@ -1386,6 +1391,13 @@ func firstUsageInt(root gjson.Result, paths ...string) (int64, bool) { return 0, false } +func interactionsFallbackItemID(st *interactionsToResponsesStreamState, index int) string { + if st != nil && st.ItemIDPrefix != "" { + return fmt.Sprintf("item_%s_%d", st.ItemIDPrefix, index) + } + return fmt.Sprintf("item_%d", index) +} + func nextResponsesSeq(st *interactionsToResponsesStreamState) int { st.Seq++ return st.Seq @@ -1532,15 +1544,10 @@ func interactionsToolIdentityMap(rawJSON []byte, forAntigravity bool) map[string identities := make(map[string]util.ResponsesToolIdentity) for name, descriptor := range util.CollectResponsesToolWinners(root) { identity := util.ResponsesToolIdentity{Name: descriptor.LocalName, Namespace: descriptor.Namespace, Custom: descriptor.ToolType == "custom", ApplyPatch: applypatch.IsCustomTool(descriptor.Tool)} - identities[name] = identity - } - if forAntigravity { - for name, descriptor := range util.CollectResponsesToolWinners(root) { - upstreamName := translatorcommon.AntigravityToolNameToUpstream(name) - if _, exists := identities[upstreamName]; !exists { - identities[upstreamName] = util.ResponsesToolIdentity{Name: descriptor.LocalName, Namespace: descriptor.Namespace, Custom: descriptor.ToolType == "custom", ApplyPatch: applypatch.IsCustomTool(descriptor.Tool)} - } + if forAntigravity { + name = translatorcommon.AntigravityToolNameToUpstream(name) } + identities[name] = identity } return identities } @@ -1781,7 +1788,7 @@ func interactionsUpdateFunctionCall(index int, step gjson.Result, st *interactio } else { call.ArgumentFragments = nil if !call.ItemIDSeen && !call.Added { - call.ID = firstNonEmpty(call.CallID, fmt.Sprintf("item_%d", index)) + call.ID = firstNonEmpty(call.CallID, interactionsFallbackItemID(st, index)) } if !call.CallIDSeen && !call.Added { call.CallID = call.ID @@ -1853,10 +1860,10 @@ func interactionsFinishPatchCalls(st *interactionsToResponsesStreamState) [][]by call := st.FunctionCalls[index] if call.PatchCall == nil { // Interactions already maps an absent call_id to id (and an absent id - // to call_id/item_). Freeze that compatibility mapping only at - // the response terminal, after all supplied snapshot IDs were reconciled. + // to call_id or a per-response fallback item ID). Freeze that compatibility + // mapping only at the response terminal, after all supplied snapshot IDs were reconciled. if !call.ItemIDSeen { - call.ID = firstNonEmpty(call.CallID, fmt.Sprintf("item_%d", index)) + call.ID = firstNonEmpty(call.CallID, interactionsFallbackItemID(st, index)) } if !call.CallIDSeen { call.CallID = call.ID diff --git a/internal/translator/openai/openai/chat-completions/openai_openai_request.go b/internal/translator/openai/openai/chat-completions/openai_openai_request.go index dd3ee5ab..bb97e243 100644 --- a/internal/translator/openai/openai/chat-completions/openai_openai_request.go +++ b/internal/translator/openai/openai/chat-completions/openai_openai_request.go @@ -17,10 +17,10 @@ import ( // // Returns: // - []byte: The transformed request data in OpenAI API format -func ConvertOpenAIRequestToOpenAI(modelName string, inputRawJSON []byte, _ bool) []byte { +func ConvertOpenAIRequestToOpenAI(modelName string, inputRawJSON []byte, _ bool) ([]byte, error) { currentModel := gjson.GetBytes(inputRawJSON, "model") if currentModel.Type == gjson.String && currentModel.String() == modelName { - return inputRawJSON + return inputRawJSON, nil } // Update the "model" field in the JSON payload with the provided modelName @@ -30,7 +30,7 @@ func ConvertOpenAIRequestToOpenAI(modelName string, inputRawJSON []byte, _ bool) // 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 inputRawJSON, nil } - return updatedJSON + return updatedJSON, nil } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index a45fa2a0..c27cd6eb 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -27,8 +27,17 @@ import ( // // Returns: // - []byte: The transformed request data in OpenAI chat completions format -func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName, inputRawJSON, stream) + +} + +// convertOpenAIResponsesRequestToOpenAIChatCompletions also reports a file or +// audio part Chat Completions cannot receive when it leaves a user turn with +// nothing to send. +func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { rawJSON := inputRawJSON + var drops translatorcommon.UserTurnDrops // Base OpenAI chat completions template with default values out := []byte(`{"model":"","messages":[],"stream":false}`) @@ -73,7 +82,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu // Convert input array to messages if input := root.Get("input"); input.Exists() && input.IsArray() { - rawInputArray := input.Array() + rawInputArray := toolIndex.shellHistory(input.Array()) explicitOutputCounts := make(map[string]int) missingIDOutputsCount := 0 for _, item := range rawInputArray { @@ -243,6 +252,9 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu case "message", "": // Handle regular message conversion role := item.Get("role").String() + // Only a real user turn can be refused; developer text is sent as user text + // but never counts as the user's own turn. + isUserTurn := role == "user" if role == "developer" { role = "user" } @@ -256,6 +268,8 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu if content := item.Get("content"); content.Exists() && content.IsArray() { var contentItems [][]byte + // Counts the parts this turn really sends; an empty text part does not. + turnSendable := 0 content.ForEach(func(_, contentItem gjson.Result) bool { contentType := contentItem.Get("type").String() if contentType == "" { @@ -268,6 +282,9 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu contentPart := []byte(`{"type":"text","text":""}`) contentPart, _ = sjson.SetBytes(contentPart, "text", text) contentItems = append(contentItems, contentPart) + if text != "" { + turnSendable++ + } case "input_video", "video_url": contentPart := []byte(`{"type":"video_url","video_url":{}}`) videoURL := contentItem.Get("video_url") @@ -282,6 +299,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu // Preserve malformed video parts for upstream validation instead of // silently turning a video request into a text-only request. contentItems = append(contentItems, contentPart) + turnSendable++ case "input_image": imageURL := contentItem.Get("image_url").String() contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`) @@ -290,9 +308,28 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu contentPart, _ = sjson.SetBytes(contentPart, "image_url.detail", detail) } contentItems = append(contentItems, contentPart) + turnSendable++ + case "input_file": + if contentPart, ok := responsesInputFileToChatPart(contentItem); ok { + contentItems = append(contentItems, contentPart) + turnSendable++ + } else if isUserTurn { + // Chat Completions takes a file id or inline bytes only. + drops.Drop(contentType) + } + case "input_audio": + if contentPart, ok := responsesInputAudioToChatPart(contentItem); ok { + contentItems = append(contentItems, contentPart) + turnSendable++ + } else if isUserTurn { + drops.Drop(contentType) + } } return true }) + if isUserTurn { + drops.EndTurn(turnSendable) + } message = translatorcommon.SetRawArrayItems(message, "content", contentItems) } else if content.Type == gjson.String { message, _ = sjson.SetBytes(message, "content", content.String()) @@ -469,7 +506,52 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu } } - return out + return out, drops.Err() +} + +// responsesInputFileToChatPart maps a Responses input_file part onto a Chat +// Completions file part. It reports false when the part carries neither a file +// id nor inline bytes, because Chat Completions has no field for a bare url. +func responsesInputFileToChatPart(contentItem gjson.Result) ([]byte, bool) { + fileID := contentItem.Get("file_id").String() + fileData := contentItem.Get("file_data").String() + if fileID == "" && fileData == "" { + return nil, false + } + part := []byte(`{"type":"file","file":{}}`) + if fileID != "" { + part, _ = sjson.SetBytes(part, "file.file_id", fileID) + } + if fileData != "" { + part, _ = sjson.SetBytes(part, "file.file_data", fileData) + } + if filename := contentItem.Get("filename").String(); filename != "" { + part, _ = sjson.SetBytes(part, "file.filename", filename) + } + return part, true +} + +// responsesInputAudioToChatPart maps a Responses input_audio part onto a Chat +// Completions input_audio part. It reports false when the part has no bytes. +func responsesInputAudioToChatPart(contentItem gjson.Result) ([]byte, bool) { + audio := contentItem.Get("input_audio") + data := audio.Get("data").String() + if data == "" { + data = contentItem.Get("data").String() + } + if data == "" { + return nil, false + } + format := audio.Get("format").String() + if format == "" { + format = contentItem.Get("format").String() + } + part := []byte(`{"type":"input_audio","input_audio":{"data":""}}`) + part, _ = sjson.SetBytes(part, "input_audio.data", data) + if format != "" { + part, _ = sjson.SetBytes(part, "input_audio.format", format) + } + return part, true } func convertResponsesToolChoiceToChatCompletions(toolChoice gjson.Result, inputRawJSON []byte) []byte { @@ -482,6 +564,12 @@ func convertResponsesToolChoiceWithIndex(toolChoice gjson.Result, toolIndex *res } choiceType := toolChoice.Get("type").String() + if choiceType == "shell" { + if name := toolIndex.shellName(); name != "" { + converted, _ := sjson.SetBytes([]byte(`{"type":"function","function":{}}`), "function.name", name) + return converted + } + } if choiceType != "function" && choiceType != "custom" { return []byte(toolChoice.Raw) } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go index 1a1ac352..18fe9537 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -21,8 +21,11 @@ type oaiToResponsesStateReasoning struct { } type oaiToResponsesState struct { translatorcommon.ApplyPatchErrorState - ApplyPatchCalls map[string]*translatorcommon.ApplyPatchCallState - RequestJSON []byte + ApplyPatchCalls map[string]*translatorcommon.ApplyPatchCallState + // RequestEcho holds the request fields echoed into the completed + // response, extracted once at stream init so the request body itself + // is neither retained nor re-scanned at stream end. + RequestEcho responsesRequestEcho ToolIndex *responsesToolIndex RequestInitialized bool Seq int @@ -30,6 +33,7 @@ type oaiToResponsesState struct { Created int64 Started bool CompletedEmitted bool + CompletionPending bool ReasoningID string ReasoningIndex int // aggregation buffers for response.output @@ -85,7 +89,215 @@ func incompleteByFinishReason(reason string) ([]byte, bool) { } } -func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte, nextSeq func() int) []byte { +// responsesRequestEcho carries the typed values of the Responses request +// fields echoed into response objects. Extraction walks the top level of +// the request once; apply writes the fields in the same fixed order and +// with the same typed conversions as the previous per-field lookups, so +// the emitted JSON is unchanged. +type responsesRequestEcho struct { + instructions string + hasInstructions bool + maxOutputTokens int64 + hasMaxOutputTokens bool + maxTokens int64 + hasMaxTokens bool + maxToolCalls int64 + hasMaxToolCalls bool + model string + hasModel bool + parallelToolCalls bool + hasParallelToolCalls bool + previousResponseID string + hasPreviousResponseID bool + promptCacheKey string + hasPromptCacheKey bool + reasoning any + hasReasoning bool + safetyIdentifier string + hasSafetyIdentifier bool + serviceTier string + hasServiceTier bool + store bool + hasStore bool + temperature float64 + hasTemperature bool + text any + hasText bool + toolChoice any + hasToolChoice bool + tools any + hasTools bool + topLogprobs int64 + hasTopLogprobs bool + topP float64 + hasTopP bool + truncation string + hasTruncation bool + user any + hasUser bool + metadata any + hasMetadata bool +} + +func extractResponsesRequestEcho(root gjson.Result) responsesRequestEcho { + var e responsesRequestEcho + root.ForEach(func(key, value gjson.Result) bool { + switch key.String() { + case "instructions": + if !e.hasInstructions { + e.instructions, e.hasInstructions = value.String(), true + } + case "max_output_tokens": + if !e.hasMaxOutputTokens { + e.maxOutputTokens, e.hasMaxOutputTokens = value.Int(), true + } + case "max_tokens": + if !e.hasMaxTokens { + e.maxTokens, e.hasMaxTokens = value.Int(), true + } + case "max_tool_calls": + if !e.hasMaxToolCalls { + e.maxToolCalls, e.hasMaxToolCalls = value.Int(), true + } + case "model": + if !e.hasModel { + e.model, e.hasModel = value.String(), true + } + case "parallel_tool_calls": + if !e.hasParallelToolCalls { + e.parallelToolCalls, e.hasParallelToolCalls = value.Bool(), true + } + case "previous_response_id": + if !e.hasPreviousResponseID { + e.previousResponseID, e.hasPreviousResponseID = value.String(), true + } + case "prompt_cache_key": + if !e.hasPromptCacheKey { + e.promptCacheKey, e.hasPromptCacheKey = value.String(), true + } + case "reasoning": + if !e.hasReasoning { + e.reasoning, e.hasReasoning = value.Value(), true + } + case "safety_identifier": + if !e.hasSafetyIdentifier { + e.safetyIdentifier, e.hasSafetyIdentifier = value.String(), true + } + case "service_tier": + if !e.hasServiceTier { + e.serviceTier, e.hasServiceTier = value.String(), true + } + case "store": + if !e.hasStore { + e.store, e.hasStore = value.Bool(), true + } + case "temperature": + if !e.hasTemperature { + e.temperature, e.hasTemperature = value.Float(), true + } + case "text": + if !e.hasText { + e.text, e.hasText = value.Value(), true + } + case "tool_choice": + if !e.hasToolChoice { + e.toolChoice, e.hasToolChoice = value.Value(), true + } + case "tools": + if !e.hasTools { + e.tools, e.hasTools = value.Value(), true + } + case "top_logprobs": + if !e.hasTopLogprobs { + e.topLogprobs, e.hasTopLogprobs = value.Int(), true + } + case "top_p": + if !e.hasTopP { + e.topP, e.hasTopP = value.Float(), true + } + case "truncation": + if !e.hasTruncation { + e.truncation, e.hasTruncation = value.String(), true + } + case "user": + if !e.hasUser { + e.user, e.hasUser = value.Value(), true + } + case "metadata": + if !e.hasMetadata { + e.metadata, e.hasMetadata = value.Value(), true + } + } + return true + }) + return e +} + +func (e responsesRequestEcho) apply(dst []byte, prefix string) []byte { + if e.hasInstructions { + dst, _ = sjson.SetBytes(dst, prefix+"instructions", e.instructions) + } + if e.hasMaxOutputTokens { + dst, _ = sjson.SetBytes(dst, prefix+"max_output_tokens", e.maxOutputTokens) + } + if e.hasMaxToolCalls { + dst, _ = sjson.SetBytes(dst, prefix+"max_tool_calls", e.maxToolCalls) + } + if e.hasModel { + dst, _ = sjson.SetBytes(dst, prefix+"model", e.model) + } + if e.hasParallelToolCalls { + dst, _ = sjson.SetBytes(dst, prefix+"parallel_tool_calls", e.parallelToolCalls) + } + if e.hasPreviousResponseID { + dst, _ = sjson.SetBytes(dst, prefix+"previous_response_id", e.previousResponseID) + } + if e.hasPromptCacheKey { + dst, _ = sjson.SetBytes(dst, prefix+"prompt_cache_key", e.promptCacheKey) + } + if e.hasReasoning { + dst, _ = sjson.SetBytes(dst, prefix+"reasoning", e.reasoning) + } + if e.hasSafetyIdentifier { + dst, _ = sjson.SetBytes(dst, prefix+"safety_identifier", e.safetyIdentifier) + } + if e.hasServiceTier { + dst, _ = sjson.SetBytes(dst, prefix+"service_tier", e.serviceTier) + } + if e.hasStore { + dst, _ = sjson.SetBytes(dst, prefix+"store", e.store) + } + if e.hasTemperature { + dst, _ = sjson.SetBytes(dst, prefix+"temperature", e.temperature) + } + if e.hasText { + dst, _ = sjson.SetBytes(dst, prefix+"text", e.text) + } + if e.hasToolChoice { + dst, _ = sjson.SetBytes(dst, prefix+"tool_choice", e.toolChoice) + } + if e.hasTools { + dst, _ = sjson.SetBytes(dst, prefix+"tools", e.tools) + } + if e.hasTopLogprobs { + dst, _ = sjson.SetBytes(dst, prefix+"top_logprobs", e.topLogprobs) + } + if e.hasTopP { + dst, _ = sjson.SetBytes(dst, prefix+"top_p", e.topP) + } + if e.hasTruncation { + dst, _ = sjson.SetBytes(dst, prefix+"truncation", e.truncation) + } + if e.hasUser { + dst, _ = sjson.SetBytes(dst, prefix+"user", e.user) + } + if e.hasMetadata { + dst, _ = sjson.SetBytes(dst, prefix+"metadata", e.metadata) + } + return dst +} + +func buildResponsesCompletedEvent(st *oaiToResponsesState, nextSeq func() int) []byte { eventType := "response.completed" status := "completed" incompleteDetails, isIncomplete := incompleteByFinishReason(st.FinishReason) @@ -104,69 +316,7 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte 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()) - } - } + completed = st.RequestEcho.apply(completed, "response.") type completedOutputItem struct { index int @@ -213,6 +363,13 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte if _, isInc := incompleteByFinishReason(st.FinishReason); isInc { toolStatus = "incomplete" } + if st.ToolIndex.isShell(name) { + item, errShellCallItem := shellCallItem(callID, args, toolStatus) + if errShellCallItem == nil { + outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item}) + } + continue + } 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)) @@ -295,14 +452,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, rawJSON = bytes.TrimSpace(rawJSON) if !st.RequestInitialized { - st.RequestJSON = pickRequestJSON(originalRequestRawJSON, requestRawJSON) - st.ToolIndex = newResponsesToolIndex(gjson.ParseBytes(st.RequestJSON)) + requestRoot := gjson.ParseBytes(pickRequestJSON(originalRequestRawJSON, requestRawJSON)) + st.ToolIndex = newResponsesToolIndex(requestRoot) + st.RequestEcho = extractResponsesRequestEcho(requestRoot) st.RequestInitialized = true } if len(rawJSON) == 0 { return nil } - requestForNamespace := st.RequestJSON isDone := bytes.Equal(rawJSON, []byte("[DONE]")) if isDone && (!st.Started || st.CompletedEmitted) { return [][]byte{} @@ -359,7 +516,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, failToolInput := func(err error) { if st.ToolInputError() == nil { st.SetToolInputError(err) - out = append(out, emitRespEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ResponseID, nextSeq()))) + out = append(out, emitRespEvent("response.failed", responsesToolInputFailure(st.ResponseID, nextSeq(), err))) } } emitToolItem := func(key string, force bool) { @@ -390,7 +547,13 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, outputIndex := st.FuncOutputIx[key] _, isCustomTool := st.CustomToolNames[name] st.FuncItemCustom[key] = isCustomTool - if isCustomTool { + if st.ToolIndex.isShell(name) { + o := []byte(`{"type":"response.output_item.added"}`) + o, _ = sjson.SetBytes(o, "sequence_number", nextSeq()) + o, _ = sjson.SetBytes(o, "output_index", outputIndex) + o, _ = sjson.SetRawBytes(o, "item", shellCallPlaceholder(callID)) + out = append(out, emitRespEvent("response.output_item.added", o)) + } else if isCustomTool { if st.ToolIndex.isApplyPatch(name) { d := st.ToolIndex.byChat[name] st.ApplyPatchCalls[key] = &translatorcommon.ApplyPatchCallState{ @@ -417,7 +580,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.FuncItemAdded[key] = true } emitPendingFunctionArgs := func(key string) { - if !st.FuncItemAdded[key] || st.ToolInputError() != nil { + if !st.FuncItemAdded[key] || st.ToolInputError() != nil || st.ToolIndex.isShell(st.FuncNames[key]) { return } argsBuf := st.FuncArgsBuf[key] @@ -481,6 +644,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.FinishReason = "" st.UsageSeen = false st.CompletedEmitted = false + st.CompletionPending = 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()) @@ -644,6 +808,22 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, toolStatus = "incomplete" } + if st.ToolIndex.isShell(name) { + item, errShellCallItem := shellCallItem(callID, args, toolStatus) + if errShellCallItem != nil { + failToolInput(errShellCallItem) + return + } + event := []byte(`{"type":"response.output_item.done"}`) + event, _ = sjson.SetBytes(event, "sequence_number", nextSeq()) + event, _ = sjson.SetBytes(event, "output_index", outputIndex) + event, _ = sjson.SetRawBytes(event, "item", item) + out = append(out, emitRespEvent("response.output_item.done", event)) + st.FuncItemDone[key] = true + st.FuncArgsDone[key] = true + continue + } + if st.FuncItemCustom[key] { input := "" if patchCall := st.ApplyPatchCalls[key]; patchCall != nil { @@ -720,7 +900,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, return out } st.CompletedEmitted = true - out = append(out, buildResponsesCompletedEvent(st, requestForNamespace, nextSeq)) + out = append(out, buildResponsesCompletedEvent(st, nextSeq)) return out } @@ -854,12 +1034,13 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, return false } - // 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. + // finish_reason triggers item-level finalization. The terminal event is + // deferred until [DONE] or transport finalization 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() + st.CompletionPending = st.canFinalizeResponse() } return st.ToolInputError() == nil @@ -910,79 +1091,21 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co resp, _ = sjson.SetBytes(resp, "created_at", created) // Echo request fields when available (aligns with streaming path behavior) + echo := responsesRequestEcho{} 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()) + echo = extractResponsesRequestEcho(gjson.ParseBytes(requestRawJSON)) + // Also support max_tokens from chat completion style + if !echo.hasMaxOutputTokens && echo.hasMaxTokens { + echo.maxOutputTokens, echo.hasMaxOutputTokens = echo.maxTokens, true } - 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() { + } + if !echo.hasModel { // Fallback model from response - resp, _ = sjson.SetBytes(resp, "model", v.String()) + if v := root.Get("model"); v.Exists() { + echo.model, echo.hasModel = v.String(), true + } } + resp = echo.apply(resp, "") // Build output list from choices[...] var outputItems [][]byte @@ -993,8 +1116,8 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co } rcText := rc.String() includeReasoning := rcText != "" - if !includeReasoning && len(requestRawJSON) > 0 { - includeReasoning = gjson.GetBytes(requestRawJSON, "reasoning").Exists() + if !includeReasoning { + includeReasoning = echo.hasReasoning } if includeReasoning { rid := id @@ -1044,6 +1167,15 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co if isIncomplete { toolStatus = "incomplete" } + if toolIndex.isShell(name) { + item, errShellCallItem := shellCallItem(callID, args, toolStatus) + if errShellCallItem != nil { + st.SetToolInputError(errShellCallItem) + return false + } + outputItems = append(outputItems, item) + return true + } 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)) @@ -1081,7 +1213,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co }) } if st.ToolInputError() != nil { - return []byte(gjson.GetBytes(translatorcommon.ApplyPatchFailure(id, 0), "response").Raw) + return []byte(gjson.GetBytes(responsesToolInputFailure(id, 0, st.ToolInputError()), "response").Raw) } if len(outputItems) > 0 { resp, _ = sjson.SetRawBytes(resp, "output", translatorcommon.JoinRawArray(outputItems)) @@ -1096,9 +1228,12 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co 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 + // Chat Completions upstreams report reasoning under completion_tokens_details; + // Responses-native upstreams report it under output_tokens_details. Accept both. if d := usage.Get("output_tokens_details.reasoning_tokens"); d.Exists() { resp, _ = sjson.SetBytes(resp, "usage.output_tokens_details.reasoning_tokens", d.Int()) + } else if d := usage.Get("completion_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 { @@ -1110,11 +1245,36 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co return resp } +func (st *oaiToResponsesState) canFinalizeResponse() bool { + if st.ToolInputError() != nil || st.FinishReason == "" || (len(st.MsgItemAdded) == 0 && len(st.FuncItemAdded) == 0) { + return false + } + for idx := range st.MsgItemAdded { + if !st.MsgItemDone[idx] { + return false + } + } + for key := range st.FuncItemAdded { + if !st.FuncItemDone[key] { + return false + } + } + return st.ReasoningID == "" +} + +// CanFinalizeResponseStream reports whether EOF can safely synthesize the source terminator. +func (st *oaiToResponsesState) CanFinalizeResponseStream() bool { + return st.ToolInputError() == nil && !st.CompletedEmitted && st.CompletionPending +} + // FinalizeToolInput rejects a patch-enabled stream lacking its source terminator. func (st *oaiToResponsesState) FinalizeToolInput() [][]byte { if st.ToolInputError() != nil || st.CompletedEmitted { return nil } + if st.CanFinalizeResponseStream() { + return nil + } enabled := false if st.ToolIndex != nil { for name := range st.ToolIndex.byChat { diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go index 6b6cb290..dd0a1405 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go @@ -20,6 +20,7 @@ type responsesToolDeclaration struct { localName string namespace string custom bool + shell bool } // walkResponsesToolDeclarations visits the tool declarations of a Responses @@ -36,15 +37,23 @@ type responsesToolDeclaration struct { func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDeclaration) bool) { var declarations []responsesToolDeclaration emit := func(tool gjson.Result, namespaceName string) { - var custom bool + var custom, shell bool switch strings.TrimSpace(tool.Get("type").String()) { case "", "function": case "custom": custom = true + case "shell": + if namespaceName != "" || tool.Get("environment.type").String() != "local" { + return + } + shell = true default: return } localName := responsesToolName(tool) + if shell { + localName = "__cpa_local_shell" + } if localName == "" { return } @@ -54,6 +63,7 @@ func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDe localName: localName, namespace: namespaceName, custom: custom, + shell: shell, }) } scan := func(tools gjson.Result) { @@ -86,6 +96,25 @@ func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDe }) } + // Reserve user identities before assigning the synthetic shell name. + reserved := make(map[string]bool) + for _, d := range declarations { + if !d.shell { + reserved[d.localName] = true + reserved[d.chatName] = true + reserved[rawResponsesNamespaceQualifiedName(d.namespace, d.localName)] = true + } + } + shellName := "__cpa_local_shell" + for suffix := 1; reserved[shellName]; suffix++ { + shellName = "__cpa_local_shell_" + strconv.Itoa(suffix) + } + for i := range declarations { + if declarations[i].shell { + declarations[i].localName = shellName + declarations[i].chatName = shellName + } + } disambiguateResponsesChatToolNames(declarations) proceed := true diff --git a/internal/translator/openai/openai/responses/responses_tool_index.go b/internal/translator/openai/openai/responses/responses_tool_index.go index f2e35a1f..353e8971 100644 --- a/internal/translator/openai/openai/responses/responses_tool_index.go +++ b/internal/translator/openai/openai/responses/responses_tool_index.go @@ -118,6 +118,8 @@ func (idx *responsesToolIndex) chatTools() [][]byte { convert := convertResponsesFunctionToolToOpenAIChat if d.custom { convert = convertResponsesCustomToolToOpenAIChat + } else if d.shell { + convert = convertResponsesShellToolToOpenAIChat } if tool, ok := convert(d.tool, d.chatName); ok { merged = append(merged, tool) diff --git a/internal/translator/openai/openai/responses/shell_tool.go b/internal/translator/openai/openai/responses/shell_tool.go new file mode 100644 index 00000000..9b8f9cd8 --- /dev/null +++ b/internal/translator/openai/openai/responses/shell_tool.go @@ -0,0 +1,132 @@ +package responses + +import ( + "errors" + "math" + "strconv" + "strings" + + translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +var errInvalidShellAction = errors.New("invalid shell action: expected nonempty commands strings and optional positive integer limits") + +func convertResponsesShellToolToOpenAIChat(_ gjson.Result, name string) ([]byte, bool) { + tool := []byte(`{"type":"function","function":{"name":"","description":"Request commands to execute in the client-provided local shell environment. Each commands entry is a complete shell command, not an argv element.","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"},"minItems":1},"timeout_ms":{"type":"integer","minimum":1},"max_output_length":{"type":"integer","minimum":1}},"required":["commands"],"additionalProperties":false}}}`) + tool, _ = sjson.SetBytes(tool, "function.name", name) + return tool, true +} + +func (idx *responsesToolIndex) isShell(name string) bool { + return idx.byChat[name].shell +} + +func (idx *responsesToolIndex) shellName() string { + for _, d := range idx.declarations { + if d.shell { + return d.chatName + } + } + return "" +} + +// Normalize shell history before the existing call grouping and output pairing. +// Keep the entire output envelope as JSON text, including truncation metadata. +func (idx *responsesToolIndex) shellHistory(items []gjson.Result) []gjson.Result { + name := idx.shellName() + if name == "" { + // Historical calls remain meaningful after the client withdraws the tool. + // Reserve a replay-only name without adding an available declaration. + reserved := make(map[string]bool) + for key := range idx.byChat { + reserved[key] = true + } + for key := range idx.byRaw { + reserved[key] = true + } + for key := range idx.byLocal { + reserved[key] = true + } + for _, item := range items { + if kind := item.Get("type").String(); kind == "function_call" || kind == "custom_tool_call" { + reserved[idx.canonicalName(item.Get("name").String())] = true + } + } + name = "__cpa_local_shell" + for suffix := 1; reserved[name]; suffix++ { + name = "__cpa_local_shell_" + strconv.Itoa(suffix) + } + } + for i, item := range items { + raw := []byte(item.Raw) + switch item.Get("type").String() { + case "shell_call": + if environment := item.Get("environment.type"); environment.Exists() && environment.String() != "local" { + continue + } + raw, _ = sjson.SetBytes(raw, "type", "function_call") + raw, _ = sjson.SetBytes(raw, "name", name) + raw, _ = sjson.SetBytes(raw, "arguments", item.Get("action").Raw) + case "shell_call_output": + raw, _ = sjson.SetBytes(raw, "type", "function_call_output") + raw, _ = sjson.SetBytes(raw, "output", item.Raw) + default: + continue + } + items[i] = gjson.ParseBytes(raw) + } + return items +} + +func shellCallItem(callID, arguments, status string) ([]byte, error) { + action := gjson.Parse(arguments) + if !gjson.Valid(arguments) || !action.IsObject() { + return nil, errInvalidShellAction + } + commands := action.Get("commands") + if !commands.IsArray() || len(commands.Array()) == 0 { + return nil, errInvalidShellAction + } + for _, command := range commands.Array() { + if command.Type != gjson.String || strings.TrimSpace(command.String()) == "" { + return nil, errInvalidShellAction + } + } + valid := true + action.ForEach(func(key, value gjson.Result) bool { + switch key.String() { + case "commands": + case "timeout_ms", "max_output_length": + if value.Type != gjson.Null && (value.Type != gjson.Number || value.Float() <= 0 || math.Trunc(value.Float()) != value.Float()) { + valid = false + } + default: + valid = false + } + return valid + }) + if !valid { + return nil, errInvalidShellAction + } + item := shellCallPlaceholder(callID) + item, _ = sjson.SetBytes(item, "status", status) + item, _ = sjson.SetRawBytes(item, "action", []byte(arguments)) + return item, nil +} + +func shellCallPlaceholder(callID string) []byte { + item := []byte(`{"id":"","type":"shell_call","status":"in_progress","call_id":"","action":{"commands":[]}}`) + item, _ = sjson.SetBytes(item, "id", "sh_"+callID) + item, _ = sjson.SetBytes(item, "call_id", callID) + return item +} + +func responsesToolInputFailure(responseID string, sequence int, err error) []byte { + failure := translatorcommon.ApplyPatchFailure(responseID, sequence) + if errors.Is(err, errInvalidShellAction) { + failure, _ = sjson.SetBytes(failure, "response.error.message", errInvalidShellAction.Error()) + } + return failure +}