diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index ae94c909..504a5b11 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -41,23 +41,26 @@ import ( // // Returns: // - []byte: The transformed request data in internal client format -func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { return convertClaudeRequestToCodex(modelName, inputRawJSON, stream, false) + } // ConvertClaudeRequestToCodexWithCompat preserves assistant thinking blocks with // empty or unknown-format signatures for configured compatibility endpoints. -func ConvertClaudeRequestToCodexWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertClaudeRequestToCodexWithCompat(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { return convertClaudeRequestToCodex(modelName, inputRawJSON, stream, true) } -func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks bool) []byte { +func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks bool) ([]byte, error) { rawJSON := inputRawJSON + var drops translatorcommon.UserTurnDrops template := []byte(`{"model":"","instructions":"","input":[]}`) rootResult := gjson.ParseBytes(rawJSON) toolNameMap := buildReverseMapFromClaudeOriginalToShort(rawJSON) + signatureTargetModel := translatorcommon.ResolveCodexReplayTargetModel(modelName, rawJSON) template, _ = sjson.SetBytes(template, "model", modelName) inputItems := translatorcommon.NewRawArrayItems(rootResult.Get("messages.#").Int()) @@ -124,9 +127,15 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, } pendingToolUseIDs = nil contentItems := make([][]byte, 0, 4) + // Counts only what this turn itself sends, not system reminders flushed beside it. + // Empty text is forwarded but does not count: it can hide an emptied attachment. + turnSendable := 0 + bufferedSendable := 0 flushMessage := func() { if len(contentItems) > 0 { + turnSendable += bufferedSendable + bufferedSendable = 0 message := []byte(`{"type":"message","role":""}`) message, _ = sjson.SetBytes(message, "role", messageRole) message, _ = sjson.SetRawBytes(message, "content", translatorcommon.JoinRawArray(contentItems)) @@ -144,18 +153,41 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, content, _ = sjson.SetBytes(content, "type", partType) content, _ = sjson.SetBytes(content, "text", text) contentItems = append(contentItems, content) + if text != "" { + bufferedSendable++ + } } - appendImageContent := func(dataURL string) { - content := []byte(`{"type":"input_image","image_url":""}`) - content, _ = sjson.SetBytes(content, "image_url", dataURL) + appendImageContent := func(content []byte) { contentItems = append(contentItems, content) + bufferedSendable++ } appendDocumentContent := func(dataURL string) { content := []byte(`{"type":"input_file","file_data":"","filename":"document.pdf"}`) content, _ = sjson.SetBytes(content, "file_data", dataURL) contentItems = append(contentItems, content) + bufferedSendable++ + } + + appendCommentaryContent := func(part gjson.Result) { + if messageRole != "assistant" { + return + } + text := part.Get("thinking").String() + if text == "" { + return + } + // A relayed commentary block becomes its own assistant message item + // carrying phase "commentary", mirroring the Codex output item it + // came from. The internal marker is consumed here and never + // forwarded upstream. + flushMessage() + content := []byte(`{"type":"output_text","text":""}`) + content, _ = sjson.SetBytes(content, "text", text) + message := []byte(`{"type":"message","role":"assistant","phase":"commentary"}`) + message, _ = sjson.SetRawBytes(message, "content", translatorcommon.JoinRawArray([][]byte{content})) + inputItems = append(inputItems, message) } appendReasoningContent := func(part gjson.Result) { @@ -164,7 +196,11 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, } rawSignature := part.Get("signature").String() - signature, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderGPT, rawSignature) + if sigcompat.IsCodexCommentarySignatureMarker(rawSignature) { + appendCommentaryContent(part) + return + } + signature, ok := translatorcommon.CodexReplaySignatureForTarget(signatureTargetModel, rawSignature) if !ok && preserveEmptyThinkingBlocks && part.Get("signature").Type == gjson.String && strings.TrimSpace(rawSignature) != "" && sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindClaudeThinking) == sigcompat.SignatureProviderUnknown { signature = rawSignature @@ -174,13 +210,7 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, if preserveEmptyThinkingBlocks && strings.TrimSpace(rawSignature) == "" { signature = rawSignature } else { - if !codexClaudeTargetAcceptsGrokSignature(modelName) { - return - } - if _, err := sigcompat.InspectGrokEncryptedContent(rawSignature); err != nil { - return - } - signature = rawSignature + return } } @@ -210,43 +240,20 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, inputItems = append(inputItems, pendingSystemReminders...) pendingSystemReminders = nil } - sourceResult := messageContentResult.Get("source") - if sourceResult.Exists() { - data := sourceResult.Get("data").String() - if data == "" { - data = sourceResult.Get("base64").String() - } - if data != "" { - mediaType := sourceResult.Get("media_type").String() - if mediaType == "" { - mediaType = sourceResult.Get("mime_type").String() - } - if mediaType == "" { - mediaType = "application/octet-stream" - } - dataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, data) - appendImageContent(dataURL) - } + if imagePart, ok := claudeImageInputPart(messageContentResult.Get("source")); ok { + appendImageContent(imagePart) + } else if messageRole == "user" { + drops.Drop(contentType) } - case "document": + case "document", "container_upload": if len(pendingSystemReminders) > 0 { inputItems = append(inputItems, pendingSystemReminders...) pendingSystemReminders = nil } - sourceResult := messageContentResult.Get("source") - if sourceResult.Get("type").String() != "base64" { - continue - } - mediaType := strings.TrimSpace(sourceResult.Get("media_type").String()) - if !strings.EqualFold(mediaType, "application/pdf") { - continue - } - data := sourceResult.Get("data").String() - if data == "" { - data = sourceResult.Get("base64").String() - } - if data != "" { - appendDocumentContent(fmt.Sprintf("data:%s;base64,%s", mediaType, data)) + if dataURL, ok := claudeDocumentDataURL(messageContentResult); ok { + appendDocumentContent(dataURL) + } else if messageRole == "user" { + drops.Drop(contentType) } case "tool_use": flushMessage() @@ -315,6 +322,7 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, } inputItems = append(inputItems, functionCallOutputMessage) + turnSendable++ } } flushMessage() @@ -330,6 +338,9 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, pendingSystemReminders = nil } } + if messageRole == "user" { + drops.EndTurn(turnSendable) + } } if len(pendingSystemReminders) > 0 { @@ -393,8 +404,12 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, // Add additional configuration parameters for the Codex API. template, _ = sjson.SetBytes(template, "parallel_tool_calls", parallelToolCalls) - // Convert thinking.budget_tokens to reasoning.effort. + // Convert Claude thinking budgets and effort to reasoning.effort. reasoningEffort := "medium" + effort := "" + if v := rootResult.Get("output_config.effort"); v.Exists() && v.Type == gjson.String { + effort = strings.ToLower(strings.TrimSpace(v.String())) + } if thinkingConfig := rootResult.Get("thinking"); thinkingConfig.Exists() && thinkingConfig.IsObject() { switch thinkingConfig.Get("type").String() { case "enabled": @@ -407,10 +422,6 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, case "adaptive", "auto": // Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6). // Pass through directly; ApplyThinking handles clamping to target model's levels. - effort := "" - if v := rootResult.Get("output_config.effort"); v.Exists() && v.Type == gjson.String { - effort = strings.ToLower(strings.TrimSpace(v.String())) - } if effort != "" { reasoningEffort = effort } else { @@ -421,6 +432,8 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, reasoningEffort = effort } } + } else if !thinkingConfig.Exists() && effort != "" { + reasoningEffort = effort } template, _ = sjson.SetBytes(template, "reasoning.effort", reasoningEffort) // OpenAI documents reasoning summaries as explicit opt-in output. Leave @@ -469,12 +482,7 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, } template = translatorcommon.SetRawArrayItems(template, "input", inputItems) - return template -} - -func codexClaudeTargetAcceptsGrokSignature(modelName string) bool { - baseModel := strings.ToLower(strings.TrimSpace(thinking.ParseSuffix(modelName).ModelName)) - return strings.Contains(baseModel, "grok") + return template, drops.Err() } func normalizeCodexServiceTier(result gjson.Result) string { @@ -845,3 +853,56 @@ func codexSchemaMissesRequired(schema gjson.Result) bool { } return false } + +// claudeDocumentDataURL turns an inline PDF into a Codex input_file URL. Anything +// else, a file id included, is not representable and is reported by the caller. +// claudeImageInputPart maps a Claude image source onto a Responses input_image +// part. Base64 bytes become a data URL, an http(s) url passes through and a file +// id is carried as file_id. It reports false for a source with none of them. +func claudeImageInputPart(source gjson.Result) ([]byte, bool) { + if !source.Exists() { + return nil, false + } + part := []byte(`{"type":"input_image"}`) + data := source.Get("data").String() + if data == "" { + data = source.Get("base64").String() + } + switch { + case data != "": + mediaType := source.Get("media_type").String() + if mediaType == "" { + mediaType = source.Get("mime_type").String() + } + if mediaType == "" { + mediaType = "application/octet-stream" + } + part, _ = sjson.SetBytes(part, "image_url", fmt.Sprintf("data:%s;base64,%s", mediaType, data)) + case source.Get("type").String() == "url" && translatorcommon.IsHTTPURL(source.Get("url").String()): + part, _ = sjson.SetBytes(part, "image_url", strings.TrimSpace(source.Get("url").String())) + case source.Get("type").String() == "file" && source.Get("file_id").String() != "": + part, _ = sjson.SetBytes(part, "file_id", source.Get("file_id").String()) + default: + return nil, false + } + return part, true +} + +func claudeDocumentDataURL(part gjson.Result) (string, bool) { + source := part.Get("source") + if source.Get("type").String() == "base64" { + mediaType := strings.TrimSpace(source.Get("media_type").String()) + if !strings.EqualFold(mediaType, "application/pdf") { + return "", false + } + data := source.Get("data").String() + if data == "" { + data = source.Get("base64").String() + } + if data == "" { + return "", false + } + return fmt.Sprintf("data:%s;base64,%s", mediaType, data), true + } + return "", false +} diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index cc144cb1..edba12ae 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -12,6 +12,7 @@ import ( "math" "strings" + sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature" translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v8/internal/util" "github.com/tidwall/gjson" @@ -26,12 +27,29 @@ var ( // the single thinking block that represents one Codex reasoning item. const codexThinkingSummaryPartSeparator = "\n\n" +// codexCommentarySignatureMarker is the CLIProxyAPI-internal marker carried in +// the signature field of the thinking block that relays a Codex commentary +// message. The shared definition lives in the signature package so every +// Claude history consumer recognizes and strips it before it can reach an +// upstream. +const codexCommentarySignatureMarker = sigcompat.CodexCommentarySignatureMarker + // ConvertCodexResponseToClaudeParams holds parameters for response conversion. type ConvertCodexResponseToClaudeParams struct { - HasEmittedToolUse bool - BlockIndex int - HasTextDelta bool - TextBlockOpen bool + // ShortToOriginalToolNames maps shortened upstream tool names back to the + // client-declared names. It is resolved once per stream on first tool use; + // callers pass the same request bytes for the stream's lifetime. + ShortToOriginalToolNames map[string]string + ToolNamesResolved bool + HasEmittedToolUse bool + BlockIndex int + HasTextDelta bool + TextBlockOpen bool + // CommentaryMessageOpen is set while the current `message` output item carries + // phase "commentary": a preamble GPT-5.x emits before its final_answer item. + // Its text goes into a thinking block, so clients that render or speak text + // blocks get the answer once instead of the preamble followed by the answer. + CommentaryMessageOpen bool ThinkingBlockOpen bool ThinkingSignature string ThinkingSummarySeen bool @@ -110,6 +128,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = translatorcommon.AppendSSEEventBytes(output, "message_start", template, 2) case "response.reasoning_summary_part.added": output = append(output, stopCodexTextBlock(params)...) + output = append(output, finalizeCodexCommentaryBlock(params)...) + params.CommentaryMessageOpen = false // Codex splits a single reasoning item into several summary parts, but only // output_item.done carries that item's final encrypted_content. Keep one // thinking block open for the whole item and separate the parts with a blank @@ -120,20 +140,34 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = append(output, startCodexThinkingBlock(params)...) } params.ThinkingSummarySeen = true - case "response.reasoning_summary_text.delta": + case "response.reasoning_summary_text.delta", "response.reasoning_text.delta": output = append(output, stopCodexTextBlock(params)...) + output = append(output, finalizeCodexCommentaryBlock(params)...) + params.CommentaryMessageOpen = false output = append(output, startCodexThinkingBlock(params)...) output = append(output, appendCodexThinkingDelta(params, rootResult.Get("delta").String())...) + params.ThinkingSummarySeen = true case "response.reasoning_summary_part.done": // Intentionally does not close the thinking block: it stays open until // output_item.done delivers the reasoning item's final encrypted_content. case "response.content_part.added": - output = append(output, finalizeCodexThinkingBlock(params)...) if rootResult.Get("part.type").String() == "output_text" { - output = append(output, startCodexTextBlock(params)...) + if params.CommentaryMessageOpen { + output = append(output, startCodexThinkingBlock(params)...) + } else { + output = append(output, finalizeCodexThinkingBlock(params)...) + output = append(output, startCodexTextBlock(params)...) + } + } else { + output = append(output, finalizeCodexThinkingBlock(params)...) } case "response.output_text.delta": params.HasTextDelta = true + if params.CommentaryMessageOpen { + output = append(output, startCodexThinkingBlock(params)...) + output = append(output, appendCodexThinkingDelta(params, rootResult.Get("delta").String())...) + break + } output = append(output, finalizeCodexThinkingBlock(params)...) output = append(output, startCodexTextBlock(params)...) template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`) @@ -143,7 +177,11 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) case "response.content_part.done": if rootResult.Get("part.type").String() == "output_text" { - output = append(output, stopCodexTextBlock(params)...) + if params.CommentaryMessageOpen { + output = append(output, finalizeCodexCommentaryBlock(params)...) + } else { + output = append(output, stopCodexTextBlock(params)...) + } } case "response.web_search_call.searching", "response.web_search_call.completed", "response.web_search_call.in_progress": // Wait for populated web_search_call items on output_item.done. @@ -175,9 +213,17 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa itemResult := rootResult.Get("item") itemType := itemResult.Get("type").String() switch itemType { + case "message": + // Each message item streams its own text; a later item's done fallback + // must not be skipped because an earlier item had deltas. + output = append(output, finalizeCodexThinkingBlock(params)...) + output = append(output, stopCodexTextBlock(params)...) + params.HasTextDelta = false + params.CommentaryMessageOpen = itemResult.Get("phase").String() == "commentary" case "function_call": output = append(output, finalizeCodexThinkingBlock(params)...) output = append(output, stopCodexTextBlock(params)...) + params.CommentaryMessageOpen = false call := recordCodexFunctionCall(params, rootResult, itemResult) updateCodexFunctionCallIdentity(params, call, rootResult, itemResult) @@ -190,6 +236,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa // A previous reasoning item that never reported output_item.done must not // leak its still-open block into this one. output = append(output, finalizeCodexThinkingBlock(params)...) + params.CommentaryMessageOpen = false params.ThinkingSummarySeen = false // Kept only as a fallback for streams whose output_item.done omits // encrypted_content; it is a pre-content snapshot, never the final value. @@ -202,7 +249,15 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa itemType := itemResult.Get("type").String() switch itemType { case "message": + commentary := params.CommentaryMessageOpen || itemResult.Get("phase").String() == "commentary" + defer func() { + params.CommentaryMessageOpen = false + params.HasTextDelta = false + }() if params.HasTextDelta { + if commentary { + output = append(output, finalizeCodexCommentaryBlock(params)...) + } return [][]byte{output} } contentResult := itemResult.Get("content") @@ -223,6 +278,16 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa if text == "" { return [][]byte{output} } + if commentary { + // Whole on done, nothing streamed: emit it as thinking. + output = append(output, finalizeCodexThinkingBlock(params)...) + output = append(output, stopCodexTextBlock(params)...) + params.CommentaryMessageOpen = true + output = append(output, startCodexThinkingBlock(params)...) + output = append(output, appendCodexThinkingDelta(params, text)...) + output = append(output, finalizeCodexCommentaryBlock(params)...) + return [][]byte{output} + } output = append(output, finalizeCodexThinkingBlock(params)...) output = append(output, startCodexTextBlock(params)...) @@ -233,7 +298,6 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) output = append(output, stopCodexTextBlock(params)...) - params.HasTextDelta = true case "function_call": output = append(output, finalizeCodexThinkingBlock(params)...) output = append(output, stopCodexTextBlock(params)...) @@ -247,9 +311,18 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa output = appendCodexFunctionCallQueue(output, params, originalRequestRawJSON) case "reasoning": output = append(output, stopCodexTextBlock(params)...) + output = append(output, finalizeCodexCommentaryBlock(params)...) + params.CommentaryMessageOpen = false if signature := itemResult.Get("encrypted_content").String(); signature != "" { params.ThinkingSignature = signature } + if !params.ThinkingSummarySeen { + if doneText := extractCodexReasoningItemText(itemResult); doneText != "" { + output = append(output, startCodexThinkingBlock(params)...) + output = append(output, appendCodexThinkingDelta(params, doneText)...) + params.ThinkingSummarySeen = true + } + } if params.ThinkingSummarySeen { output = append(output, finalizeCodexThinkingBlock(params)...) } else { @@ -385,56 +458,30 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original output.ForEach(func(_, item gjson.Result) bool { switch item.Get("type").String() { case "reasoning": - thinkingBuilder := strings.Builder{} + thinkingText := extractCodexReasoningItemText(item) signature := item.Get("encrypted_content").String() - if summary := item.Get("summary"); summary.Exists() { - if summary.IsArray() { - summary.ForEach(func(_, part gjson.Result) bool { - if txt := part.Get("text"); txt.Exists() { - thinkingBuilder.WriteString(txt.String()) - } else { - thinkingBuilder.WriteString(part.String()) - } - return true - }) - } else { - thinkingBuilder.WriteString(summary.String()) - } - } - if thinkingBuilder.Len() == 0 { - if content := item.Get("content"); content.Exists() { - if content.IsArray() { - content.ForEach(func(_, part gjson.Result) bool { - if txt := part.Get("text"); txt.Exists() { - thinkingBuilder.WriteString(txt.String()) - } else { - thinkingBuilder.WriteString(part.String()) - } - return true - }) - } else { - thinkingBuilder.WriteString(content.String()) - } - } - } - if thinkingBuilder.Len() > 0 || signature != "" { + if thinkingText != "" || signature != "" { block := []byte(`{"type":"thinking","thinking":""}`) - block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String()) + block, _ = sjson.SetBytes(block, "thinking", thinkingText) if signature != "" { block, _ = sjson.SetBytes(block, "signature", signature) } contentBlocks = append(contentBlocks, block) } case "message": + // A "commentary" phase message is the model's preamble, not its answer: + // relay it as thinking so text consumers see the final_answer alone. + blockType := "text" + if item.Get("phase").String() == "commentary" { + blockType = "thinking" + } if content := item.Get("content"); content.Exists() { if content.IsArray() { content.ForEach(func(_, part gjson.Result) bool { if part.Get("type").String() == "output_text" { text := part.Get("text").String() if text != "" { - block := []byte(`{"type":"text","text":""}`) - block, _ = sjson.SetBytes(block, "text", text) - contentBlocks = append(contentBlocks, block) + contentBlocks = append(contentBlocks, codexMessageTextBlock(blockType, text)) } } return true @@ -442,9 +489,7 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original } else { text := content.String() if text != "" { - block := []byte(`{"type":"text","text":""}`) - block, _ = sjson.SetBytes(block, "text", text) - contentBlocks = append(contentBlocks, block) + contentBlocks = append(contentBlocks, codexMessageTextBlock(blockType, text)) } } } @@ -646,11 +691,11 @@ func updateCodexFunctionCallArguments(call *codexFunctionCallStream, arguments s } } -func appendCodexFunctionCallStart(output []byte, originalRequestRawJSON []byte, callID, name string, blockIndex int) []byte { +func appendCodexFunctionCallStart(output []byte, params *ConvertCodexResponseToClaudeParams, originalRequestRawJSON []byte, callID, name string, blockIndex int) []byte { template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`) template, _ = sjson.SetBytes(template, "index", blockIndex) template, _ = sjson.SetBytes(template, "content_block.id", shortenCodexCallIDIfNeeded(util.SanitizeClaudeToolID(callID))) - template, _ = sjson.SetBytes(template, "content_block.name", resolveCodexClaudeToolUseName(originalRequestRawJSON, name)) + template, _ = sjson.SetBytes(template, "content_block.name", params.resolveToolUseName(originalRequestRawJSON, name)) return translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2) } @@ -713,7 +758,7 @@ func appendCodexFunctionCallQueue(output []byte, params *ConvertCodexResponseToC } call.BlockIndex = params.BlockIndex - output = appendCodexFunctionCallStart(output, originalRequestRawJSON, call.CallID, call.Name, call.BlockIndex) + output = appendCodexFunctionCallStart(output, params, originalRequestRawJSON, call.CallID, call.Name, call.BlockIndex) if call.EmitInitialEmptyDelta { output = appendCodexFunctionCallArgumentDelta(output, "", call.BlockIndex) } @@ -795,6 +840,22 @@ func clearCodexFunctionCalls(params *ConvertCodexResponseToClaudeParams) { params.LastFunctionCall = nil } +// resolveToolUseName restores the client-declared tool name using the reverse +// map cached on first use for this stream. +func (p *ConvertCodexResponseToClaudeParams) resolveToolUseName(originalRequestRawJSON []byte, name string) string { + if p == nil { + return resolveCodexClaudeToolUseName(originalRequestRawJSON, name) + } + if !p.ToolNamesResolved { + p.ShortToOriginalToolNames = buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON) + p.ToolNamesResolved = true + } + if orig, ok := p.ShortToOriginalToolNames[name]; ok { + return orig + } + return name +} + func resolveCodexClaudeToolUseName(originalRequestRawJSON []byte, name string) string { rev := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON) if orig, ok := rev[name]; ok { @@ -904,6 +965,32 @@ func startCodexTextBlock(params *ConvertCodexResponseToClaudeParams) []byte { return translatorcommon.AppendSSEEventBytes(nil, "content_block_start", template, 2) } +// finalizeCodexCommentaryBlock closes the thinking block that carries a commentary +// message. A reasoning item's encrypted_content never belongs to it, so the +// internal commentary marker is emitted as the block signature instead of any +// pending reasoning signature. +func finalizeCodexCommentaryBlock(params *ConvertCodexResponseToClaudeParams) []byte { + if !params.CommentaryMessageOpen || !params.ThinkingBlockOpen { + return nil + } + return finalizeCodexThinkingBlock(params) +} + +// codexMessageTextBlock builds a non-stream content block: {"type":"text","text":...} +// or, for a commentary message, {"type":"thinking","thinking":...} carrying the +// internal commentary marker in its signature field. +func codexMessageTextBlock(blockType, text string) []byte { + if blockType == "thinking" { + block := []byte(`{"type":"thinking","thinking":"","signature":""}`) + block, _ = sjson.SetBytes(block, "thinking", text) + block, _ = sjson.SetBytes(block, "signature", codexCommentarySignatureMarker) + return block + } + block := []byte(`{"type":"text","text":""}`) + block, _ = sjson.SetBytes(block, "text", text) + return block +} + func stopCodexTextBlock(params *ConvertCodexResponseToClaudeParams) []byte { if !params.TextBlockOpen { return nil @@ -917,6 +1004,41 @@ func stopCodexTextBlock(params *ConvertCodexResponseToClaudeParams) []byte { return translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", template, 2) } +func extractCodexReasoningItemText(item gjson.Result) string { + thinkingBuilder := strings.Builder{} + if summary := item.Get("summary"); summary.Exists() { + if summary.IsArray() { + summary.ForEach(func(_, part gjson.Result) bool { + if txt := part.Get("text"); txt.Exists() { + thinkingBuilder.WriteString(txt.String()) + } else { + thinkingBuilder.WriteString(part.String()) + } + return true + }) + } else { + thinkingBuilder.WriteString(summary.String()) + } + } + if thinkingBuilder.Len() == 0 { + if content := item.Get("content"); content.Exists() { + if content.IsArray() { + content.ForEach(func(_, part gjson.Result) bool { + if txt := part.Get("text"); txt.Exists() { + thinkingBuilder.WriteString(txt.String()) + } else { + thinkingBuilder.WriteString(part.String()) + } + return true + }) + } else { + thinkingBuilder.WriteString(content.String()) + } + } + } + return thinkingBuilder.String() +} + func startCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte { if params.ThinkingBlockOpen { return nil @@ -958,10 +1080,17 @@ func finalizeCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []by } output := make([]byte, 0, 256) - if params.ThinkingSignature != "" { + signatureToEmit := "" + if params.CommentaryMessageOpen { + signatureToEmit = codexCommentarySignatureMarker + } else if params.ThinkingSignature != "" { + signatureToEmit = params.ThinkingSignature + params.ThinkingSignature = "" + } + if signatureToEmit != "" { signatureDelta := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":""}}`) signatureDelta, _ = sjson.SetBytes(signatureDelta, "index", params.BlockIndex) - signatureDelta, _ = sjson.SetBytes(signatureDelta, "delta.signature", params.ThinkingSignature) + signatureDelta, _ = sjson.SetBytes(signatureDelta, "delta.signature", signatureToEmit) output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", signatureDelta, 2) } diff --git a/internal/translator/codex/claude/codex_claude_response_web_search.go b/internal/translator/codex/claude/codex_claude_response_web_search.go index 7db54b5e..333e021a 100644 --- a/internal/translator/codex/claude/codex_claude_response_web_search.go +++ b/internal/translator/codex/claude/codex_claude_response_web_search.go @@ -127,9 +127,17 @@ func codexWebSearchQuery(root, item gjson.Result) string { } func codexWebSearchResultContent(root, item gjson.Result) []byte { - results := item.Get("results") - if !results.IsArray() { - results = root.Get("results") + var results gjson.Result + for _, candidate := range []gjson.Result{ + item.Get("results"), + root.Get("results"), + item.Get("action.sources"), + root.Get("action.sources"), + } { + if candidate.IsArray() { + results = candidate + break + } } if !results.IsArray() { return nil diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go index 7a3c8408..2fce6b1d 100644 --- a/internal/translator/codex/gemini/codex_gemini_request.go +++ b/internal/translator/codex/gemini/codex_gemini_request.go @@ -6,6 +6,7 @@ package gemini import ( + "encoding/base64" "fmt" "strconv" "strings" @@ -34,7 +35,7 @@ import ( // // Returns: // - []byte: The transformed request data in Codex API format -func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte { +func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) ([]byte, error) { rawJSON := inputRawJSON // Base template out := []byte(`{"model":"","instructions":"","input":[]}`) @@ -69,6 +70,8 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // consume them in order when functionResponses arrive. var pendingCallIDs []string callCounter := 0 + emittedThoughtSignatures := map[string]bool{} + signatureTargetModel := translatorcommon.ResolveCodexReplayTargetModel(modelName, inputRawJSON) getGeminiCallID := func(value gjson.Result) string { if callID := strings.TrimSpace(value.Get("id").String()); callID != "" { @@ -138,8 +141,49 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) continue } parr := parts.Array() + var pendingThoughtSummaryParts [][]byte for j := 0; j < len(parr); j++ { p := parr[j] + + // A thoughtSignature is replayable reasoning state, not visible + // content. Restore it as a reasoning item only when its detected + // family matches this request's target model family, so a Muse + // (or GPT/Grok) signature can never be replayed to a wrong upstream + // through the Gemini ingress. Unmatched signatures fall through to + // the existing hidden-thought drop below. + if role == "assistant" { + if rawSignature := geminiPartThoughtSignature(p); rawSignature != "" { + if normalizedSignature, ok := normalizedGeminiThoughtSignatureForCodexTarget(signatureTargetModel, rawSignature); ok && !emittedThoughtSignatures[normalizedSignature] { + reasoningItem := []byte(`{"type":"reasoning","encrypted_content":"","summary":[]}`) + reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", normalizedSignature) + summaryParts := pendingThoughtSummaryParts + pendingThoughtSummaryParts = nil + if translatorcommon.IsGeminiThoughtPart(p) { + if thoughtText := p.Get("text").String(); thoughtText != "" { + summaryPart := []byte(`{"type":"summary_text","text":""}`) + summaryPart, _ = sjson.SetBytes(summaryPart, "text", thoughtText) + summaryParts = append(summaryParts, summaryPart) + } + } + if len(summaryParts) > 0 { + reasoningItem, _ = sjson.SetRawBytes(reasoningItem, "summary", translatorcommon.JoinRawArray(summaryParts)) + } + inputItems = append(inputItems, reasoningItem) + emittedThoughtSignatures[normalizedSignature] = true + } else { + pendingThoughtSummaryParts = nil + } + } else if translatorcommon.IsGeminiThoughtPart(p) { + if thoughtText := p.Get("text").String(); thoughtText != "" { + summaryPart := []byte(`{"type":"summary_text","text":""}`) + summaryPart, _ = sjson.SetBytes(summaryPart, "text", thoughtText) + pendingThoughtSummaryParts = append(pendingThoughtSummaryParts, summaryPart) + } + } else { + pendingThoughtSummaryParts = nil + } + } + if translatorcommon.IsGeminiThoughtPart(p) { continue } @@ -341,7 +385,38 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) out, _ = sjson.SetBytes(out, fullPath, normalizedType) } - return out + return out, nil +} + +// geminiPartThoughtSignature extracts a part's thought signature under either +// the camelCase or snake_case field spelling. +func geminiPartThoughtSignature(part gjson.Result) string { + if signature := strings.TrimSpace(part.Get("thoughtSignature").String()); signature != "" { + return signature + } + return strings.TrimSpace(part.Get("thought_signature").String()) +} + +// decodeGeminiCodexThoughtSignature unwraps the carrier written by +// encodeGeminiCodexThoughtSignature. Values without the carrier magic are +// returned unchanged, preserving legacy raw-signature echoes. +func decodeGeminiCodexThoughtSignature(value string) string { + value = strings.TrimSpace(value) + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil || !strings.HasPrefix(string(decoded), geminiCodexThoughtSignatureCarrierMagic) { + return value + } + return strings.TrimSpace(strings.TrimPrefix(string(decoded), geminiCodexThoughtSignatureCarrierMagic)) +} + +// normalizedGeminiThoughtSignatureForCodexTarget unwraps and normalizes a +// client-echoed thought signature for the Codex-format upstream serving +// modelName, returning the exact value that may be written as reasoning +// encrypted_content. The carrier magic written by +// encodeGeminiCodexThoughtSignature is stripped first; the family match and +// normalization rules live in translatorcommon.CodexReplaySignatureForTarget. +func normalizedGeminiThoughtSignatureForCodexTarget(modelName, rawSignature string) (string, bool) { + return translatorcommon.CodexReplaySignatureForTarget(modelName, decodeGeminiCodexThoughtSignature(rawSignature)) } func setCodexToolChoiceFromGeminiToolConfig(out []byte, functionCallingConfig gjson.Result) []byte { diff --git a/internal/translator/codex/gemini/codex_gemini_response.go b/internal/translator/codex/gemini/codex_gemini_response.go index ccf3877a..3de5631d 100644 --- a/internal/translator/codex/gemini/codex_gemini_response.go +++ b/internal/translator/codex/gemini/codex_gemini_response.go @@ -8,6 +8,7 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/base64" "strings" "time" @@ -22,12 +23,27 @@ var ( // ConvertCodexResponseToGeminiParams holds parameters for response conversion. type ConvertCodexResponseToGeminiParams struct { - Model string - CreatedAt int64 - ResponseID string - LastStorageOutput []byte + Model string + CreatedAt int64 + ResponseID string + // StorageOutputs queues completed function-call chunks in stream order + // until the next non-storage event flushes them. A queue (not a single + // slot) so consecutive function calls can never overwrite each other. + StorageOutputs [][]byte HasOutputTextDelta bool LastImageHashByID map[string][32]byte + // PendingThoughtSignature holds a completed reasoning item's + // encrypted_content until the next visible part can carry it as a + // Gemini thoughtSignature, mirroring native Gemini part placement. + PendingThoughtSignature string + // SeenReasoningSignatures deduplicates repeated reasoning done events + // by item ID + signature hash within one response stream. + SeenReasoningSignatures map[[32]byte]bool + // HasReasoningDelta and StreamedReasoningItems record whether reasoning + // deltas were already emitted for the current or keyed reasoning item so + // done-only reasoning summaries can fall back cleanly. + HasReasoningDelta bool + StreamedReasoningItems map[string]bool } // ConvertCodexResponseToGemini converts Codex streaming response format to Gemini format. @@ -49,7 +65,6 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR Model: modelName, CreatedAt: 0, ResponseID: "", - LastStorageOutput: nil, HasOutputTextDelta: false, LastImageHashByID: make(map[string][32]byte), } @@ -66,6 +81,25 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR params := (*param).(*ConvertCodexResponseToGeminiParams) + // flushStorageOutputs drains queued function-call chunks ahead of any + // extra chunks, preserving stream order on every return path. + flushStorageOutputs := func(extra ...[]byte) [][]byte { + outputs := make([][]byte, 0, len(params.StorageOutputs)+len(extra)) + for _, stored := range params.StorageOutputs { + outputs = append(outputs, append([]byte(nil), stored...)) + } + params.StorageOutputs = nil + return append(outputs, extra...) + } + attachPendingThoughtSignature := func(part []byte) []byte { + if params.PendingThoughtSignature == "" { + return part + } + part = setGeminiThoughtSignature(part, params.PendingThoughtSignature) + params.PendingThoughtSignature = "" + return part + } + // Base Gemini response template template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-2.5-pro","createTime":"2025-08-15T02:52:03.884209Z","responseId":"06CeaPH7NaCU48APvNXDyA4"}`) { @@ -101,14 +135,62 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR part := []byte(`{"inlineData":{"data":"","mimeType":""}}`) part, _ = sjson.SetBytes(part, "inlineData.data", b64) part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType) + part = attachPendingThoughtSignature(part) template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part}) - return [][]byte{template} + return flushStorageOutputs(template) } // Handle function call completion if typeStr == "response.output_item.done" { itemResult := rootResult.Get("item") itemType := itemResult.Get("type").String() + if itemType == "reasoning" { + signature := strings.TrimSpace(itemResult.Get("encrypted_content").String()) + itemID := strings.TrimSpace(itemResult.Get("id").String()) + hadStreamedReasoning := params.HasReasoningDelta + if itemID != "" && len(params.StreamedReasoningItems) > 0 { + hadStreamedReasoning = params.StreamedReasoningItems[itemID] + } + params.HasReasoningDelta = false + var thoughtTexts []string + if !hadStreamedReasoning { + thoughtTexts = extractCodexGeminiReasoningTexts(itemResult) + } + if signature == "" && len(thoughtTexts) == 0 { + return [][]byte{} + } + if signature != "" { + seenKey := sha256.Sum256([]byte(itemID + "\x00" + signature)) + if params.SeenReasoningSignatures == nil { + params.SeenReasoningSignatures = make(map[[32]byte]bool) + } + if params.SeenReasoningSignatures[seenKey] { + return [][]byte{} + } + params.SeenReasoningSignatures[seenKey] = true + } + var emittedParts [][]byte + if params.PendingThoughtSignature != "" && signature != "" { + // A previous reasoning step never got a visible carrier part; + // flush it as an empty thought part so no signature is lost. + part := setGeminiThoughtSignature([]byte(`{"thought":true,"text":""}`), params.PendingThoughtSignature) + params.PendingThoughtSignature = "" + emittedParts = append(emittedParts, part) + } + for _, thoughtText := range thoughtTexts { + part := []byte(`{"thought":true,"text":""}`) + part, _ = sjson.SetBytes(part, "text", thoughtText) + emittedParts = append(emittedParts, part) + } + if signature != "" { + params.PendingThoughtSignature = signature + } + if len(emittedParts) > 0 { + template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", emittedParts) + return flushStorageOutputs(template) + } + return [][]byte{} + } if itemType == "image_generation_call" { itemID := itemResult.Get("id").String() b64 := itemResult.Get("result").String() @@ -132,8 +214,9 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR part := []byte(`{"inlineData":{"data":"","mimeType":""}}`) part, _ = sjson.SetBytes(part, "inlineData.data", b64) part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType) + part = attachPendingThoughtSignature(part) template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part}) - return [][]byte{template} + return flushStorageOutputs(template) } if itemType == "function_call" { // Create function call part @@ -157,11 +240,12 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR } } functionCall = setGeminiFunctionCallID(functionCall, itemResult) + functionCall = attachPendingThoughtSignature(functionCall) template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{functionCall}) template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP") - params.LastStorageOutput = append([]byte(nil), template...) + params.StorageOutputs = append(params.StorageOutputs, append([]byte(nil), template...)) // Use this return to storage message return [][]byte{} @@ -169,17 +253,53 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR } if typeStr == "response.created" { // Handle response creation - set model and response ID + // A new response must not inherit per-response streaming state if the + // caller reuses params across responses. + params.StorageOutputs = nil + params.PendingThoughtSignature = "" + params.SeenReasoningSignatures = nil + params.HasReasoningDelta = false + params.StreamedReasoningItems = nil + params.HasOutputTextDelta = false template, _ = sjson.SetBytes(template, "modelVersion", rootResult.Get("response.model").String()) template, _ = sjson.SetBytes(template, "responseId", rootResult.Get("response.id").String()) params.ResponseID = rootResult.Get("response.id").String() - } else if typeStr == "response.reasoning_summary_text.delta" { // Handle reasoning/thinking content delta + } else if typeStr == "response.output_item.added" && rootResult.Get("item.type").String() == "reasoning" { + params.HasReasoningDelta = false + if params.PendingThoughtSignature != "" { + part := setGeminiThoughtSignature([]byte(`{"thought":true,"text":""}`), params.PendingThoughtSignature) + params.PendingThoughtSignature = "" + template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part}) + return flushStorageOutputs(template) + } + return [][]byte{} + } else if typeStr == "response.reasoning_summary_text.delta" || typeStr == "response.reasoning_text.delta" { // Handle reasoning/thinking content delta + params.HasReasoningDelta = true + if itemID := strings.TrimSpace(rootResult.Get("item_id").String()); itemID != "" { + if params.StreamedReasoningItems == nil { + params.StreamedReasoningItems = make(map[string]bool) + } + params.StreamedReasoningItems[itemID] = true + } + var emittedParts [][]byte + if params.PendingThoughtSignature != "" { + carrier := setGeminiThoughtSignature([]byte(`{"thought":true,"text":""}`), params.PendingThoughtSignature) + params.PendingThoughtSignature = "" + emittedParts = append(emittedParts, carrier) + } part := []byte(`{"thought":true,"text":""}`) part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String()) - template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part}) + emittedParts = append(emittedParts, part) + template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", emittedParts) } else if typeStr == "response.output_text.delta" { // Handle regular text content delta params.HasOutputTextDelta = true part := []byte(`{"text":""}`) part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String()) + if rootResult.Get("delta").String() != "" { + // An empty text part may be discarded by clients; never spend the + // only signature copy on one. + part = attachPendingThoughtSignature(part) + } template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part}) } else if typeStr == "response.output_item.done" { // Fallback: emit final message text when no delta chunks were received itemResult := rootResult.Get("item") @@ -201,33 +321,36 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR } part := []byte(`{"text":""}`) part, _ = sjson.SetBytes(part, "text", text) + part = attachPendingThoughtSignature(part) template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part) wroteText = true return true }) if wroteText { params.HasOutputTextDelta = true - return [][]byte{template} + return flushStorageOutputs(template) } return [][]byte{} } else if typeStr == "response.completed" || typeStr == "response.incomplete" { // Handle response completion with usage metadata + if params.PendingThoughtSignature != "" { + part := setGeminiThoughtSignature([]byte(`{"thought":true,"text":""}`), params.PendingThoughtSignature) + params.PendingThoughtSignature = "" + template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part}) + } template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", rootResult.Get("response.usage.input_tokens").Int()) template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", rootResult.Get("response.usage.output_tokens").Int()) totalTokens := rootResult.Get("response.usage.input_tokens").Int() + rootResult.Get("response.usage.output_tokens").Int() template, _ = sjson.SetBytes(template, "usageMetadata.totalTokenCount", totalTokens) if typeStr == "response.incomplete" { template, _ = sjson.SetBytes(template, "candidates.0.finishReason", codexGeminiIncompleteFinishReason(rootResult.Get("response.incomplete_details.reason").String())) + } else { + template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP") } } else { return [][]byte{} } - if len(params.LastStorageOutput) > 0 { - stored := append([]byte(nil), params.LastStorageOutput...) - params.LastStorageOutput = nil - return [][]byte{stored, template} - } - return [][]byte{template} + return flushStorageOutputs(template) } // ConvertCodexResponseToGeminiNonStream converts a non-streaming Codex response to a non-streaming Gemini response. @@ -308,10 +431,23 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string, // Flush any pending function calls before adding non-function content flushPendingFunctionCalls() - // Add thinking content - if content := value.Get("content"); content.Exists() { + // Surface the reasoning summary as thought parts and carry + // the encrypted reasoning state as thoughtSignature on the + // first of them, so a Gemini client can replay it next turn. + // With no summary text, emit an empty thought carrier part; + // dropping the signature here would silently break replay. + signature := strings.TrimSpace(value.Get("encrypted_content").String()) + thoughtTexts := extractCodexGeminiReasoningTexts(value) + for i, thoughtText := range thoughtTexts { part := []byte(`{"text":"","thought":true}`) - part, _ = sjson.SetBytes(part, "text", content.String()) + part, _ = sjson.SetBytes(part, "text", thoughtText) + if i == len(thoughtTexts)-1 && signature != "" { + part = setGeminiThoughtSignature(part, signature) + } + parts = append(parts, part) + } + if len(thoughtTexts) == 0 && signature != "" { + part := setGeminiThoughtSignature([]byte(`{"text":"","thought":true}`), signature) parts = append(parts, part) } @@ -413,6 +549,55 @@ func buildReverseMapFromGeminiOriginal(original []byte) map[string]string { return rev } +// geminiCodexThoughtSignatureCarrierMagic marks a CPA carrier payload inside +// a Gemini thoughtSignature bytes field. The Gemini JSON wire form of a bytes +// field is standard padded base64, while Responses signatures are opaque +// strings that may use the base64url alphabet without padding; writing such a +// string verbatim makes official SDK []byte decoding fail. The carrier therefore +// stores magic+rawSignature as the byte payload and relies on standard base64 +// JSON encoding, which every Gemini client decodes and echoes losslessly. +func extractCodexGeminiReasoningTexts(value gjson.Result) []string { + var thoughtTexts []string + if summary := value.Get("summary"); summary.IsArray() { + for _, entry := range summary.Array() { + if text := entry.Get("text").String(); text != "" { + thoughtTexts = append(thoughtTexts, text) + } + } + } + if content := value.Get("content"); content.IsArray() { + for _, entry := range content.Array() { + if entry.Get("type").String() == "reasoning_text" { + if text := entry.Get("text").String(); text != "" { + thoughtTexts = append(thoughtTexts, text) + } + } + } + } else if content.Type == gjson.String && content.String() != "" { + thoughtTexts = append(thoughtTexts, content.String()) + } + return thoughtTexts +} + +const geminiCodexThoughtSignatureCarrierMagic = "cpa-codex-gemini-carrier-v1:" + +func encodeGeminiCodexThoughtSignature(rawSignature string) string { + rawSignature = strings.TrimSpace(rawSignature) + if rawSignature == "" { + return "" + } + return base64.StdEncoding.EncodeToString([]byte(geminiCodexThoughtSignatureCarrierMagic + rawSignature)) +} + +func setGeminiThoughtSignature(part []byte, rawSignature string) []byte { + encoded := encodeGeminiCodexThoughtSignature(rawSignature) + if encoded == "" { + return part + } + part, _ = sjson.SetBytes(part, "thoughtSignature", encoded) + return part +} + func setGeminiFunctionCallID(functionCall []byte, item gjson.Result) []byte { if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" { functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", callID) diff --git a/internal/translator/codex/interactions/interactions_codex_request.go b/internal/translator/codex/interactions/interactions_codex_request.go index 70649af6..47be307a 100644 --- a/internal/translator/codex/interactions/interactions_codex_request.go +++ b/internal/translator/codex/interactions/interactions_codex_request.go @@ -11,21 +11,30 @@ import ( "github.com/tidwall/sjson" ) -func ConvertInteractionsRequestToCodex(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertInteractionsRequestToCodex(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertInteractionsRequestToCodex(modelName, inputRawJSON, stream) + +} + +// convertInteractionsRequestToCodex also reports a user turn that was left empty +// because its only attachment has no Codex equivalent. +func convertInteractionsRequestToCodex(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { root := gjson.ParseBytes(inputRawJSON) - out := []byte(`{"model":"","instructions":"","input":[]}`) + out := []byte(`{"model":"","instructions":"","store":false,"input":[]}`) out, _ = sjson.SetBytes(out, "model", modelName) if stream || root.Get("stream").Bool() { out, _ = sjson.SetBytes(out, "stream", true) } out = copyInteractionsSystemToCodex(out, root) out = copyInteractionsGenerationConfigToCodex(out, root) + signatureTargetModel := translatorcommon.ResolveCodexReplayTargetModel(modelName, inputRawJSON) inputItems := translatorcommon.NewRawArrayItems(root.Get("input.#").Int()) - appendInteractionsInputToCodex(&inputItems, root.Get("input")) + errInput := appendInteractionsInputToCodex(&inputItems, root.Get("input"), signatureTargetModel) out = translatorcommon.SetRawArrayItems(out, "input", inputItems) out = copyInteractionsToolsToCodex(out, root) out = copyInteractionsCodexTopLevel(out, root) - return out + out = ensureInteractionsCodexEncryptedContentInclude(out) + return out, errInput } func copyInteractionsSystemToCodex(out []byte, root gjson.Result) []byte { @@ -181,41 +190,55 @@ func interactionsCodexReasoningSummary(cfg gjson.Result) string { return "" } -func appendInteractionsInputToCodex(items *[][]byte, input gjson.Result) { +// appendInteractionsInputToCodex adds the input steps and reports a user turn that +// was left with nothing to send because of an attachment Codex cannot carry. +func appendInteractionsInputToCodex(items *[][]byte, input gjson.Result, modelName string) error { if !input.Exists() { - return + return nil } if input.Type == gjson.String { appendInteractionsTextToCodex(items, "user", input.String()) - return + return nil } + var run translatorcommon.UserRun + emittedSignatures := make(map[string]bool) if input.IsArray() { input.ForEach(func(_, step gjson.Result) bool { - appendInteractionsStepToCodex(items, step, "user") + appendInteractionsStepToCodex(items, step, "user", &run, modelName, emittedSignatures) return true }) - return - } - if steps := input.Get("steps"); steps.Exists() && steps.IsArray() { - defaultRole := interactionsCodexDefaultRole(input.Get("role").String(), "user") + } else if steps := input.Get("steps"); steps.Exists() && steps.IsArray() { + defaultRole := interactionsCodexStepRole(input, "user") steps.ForEach(func(_, step gjson.Result) bool { - appendInteractionsStepToCodex(items, step, defaultRole) + appendInteractionsStepToCodex(items, step, defaultRole, &run, modelName, emittedSignatures) return true }) - return + } else { + appendInteractionsStepToCodex(items, input, "user", &run, modelName, emittedSignatures) } - appendInteractionsStepToCodex(items, input, "user") + run.End() + return run.Err() } -func appendInteractionsStepToCodex(items *[][]byte, step gjson.Result, defaultRole string) { +// interactionsCodexUserRun returns run for user content. Content of any other +// role closes the open user turn and has no tracker. +func interactionsCodexUserRun(run *translatorcommon.UserRun, role string) *translatorcommon.UserRun { + if role == "user" { + return run + } + run.End() + return nil +} + +func appendInteractionsStepToCodex(items *[][]byte, step gjson.Result, defaultRole string, run *translatorcommon.UserRun, modelName string, emittedSignatures map[string]bool) { if step.Type == gjson.String { - appendInteractionsTextToCodex(items, defaultRole, step.String()) + appendInteractionsRoleTextToCodex(items, defaultRole, step.String(), run) return } if steps := step.Get("steps"); steps.Exists() && steps.IsArray() { - role := interactionsCodexDefaultRole(step.Get("role").String(), defaultRole) + role := interactionsCodexStepRole(step, defaultRole) steps.ForEach(func(_, nested gjson.Result) bool { - appendInteractionsStepToCodex(items, nested, role) + appendInteractionsStepToCodex(items, nested, role, run, modelName, emittedSignatures) return true }) return @@ -223,54 +246,90 @@ func appendInteractionsStepToCodex(items *[][]byte, step gjson.Result, defaultRo stepType := strings.ToLower(strings.TrimSpace(step.Get("type").String())) switch stepType { case "function_call": + run.End() appendInteractionsFunctionCallToCodex(items, step) case "function_result", "function_call_output": + // A tool result is content the model reads, so it keeps the surrounding user turn. + run.Add() appendInteractionsFunctionResultToCodex(items, step) case "model_output", "assistant": - appendInteractionsContentToCodexItem(items, step.Get("content"), "assistant") + run.End() + appendInteractionsContentToCodexItem(items, step.Get("content"), "assistant", run) case "thought", "reasoning": - appendInteractionsThoughtToCodex(items, step) + run.End() + appendInteractionsThoughtToCodex(items, step, modelName, emittedSignatures) case "user_input", "message", "": - role := interactionsCodexDefaultRole(step.Get("role").String(), defaultRole) + role := interactionsCodexStepRole(step, defaultRole) if content := step.Get("content"); content.Exists() { - appendInteractionsContentToCodexItem(items, content, role) + appendInteractionsContentToCodexItem(items, content, role, run) } else if text := step.Get("text"); text.Exists() { - appendInteractionsTextToCodex(items, role, text.String()) + appendInteractionsRoleTextToCodex(items, role, text.String(), run) } default: - role := interactionsCodexDefaultRole(step.Get("role").String(), defaultRole) + role := interactionsCodexStepRole(step, defaultRole) if content := step.Get("content"); content.Exists() { - appendInteractionsContentToCodexItem(items, content, role) + appendInteractionsContentToCodexItem(items, content, role, run) } else if text := step.Get("text"); text.Exists() { - appendInteractionsTextToCodex(items, role, text.String()) + appendInteractionsRoleTextToCodex(items, role, text.String(), run) } } } -func appendInteractionsContentToCodexItem(items *[][]byte, content gjson.Result, role string) { +// appendInteractionsRoleTextToCodex adds a text message; blank text does not keep a user turn alive. +func appendInteractionsRoleTextToCodex(items *[][]byte, role, text string, run *translatorcommon.UserRun) { + appendInteractionsTextToCodex(items, role, text) + userRun := interactionsCodexUserRun(run, role) + if strings.TrimSpace(text) != "" { + userRun.Add() + } +} + +func appendInteractionsContentToCodexItem(items *[][]byte, content gjson.Result, role string, run *translatorcommon.UserRun) { if !content.Exists() { return } if content.Type == gjson.String { - appendInteractionsTextToCodex(items, role, content.String()) + appendInteractionsRoleTextToCodex(items, role, content.String(), run) return } + userRun := interactionsCodexUserRun(run, role) if content.IsArray() { content.ForEach(func(_, part gjson.Result) bool { - if item := interactionsCodexMessagePart(part, role); len(item) > 0 { - appendInteractionsMessagePartToCodex(items, role, item) - } + appendInteractionsContentPartToCodex(items, part, role, userRun) return true }) return } if content.IsObject() { - if item := interactionsCodexMessagePart(content, role); len(item) > 0 { - appendInteractionsMessagePartToCodex(items, role, item) + appendInteractionsContentPartToCodex(items, content, role, userRun) + } +} + +// appendInteractionsContentPartToCodex adds one content part. run is nil for +// content that is not user content; for user content it records what is sent and +// what cannot be. +func appendInteractionsContentPartToCodex(items *[][]byte, part gjson.Result, role string, run *translatorcommon.UserRun) { + item := interactionsCodexMessagePart(part, role) + if len(item) == 0 { + if droppedType := translatorcommon.InteractionsAttachmentType(part); droppedType != "" { + run.Drop(droppedType) } + return + } + appendInteractionsMessagePartToCodex(items, role, item) + if !interactionsCodexIsBlankText(item) { + run.Add() } } +// interactionsCodexIsBlankText reports whether a Codex message part is a text part +// holding only whitespace. +func interactionsCodexIsBlankText(item []byte) bool { + parsed := gjson.ParseBytes(item) + partType := parsed.Get("type").String() + return (partType == "input_text" || partType == "output_text") && strings.TrimSpace(parsed.Get("text").String()) == "" +} + func appendInteractionsFunctionCallToCodex(items *[][]byte, step gjson.Result) { item := []byte(`{"type":"function_call"}`) if name := step.Get("name"); name.Exists() { @@ -370,19 +429,67 @@ func setInteractionsCodexRawIfDifferent(out []byte, path string, value gjson.Res return updated } -func appendInteractionsThoughtToCodex(items *[][]byte, step gjson.Result) { +// ensureInteractionsCodexEncryptedContentInclude makes the outbound request +// ask the upstream for encrypted reasoning content so a signature can come +// back for replay. +func ensureInteractionsCodexEncryptedContentInclude(out []byte) []byte { + const reasoningEncryptedContent = "reasoning.encrypted_content" + include := gjson.GetBytes(out, "include") + if !include.Exists() { + out, _ = sjson.SetRawBytes(out, "include", []byte(`["reasoning.encrypted_content"]`)) + return out + } + if !include.IsArray() { + return out + } + for _, value := range include.Array() { + if value.Type == gjson.String && value.String() == reasoningEncryptedContent { + return out + } + } + out, _ = sjson.SetBytes(out, "include.-1", reasoningEncryptedContent) + return out +} + +// appendInteractionsThoughtToCodex adds a thought step. A client-echoed +// signature that CodexReplaySignatureForTarget accepts for this request's +// target model is restored as Responses reasoning encrypted_content, with +// the thought text carried as a summary; identical signatures already +// emitted in this request are replayed only once. The step id is +// deliberately not copied onto replay items: replay is stateless and the +// executor strips orphan ids. Every other thought step is dropped entirely: +// a reasoning item without accepted encrypted_content carries no replayable +// state, and the legacy text fallback shape is rejected by both the native +// Meta upstream (missing summary) and routers (content expected array). +func appendInteractionsThoughtToCodex(items *[][]byte, step gjson.Result, modelName string, emittedSignatures map[string]bool) { text := interactionsCodexContentText(step.Get("content")) if text == "" { text = step.Get("text").String() } - item := []byte(`{"type":"reasoning"}`) - if text != "" { - item, _ = sjson.SetBytes(item, "content", text) + if raw := interactionsThoughtStepSignature(step); raw != "" { + if normalized, ok := translatorcommon.CodexReplaySignatureForTarget(modelName, raw); ok && !emittedSignatures[normalized] { + emittedSignatures[normalized] = true + item := []byte(`{"type":"reasoning","encrypted_content":"","summary":[]}`) + item, _ = sjson.SetBytes(item, "encrypted_content", normalized) + if text != "" { + summaryPart := []byte(`{"type":"summary_text","text":""}`) + summaryPart, _ = sjson.SetBytes(summaryPart, "text", text) + item, _ = sjson.SetRawBytes(item, "summary", translatorcommon.JoinRawArray([][]byte{summaryPart})) + } + *items = append(*items, item) + } } - if id := step.Get("id"); id.Exists() { - item, _ = sjson.SetBytes(item, "id", id.String()) +} + +// interactionsThoughtStepSignature extracts a thought step's signature under +// any of the field spellings seen on the wire. +func interactionsThoughtStepSignature(step gjson.Result) string { + for _, path := range []string{"signature", "thought_signature", "thoughtSignature"} { + if signature := strings.TrimSpace(step.Get(path).String()); signature != "" { + return signature + } } - *items = append(*items, item) + return "" } func appendInteractionsTextToCodex(items *[][]byte, role, text string) { @@ -452,14 +559,10 @@ func interactionsCodexMessagePart(part gjson.Result, role string) []byte { } func interactionsCodexImagePart(part gjson.Result) []byte { - if url := part.Get("url"); url.Exists() { + // uri is the Interactions spelling of file_uri. + if imageURL := firstNonBlankString(part, "url", "file_uri", "fileUri", "uri"); imageURL != "" { item := []byte(`{"type":"input_image","image_url":""}`) - item, _ = sjson.SetBytes(item, "image_url", url.String()) - return item - } - if fileURI := firstString(part, "file_uri", "fileUri"); fileURI != "" { - item := []byte(`{"type":"input_image","image_url":""}`) - item, _ = sjson.SetBytes(item, "image_url", fileURI) + item, _ = sjson.SetBytes(item, "image_url", imageURL) return item } mimeType := firstString(part, "mime_type", "mimeType") @@ -492,7 +595,8 @@ func interactionsCodexFilePart(part gjson.Result) []byte { return item } mimeType := firstString(part, "mime_type", "mimeType") - if fileURI := firstString(part, "file_uri", "fileUri", "url"); fileURI != "" { + // uri is the Interactions spelling of file_uri. + if fileURI := firstNonBlankString(part, "file_uri", "fileUri", "uri", "url"); fileURI != "" { item := []byte(`{"type":"input_file","file_url":"","filename":""}`) item, _ = sjson.SetBytes(item, "file_url", fileURI) item, _ = sjson.SetBytes(item, "filename", codexFileNameFromMIME(mimeType)) @@ -638,6 +742,17 @@ func interactionsCodexOutputString(value gjson.Result) string { return "" } +// interactionsCodexStepRole resolves the Codex role of a step. A step that +// carries system or developer content, named by its role or by its type, is +// developer content: it closes the open user turn and never keeps an emptied one +// alive. Any other step uses its own role, then the role it inherits. +func interactionsCodexStepRole(step gjson.Result, inherited string) string { + if translatorcommon.IsInteractionsInstructionStep(step, inherited == "developer") { + return "developer" + } + return interactionsCodexDefaultRole(step.Get("role").String(), inherited) +} + func interactionsCodexDefaultRole(role, fallback string) string { switch strings.ToLower(strings.TrimSpace(role)) { case "model", "assistant": @@ -717,6 +832,16 @@ func shortenCodexToolNameIfNeeded(name string) string { return name[:limit] } +// firstNonBlankString returns the first non-blank string found at paths. +func firstNonBlankString(root gjson.Result, paths ...string) string { + for _, path := range paths { + if value := strings.TrimSpace(root.Get(path).String()); value != "" { + return value + } + } + return "" +} + func firstString(root gjson.Result, paths ...string) string { for _, path := range paths { if value := root.Get(path); value.Exists() { diff --git a/internal/translator/codex/interactions/interactions_codex_response.go b/internal/translator/codex/interactions/interactions_codex_response.go index 1f112765..9e1a8993 100644 --- a/internal/translator/codex/interactions/interactions_codex_response.go +++ b/internal/translator/codex/interactions/interactions_codex_response.go @@ -3,6 +3,7 @@ package interactions import ( "bytes" "context" + "crypto/sha256" "fmt" "strings" "time" @@ -13,19 +14,24 @@ import ( ) type codexToInteractionsStreamState struct { - Started bool - Completed bool - Done bool - ActiveStepOpen bool - ActiveStepType string - ActiveStepIndex int - StepIndex int - ID string - Model string - CreatedAt int64 - HasOutputText bool - FunctionCallName string - FunctionCallID string + Started bool + Completed bool + Done bool + ActiveStepOpen bool + ActiveStepType string + ActiveStepIndex int + StepIndex int + ID string + Model string + CreatedAt int64 + HasOutputText bool + HasThoughtSummary bool + StreamedSummaryItems map[string]bool + FunctionCallName string + FunctionCallID string + // SeenReasoningSignatures dedupes repeated reasoning output_item.done + // events so one signature is emitted as a thought_signature delta once. + SeenReasoningSignatures map[[32]byte]bool } func ConvertCodexResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { @@ -233,6 +239,13 @@ func codexReasoningDeltaToInteractions(st *codexToInteractionsStreamState, root delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`) delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) delta, _ = sjson.SetBytes(delta, "delta.content.text", root.Get("delta").String()) + if itemID := strings.TrimSpace(root.Get("item_id").String()); itemID != "" { + if st.StreamedSummaryItems == nil { + st.StreamedSummaryItems = make(map[string]bool) + } + st.StreamedSummaryItems[itemID] = true + } + st.HasThoughtSummary = true return append(out, translatorcommon.SSEEventData("step.delta", delta)) } @@ -280,6 +293,9 @@ func appendCodexInteractionsStepStart(out [][]byte, st *codexToInteractionsStrea st.StepIndex++ st.ActiveStepOpen = true st.ActiveStepType = stepType + if stepType == "thought" { + st.HasThoughtSummary = false + } stepStart := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`) stepStart, _ = sjson.SetBytes(stepStart, "index", st.ActiveStepIndex) stepStart, _ = sjson.SetBytes(stepStart, "step.type", stepType) @@ -310,6 +326,9 @@ func appendCodexInteractionsStepStop(out [][]byte, st *codexToInteractionsStream stepStop := []byte(`{"index":0,"event_type":"step.stop"}`) stepStop, _ = sjson.SetBytes(stepStop, "index", st.ActiveStepIndex) out = append(out, translatorcommon.SSEEventData("step.stop", stepStop)) + if st.ActiveStepType == "thought" { + st.HasThoughtSummary = false + } st.ActiveStepOpen = false st.ActiveStepType = "" return out @@ -332,11 +351,18 @@ func buildCodexMessageItemToInteractions(item gjson.Result) []byte { func buildCodexReasoningItemToInteractions(item gjson.Result) []byte { text := codexReasoningText(item) - if text == "" { + sig := strings.TrimSpace(item.Get("encrypted_content").String()) + if text == "" && sig == "" { return nil } - step := []byte(`{"type":"thought","content":[{"type":"text","text":""}]}`) - step, _ = sjson.SetBytes(step, "content.0.text", text) + step := []byte(`{"type":"thought"}`) + if sig != "" { + step, _ = sjson.SetBytes(step, "signature", sig) + } + if text != "" { + step, _ = sjson.SetRawBytes(step, "content", []byte(`[{"type":"text","text":""}]`)) + step, _ = sjson.SetBytes(step, "content.0.text", text) + } return step } @@ -407,14 +433,41 @@ func appendCodexMessageItemToInteractionsStream(out [][]byte, st *codexToInterac func appendCodexReasoningItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte { text := codexReasoningText(item) - if text == "" { - return out - } - out = ensureCodexInteractionsStep(out, st, "thought", item) - delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`) - delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) - delta, _ = sjson.SetBytes(delta, "delta.content.text", text) - return append(out, translatorcommon.SSEEventData("step.delta", delta)) + sig := strings.TrimSpace(item.Get("encrypted_content").String()) + if sig != "" { + key := sha256.Sum256([]byte(item.Get("id").String() + "\x00" + sig)) + if st.SeenReasoningSignatures[key] { + return out + } + if st.SeenReasoningSignatures == nil { + st.SeenReasoningSignatures = make(map[[32]byte]bool) + } + st.SeenReasoningSignatures[key] = true + } + itemID := strings.TrimSpace(item.Get("id").String()) + hadStreamedSummary := st.HasThoughtSummary + if itemID != "" && len(st.StreamedSummaryItems) > 0 { + hadStreamedSummary = st.StreamedSummaryItems[itemID] + } + if text != "" && !hadStreamedSummary { + out = ensureCodexInteractionsStep(out, st, "thought", item) + delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + delta, _ = sjson.SetBytes(delta, "delta.content.text", text) + out = append(out, translatorcommon.SSEEventData("step.delta", delta)) + } + if sig != "" { + out = ensureCodexInteractionsStep(out, st, "thought", item) + signatureDelta := []byte(`{"index":0,"delta":{"signature":"","type":"thought_signature"},"event_type":"step.delta"}`) + signatureDelta, _ = sjson.SetBytes(signatureDelta, "index", st.ActiveStepIndex) + signatureDelta, _ = sjson.SetBytes(signatureDelta, "delta.signature", sig) + out = append(out, translatorcommon.SSEEventData("step.delta", signatureDelta)) + } + // Completing a reasoning item resets the current item's summary flag so a + // subsequent done-only reasoning item is not suppressed by an earlier + // item's streamed deltas. + st.HasThoughtSummary = false + return out } func appendCodexFunctionCallItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte { diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_request.go b/internal/translator/codex/openai/chat-completions/codex_openai_request.go index 3f855f99..57119489 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_request.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_request.go @@ -7,6 +7,8 @@ package chat_completions import ( + "crypto/sha256" + "encoding/hex" "strconv" "strings" @@ -28,8 +30,16 @@ import ( // // Returns: // - []byte: The transformed request data in OpenAI Responses API format -func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertOpenAIRequestToCodex(modelName, inputRawJSON, stream) + +} + +// convertOpenAIRequestToCodex also reports a file or audio part Responses cannot +// receive when it leaves a user turn with nothing to send. +func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { rawJSON := inputRawJSON + var drops translatorcommon.UserTurnDrops root := gjson.ParseBytes(rawJSON) tools := root.Get("tools") toolResults := tools.Array() @@ -67,7 +77,7 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b if serviceTier := normalizeCodexServiceTier(root.Get("service_tier")); serviceTier != "" { out, _ = sjson.SetBytes(out, "service_tier", serviceTier) } - out, _ = sjson.SetBytes(out, "parallel_tool_calls", true) + out, _ = sjson.SetBytes(out, "parallel_tool_calls", gjson.GetBytes(rawJSON, "parallel_tool_calls").Type != gjson.False) // OpenAI documents reasoning summaries as explicit opt-in output. Leave // reasoning.summary to the source request's canonical summary intent instead // of coupling it to reasoning effort. @@ -212,10 +222,13 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b } contentItems := make([][]byte, 0, 4) + // Counts the parts this user turn really sends; an empty text part does not. + turnSendable := 0 // Handle regular content c := m.Get("content") if c.Exists() && c.Type == gjson.String && c.String() != "" { + turnSendable++ // Single string content partType := "input_text" if role == "assistant" { @@ -240,6 +253,9 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b part, _ = sjson.SetBytes(part, "type", partType) part, _ = sjson.SetBytes(part, "text", it.Get("text").String()) contentItems = append(contentItems, part) + if it.Get("text").String() != "" { + turnSendable++ + } case "image_url": // Map image inputs to input_image for Responses API if role == "user" { @@ -249,19 +265,15 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b part, _ = sjson.SetBytes(part, "image_url", u.String()) } contentItems = append(contentItems, part) + turnSendable++ } case "file": if role == "user" { - fileData := it.Get("file.file_data").String() - filename := it.Get("file.filename").String() - if fileData != "" { - part := []byte(`{}`) - part, _ = sjson.SetBytes(part, "type", "input_file") - part, _ = sjson.SetBytes(part, "file_data", fileData) - if filename != "" { - part, _ = sjson.SetBytes(part, "filename", filename) - } + if part, ok := codexInputFilePart(it); ok { contentItems = append(contentItems, part) + turnSendable++ + } else { + drops.Drop(t) } } case "input_audio": @@ -276,11 +288,17 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b part, _ = sjson.SetBytes(part, "format", audioFormat) } contentItems = append(contentItems, part) + turnSendable++ + } else { + drops.Drop(t) } } } } } + if role == "user" { + drops.EndTurn(turnSendable) + } // Don't emit empty assistant messages when only tool_calls // are present — Responses API needs function_call items @@ -333,6 +351,17 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b } usedCallIDs[callID] = struct{}{} } + // Codex rejects call_id values longer than 64 characters. + if shortID := shortenCodexCallIDIfNeeded(callID); shortID != callID { + for attempt := 1; ; attempt++ { + if _, used := usedCallIDs[shortID]; !used { + break + } + shortID = shortenCodexCallIDIfNeeded(callID + "_" + strconv.Itoa(attempt)) + } + usedCallIDs[shortID] = struct{}{} + callID = shortID + } pendingToolCalls = append(pendingToolCalls, pendingToolCall{ callID: callID, sourceCallID: sourceCallID, @@ -518,7 +547,7 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b } out, _ = sjson.SetBytes(out, "store", false) - return out + return out, drops.Err() } func setToolCallOutputContent(funcOutput []byte, content gjson.Result) []byte { @@ -580,32 +609,41 @@ func toolOutputContentPart(item gjson.Result) []byte { } return part case "file": - fileID := item.Get("file.file_id").String() - fileData := item.Get("file.file_data").String() - fileURL := item.Get("file.file_url").String() - if fileID == "" && fileData == "" && fileURL == "" { - return toolOutputFallbackPart(item) - } - part := []byte(`{}`) - part, _ = sjson.SetBytes(part, "type", "input_file") - if fileID != "" { - part, _ = sjson.SetBytes(part, "file_id", fileID) + if part, ok := codexInputFilePart(item); ok { + return part } - if fileData != "" { - part, _ = sjson.SetBytes(part, "file_data", fileData) - } - if fileURL != "" { - part, _ = sjson.SetBytes(part, "file_url", fileURL) - } - if filename := item.Get("file.filename").String(); filename != "" { - part, _ = sjson.SetBytes(part, "filename", filename) - } - return part + return toolOutputFallbackPart(item) default: return toolOutputFallbackPart(item) } } +// codexInputFilePart maps a Chat Completions file part onto a Responses input_file +// part. It reports false when the part names no file id, bytes or url. +func codexInputFilePart(item gjson.Result) ([]byte, bool) { + fileID := item.Get("file.file_id").String() + fileData := item.Get("file.file_data").String() + fileURL := item.Get("file.file_url").String() + if fileID == "" && fileData == "" && fileURL == "" { + return nil, false + } + part := []byte(`{}`) + part, _ = sjson.SetBytes(part, "type", "input_file") + if fileID != "" { + part, _ = sjson.SetBytes(part, "file_id", fileID) + } + if fileData != "" { + part, _ = sjson.SetBytes(part, "file_data", fileData) + } + if fileURL != "" { + part, _ = sjson.SetBytes(part, "file_url", fileURL) + } + if filename := item.Get("file.filename").String(); filename != "" { + part, _ = sjson.SetBytes(part, "filename", filename) + } + return part, true +} + func hasToolOutputImagePart(content gjson.Result) bool { if !content.IsArray() { return false @@ -799,3 +837,16 @@ func normalizeCodexServiceTier(result gjson.Result) string { return "" } } + +// shortenCodexCallIDIfNeeded keeps client tool call IDs within the Codex +// call_id limit with a deterministic, low-collision mapping. +func shortenCodexCallIDIfNeeded(id string) string { + const limit = 64 + if len(id) <= limit { + return id + } + + sum := sha256.Sum256([]byte(id)) + suffix := "_" + hex.EncodeToString(sum[:8]) + return id[:limit-len(suffix)] + suffix +} diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response.go b/internal/translator/codex/openai/chat-completions/codex_openai_response.go index 435abad5..a311a2d6 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_response.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_response.go @@ -9,8 +9,10 @@ import ( "bytes" "context" "crypto/sha256" + "strconv" "strings" "time" + "unicode/utf8" applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" @@ -33,15 +35,72 @@ type toolCallStreamState struct { Done bool } +// codexChatToolMetadata carries request-derived tool facts shared by the +// streaming and non-streaming translators. It is resolved once, on first +// tool call, instead of re-scanning the original request per tool event; +// callers pass the same request bytes for a stream's lifetime. +type codexChatToolMetadata struct { + resolved bool + reverseToolNames map[string]string + functionToolNames map[string]struct{} + customPatchWinners map[string]bool +} + +func (m *codexChatToolMetadata) resolve(original []byte) { + if m.resolved { + return + } + m.resolved = true + m.reverseToolNames = buildReverseMapFromOriginalOpenAI(original) + m.functionToolNames = map[string]struct{}{} + for _, tool := range gjson.GetBytes(original, "tools").Array() { + if tool.Get("type").String() == "function" { + m.functionToolNames[tool.Get("function.name").String()] = struct{}{} + } + } + m.customPatchWinners = map[string]bool{} + for name, winner := range util.CollectResponsesToolWinners(gjson.ParseBytes(original)) { + if applypatch.IsCustomTool(winner.Tool) { + m.customPatchWinners[name] = true + } + } +} + +// restoreToolName maps a shortened upstream tool name back to the +// client-declared original, matching buildReverseMapFromOriginalOpenAI. +func (m *codexChatToolMetadata) restoreToolName(original []byte, name string) string { + m.resolve(original) + if orig, ok := m.reverseToolNames[name]; ok { + return orig + } + return name +} + +// isCustomPatch matches isOriginalCustomPatch using the cached metadata. +func (m *codexChatToolMetadata) isCustomPatch(original []byte, item gjson.Result) bool { + if item.Get("type").String() != "custom_tool_call" { + return false + } + m.resolve(original) + name := util.QualifyResponsesNamespaceToolName(item.Get("namespace").String(), item.Get("name").String()) + if _, ok := m.functionToolNames[name]; ok { + return false + } + return m.customPatchWinners[name] +} + // ConvertCliToOpenAIParams holds parameters for response conversion. type ConvertCliToOpenAIParams struct { ServiceTier string + toolMetadata codexChatToolMetadata ResponseID string CreatedAt int64 Model string FunctionCallIndex int toolCallStates map[string]*toolCallStreamState currentToolCall *toolCallStreamState + citationKeys map[string]struct{} + emittedTextRunes int64 LastImageHashByItemID map[string][32]byte } @@ -67,6 +126,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR ResponseID: "", FunctionCallIndex: -1, toolCallStates: make(map[string]*toolCallStreamState), + citationKeys: make(map[string]struct{}), LastImageHashByItemID: make(map[string][32]byte), } } @@ -148,9 +208,18 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", "\n\n") } else if dataType == "response.output_text.delta" { if deltaResult := rootResult.Get("delta"); deltaResult.Exists() { + delta := deltaResult.String() template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") - template, _ = sjson.SetBytes(template, "choices.0.delta.content", deltaResult.String()) + template, _ = sjson.SetBytes(template, "choices.0.delta.content", delta) + p.emittedTextRunes += int64(utf8.RuneCountInString(delta)) + } + } else if dataType == "response.output_text.annotation.added" || dataType == "response.output_text.done" || dataType == "response.content_part.done" { + citations := buildCodexURLCitations(codexAnnotationsFromEvent(rootResult), p.emittedTextRunes, p.citationKeys) + if len(citations) == 0 { + return [][]byte{} } + template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") + template, _ = sjson.SetRawBytes(template, "choices.0.delta.annotations", translatorcommon.JoinRawArray(citations)) } else if dataType == "response.image_generation_call.partial_image" { itemID := rootResult.Get("item_id").String() b64 := rootResult.Get("partial_image_b64").String() @@ -210,7 +279,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR // Increment index for this new tool call item. p := (*param).(*ConvertCliToOpenAIParams) p.FunctionCallIndex++ - state := &toolCallStreamState{Index: p.FunctionCallIndex, Patch: isOriginalCustomPatch(originalRequestRawJSON, itemResult)} + state := &toolCallStreamState{Index: p.FunctionCallIndex, Patch: p.toolMetadata.isCustomPatch(originalRequestRawJSON, itemResult)} registerToolCallState(p, rootResult, itemResult, state) functionCallItemTemplate := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`) @@ -218,11 +287,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", itemResult.Get("call_id").String()) // Restore original tool name if it was shortened. - name := itemResult.Get("name").String() - rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON) - if orig, ok := rev[name]; ok { - name = orig - } + name := p.toolMetadata.restoreToolName(originalRequestRawJSON, itemResult.Get("name").String()) functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", name) functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", "") @@ -285,6 +350,15 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR if !itemResult.Exists() { return [][]byte{} } + if itemResult.Get("type").String() == "message" { + citations := buildCodexURLCitations(codexAnnotationsFromEvent(rootResult), p.emittedTextRunes, p.citationKeys) + if len(citations) == 0 { + return [][]byte{} + } + template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") + template, _ = sjson.SetRawBytes(template, "choices.0.delta.annotations", translatorcommon.JoinRawArray(citations)) + return [][]byte{template} + } itemType := itemResult.Get("type").String() if itemType == "image_generation_call" { itemID := itemResult.Get("id").String() @@ -356,7 +430,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR // Fallback path: model skipped output_item.added, so emit the complete tool call now. p.FunctionCallIndex++ - state = &toolCallStreamState{Index: p.FunctionCallIndex, ArgumentsEmitted: true, Done: true, Patch: isOriginalCustomPatch(originalRequestRawJSON, itemResult)} + state = &toolCallStreamState{Index: p.FunctionCallIndex, ArgumentsEmitted: true, Done: true, Patch: p.toolMetadata.isCustomPatch(originalRequestRawJSON, itemResult)} registerToolCallState(p, rootResult, itemResult, state) functionCallItemTemplate := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`) @@ -366,11 +440,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", itemResult.Get("call_id").String()) // Restore original tool name if it was shortened. - name := itemResult.Get("name").String() - rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON) - if orig, ok := rev[name]; ok { - name = orig - } + name := p.toolMetadata.restoreToolName(originalRequestRawJSON, itemResult.Get("name").String()) functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", name) fullArgs := codexToolCallArguments(itemResult) @@ -459,6 +529,7 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original } // Process the output array for content and function calls + var toolMetadata codexChatToolMetadata var toolCalls [][]byte var images [][]byte outputResult := responseResult.Get("output") @@ -466,6 +537,9 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original outputArray := outputResult.Array() var contentText string var reasoningText string + var messageAnnotations [][]byte + annotationKeys := make(map[string]struct{}) + var contentRuneOffset int64 for _, outputItem := range outputArray { outputType := outputItem.Get("type").String() @@ -496,16 +570,20 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original } } case "message": - // Extract message content + // Extract message content and URL citations. if contentResult := outputItem.Get("content"); contentResult.IsArray() { - contentArray := contentResult.Array() - for _, contentItem := range contentArray { - if contentItem.Get("type").String() == "output_text" { - if text := contentItem.Get("text").String(); text != "" { - contentText += text - } - break + for _, contentItem := range contentResult.Array() { + if contentItem.Get("type").String() != "output_text" { + continue } + text := contentItem.Get("text").String() + contentText += text + messageAnnotations = append(messageAnnotations, buildCodexURLCitations( + codexAnnotationResults(contentItem.Get("annotations")), + contentRuneOffset, + annotationKeys, + )...) + contentRuneOffset += int64(utf8.RuneCountInString(text)) } } case "function_call", "custom_tool_call": @@ -517,16 +595,12 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original } if nameResult := outputItem.Get("name"); nameResult.Exists() { - n := nameResult.String() - rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON) - if orig, ok := rev[n]; ok { - n = orig - } + n := toolMetadata.restoreToolName(originalRequestRawJSON, nameResult.String()) functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", n) } fullArgs := codexToolCallArguments(outputItem) - if isOriginalCustomPatch(originalRequestRawJSON, outputItem) { + if toolMetadata.isCustomPatch(originalRequestRawJSON, outputItem) { fullArgs = applypatch.WrapInput(fullArgs) } functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.arguments", fullArgs) @@ -557,6 +631,10 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original template, _ = sjson.SetBytes(template, "choices.0.message.reasoning_content", reasoningText) } + if len(messageAnnotations) > 0 { + template, _ = sjson.SetRawBytes(template, "choices.0.message.annotations", translatorcommon.JoinRawArray(messageAnnotations)) + } + // Add tool calls if any if len(toolCalls) > 0 { template, _ = sjson.SetRawBytes(template, "choices.0.message.tool_calls", translatorcommon.JoinRawArray(toolCalls)) @@ -601,6 +679,88 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original return template } +func codexAnnotationResults(value gjson.Result) []gjson.Result { + if !value.Exists() { + return nil + } + if value.IsArray() { + results := make([]gjson.Result, 0, len(value.Array())) + value.ForEach(func(_, item gjson.Result) bool { + results = append(results, item) + return true + }) + return results + } + return []gjson.Result{value} +} + +func codexAnnotationsFromEvent(event gjson.Result) []gjson.Result { + annotations := make([]gjson.Result, 0) + for _, path := range []string{"annotation", "annotations", "part.annotations"} { + annotations = append(annotations, codexAnnotationResults(event.Get(path))...) + } + item := event.Get("item") + if item.Exists() { + annotations = append(annotations, codexAnnotationResults(item.Get("annotations"))...) + if content := item.Get("content"); content.IsArray() { + for _, contentItem := range content.Array() { + annotations = append(annotations, codexAnnotationResults(contentItem.Get("annotations"))...) + } + } + } + return annotations +} + +func buildCodexURLCitations(annotations []gjson.Result, runeOffset int64, seen map[string]struct{}) [][]byte { + citations := make([][]byte, 0, len(annotations)) + for _, annotation := range annotations { + if annotation.Get("type").String() != "url_citation" { + continue + } + + rawStartIndex := annotation.Get("start_index").Int() + rawEndIndex := annotation.Get("end_index").Int() + startIndex := rawStartIndex + runeOffset + endIndex := rawEndIndex + runeOffset + if startIndex < 0 || endIndex < startIndex { + continue + } + + url := annotation.Get("url").String() + key := url + "\x00" + strconv.FormatInt(rawStartIndex, 10) + if url == "" { + key = annotation.Get("id").String() + "\x00" + strconv.FormatInt(rawStartIndex, 10) + } + keys := []string{key} + if id := annotation.Get("id").String(); id != "" { + keys = append(keys, "id\x00"+id) + } + if seen != nil { + duplicate := false + for _, key := range keys { + if _, exists := seen[key]; exists { + duplicate = true + break + } + } + if duplicate { + continue + } + for _, key := range keys { + seen[key] = struct{}{} + } + } + + citation := []byte(`{"type":"url_citation","url":"","title":"","start_index":0,"end_index":0}`) + citation, _ = sjson.SetBytes(citation, "url", url) + citation, _ = sjson.SetBytes(citation, "title", annotation.Get("title").String()) + citation, _ = sjson.SetBytes(citation, "start_index", startIndex) + citation, _ = sjson.SetBytes(citation, "end_index", endIndex) + citations = append(citations, citation) + } + return citations +} + func registerToolCallState(p *ConvertCliToOpenAIParams, eventResult, itemResult gjson.Result, state *toolCallStreamState) { if p.toolCallStates == nil { p.toolCallStates = make(map[string]*toolCallStreamState) diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request.go b/internal/translator/codex/openai/responses/codex_openai-responses_request.go index 447e5e72..4a7ecf1b 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request.go @@ -12,7 +12,7 @@ import ( "github.com/tidwall/sjson" ) -func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte { +func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, _ bool) ([]byte, error) { rawJSON := inputRawJSON inputResult := util.GetGJSONBytesNoCopy(rawJSON, "input") @@ -24,7 +24,9 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, rawJSON = setCodexRequiredBool(rawJSON, "stream", true) rawJSON = setCodexRequiredBool(rawJSON, "store", false) - rawJSON = setCodexRequiredBool(rawJSON, "parallel_tool_calls", true) + if gjson.GetBytes(rawJSON, "parallel_tool_calls").Type != gjson.False { + rawJSON = setCodexRequiredBool(rawJSON, "parallel_tool_calls", true) + } rawJSON = setCodexRequiredInclude(rawJSON) // Codex Responses rejects token limit fields, so strip them out before forwarding. rawJSON = deleteCodexRequestFields(rawJSON, "max_output_tokens", "max_completion_tokens", "temperature", "top_p") @@ -59,7 +61,7 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, rawJSON = normalizeCodexBuiltinTools(rawJSON) rawJSON = normalizeEmptyFunctionCallArguments(rawJSON) - return rawJSON + return rawJSON, nil } // normalizeEmptyFunctionCallArguments rewrites blank string arguments on diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index 2beb9a07..cae76027 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -30,18 +30,22 @@ const geminiClaudeThoughtSignature = "skip_thought_signature_validator" // // Returns: // - []byte: The transformed request in Gemini format. -func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { return convertClaudeRequestToGemini(modelName, inputRawJSON, stream, false) + } // ConvertClaudeRequestToGeminiWithCompat preserves assistant thinking blocks // with empty signatures for configured compatibility endpoints. -func ConvertClaudeRequestToGeminiWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertClaudeRequestToGeminiWithCompat(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { return convertClaudeRequestToGemini(modelName, inputRawJSON, stream, true) } -func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks bool) []byte { - rawJSON := inputRawJSON +func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks 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 thought. + rawJSON, _ := sigcompat.RestoreCodexCommentaryMarkers(inputRawJSON) + var drops translatorcommon.UserTurnDrops // Build output Gemini request JSON out := []byte(`{"contents":[]}`) out, _ = sjson.SetBytes(out, "model", modelName) @@ -188,27 +192,28 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, partItems = append(partItems, imagePart) } - case "image": - source := contentResult.Get("source") - if source.Get("type").String() != "base64" { - return true - } - mimeType := source.Get("media_type").String() - data := source.Get("data").String() - if mimeType == "" || data == "" { - return true + case "image", "document", "container_upload": + if part := claudeBase64InlineData(contentResult.Get("source")); part != nil { + partItems = append(partItems, part) + } else if originalRole == "user" { + // A part that cannot be inlined (url or file source) is dropped; the turn is refused only if nothing else is left. + drops.Drop(contentResult.Get("type").String()) } - part := []byte(`{"inline_data":{"mime_type":"","data":""}}`) - part, _ = sjson.SetBytes(part, "inline_data.mime_type", mimeType) - part, _ = sjson.SetBytes(part, "inline_data.data", data) - partItems = append(partItems, part) + default: + return true } return true }) if role == "user" { partItems = translatorcommon.ReorderGeminiUserParts(partItems) } - contentItems = append(contentItems, geminiContentWithParts(role, partItems)) + if originalRole == "user" { + // Whitespace-only text is forwarded but never keeps an emptied turn alive. + drops.EndTurn(translatorcommon.CountSendableGeminiParts(partItems)) + } + if len(partItems) > 0 { + contentItems = append(contentItems, geminiContentWithParts(role, partItems)) + } } else if contentsResult.Type == gjson.String { part := []byte(`{"text":""}`) part, _ = sjson.SetBytes(part, "text", contentsResult.String()) @@ -362,7 +367,22 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, result := out result = common.AttachDefaultSafetySettings(result, "safetySettings") - return result + return result, drops.Err() +} + +func claudeBase64InlineData(source gjson.Result) []byte { + if source.Get("type").String() != "base64" { + return nil + } + mimeType := source.Get("media_type").String() + data := source.Get("data").String() + if mimeType == "" || data == "" { + return nil + } + part := []byte(`{"inline_data":{"mime_type":"","data":""}}`) + part, _ = sjson.SetBytes(part, "inline_data.mime_type", mimeType) + part, _ = sjson.SetBytes(part, "inline_data.data", data) + return part } func geminiContentWithParts(role string, parts [][]byte) []byte { diff --git a/internal/translator/gemini/claude/gemini_claude_response.go b/internal/translator/gemini/claude/gemini_claude_response.go index 2d0c870f..0a9452f1 100644 --- a/internal/translator/gemini/claude/gemini_claude_response.go +++ b/internal/translator/gemini/claude/gemini_claude_response.go @@ -21,15 +21,45 @@ import ( // Params holds parameters for response conversion. type Params struct { - IsGlAPIKey bool - HasFirstResponse bool - ResponseType int - ResponseIndex int - HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output - ToolNameMap map[string]string - SanitizedNameMap map[string]string - SawToolCall bool - HasFinalEvents bool + IsGlAPIKey bool + HasFirstResponse bool + ResponseType int + ResponseIndex int + HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output + ToolNameMap map[string]string + SanitizedNameMap map[string]string + SawToolCall bool + HasFinalEvents bool + FinishReason string + InputTokens int64 + OutputTokens int64 + CachedTokens int64 + CurrentToolName string // Client-facing name of the currently open tool_use block + CurrentToolIsParameterless bool // Whether the open tool_use block was declared parameterless + CurrentToolInputEmitted bool // Whether cleaned input JSON was already emitted for the open tool_use block + CurrentToolPendingEmpty bool // Whether an empty "{}" input_json_delta is deferred awaiting tool_use completion +} + +const geminiMalformedFunctionCallErrorPayload = `{"type":"error","error":{"type":"api_error","message":"Gemini response finished with MALFORMED_FUNCTION_CALL"}}` + +func isGeminiMalformedFunctionCall(finishReason string, sawToolCall bool) bool { + return finishReason == "MALFORMED_FUNCTION_CALL" && !sawToolCall +} + +func resolveGeminiClaudeStopReason(finishReason string, sawToolCall bool) string { + if sawToolCall { + return "tool_use" + } + switch finishReason { + case "MAX_TOKENS": + return "max_tokens" + case "SAFETY", "RECITATION", "PROHIBITED_CONTENT", "SPII", "BLOCKLIST", "IMAGE_SAFETY", "MALFORMED_RESPONSE": + return "refusal" + case "STOP", "FINISH_REASON_UNSPECIFIED", "UNKNOWN", "": + return "end_turn" + default: + return "end_turn" + } } // toolUseIDCounter provides a process-wide unique counter for tool use identifiers. @@ -58,24 +88,74 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR HasFirstResponse: false, ResponseType: 0, ResponseIndex: 0, - ToolNameMap: util.ToolNameMapFromClaudeRequest(originalRequestRawJSON), + ToolNameMap: util.UnambiguousToolNameMapFromClaudeRequest(originalRequestRawJSON), SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), SawToolCall: false, } } + output := make([]byte, 0, 1024) + appendEvent := func(event, payload string) { + output = translatorcommon.AppendSSEEventString(output, event, payload, 3) + } + p := (*param).(*Params) + flushPendingToolInput := func() { + if p.ResponseType == 3 && p.CurrentToolPendingEmpty && !p.CurrentToolInputEmitted { + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, p.ResponseIndex)), "delta.partial_json", "{}") + appendEvent("content_block_delta", string(data)) + p.CurrentToolInputEmitted = true + p.CurrentToolPendingEmpty = false + } + } + if bytes.Equal(rawJSON, []byte("[DONE]")) { - // Only send message_stop if we have actually output content - if (*param).(*Params).HasContent { - return [][]byte{translatorcommon.AppendSSEEventString(nil, "message_stop", `{"type":"message_stop"}`, 3)} + if isGeminiMalformedFunctionCall(p.FinishReason, p.SawToolCall) { + if p.ResponseType != 0 { + flushPendingToolInput() + appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, p.ResponseIndex)) + p.ResponseType = 0 + } + if !p.HasFinalEvents { + appendEvent("error", geminiMalformedFunctionCallErrorPayload) + p.HasFinalEvents = true + } + if len(output) > 0 { + return [][]byte{output} + } + return [][]byte{} + } + if p.HasFirstResponse && !p.HasContent { + appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, p.ResponseIndex)) + p.ResponseType = 1 + p.HasContent = true + } + if p.HasContent { + if p.ResponseType != 0 { + flushPendingToolInput() + appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, p.ResponseIndex)) + p.ResponseType = 0 + } + if !p.HasFinalEvents { + stopReason := resolveGeminiClaudeStopReason(p.FinishReason, p.SawToolCall) + template := []byte(fmt.Sprintf(`{"type":"message_delta","delta":{"stop_reason":"%s","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`, stopReason)) + template, _ = sjson.SetBytes(template, "usage.output_tokens", p.OutputTokens) + template, _ = sjson.SetBytes(template, "usage.input_tokens", p.InputTokens) + if p.CachedTokens > 0 { + template, _ = sjson.SetBytes(template, "usage.cache_read_input_tokens", p.CachedTokens) + } + appendEvent("message_delta", string(template)) + p.HasFinalEvents = true + } + appendEvent("message_stop", `{"type":"message_stop"}`) + return [][]byte{output} } return [][]byte{} } - output := make([]byte, 0, 1024) - appendEvent := func(event, payload string) { - output = translatorcommon.AppendSSEEventString(output, event, payload, 3) + if isGeminiMalformedFunctionCall(p.FinishReason, p.SawToolCall) && p.HasFinalEvents { + return [][]byte{} } + appendSignatureDelta := func(signature string) { if signature == "" || (*param).(*Params).ResponseType != 2 { return @@ -85,6 +165,26 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR (*param).(*Params).HasContent = true } + appendCarrierThinkingBlock := func(signature string) { + if signature == "" { + return + } + p := (*param).(*Params) + if p.ResponseType != 0 { + flushPendingToolInput() + appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, p.ResponseIndex)) + p.ResponseIndex++ + p.ResponseType = 0 + } + appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, p.ResponseIndex)) + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, p.ResponseIndex)), "delta.signature", signature) + appendEvent("content_block_delta", string(data)) + appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, p.ResponseIndex)) + p.ResponseIndex++ + p.ResponseType = 0 + p.HasContent = true + } + // Initialize the streaming session with a message_start event // This is only sent for the very first response chunk if !(*param).(*Params).HasFirstResponse { @@ -119,160 +219,236 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR if !thoughtSignatureResult.Exists() { thoughtSignatureResult = partResult.Get("thought_signature") } - hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" + partSig := "" + if thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" { + partSig = thoughtSignatureResult.String() + } + hasThoughtSignature := partSig != "" + isThought := partResult.Get("thought").Bool() - if hasThoughtSignature && !partTextResult.Exists() && !functionCallResult.Exists() { - appendSignatureDelta(thoughtSignatureResult.String()) + if hasThoughtSignature && (!partTextResult.Exists() || partTextResult.String() == "") && !functionCallResult.Exists() { + if (*param).(*Params).ResponseType == 2 { + appendSignatureDelta(partSig) + continue + } + appendCarrierThinkingBlock(partSig) continue } - // Handle text content (both regular content and thinking) - if partTextResult.Exists() { - // Process thinking content (internal reasoning) - if partResult.Get("thought").Bool() || hasThoughtSignature { - if hasThoughtSignature && partTextResult.String() == "" { - appendSignatureDelta(thoughtSignatureResult.String()) - continue - } - // Continue existing thinking block + if isThought { + if hasThoughtSignature && (!partTextResult.Exists() || partTextResult.String() == "") { if (*param).(*Params).ResponseType == 2 { - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partTextResult.String()) - appendEvent("content_block_delta", string(data)) - (*param).(*Params).HasContent = true + appendSignatureDelta(partSig) } else { - // Transition from another state to thinking - // First, close any existing content block - if (*param).(*Params).ResponseType != 0 { - if (*param).(*Params).ResponseType == 2 { - // output = output + "event: content_block_delta\n" - // output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex) - // output = output + "\n\n\n" - } - appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) - (*param).(*Params).ResponseIndex++ - } - - // Start a new thinking content block - appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, (*param).(*Params).ResponseIndex)) - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partTextResult.String()) - appendEvent("content_block_delta", string(data)) - (*param).(*Params).ResponseType = 2 // Set state to thinking - (*param).(*Params).HasContent = true + appendCarrierThinkingBlock(partSig) } - appendSignatureDelta(thoughtSignatureResult.String()) - } else { - // Process regular text content (user-visible output) - // Continue existing text block - if (*param).(*Params).ResponseType == 1 { - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partTextResult.String()) - appendEvent("content_block_delta", string(data)) - (*param).(*Params).HasContent = true - } else { - // Transition from another state to text content - // First, close any existing content block - if (*param).(*Params).ResponseType != 0 { - if (*param).(*Params).ResponseType == 2 { - // output = output + "event: content_block_delta\n" - // output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex) - // output = output + "\n\n\n" - } - appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) - (*param).(*Params).ResponseIndex++ - } + continue + } - // Start a new text content block - appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, (*param).(*Params).ResponseIndex)) - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partTextResult.String()) - appendEvent("content_block_delta", string(data)) - (*param).(*Params).ResponseType = 1 // Set state to content - (*param).(*Params).HasContent = true + partText := partTextResult.String() + if (*param).(*Params).ResponseType == 2 { + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partText) + appendEvent("content_block_delta", string(data)) + (*param).(*Params).HasContent = true + } else { + if (*param).(*Params).ResponseType != 0 { + flushPendingToolInput() + appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) + (*param).(*Params).ResponseIndex++ } + + appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, (*param).(*Params).ResponseIndex)) + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partText) + appendEvent("content_block_delta", string(data)) + (*param).(*Params).ResponseType = 2 + (*param).(*Params).HasContent = true + } + if hasThoughtSignature { + appendSignatureDelta(partSig) } - } else if functionCallResult.Exists() { - // Handle function/tool calls from the AI model - // This processes tool usage requests and formats them for Claude API compatibility + continue + } + + // From here on, !isThought (visible text, functionCall, or standalone signature) + if functionCallResult.Exists() { (*param).(*Params).SawToolCall = true upstreamToolName := functionCallResult.Get("name").String() upstreamToolName = util.RestoreSanitizedToolName((*param).(*Params).SanitizedNameMap, upstreamToolName) clientToolName := util.MapToolName((*param).(*Params).ToolNameMap, upstreamToolName) - // FIX: Handle streaming split/delta where name might be empty in subsequent chunks. - // If we are already in tool use mode and name is empty, treat as continuation (delta). if (*param).(*Params).ResponseType == 3 && upstreamToolName == "" { if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", fcArgsResult.Raw) - appendEvent("content_block_delta", string(data)) + partialJSON := util.CleanClaudeToolArgumentsWithTranslated(fcArgsResult.Raw, (*param).(*Params).CurrentToolName, originalRequestRawJSON, requestRawJSON) + if (*param).(*Params).CurrentToolIsParameterless { + if (*param).(*Params).CurrentToolInputEmitted { + partialJSON = "" + } else { + (*param).(*Params).CurrentToolInputEmitted = true + (*param).(*Params).CurrentToolPendingEmpty = false + } + } else if partialJSON == "{}" { + if !(*param).(*Params).CurrentToolInputEmitted { + (*param).(*Params).CurrentToolPendingEmpty = true + } + partialJSON = "" + } else if partialJSON != "" { + (*param).(*Params).CurrentToolInputEmitted = true + (*param).(*Params).CurrentToolPendingEmpty = false + } + if partialJSON != "" || !(*param).(*Params).CurrentToolPendingEmpty { + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", partialJSON) + appendEvent("content_block_delta", string(data)) + } + } + if hasThoughtSignature { + appendCarrierThinkingBlock(partSig) } - // Continue to next part without closing/opening logic continue } - // Handle state transitions when switching to function calls - // Close any existing function call block first + if hasThoughtSignature { + appendCarrierThinkingBlock(partSig) + } + if (*param).(*Params).ResponseType == 3 { + flushPendingToolInput() appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) (*param).(*Params).ResponseIndex++ (*param).(*Params).ResponseType = 0 } - - // Special handling for thinking state transition - if (*param).(*Params).ResponseType == 2 { - // output = output + "event: content_block_delta\n" - // output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex) - // output = output + "\n\n\n" - } - - // Close any other existing content block if (*param).(*Params).ResponseType != 0 { + flushPendingToolInput() appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) (*param).(*Params).ResponseIndex++ } - // Start a new tool use content block - // This creates the structure for a function call in Claude format - // Create the tool use block with unique ID and function details + (*param).(*Params).CurrentToolName = clientToolName + (*param).(*Params).CurrentToolIsParameterless = util.ClaudeToolIsParameterless(clientToolName, originalRequestRawJSON) + (*param).(*Params).CurrentToolInputEmitted = false + (*param).(*Params).CurrentToolPendingEmpty = false data := []byte(fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, (*param).(*Params).ResponseIndex)) data, _ = sjson.SetBytes(data, "content_block.id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, atomic.AddUint64(&toolUseIDCounter, 1)))) data, _ = sjson.SetBytes(data, "content_block.name", clientToolName) appendEvent("content_block_start", string(data)) if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { - data, _ = sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", fcArgsResult.Raw) - appendEvent("content_block_delta", string(data)) + partialJSON := util.CleanClaudeToolArgumentsWithTranslated(fcArgsResult.Raw, clientToolName, originalRequestRawJSON, requestRawJSON) + if (*param).(*Params).CurrentToolIsParameterless { + if (*param).(*Params).CurrentToolInputEmitted { + partialJSON = "" + } else { + (*param).(*Params).CurrentToolInputEmitted = true + } + } else if partialJSON == "{}" { + (*param).(*Params).CurrentToolPendingEmpty = true + partialJSON = "" + } else if partialJSON != "" { + (*param).(*Params).CurrentToolInputEmitted = true + } + if partialJSON != "" || !(*param).(*Params).CurrentToolPendingEmpty { + data, _ = sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", partialJSON) + appendEvent("content_block_delta", string(data)) + } } (*param).(*Params).ResponseType = 3 (*param).(*Params).HasContent = true + continue + } + + if partTextResult.Exists() { + partText := partTextResult.String() + if hasThoughtSignature && partText == "" { + if (*param).(*Params).ResponseType == 2 { + appendSignatureDelta(partSig) + continue + } + appendCarrierThinkingBlock(partSig) + continue + } + if hasThoughtSignature { + appendCarrierThinkingBlock(partSig) + } + if (*param).(*Params).ResponseType == 1 { + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partText) + appendEvent("content_block_delta", string(data)) + (*param).(*Params).HasContent = true + } else { + if (*param).(*Params).ResponseType != 0 { + flushPendingToolInput() + appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) + (*param).(*Params).ResponseIndex++ + } + appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, (*param).(*Params).ResponseIndex)) + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partText) + appendEvent("content_block_delta", string(data)) + (*param).(*Params).ResponseType = 1 + (*param).(*Params).HasContent = true + } + continue + } + + if hasThoughtSignature { + appendCarrierThinkingBlock(partSig) + continue } } } + if finish := gjson.GetBytes(rawJSON, "candidates.0.finishReason"); finish.Exists() && finish.String() != "" { + (*param).(*Params).FinishReason = finish.String() + flushPendingToolInput() + } + usageResult := gjson.GetBytes(rawJSON, "usageMetadata") + if usageResult.Exists() { + cachedTokens := usageResult.Get("cachedContentTokenCount").Int() + promptTokens := usageResult.Get("promptTokenCount").Int() - cachedTokens + if promptTokens < 0 { + promptTokens = 0 + } + outputTokens := usageResult.Get("candidatesTokenCount").Int() + usageResult.Get("thoughtsTokenCount").Int() + if outputTokens == 0 && usageResult.Get("totalTokenCount").Int() > 0 { + outputTokens = usageResult.Get("totalTokenCount").Int() - usageResult.Get("promptTokenCount").Int() + if outputTokens < 0 { + outputTokens = 0 + } + } + (*param).(*Params).InputTokens = promptTokens + (*param).(*Params).OutputTokens = outputTokens + (*param).(*Params).CachedTokens = cachedTokens + } + + if isGeminiMalformedFunctionCall((*param).(*Params).FinishReason, (*param).(*Params).SawToolCall) && !(*param).(*Params).HasFinalEvents { + if (*param).(*Params).ResponseType != 0 { + flushPendingToolInput() + appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) + (*param).(*Params).ResponseType = 0 + } + appendEvent("error", geminiMalformedFunctionCallErrorPayload) + (*param).(*Params).HasFinalEvents = true + return [][]byte{output} + } + if usageResult.Exists() && bytes.Contains(rawJSON, []byte(`"finishReason"`)) && !(*param).(*Params).HasFinalEvents { - // Only send final events if we have actually output content + if !(*param).(*Params).HasContent && (*param).(*Params).HasFirstResponse { + appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, (*param).(*Params).ResponseIndex)) + (*param).(*Params).ResponseType = 1 + (*param).(*Params).HasContent = true + } + if (*param).(*Params).HasContent { if (*param).(*Params).ResponseType != 0 { + flushPendingToolInput() appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) (*param).(*Params).ResponseType = 0 } - template := []byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`) - if (*param).(*Params).SawToolCall { - template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`) - } else if finish := gjson.GetBytes(rawJSON, "candidates.0.finishReason"); finish.Exists() && finish.String() == "MAX_TOKENS" { - template = []byte(`{"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`) - } - - thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int() - candidatesTokenCount := usageResult.Get("candidatesTokenCount").Int() - cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int() - promptTokenCount := usageResult.Get("promptTokenCount").Int() - cachedTokenCount - if promptTokenCount < 0 { - promptTokenCount = 0 - } - template, _ = sjson.SetBytes(template, "usage.output_tokens", candidatesTokenCount+thoughtsTokenCount) - template, _ = sjson.SetBytes(template, "usage.input_tokens", promptTokenCount) - if cachedTokenCount > 0 { - template, _ = sjson.SetBytes(template, "usage.cache_read_input_tokens", cachedTokenCount) + stopReason := resolveGeminiClaudeStopReason((*param).(*Params).FinishReason, (*param).(*Params).SawToolCall) + template := []byte(fmt.Sprintf(`{"type":"message_delta","delta":{"stop_reason":"%s","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`, stopReason)) + template, _ = sjson.SetBytes(template, "usage.output_tokens", (*param).(*Params).OutputTokens) + template, _ = sjson.SetBytes(template, "usage.input_tokens", (*param).(*Params).InputTokens) + if (*param).(*Params).CachedTokens > 0 { + template, _ = sjson.SetBytes(template, "usage.cache_read_input_tokens", (*param).(*Params).CachedTokens) } appendEvent("message_delta", string(template)) @@ -297,7 +473,7 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina _ = requestRawJSON root := gjson.ParseBytes(rawJSON) - toolNameMap := util.ToolNameMapFromClaudeRequest(originalRequestRawJSON) + toolNameMap := util.UnambiguousToolNameMapFromClaudeRequest(originalRequestRawJSON) sanitizedNameMap := util.SanitizedToolNameMap(originalRequestRawJSON) out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`) @@ -320,7 +496,6 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina textBuilder := strings.Builder{} thinkingBuilder := strings.Builder{} var thinkingSignature string - toolIDCounter := 0 hasToolCall := false var blocks [][]byte @@ -348,55 +523,96 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina thinkingSignature = "" } + appendCarrierThinkingBlock := func(signature string) { + if signature == "" { + return + } + carrier := []byte(`{"type":"thinking","thinking":"","signature":""}`) + carrier, _ = sjson.SetBytes(carrier, "signature", signature) + blocks = append(blocks, carrier) + } + if parts.IsArray() { for _, part := range parts.Array() { thoughtSignatureResult := part.Get("thoughtSignature") if !thoughtSignatureResult.Exists() { thoughtSignatureResult = part.Get("thought_signature") } - hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" - if hasThoughtSignature { - thinkingSignature = thoughtSignatureResult.String() + partSig := "" + if thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" { + partSig = thoughtSignatureResult.String() } text := part.Get("text") functionCall := part.Get("functionCall") + isThought := part.Get("thought").Bool() - if hasThoughtSignature && (!text.Exists() || text.String() == "") && !functionCall.Exists() { + if isThought { + flushText() + if partSig != "" { + thinkingSignature = partSig + } + if text.Exists() && text.String() != "" { + thinkingBuilder.WriteString(text.String()) + } continue } - if text.Exists() && text.String() != "" { - if part.Get("thought").Bool() || hasThoughtSignature { + // If this is a part without visible text or function call, and thinking is in progress: + // this signature belongs to the thinking block! + if (!text.Exists() || text.String() == "") && !functionCall.Exists() { + if thinkingBuilder.Len() > 0 && partSig != "" { + thinkingSignature = partSig + continue + } + if partSig != "" { + flushThinking() flushText() - thinkingBuilder.WriteString(text.String()) + appendCarrierThinkingBlock(partSig) continue } - flushThinking() - textBuilder.WriteString(text.String()) continue } + // From here on, !isThought (visible text or functionCall) + flushThinking() + if functionCall.Exists() { - flushThinking() flushText() + if partSig != "" { + appendCarrierThinkingBlock(partSig) + } hasToolCall = true upstreamToolName := functionCall.Get("name").String() upstreamToolName = util.RestoreSanitizedToolName(sanitizedNameMap, upstreamToolName) clientToolName := util.MapToolName(toolNameMap, upstreamToolName) - toolIDCounter++ toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) - toolBlock, _ = sjson.SetBytes(toolBlock, "id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, toolIDCounter))) + toolBlock, _ = sjson.SetBytes(toolBlock, "id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, atomic.AddUint64(&toolUseIDCounter, 1)))) toolBlock, _ = sjson.SetBytes(toolBlock, "name", clientToolName) inputRaw := "{}" if args := functionCall.Get("args"); args.Exists() && gjson.Valid(args.Raw) && args.IsObject() { - inputRaw = args.Raw + inputRaw = util.CleanClaudeToolArgumentsWithTranslated(args.Raw, clientToolName, originalRequestRawJSON, requestRawJSON) } toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(inputRaw)) blocks = append(blocks, toolBlock) continue } + + if text.Exists() && text.String() != "" { + if partSig != "" { + flushText() + appendCarrierThinkingBlock(partSig) + } + textBuilder.WriteString(text.String()) + continue + } + + if partSig != "" { + flushText() + appendCarrierThinkingBlock(partSig) + continue + } } } @@ -407,22 +623,14 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina out, _ = sjson.SetRawBytes(out, "content", translatorcommon.JoinRawArray(blocks)) } - stopReason := "end_turn" - if hasToolCall { - stopReason = "tool_use" - } else { - if finish := root.Get("candidates.0.finishReason"); finish.Exists() { - switch finish.String() { - case "MAX_TOKENS": - stopReason = "max_tokens" - case "STOP", "FINISH_REASON_UNSPECIFIED", "UNKNOWN": - stopReason = "end_turn" - default: - stopReason = "end_turn" - } - } + var finishReason string + if finish := root.Get("candidates.0.finishReason"); finish.Exists() { + finishReason = finish.String() + } + if isGeminiMalformedFunctionCall(finishReason, hasToolCall) { + return []byte(geminiMalformedFunctionCallErrorPayload) } - out, _ = sjson.SetBytes(out, "stop_reason", stopReason) + out, _ = sjson.SetBytes(out, "stop_reason", resolveGeminiClaudeStopReason(finishReason, hasToolCall)) if inputTokens == int64(0) && outputTokens == int64(0) && !root.Get("usageMetadata").Exists() { out, _ = sjson.DeleteBytes(out, "usage") diff --git a/internal/translator/gemini/gemini/gemini_gemini_request.go b/internal/translator/gemini/gemini/gemini_gemini_request.go index ba7add2c..330ac46e 100644 --- a/internal/translator/gemini/gemini/gemini_gemini_request.go +++ b/internal/translator/gemini/gemini/gemini_gemini_request.go @@ -21,12 +21,12 @@ import ( // The first message defaults to "user", then alternates user/model when needed. // // It keeps the payload otherwise unchanged. -func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) []byte { +func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) ([]byte, error) { rawJSON := inputRawJSON // Fast path: if no contents field, only attach safety settings contents := util.GetGJSONBytesNoCopy(rawJSON, "contents") if !contents.Exists() { - return common.AttachDefaultSafetySettings(rawJSON, "safetySettings") + return common.AttachDefaultSafetySettings(rawJSON, "safetySettings"), nil } toolsResult := gjson.GetBytes(rawJSON, "tools") @@ -138,7 +138,7 @@ func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) []byte out = backfillEmptyFunctionResponseNames(out) out = common.AttachDefaultSafetySettings(out, "safetySettings") - return out + return out, nil } // backfillEmptyFunctionResponseNames walks the contents array and for each diff --git a/internal/translator/gemini/interactions/interactions_gemini_common.go b/internal/translator/gemini/interactions/interactions_gemini_common.go index 56097c8a..910878e5 100644 --- a/internal/translator/gemini/interactions/interactions_gemini_common.go +++ b/internal/translator/gemini/interactions/interactions_gemini_common.go @@ -19,6 +19,9 @@ type StreamState struct { Completed bool Done bool ActiveStepOpen bool + PendingUsage gjson.Result + HasPendingUsage bool + UsageAfterSeen bool ID string StepID string ActiveStepType string @@ -26,7 +29,14 @@ type StreamState struct { StepIndex int } -func ConvertInteractionsRequestToGemini(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertInteractionsRequestToGemini(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertInteractionsRequestToGemini(modelName, inputRawJSON, stream) + +} + +// convertInteractionsRequestToGemini also reports a user turn that was left empty +// because its only attachment has no Gemini equivalent. +func convertInteractionsRequestToGemini(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { root := gjson.ParseBytes(inputRawJSON) out := []byte(`{"model":"","contents":[]}`) if modelName != "" && root.Get("model").Exists() { @@ -39,12 +49,20 @@ func ConvertInteractionsRequestToGemini(modelName string, inputRawJSON []byte, s out = copyInteractionsToolChoice(out, root) out = copyInteractionsServiceTier(out, root) contentItems := translatorcommon.NewRawArrayItems(root.Get("input.#").Int()) - appendInteractionsInput(&contentItems, root.Get("input")) + errInput := appendInteractionsInput(&contentItems, root.Get("input")) out = translatorcommon.SetRawArrayItems(out, "contents", contentItems) - return out + return out, errInput } -func ConvertGeminiRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertGeminiRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertGeminiRequestToInteractions(modelName, inputRawJSON, stream) + +} + +// convertGeminiRequestToInteractions also reports a user turn that was left empty +// because its only part could not be carried over to Interactions. +func convertGeminiRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + var drops translatorcommon.UserTurnDrops root := gjson.ParseBytes(inputRawJSON) out := []byte(`{"model":"","input":[]}`) out, _ = sjson.SetBytes(out, "model", modelName) @@ -62,11 +80,14 @@ func ConvertGeminiRequestToInteractions(modelName string, inputRawJSON []byte, s if role == "model" { stepType = "model_output" } + // Counts what this turn sends; empty text is not sendable. + sendable := 0 content.Get("parts").ForEach(func(_, part gjson.Result) bool { if fc := part.Get("functionCall"); fc.Exists() { for _, step := range geminiPartToInteractionsSteps(part) { if len(step) > 0 { inputItems = append(inputItems, step) + sendable++ } } return true @@ -75,6 +96,7 @@ func ConvertGeminiRequestToInteractions(modelName string, inputRawJSON []byte, s for _, step := range geminiPartToInteractionsSteps(part) { if len(step) > 0 { inputItems = append(inputItems, step) + sendable++ } } return true @@ -89,6 +111,9 @@ func ConvertGeminiRequestToInteractions(modelName string, inputRawJSON []byte, s } item := geminiPartToInteractionsContent(part) if len(item) == 0 { + if role != "model" && geminiPartFileData(part).Exists() { + drops.Drop("fileData") + } return true } currentStepType := stepType @@ -99,13 +124,17 @@ func ConvertGeminiRequestToInteractions(modelName string, inputRawJSON []byte, s step, _ = sjson.SetBytes(step, "type", currentStepType) step = translatorcommon.SetRawArrayItems(step, "content", [][]byte{item}) inputItems = append(inputItems, step) + sendable++ return true }) + if role != "model" { + drops.EndTurn(sendable) + } return true }) out = translatorcommon.SetRawArrayItems(out, "input", inputItems) out, _ = sjson.SetBytes(out, "stream", stream) - return out + return out, drops.Err() } func copyGeminiSystemInstructionToInteractions(out []byte, root gjson.Result) []byte { @@ -310,9 +339,20 @@ func geminiPartToInteractionsContent(part gjson.Result) []byte { if inline := part.Get("inline_data"); inline.Exists() { return geminiInlineDataToInteractionsContent(inline.Get("mime_type").String(), inline.Get("data").String()) } + if fileData := geminiPartFileData(part); fileData.Exists() { + return geminiFileDataToInteractionsContent(fileData) + } return nil } +// geminiPartFileData returns the fileData object of a Gemini part in either spelling. +func geminiPartFileData(part gjson.Result) gjson.Result { + if fileData := part.Get("fileData"); fileData.Exists() { + return fileData + } + return part.Get("file_data") +} + func ConvertGeminiResponseToInteractionsStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { _ = ctx if *param == nil { @@ -323,7 +363,11 @@ func ConvertGeminiResponseToInteractionsStream(ctx context.Context, modelName st var out [][]byte if !st.Completed { out = appendInteractionsStepStop(out, st) - out = appendInteractionsCompleted(out, st, modelName, gjson.Result{}) + completedRoot := gjson.Result{} + if st.HasPendingUsage { + completedRoot = st.PendingUsage + } + out = appendInteractionsCompleted(out, st, modelName, completedRoot) } return appendInteractionsDone(out, st) } @@ -338,23 +382,57 @@ func ConvertGeminiResponseToInteractionsStream(ctx context.Context, modelName st out = appendGeminiPartToInteractionsStream(out, st, part) return true }) - hasFinish := root.Get("candidates.0.finishReason").Exists() - hasUsage := hasInteractionsGeminiStreamUsage(root) + finishReason := root.Get("candidates.0.finishReason") + hasFinish := finishReason.Exists() && strings.TrimSpace(finishReason.String()) != "" + root = restoreInteractionsGeminiUsageMetadata(root) + frameHasUsage := hasInteractionsGeminiStreamUsage(root) + if frameHasUsage { + st.PendingUsage = root + st.HasPendingUsage = true + if st.Finished { + st.UsageAfterSeen = true + } + } if hasFinish && !st.Finished { out = appendInteractionsStepStop(out, st) st.Finished = true + if frameHasUsage { + st.UsageAfterSeen = true + } } - if hasUsage && st.Finished && !st.Completed { - out = appendInteractionsCompleted(out, st, modelName, root) + // Complete only on usage observed at or after the finish frame. Usage + // cached from earlier content frames is a fallback for the explicit + // [DONE] terminator, not a completion trigger: a later frame may still + // carry the final usage. + if st.UsageAfterSeen && st.Finished && !st.Completed { + out = appendInteractionsCompleted(out, st, modelName, st.PendingUsage) + out = appendInteractionsDone(out, st) } return out } +// restoreInteractionsGeminiUsageMetadata renames cpaUsageMetadata back to +// usageMetadata. Executors rename non-terminal usage before translation, so +// the filtered spelling must be restored for usage detection and output. +func restoreInteractionsGeminiUsageMetadata(root gjson.Result) gjson.Result { + if !root.Get("usageMetadata").Exists() && !root.Get("usage_metadata").Exists() { + if cpaUsage := root.Get("cpaUsageMetadata"); cpaUsage.Exists() { + raw, _ := sjson.SetRawBytes([]byte(root.Raw), "usageMetadata", []byte(cpaUsage.Raw)) + raw, _ = sjson.DeleteBytes(raw, "cpaUsageMetadata") + return gjson.ParseBytes(raw) + } + } + return root +} + func hasInteractionsGeminiStreamUsage(root gjson.Result) bool { usage := root.Get("usageMetadata") if !usage.Exists() { usage = root.Get("usage_metadata") } + if !usage.Exists() { + usage = root.Get("cpaUsageMetadata") + } if !usage.Exists() { return false } @@ -808,11 +886,36 @@ type geminiInteractionsInputContext struct { inModelTurn bool lastStepType string pendingSignature string + // run follows the consecutive user content so that an attachment Gemini cannot + // carry is refused only when its whole user turn is left with nothing to send. + run translatorcommon.UserRun + // instruction is set while the step being added is developer or system content. + instruction bool +} + +// userRun returns the tracker for content added with role. Model content and +// developer or system content close the open user turn and have no tracker, so +// they can never keep an emptied user turn alive. +func (ctx *geminiInteractionsInputContext) userRun(role string) *translatorcommon.UserRun { + if role == "user" && !ctx.instruction { + return &ctx.run + } + ctx.run.End() + return nil } -func appendInteractionsInput(items *[][]byte, input gjson.Result) { +// appendText adds a text content; blank text does not keep a user turn alive. +func (ctx *geminiInteractionsInputContext) appendText(role, text string) { + appendGeminiTextContent(&ctx.items, role, text) + run := ctx.userRun(role) + if strings.TrimSpace(text) != "" { + run.Add() + } +} + +func appendInteractionsInput(items *[][]byte, input gjson.Result) error { if !input.Exists() { - return + return nil } ctx := &geminiInteractionsInputContext{ items: *items, @@ -821,7 +924,7 @@ func appendInteractionsInput(items *[][]byte, input gjson.Result) { appendGeminiTextContent(&ctx.items, "user", input.String()) ctx.lastStepType = "text" *items = ctx.items - return + return nil } if input.IsArray() { input.ForEach(func(_, item gjson.Result) bool { @@ -833,24 +936,31 @@ func appendInteractionsInput(items *[][]byte, input gjson.Result) { if role := input.Get("role").String(); role == "model" || role == "assistant" { defaultRole = "model" } + ctx.instruction = translatorcommon.IsInteractionsInstructionStep(input, false) steps.ForEach(func(_, step gjson.Result) bool { appendInteractionsStepToGemini(ctx, step, defaultRole) return true }) + ctx.instruction = false } else { appendInteractionsStepToGemini(ctx, input, "user") } flushPendingGeminiSignature(ctx) *items = ctx.items + ctx.run.End() + return ctx.run.Err() } func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gjson.Result, defaultRole string) { + inheritedInstruction := ctx.instruction + ctx.instruction = translatorcommon.IsInteractionsInstructionStep(item, inheritedInstruction) + defer func() { ctx.instruction = inheritedInstruction }() if item.Type == gjson.String { if ctx.inModelTurn { flushPendingGeminiSignature(ctx) ctx.inModelTurn = false } - appendGeminiTextContent(&ctx.items, defaultRole, item.String()) + ctx.appendText(defaultRole, item.String()) ctx.lastStepType = "text" return } @@ -870,6 +980,7 @@ func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gj stepType := item.Get("type").String() switch stepType { case "model_output": + ctx.run.End() if ctx.pendingSignature != "" { carrier := geminiTextPartJSON("", false) carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature) @@ -891,6 +1002,7 @@ func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gj ctx.inModelTurn = true ctx.lastStepType = "model_output" case "thought": + ctx.run.End() if sig := firstNonEmptyString(item.Get("signature").String(), item.Get("thought_signature").String(), item.Get("thoughtSignature").String()); sig != "" { if ctx.pendingSignature != "" && ctx.pendingSignature != sig { carrier := geminiTextPartJSON("", false) @@ -914,6 +1026,7 @@ func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gj ctx.inModelTurn = true ctx.lastStepType = "thought" case "function_call": + ctx.run.End() part := buildGeminiFunctionCallPart(item) sig := firstNonEmptyString(item.Get("signature").String(), item.Get("thought_signature").String(), item.Get("thoughtSignature").String()) if sig == "" && ctx.pendingSignature != "" { @@ -949,6 +1062,8 @@ func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gj ctx.inModelTurn = false } part := buildGeminiFunctionResultPart(item) + // A tool result is content the model reads, so it keeps the surrounding user turn. + ctx.run.Add() if ctx.lastStepType == "function_result" && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "user" { ctx.items[len(ctx.items)-1] = appendGeminiUserContentPart(ctx.items[len(ctx.items)-1], part) } else { @@ -961,9 +1076,9 @@ func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gj ctx.inModelTurn = false } if item.Get("parts").Exists() { - appendInteractionsNativeContent(&ctx.items, item, defaultRole) + appendInteractionsNativeContent(ctx, item, defaultRole) } else { - appendInteractionsContentList(&ctx.items, defaultRole, item.Get("content")) + appendInteractionsContentList(ctx, defaultRole, item.Get("content")) } ctx.lastStepType = "user_input" default: @@ -972,11 +1087,11 @@ func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gj ctx.inModelTurn = false } if item.Get("parts").Exists() { - appendInteractionsNativeContent(&ctx.items, item, defaultRole) + appendInteractionsNativeContent(ctx, item, defaultRole) } else if item.Get("content").Exists() { - appendInteractionsContentList(&ctx.items, defaultRole, item.Get("content")) + appendInteractionsContentList(ctx, defaultRole, item.Get("content")) } else if text := item.Get("text"); text.Exists() { - appendGeminiTextContent(&ctx.items, defaultRole, text.String()) + ctx.appendText(defaultRole, text.String()) } ctx.lastStepType = "default" } @@ -1134,23 +1249,32 @@ func buildGeminiFunctionResultPart(item gjson.Result) []byte { return part } -func appendInteractionsNativeContent(items *[][]byte, item gjson.Result, defaultRole string) { +func appendInteractionsNativeContent(ctx *geminiInteractionsInputContext, item gjson.Result, defaultRole string) { parts := item.Get("parts") if !parts.Exists() || !parts.IsArray() { return } + role := interactionsGeminiContentRole(item.Get("role").String(), defaultRole) + run := ctx.userRun(role) partItems := make([][]byte, 0, 4) parts.ForEach(func(_, part gjson.Result) bool { - if partJSON := interactionsNativeGeminiPart(part); len(partJSON) > 0 { - partItems = append(partItems, partJSON) + partJSON := interactionsNativeGeminiPart(part) + if len(partJSON) == 0 { + if droppedType := translatorcommon.InteractionsAttachmentType(part); droppedType != "" { + run.Drop(droppedType) + } + return true + } + partItems = append(partItems, partJSON) + if translatorcommon.GeminiPartIsSendable(partJSON) { + run.Add() } return true }) if len(partItems) == 0 { return } - role := interactionsGeminiContentRole(item.Get("role").String(), defaultRole) - *items = append(*items, interactionsGeminiContent(role, partItems)) + ctx.items = append(ctx.items, interactionsGeminiContent(role, partItems)) } func interactionsGeminiContentRole(role, defaultRole string) string { @@ -1182,12 +1306,20 @@ func interactionsNativeGeminiPart(part gjson.Result) []byte { return nil } -func appendInteractionsContentPart(items *[][]byte, role string, part gjson.Result) { +// appendInteractionsContentPart adds one content part. run is nil for model +// content; for user content it records what is sent and what cannot be. +func appendInteractionsContentPart(items *[][]byte, role string, part gjson.Result, run *translatorcommon.UserRun) { partJSON := interactionsContentPartToGeminiPart(part, false) if len(partJSON) == 0 { + if droppedType := translatorcommon.InteractionsAttachmentType(part); droppedType != "" { + run.Drop(droppedType) + } return } *items = append(*items, interactionsGeminiContent(role, [][]byte{partJSON})) + if translatorcommon.GeminiPartIsSendable(partJSON) { + run.Add() + } } func interactionsContentPartToGeminiPart(part gjson.Result, thought bool) []byte { @@ -1217,11 +1349,8 @@ func interactionsContentPartToGeminiPart(part gjson.Result, thought bool) []byte return geminiInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data))) } } - if uri := part.Get("file_uri"); uri.Exists() || part.Get("fileUri").Exists() { - fileURI := uri.String() - if fileURI == "" { - fileURI = part.Get("fileUri").String() - } + // uri is the Interactions spelling of file_uri. + if fileURI := firstNonEmptyString(part.Get("file_uri").String(), part.Get("fileUri").String(), part.Get("uri").String()); fileURI != "" { mimeType := part.Get("mime_type").String() if mimeType == "" { mimeType = part.Get("mimeType").String() @@ -1320,38 +1449,67 @@ func interactionsInputAudioMimeType(format string) string { } func geminiInlineDataToInteractionsContent(mimeType, data string) []byte { - contentType := "document" + item := []byte(`{"type":"","mime_type":"","data":""}`) + item, _ = sjson.SetBytes(item, "type", geminiInteractionsMediaType(mimeType)) + item, _ = sjson.SetBytes(item, "mime_type", mimeType) + item, _ = sjson.SetBytes(item, "data", data) + return item +} + +// geminiFileDataToInteractionsContent maps a Gemini fileData part onto an +// Interactions media part that references the file by uri. It returns nil when +// the part names no uri, which leaves nothing to send. +func geminiFileDataToInteractionsContent(fileData gjson.Result) []byte { + fileURI := strings.TrimSpace(fileData.Get("fileUri").String()) + if fileURI == "" { + fileURI = strings.TrimSpace(fileData.Get("file_uri").String()) + } + if fileURI == "" { + return nil + } + mimeType := fileData.Get("mimeType").String() + if mimeType == "" { + mimeType = fileData.Get("mime_type").String() + } + item := []byte(`{"type":"","uri":""}`) + item, _ = sjson.SetBytes(item, "type", geminiInteractionsMediaType(mimeType)) + item, _ = sjson.SetBytes(item, "uri", fileURI) + if mimeType != "" { + item, _ = sjson.SetBytes(item, "mime_type", mimeType) + } + return item +} + +// geminiInteractionsMediaType picks the Interactions content type for a MIME type. +func geminiInteractionsMediaType(mimeType string) string { lower := strings.ToLower(mimeType) switch { case strings.HasPrefix(lower, "image/"): - contentType = "image" + return "image" case strings.HasPrefix(lower, "audio/"): - contentType = "audio" + return "audio" case strings.HasPrefix(lower, "video/"): - contentType = "video" + return "video" } - item := []byte(`{"type":"","mime_type":"","data":""}`) - item, _ = sjson.SetBytes(item, "type", contentType) - item, _ = sjson.SetBytes(item, "mime_type", mimeType) - item, _ = sjson.SetBytes(item, "data", data) - return item + return "document" } -func appendInteractionsContentList(items *[][]byte, role string, content gjson.Result) { +func appendInteractionsContentList(ctx *geminiInteractionsInputContext, role string, content gjson.Result) { if !content.Exists() { return } + run := ctx.userRun(role) if content.IsArray() { content.ForEach(func(_, part gjson.Result) bool { - appendInteractionsContentPart(items, role, part) + appendInteractionsContentPart(&ctx.items, role, part, run) return true }) return } if content.IsObject() { - appendInteractionsContentPart(items, role, content) + appendInteractionsContentPart(&ctx.items, role, content, run) } else if content.Type == gjson.String { - appendGeminiTextContent(items, role, content.String()) + ctx.appendText(role, content.String()) } } @@ -1415,6 +1573,9 @@ func setInteractionsUsageFromGemini(out []byte, path string, root gjson.Result) if !usage.Exists() { usage = root.Get("usage_metadata") } + if !usage.Exists() { + usage = root.Get("cpaUsageMetadata") + } if !usage.Exists() { return out } @@ -1437,6 +1598,9 @@ func setInteractionsStreamUsageFromGemini(out []byte, path string, root gjson.Re if !usage.Exists() { usage = root.Get("usage_metadata") } + if !usage.Exists() { + usage = root.Get("cpaUsageMetadata") + } if !usage.Exists() { return out } diff --git a/internal/translator/gemini/interactions/interactions_gemini_response.go b/internal/translator/gemini/interactions/interactions_gemini_response.go index d934f3d6..202ea0e1 100644 --- a/internal/translator/gemini/interactions/interactions_gemini_response.go +++ b/internal/translator/gemini/interactions/interactions_gemini_response.go @@ -67,10 +67,10 @@ func ConvertInteractionsResponseToGeminiNonStream(_ context.Context, modelName s return out } -func ConvertInteractionsRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertInteractionsRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { _ = modelName _ = stream - return inputRawJSON + return inputRawJSON, nil } func ConvertInteractionsResponsePassthrough(_ context.Context, _ string, _, _, rawJSON []byte, _ *any) [][]byte { diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index b4c0ec53..46e16c3c 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -26,8 +26,16 @@ const geminiFunctionThoughtSignature = "skip_thought_signature_validator" // // Returns: // - []byte: The transformed request data in Gemini API format -func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) []byte { +func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertOpenAIRequestToGemini(modelName, inputRawJSON, stream) + +} + +// convertOpenAIRequestToGemini also reports a file part Gemini cannot receive +// when nothing else was left to send. +func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) ([]byte, error) { rawJSON := inputRawJSON + var drops translatorcommon.UserTurnDrops // Base envelope (no default thinkingConfig) out := []byte(`{"contents":[]}`) @@ -152,20 +160,17 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) partItems = append(partItems, geminiTextPart(geminiDemotedSystemText(text, isDemotedSystem))) } case "image_url": - imageURL := item.Get("image_url.url").String() - if len(imageURL) > 5 { - pieces := strings.SplitN(imageURL[5:], ";", 2) - if len(pieces) == 2 && len(pieces[1]) > 7 { - partItems = append(partItems, geminiInlineDataPart(pieces[0], pieces[1][7:], "")) - } + // Only a base64 data URL can be inlined; a remote URL has no equivalent here. + if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData("", "", item.Get("image_url.url").String()); ok { + partItems = append(partItems, geminiInlineDataPart(mimeType, data, "")) + } else { + drops.Drop("image_url") } case "video_url": - videoURL := item.Get("video_url.url").String() - if len(videoURL) > 5 { - pieces := strings.SplitN(videoURL[5:], ";", 2) - if len(pieces) == 2 && len(pieces[1]) > 7 { - partItems = append(partItems, geminiInlineDataPart(pieces[0], pieces[1][7:], "")) - } + if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData("", "", item.Get("video_url.url").String()); ok { + partItems = append(partItems, geminiInlineDataPart(mimeType, data, "")) + } else { + drops.Drop("video_url") } case "file": filename := item.Get("file.filename").String() @@ -174,16 +179,21 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) partItems = append(partItems, geminiInlineDataPart(mimeType, data, "")) } else { log.Warn("Invalid file data or unknown file name extension in user message, skip") + drops.Drop("file") } case "input_audio": audioData := item.Get("input_audio.data").String() if audioData != "" { mimeType := openAIInputAudioMimeType(item.Get("input_audio.format").String()) partItems = append(partItems, geminiInlineDataPart(mimeType, audioData, "")) + } else { + drops.Drop("input_audio") } } } } + // Whitespace-only text is forwarded but never keeps an emptied turn alive. + drops.EndTurn(translatorcommon.CountSendableGeminiParts(partItems)) if len(partItems) > 0 { contentItems = append(contentItems, geminiContentNode("user", partItems)) } @@ -454,6 +464,11 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) toolItems = append(toolItems, codeExecutionNodes...) toolItems = append(toolItems, urlContextNodes...) out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems)) + // Gemini rejects built-in tools combined with functionDeclarations + // unless server-side tool invocations are declared. + if len(functionDeclarations) > 0 && (len(googleSearchNodes) > 0 || len(codeExecutionNodes) > 0 || len(urlContextNodes) > 0) { + out, _ = sjson.SetBytes(out, "toolConfig.includeServerSideToolInvocations", true) + } } } @@ -537,7 +552,7 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) out = common.AttachDefaultSafetySettings(out, "safetySettings") - return out + return out, drops.Err() } func geminiTextPart(text string) []byte { diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go index 2ff55037..eb93fe37 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go @@ -9,6 +9,7 @@ import ( "bytes" "context" "fmt" + "sort" "strings" "sync/atomic" "time" @@ -24,10 +25,15 @@ import ( type convertGeminiResponseToOpenAIChatParams struct { UnixTimestamp int64 // FunctionIndex tracks tool call indices per candidate index to support multiple candidates. - FunctionIndex map[int]int - SawToolCall map[int]bool - UpstreamFinishReason map[int]string - SanitizedNameMap map[string]string + FunctionIndex map[int]int + SawToolCall map[int]bool + SawFinishReason map[int]bool + UpstreamFinishReason map[int]string + ModelVersion string + ResponseID string + PendingUsageMetadata []byte + SanitizedNameMap map[string]string + SanitizedNameMapResolved bool } // functionCallIDCounter provides a process-wide unique counter for function call identifiers. @@ -51,11 +57,13 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR // Initialize parameters if nil. if *param == nil { *param = &convertGeminiResponseToOpenAIChatParams{ - UnixTimestamp: 0, - FunctionIndex: make(map[int]int), - SawToolCall: make(map[int]bool), - UpstreamFinishReason: make(map[int]string), - SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), + UnixTimestamp: 0, + FunctionIndex: make(map[int]int), + SawToolCall: make(map[int]bool), + SawFinishReason: make(map[int]bool), + UpstreamFinishReason: make(map[int]string), + SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), + SanitizedNameMapResolved: true, } } @@ -67,11 +75,17 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR if p.SawToolCall == nil { p.SawToolCall = make(map[int]bool) } + if p.SawFinishReason == nil { + p.SawFinishReason = make(map[int]bool) + } if p.UpstreamFinishReason == nil { p.UpstreamFinishReason = make(map[int]string) } - if p.SanitizedNameMap == nil { - p.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON) + if !p.SanitizedNameMapResolved { + if p.SanitizedNameMap == nil { + p.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON) + } + p.SanitizedNameMapResolved = true } if bytes.HasPrefix(rawJSON, []byte("data:")) { @@ -79,7 +93,37 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR } if bytes.Equal(rawJSON, []byte("[DONE]")) { - return [][]byte{} + var indices []int + for idx, reason := range p.UpstreamFinishReason { + if reason != "" && !p.SawFinishReason[idx] { + indices = append(indices, idx) + } + } + if len(indices) == 0 { + return [][]byte{} + } + sort.Ints(indices) + var doneChunks [][]byte + for _, idx := range indices { + p.SawFinishReason[idx] = true + finishReason, nativeFinishReason := resolveGeminiOpenAIFinishReason(p.UpstreamFinishReason[idx], p.SawToolCall[idx]) + template := []byte(`{"id":"","object":"chat.completion.chunk","created":0,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`) + template, _ = sjson.SetBytes(template, "created", p.UnixTimestamp) + if p.ModelVersion != "" { + template, _ = sjson.SetBytes(template, "model", p.ModelVersion) + } + if p.ResponseID != "" { + template, _ = sjson.SetBytes(template, "id", p.ResponseID) + } + if len(p.PendingUsageMetadata) > 0 { + template = applyGeminiOpenAIUsageMetadata(template, gjson.ParseBytes(p.PendingUsageMetadata)) + } + template, _ = sjson.SetBytes(template, "choices.0.index", idx) + template, _ = sjson.SetBytes(template, "choices.0.finish_reason", finishReason) + template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", nativeFinishReason) + doneChunks = append(doneChunks, template) + } + return doneChunks } // Initialize the OpenAI SSE base template. @@ -88,7 +132,10 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR // Extract and set the model version. if modelVersionResult := gjson.GetBytes(rawJSON, "modelVersion"); modelVersionResult.Exists() { - baseTemplate, _ = sjson.SetBytes(baseTemplate, "model", modelVersionResult.String()) + p.ModelVersion = modelVersionResult.String() + baseTemplate, _ = sjson.SetBytes(baseTemplate, "model", p.ModelVersion) + } else if p.ModelVersion != "" { + baseTemplate, _ = sjson.SetBytes(baseTemplate, "model", p.ModelVersion) } // Extract and set the creation timestamp. @@ -104,31 +151,19 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR // Extract and set the response ID. if responseIDResult := gjson.GetBytes(rawJSON, "responseId"); responseIDResult.Exists() { - baseTemplate, _ = sjson.SetBytes(baseTemplate, "id", responseIDResult.String()) + p.ResponseID = responseIDResult.String() + baseTemplate, _ = sjson.SetBytes(baseTemplate, "id", p.ResponseID) + } else if p.ResponseID != "" { + baseTemplate, _ = sjson.SetBytes(baseTemplate, "id", p.ResponseID) } // Extract and set usage metadata (token counts). // Usage is applied to the base template so it appears in the chunks. if usageResult := gjson.GetBytes(rawJSON, "usageMetadata"); usageResult.Exists() { - cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int() - baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int()+usageResult.Get("thoughtsTokenCount").Int()) - if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() { - baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.total_tokens", totalTokenCountResult.Int()) - } - promptTokenCount := usageResult.Get("promptTokenCount").Int() - thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int() - baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.prompt_tokens", promptTokenCount) - if thoughtsTokenCount > 0 { - baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount) - } - // Include cached token count if present (indicates prompt caching is working) - if cachedTokenCount > 0 { - var err error - baseTemplate, err = sjson.SetBytes(baseTemplate, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount) - if err != nil { - log.Warnf("gemini openai response: failed to set cached_tokens in streaming: %v", err) - } - } + baseTemplate = applyGeminiOpenAIUsageMetadata(baseTemplate, usageResult) + p.PendingUsageMetadata = nil + } else if pendingUsage := gjson.GetBytes(rawJSON, "cpaUsageMetadata"); pendingUsage.Exists() { + p.PendingUsageMetadata = append(p.PendingUsageMetadata[:0], pendingUsage.Raw...) } var responseStrings [][]byte @@ -206,15 +241,17 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR functionCallIndex := p.FunctionIndex[candidateIndex] p.FunctionIndex[candidateIndex]++ - if toolCallsResult.Exists() && toolCallsResult.IsArray() { - functionCallIndex = len(toolCallsResult.Array()) - } else { + if !toolCallsResult.Exists() || !toolCallsResult.IsArray() { template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`)) } functionCallTemplate := []byte(`{"id":"","index":0,"type":"function","function":{"name":"","arguments":""}}`) fcName := util.RestoreSanitizedToolName(p.SanitizedNameMap, functionCallResult.Get("name").String()) - functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))) + fcID := strings.TrimSpace(functionCallResult.Get("id").String()) + if fcID == "" { + fcID = fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)) + } + functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", fcID) functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "index", functionCallIndex) functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", fcName) if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { @@ -255,16 +292,10 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR isFinalChunk := upstreamFinishReason != "" && usageExists if isFinalChunk { - var finishReason string - if sawToolCall { - finishReason = "tool_calls" - } else if upstreamFinishReason == "MAX_TOKENS" { - finishReason = "max_tokens" - } else { - finishReason = "stop" - } + p.SawFinishReason[candidateIndex] = true + finishReason, nativeFinishReason := resolveGeminiOpenAIFinishReason(upstreamFinishReason, sawToolCall) template, _ = sjson.SetBytes(template, "choices.0.finish_reason", finishReason) - template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", strings.ToLower(upstreamFinishReason)) + template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", nativeFinishReason) } responseStrings = append(responseStrings, template) @@ -273,7 +304,14 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR } else { // If there are no candidates (e.g., a pure usageMetadata chunk), return the usage chunk if present. if gjson.GetBytes(rawJSON, "usageMetadata").Exists() && len(responseStrings) == 0 { - responseStrings = append(responseStrings, append([]byte(nil), baseTemplate...)) + chunk := append([]byte(nil), baseTemplate...) + if upstreamFinishReason := p.UpstreamFinishReason[0]; upstreamFinishReason != "" && !p.SawFinishReason[0] { + p.SawFinishReason[0] = true + finishReason, nativeFinishReason := resolveGeminiOpenAIFinishReason(upstreamFinishReason, p.SawToolCall[0]) + chunk, _ = sjson.SetBytes(chunk, "choices.0.finish_reason", finishReason) + chunk, _ = sjson.SetBytes(chunk, "choices.0.native_finish_reason", nativeFinishReason) + } + responseStrings = append(responseStrings, chunk) } } @@ -352,7 +390,11 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina // Set finish reason. if finishReasonResult := candidate.Get("finishReason"); finishReasonResult.Exists() { - choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "finish_reason", strings.ToLower(finishReasonResult.String())) + finishReason := strings.ToLower(finishReasonResult.String()) + if isGeminiContentFilterFinishReason(finishReasonResult.String()) { + finishReason = "content_filter" + } + choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "finish_reason", finishReason) choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "native_finish_reason", strings.ToLower(finishReasonResult.String())) } @@ -397,7 +439,11 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina hasFunctionCall = true functionCallItemTemplate := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`) fcName := util.RestoreSanitizedToolName(sanitizedNameMap, functionCallResult.Get("name").String()) - functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))) + fcID := strings.TrimSpace(functionCallResult.Get("id").String()) + if fcID == "" { + fcID = fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)) + } + functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", fcID) functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", fcName) if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", fcArgsResult.Raw) @@ -452,3 +498,52 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina return template } + +func resolveGeminiOpenAIFinishReason(upstreamFinishReason string, sawToolCall bool) (finishReason, nativeFinishReason string) { + switch { + case sawToolCall: + finishReason = "tool_calls" + case upstreamFinishReason == "MAX_TOKENS": + finishReason = "max_tokens" + case isGeminiContentFilterFinishReason(upstreamFinishReason): + finishReason = "content_filter" + default: + finishReason = "stop" + } + nativeFinishReason = "stop" + if upstreamFinishReason != "" { + nativeFinishReason = strings.ToLower(upstreamFinishReason) + } + return finishReason, nativeFinishReason +} + +func applyGeminiOpenAIUsageMetadata(template []byte, usageResult gjson.Result) []byte { + cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int() + template, _ = sjson.SetBytes(template, "usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int()+usageResult.Get("thoughtsTokenCount").Int()) + if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() { + template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokenCountResult.Int()) + } + promptTokenCount := usageResult.Get("promptTokenCount").Int() + thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int() + template, _ = sjson.SetBytes(template, "usage.prompt_tokens", promptTokenCount) + if thoughtsTokenCount > 0 { + template, _ = sjson.SetBytes(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount) + } + if cachedTokenCount > 0 { + var err error + template, err = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount) + if err != nil { + log.Warnf("gemini openai response: failed to set cached_tokens in streaming: %v", err) + } + } + return template +} + +func isGeminiContentFilterFinishReason(reason string) bool { + switch strings.ToUpper(strings.TrimSpace(reason)) { + case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "IMAGE_SAFETY", "MALFORMED_RESPONSE", "MALFORMED_FUNCTION_CALL": + return true + default: + return false + } +} diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index b52561bd..62239c88 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -18,8 +18,13 @@ import ( const geminiResponsesThoughtSignature = "skip_thought_signature_validator" -func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertOpenAIResponsesRequestToGemini(modelName, inputRawJSON, stream) +} + +func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { rawJSON := inputRawJSON + var drops translatorcommon.UserTurnDrops // Note: stream parameter is part of the fixed method signature useGeminiNativeReasoningLayout := sigcompat.SignatureProviderFromModelName(modelName) == sigcompat.SignatureProviderGemini @@ -33,6 +38,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte // Extract tools and forward map early so request contents and toolDeclarations use the exact same forward map functionDeclarations, forwardMap, _ := util.BuildGeminiFunctionDeclarations(root) var toolBlocks [][]byte + hasGoogleSearch := false if HasResponsesWebSearchTool(root) && ModelSupportsWebSearch(modelName) && AllowsResponsesWebSearchToolChoice(root) { googleSearchBlock := []byte(`{"googleSearch":{}}`) if allowedDomains := ExtractResponsesWebSearchAllowedDomains(root); len(allowedDomains) > 0 { @@ -41,6 +47,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte } } toolBlocks = append(toolBlocks, googleSearchBlock) + hasGoogleSearch = true } if len(functionDeclarations) > 0 { fnBlock := []byte(`{"functionDeclarations":[]}`) @@ -59,6 +66,11 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte } } } + // Gemini rejects built-in googleSearch combined with functionDeclarations + // unless server-side tool invocations are declared. + if hasGoogleSearch && len(functionDeclarations) > 0 { + out, _ = sjson.SetBytes(out, "toolConfig.includeServerSideToolInvocations", true) + } // Extract system instruction from OpenAI "instructions" field. systemParts := make([][]byte, 0, 2) @@ -70,7 +82,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte // Convert input messages to Gemini contents format if input := root.Get("input"); input.Exists() && input.IsArray() { - inputItems, hasGeminiCarrier := normalizeGeminiResponsesCarriers(restoreGeminiResponsesTextSignatures(modelName, input.Array())) + inputItems, hasGeminiCarrier := normalizeGeminiResponsesCarriers(restoreGeminiResponsesTextSignatures(modelName, normalizeOpenAIResponsesToolSearchItems(input.Array(), forwardMap))) if hasGeminiCarrier { useGeminiNativeReasoningLayout = true } @@ -230,6 +242,8 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte if len(partsToProcess) > 0 { currentRole := "" currentParts := make([][]byte, 0) + // Counts the user parts this item really sends; an empty text part does not. + userSendable := 0 flush := func() { if currentRole == "" || len(currentParts) == 0 { @@ -271,24 +285,35 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte } var partJSON []byte + sendsContent := false switch contentType { case "input_text", "output_text", "text": if text := contentItem.Get("text"); text.Exists() { partJSON = []byte(`{"text":""}`) partJSON, _ = sjson.SetBytes(partJSON, "text", text.String()) + sendsContent = text.String() != "" } default: if part, ok := openAIResponsesPartFromBlock(contentItem); ok { partJSON = part + sendsContent = true + } else if effRole == "user" && isResponsesAttachmentPartType(contentType) { + // Gemini has no field for a bare file id and no way to fetch + // the bytes, so the attachment cannot be sent. + drops.Drop(contentType) } } if len(partJSON) > 0 { currentParts = append(currentParts, partJSON) } + if sendsContent && effRole == "user" { + userSendable++ + } } flush() + drops.EndTurn(userSendable) } else if contentArray.Type == gjson.String { effRole := "user" if itemRole != "" { @@ -504,7 +529,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte if useGeminiNativeReasoningLayout { result = sigcompat.SanitizeGeminiRequestThoughtSignatures(result, "contents") } - return stripTrailingOpenAIResponsesModelPrefill(result) + return stripTrailingOpenAIResponsesModelPrefill(result), drops.Err() } func geminiContent(role string, parts [][]byte) []byte { @@ -959,6 +984,20 @@ func isResponsesContentPartType(itemType string) bool { } } +// isResponsesAttachmentPartType reports the content part types that carry a +// file, image, audio or video rather than text. +func isResponsesAttachmentPartType(itemType string) bool { + switch strings.ToLower(strings.TrimSpace(itemType)) { + case "input_image", "image_url", "image", + "input_audio", "audio", + "input_video", "video_url", "video", + "input_file", "file": + return true + default: + return false + } +} + func openAIResponsesAudioMimeType(audioFormat string) string { audioFormat = strings.TrimSpace(audioFormat) if isGenericMIME(audioFormat) { diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go index c395b527..cf59ff19 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go @@ -93,10 +93,14 @@ func (usage geminiResponsesUsage) JSON() []byte { } func geminiResponsesTerminalState(finishReason string) (eventType, status string, incompleteDetails []byte) { - if strings.EqualFold(strings.TrimSpace(finishReason), "MAX_TOKENS") { + switch strings.ToUpper(strings.TrimSpace(finishReason)) { + case "MAX_TOKENS": return "response.incomplete", "incomplete", []byte(`{"reason":"max_output_tokens"}`) + case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "IMAGE_SAFETY", "MALFORMED_RESPONSE", "MALFORMED_FUNCTION_CALL": + return "response.incomplete", "incomplete", []byte(`{"reason":"content_filter"}`) + default: + return "response.completed", "completed", nil } - return "response.completed", "completed", nil } type geminiToResponsesState struct { @@ -139,12 +143,16 @@ type geminiToResponsesState struct { FuncArgsBuf map[int]*strings.Builder FuncInputBuf map[int]string FuncCustom map[int]bool + FuncToolSearch map[int]bool FuncNames map[int]string FuncNamespaces map[int]string FuncCallIDs map[int]string FuncDone map[int]bool SanitizedNameMap map[string]string ToolIdentityMap map[string]util.ResponsesToolIdentity + RequestJSON []byte + RequestResolved bool + ToolMapsResolved bool FunctionEvidence map[string]*geminiFunctionCallEvidence // web search aggregation @@ -256,7 +264,7 @@ func isUpstreamGeminiRequest(rawJSON []byte) bool { return false } -func determineWebSearchStreamMode(modelName, requestModelName string, originalRequestRawJSON, requestRawJSON []byte) bool { +func determineWebSearchStreamMode(modelName, requestModelName string, originalRequestRawJSON, requestRawJSON, reqJSON []byte) bool { if len(originalRequestRawJSON) > 0 { origRoot := unwrapRequestRoot(gjson.ParseBytes(originalRequestRawJSON)) if !AllowsResponsesWebSearchToolChoice(origRoot) { @@ -272,7 +280,9 @@ func determineWebSearchStreamMode(modelName, requestModelName string, originalRe return hasEffectiveGoogleSearchTool(requestRawJSON) } } - reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON) + if len(reqJSON) == 0 { + reqJSON = pickRequestJSON(originalRequestRawJSON, requestRawJSON) + } if len(reqJSON) > 0 { reqRoot := unwrapRequestRoot(gjson.ParseBytes(reqJSON)) return HasResponsesWebSearchTool(reqRoot) && @@ -284,12 +294,13 @@ func determineWebSearchStreamMode(modelName, requestModelName string, originalRe // ConvertGeminiResponseToOpenAIResponses converts Gemini SSE chunks into OpenAI Responses SSE events. func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { - reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON) if *param == nil { + reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON) *param = &geminiToResponsesState{ FuncArgsBuf: make(map[int]*strings.Builder), FuncInputBuf: make(map[int]string), FuncCustom: make(map[int]bool), + FuncToolSearch: make(map[int]bool), FuncNames: make(map[int]string), FuncNamespaces: make(map[int]string), FuncCallIDs: make(map[int]string), @@ -300,10 +311,18 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, SeenReasoningSignatures: make(map[string]bool), SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), ToolIdentityMap: util.ResponsesToolReverseIdentityMap(reqJSON), + RequestJSON: reqJSON, + RequestResolved: true, + ToolMapsResolved: true, EmittedAnnotationCount: make(map[int]int), } } st := (*param).(*geminiToResponsesState) + if !st.RequestResolved { + st.RequestJSON = pickRequestJSON(originalRequestRawJSON, requestRawJSON) + st.RequestResolved = true + } + reqJSON := st.RequestJSON if st.FuncArgsBuf == nil { st.FuncArgsBuf = make(map[int]*strings.Builder) } @@ -313,6 +332,9 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, if st.FuncCustom == nil { st.FuncCustom = make(map[int]bool) } + if st.FuncToolSearch == nil { + st.FuncToolSearch = make(map[int]bool) + } if st.FuncNames == nil { st.FuncNames = make(map[int]string) } @@ -337,11 +359,14 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, if st.SeenReasoningSignatures == nil { st.SeenReasoningSignatures = make(map[string]bool) } - if st.SanitizedNameMap == nil { - st.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON) - } - if st.ToolIdentityMap == nil { - st.ToolIdentityMap = util.ResponsesToolReverseIdentityMap(reqJSON) + if !st.ToolMapsResolved { + if st.SanitizedNameMap == nil { + st.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON) + } + if st.ToolIdentityMap == nil { + st.ToolIdentityMap = util.ResponsesToolReverseIdentityMap(reqJSON) + } + st.ToolMapsResolved = true } if st.EmittedAnnotationCount == nil { st.EmittedAnnotationCount = make(map[int]int) @@ -780,6 +805,9 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, created, _ = sjson.SetBytes(created, "response.id", st.ResponseID) created, _ = sjson.SetBytes(created, "response.created_at", st.CreatedAt) requestModelName := translatorcommon.RequestModelName(originalRequestRawJSON, requestRawJSON) + if requestModelName == "" && len(reqJSON) > 0 { + requestModelName = translatorcommon.RequestModelName(reqJSON, nil) + } if requestModelName == "" { requestModelName = modelName } @@ -799,7 +827,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, st.Started = true st.NextIndex = 0 - st.WebSearchStreamMode = determineWebSearchStreamMode(modelName, requestModelName, originalRequestRawJSON, requestRawJSON) + st.WebSearchStreamMode = determineWebSearchStreamMode(modelName, requestModelName, originalRequestRawJSON, requestRawJSON, reqJSON) } // Handle groundingMetadata for web search @@ -1141,6 +1169,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, name := identity.Name namespace := identity.Namespace isCustom := identity.Custom + isToolSearch := identity.ToolSearch if evidence.ApplyPatch && evidence.PatchCall != nil { if _, _, errFinishArguments := evidence.PatchCall.FinishArguments(fc.Get("args").Raw); errFinishArguments != nil { st.SetToolInputError(errFinishArguments) @@ -1157,7 +1186,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, if st.FuncArgsBuf[idx] == nil { st.FuncArgsBuf[idx] = &strings.Builder{} } - if identity.ApplyPatch { + if identity.ApplyPatch || isToolSearch { st.FuncCallIDs[idx] = evidence.UpstreamID } if st.FuncCallIDs[idx] == "" { @@ -1166,6 +1195,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, st.FuncNames[idx] = name st.FuncNamespaces[idx] = namespace st.FuncCustom[idx] = isCustom + st.FuncToolSearch[idx] = isToolSearch argsJSON := "{}" if args := fc.Get("args"); args.Exists() { @@ -1175,7 +1205,30 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, st.FuncArgsBuf[idx].WriteString(argsJSON) } - if isCustom { + if isToolSearch { + itemStatus := "completed" + if fr := root.Get("candidates.0.finishReason").String(); fr != "" { + _, itemStatus, _ = geminiResponsesTerminalState(fr) + } else if st.FinishReason != "" { + _, itemStatus, _ = geminiResponsesTerminalState(st.FinishReason) + } + itemID := openAIResponsesToolSearchCallItemID(st.FuncCallIDs[idx]) + item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{}}`) + item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) + item, _ = sjson.SetBytes(item, "output_index", idx) + item, _ = sjson.SetRawBytes(item, "item", buildOpenAIResponsesToolSearchCallItem(itemID, st.FuncCallIDs[idx], "in_progress", argsJSON)) + out = append(out, emitEvent("response.output_item.added", item)) + + if !st.FuncDone[idx] { + itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{}}`) + itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) + itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx) + itemDone, _ = sjson.SetRawBytes(itemDone, "item", buildOpenAIResponsesToolSearchCallItem(itemID, st.FuncCallIDs[idx], itemStatus, argsJSON)) + out = append(out, emitEvent("response.output_item.done", itemDone)) + + st.FuncDone[idx] = true + } + } else if isCustom { inputStr := util.UnwrapResponsesCustomToolInput(argsJSON) var patchCall *translatorcommon.ApplyPatchCallState if identity.ApplyPatch { @@ -1328,7 +1381,17 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, if st.FuncDone[idx] { continue } - if st.FuncCustom[idx] { + if st.FuncToolSearch[idx] { + args := "{}" + if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 { + args = b.String() + } + itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{}}`) + itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) + itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx) + itemDone, _ = sjson.SetRawBytes(itemDone, "item", buildOpenAIResponsesToolSearchCallItem(openAIResponsesToolSearchCallItemID(st.FuncCallIDs[idx]), st.FuncCallIDs[idx], status, args)) + out = append(out, emitEvent("response.output_item.done", itemDone)) + } else if st.FuncCustom[idx] { inputStr := st.FuncInputBuf[idx] inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`) inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq()) @@ -1383,7 +1446,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, completed, _ = sjson.SetBytes(completed, "response.id", st.ResponseID) completed, _ = sjson.SetBytes(completed, "response.created_at", st.CreatedAt) - if reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON); len(reqJSON) > 0 { + if len(reqJSON) > 0 { req := unwrapRequestRoot(gjson.ParseBytes(reqJSON)) if v := req.Get("instructions"); v.Exists() { completed, _ = sjson.SetBytes(completed, "response.instructions", v.String()) @@ -1484,7 +1547,13 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, } if callID, ok := st.FuncCallIDs[idx]; ok && callID != "" { - if st.FuncCustom[idx] { + if st.FuncToolSearch[idx] { + args := "{}" + if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 { + args = b.String() + } + outputs = append(outputs, buildOpenAIResponsesToolSearchCallItem(openAIResponsesToolSearchCallItemID(callID), callID, status, args)) + } else if st.FuncCustom[idx] { inputStr := st.FuncInputBuf[idx] item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID)) @@ -1561,7 +1630,7 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string resp, _ = sjson.SetBytes(resp, "created_at", createdAt) // Echo request fields when present; fallback model from response modelVersion - if reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON); len(reqJSON) > 0 { + if len(reqJSON) > 0 { req := unwrapRequestRoot(gjson.ParseBytes(reqJSON)) if v := req.Get("instructions"); v.Exists() { resp, _ = sjson.SetBytes(resp, "instructions", v.String()) @@ -1828,11 +1897,13 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string argsStr = args.Raw } callID := fmt.Sprintf("call_%x_%d", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1)) - if identity.ApplyPatch && evidence.UpstreamID != "" { + if (identity.ApplyPatch || identity.ToolSearch) && evidence.UpstreamID != "" { callID = evidence.UpstreamID } var itemJSON []byte - if isCustom { + if identity.ToolSearch { + itemJSON = buildOpenAIResponsesToolSearchCallItem(openAIResponsesToolSearchCallItemID(callID), callID, status, argsStr) + } else if isCustom { inputStr := util.UnwrapResponsesCustomToolInput(argsStr) if identity.ApplyPatch { var patchCall translatorcommon.ApplyPatchCallState diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search.go new file mode 100644 index 00000000..46cbb157 --- /dev/null +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search.go @@ -0,0 +1,151 @@ +package responses + +import ( + "strings" + + translatorcommon "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" +) + +const openAIResponsesToolSearchIDPrefix = "ts_" + +// normalizeOpenAIResponsesToolSearchItems rewrites replayed client-executed +// tool_search history into the function_call / function_call_output shape that +// the Gemini request conversion already pairs and serializes. +func normalizeOpenAIResponsesToolSearchItems(items []gjson.Result, forwardMap map[string]string) []gjson.Result { + normalized := make([]gjson.Result, 0, len(items)) + for _, item := range items { + switch item.Get("type").String() { + case util.ResponsesToolSearchCallType: + if util.IsResponsesClientToolSearchItem(item) { + normalized = append(normalized, openAIResponsesToolSearchCallAsFunctionCall(item, forwardMap)) + } + case util.ResponsesToolSearchOutputType: + if util.IsResponsesClientToolSearchItem(item) { + normalized = append(normalized, openAIResponsesToolSearchOutputAsFunctionCallOutput(item, forwardMap)) + } + default: + normalized = append(normalized, item) + } + } + return normalized +} + +func openAIResponsesToolSearchCallAsFunctionCall(item gjson.Result, forwardMap map[string]string) gjson.Result { + call := []byte(`{"type":"function_call","name":"","call_id":"","arguments":"{}"}`) + call, _ = sjson.SetBytes(call, "name", util.MapResponsesClientToolSearchName(forwardMap)) + call, _ = sjson.SetBytes(call, "call_id", extractOpenAIResponsesCallID(item)) + if id := item.Get("id"); id.Exists() { + call, _ = sjson.SetBytes(call, "id", id.String()) + } + if status := item.Get("status"); status.Exists() { + call, _ = sjson.SetBytes(call, "status", status.String()) + } + if arguments := item.Get("arguments"); arguments.Exists() { + if arguments.Type == gjson.String { + call, _ = translatorcommon.SetStringWithoutHTMLEscape(call, "arguments", arguments.String()) + } else if arguments.IsObject() || arguments.IsArray() { + call, _ = translatorcommon.SetStringWithoutHTMLEscape(call, "arguments", arguments.Raw) + } + } + return gjson.ParseBytes(call) +} + +func openAIResponsesToolSearchOutputAsFunctionCallOutput(item gjson.Result, forwardMap map[string]string) gjson.Result { + output := []byte(`{"type":"function_call_output","name":"","call_id":"","output":""}`) + output, _ = sjson.SetBytes(output, "name", util.MapResponsesClientToolSearchName(forwardMap)) + output, _ = sjson.SetBytes(output, "call_id", extractOpenAIResponsesCallID(item)) + if id := item.Get("id"); id.Exists() { + output, _ = sjson.SetBytes(output, "id", id.String()) + } + + summary := []byte(`{"status":"completed","tools":[]}`) + if status := strings.TrimSpace(item.Get("status").String()); status != "" { + summary, _ = sjson.SetBytes(summary, "status", status) + } + if errorResult := item.Get("error"); errorResult.Exists() && errorResult.Type != gjson.Null { + summary, _ = sjson.SetRawBytes(summary, "error", []byte(errorResult.Raw)) + } + names := openAIResponsesDiscoveredToolNames(item.Get("tools"), forwardMap) + summary, _ = sjson.SetBytes(summary, "tools", names) + output, _ = translatorcommon.SetStringWithoutHTMLEscape(output, "output", string(summary)) + return gjson.ParseBytes(output) +} + +func openAIResponsesDiscoveredToolNames(tools gjson.Result, forwardMap map[string]string) []string { + names := make([]string, 0) + if !tools.Exists() || !tools.IsArray() { + return names + } + seen := make(map[string]struct{}) + appendName := func(name string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + mapped := util.MapResponsesToolName(forwardMap, name) + if _, exists := seen[mapped]; exists { + return + } + seen[mapped] = struct{}{} + names = append(names, mapped) + } + tools.ForEach(func(_, tool gjson.Result) bool { + switch strings.TrimSpace(tool.Get("type").String()) { + case "", "function", "custom": + name := strings.TrimSpace(tool.Get("name").String()) + if name == "" { + name = strings.TrimSpace(tool.Get("function.name").String()) + } + appendName(name) + case "namespace": + namespaceName := strings.TrimSpace(tool.Get("name").String()) + children := tool.Get("tools") + if !children.Exists() || !children.IsArray() { + children = tool.Get("children") + } + children.ForEach(func(_, child gjson.Result) bool { + childName := strings.TrimSpace(child.Get("name").String()) + if childName == "" { + childName = strings.TrimSpace(child.Get("function.name").String()) + } + if childName != "" { + appendName(util.QualifyResponsesNamespaceToolName(namespaceName, childName)) + } + return true + }) + } + return true + }) + return names +} + +func openAIResponsesToolSearchCallStatus(status, argsJSON string) string { + if status == "completed" { + trimmed := strings.TrimSpace(argsJSON) + if !gjson.Valid(trimmed) || !gjson.Parse(trimmed).IsObject() { + return "incomplete" + } + } + return status +} + +func buildOpenAIResponsesToolSearchCallItem(id, callID, status, argsJSON string) []byte { + status = openAIResponsesToolSearchCallStatus(status, argsJSON) + item := []byte(`{"id":"","type":"tool_search_call","status":"","execution":"client","call_id":"","arguments":{}}`) + item, _ = sjson.SetBytes(item, "id", id) + item, _ = sjson.SetBytes(item, "status", status) + item, _ = sjson.SetBytes(item, "call_id", callID) + if trimmed := strings.TrimSpace(argsJSON); gjson.Valid(trimmed) { + if args := gjson.Parse(trimmed); args.IsObject() { + item, _ = sjson.SetRawBytes(item, "arguments", []byte(args.Raw)) + } + } + return item +} + +func openAIResponsesToolSearchCallItemID(callID string) string { + return openAIResponsesToolSearchIDPrefix + callID +}