From e0e1d512de89ade2bc8d1c0e8ed00ae3ff35ecf2 Mon Sep 17 00:00:00 2001 From: warelik Date: Sat, 22 Aug 2026 00:51:26 +0300 Subject: [PATCH] feat(translator): propagate prompt cache hints and service tier across formats Map Claude cache_control to OpenAI/Codex prompt_cache_breakpoint, preserve message-level markers when rebuilding arrays, and echo prompt_cache_key in Codex/OpenAI Responses. Normalize service_tier to Codex-accepted values. Docs: drop Gemini cache_control because cachedContent needs a separate resource. --- .../claude/gemini/claude_gemini_request.go | 8 ++ .../codex/claude/codex_claude_request.go | 59 ++++++--- .../codex/claude/codex_claude_request_test.go | 6 +- .../codex/gemini/codex_gemini_request.go | 25 ++-- .../chat-completions/codex_openai_request.go | 30 +++++ .../codex_openai-responses_request.go | 48 +++---- .../codex_openai-responses_request_test.go | 12 +- .../codex_openai-responses_response.go | 59 ++++++--- internal/translator/common/cache_control.go | 122 ++++++++++++++++++ .../translator/common/cache_control_test.go | 78 +++++++++++ .../gemini/claude/gemini_claude_request.go | 4 + .../openai/claude/openai_claude_request.go | 25 +++- 12 files changed, 401 insertions(+), 75 deletions(-) diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index f0b7500dc..96b9f32c9 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -227,6 +227,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream // Create system message in Claude Code format. systemMessage := []byte(`{"role":"user","content":[{"type":"text","text":""}]}`) systemMessage, _ = sjson.SetBytes(systemMessage, "content.0.text", systemText.String()) + systemMessage = translatorcommon.AttachMessageCacheControl(systemMessage, sysInstr) messageAccumulator.Append(systemMessage) messageAccumulator.Flush() } @@ -261,6 +262,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream if text := part.Get("text"); text.Exists() { textContent := []byte(`{"type":"text","text":""}`) textContent, _ = sjson.SetBytes(textContent, "text", text.String()) + textContent = translatorcommon.AttachCacheControl(textContent, part) contentItems = append(contentItems, textContent) return true } @@ -283,6 +285,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream if args := fc.Get("args"); args.Exists() && args.IsObject() { toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(args.Raw)) } + toolUse = translatorcommon.AttachCacheControl(toolUse, part) contentItems = append(contentItems, toolUse) return true } @@ -313,6 +316,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream } else if response := fr.Get("response"); response.Exists() { toolResult, _ = sjson.SetBytes(toolResult, "content", response.Raw) } + toolResult = translatorcommon.AttachCacheControl(toolResult, part) contentItems = append(contentItems, toolResult) return true } @@ -320,6 +324,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream // Inline data conversion to Claude Code content format if inlineData := geminiClaudeInlineData(part); inlineData.Exists() { if contentPart, ok := claudeContentPartFromGeminiInlineData(inlineData); ok { + contentPart = translatorcommon.AttachCacheControl(contentPart, part) contentItems = append(contentItems, contentPart) } return true @@ -328,6 +333,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream // File data conversion to Claude Code content format if fileData := geminiClaudeFileData(part); fileData.Exists() { if contentPart, ok := claudeContentPartFromGeminiFileData(fileData); ok { + contentPart = translatorcommon.AttachCacheControl(contentPart, part) contentItems = append(contentItems, contentPart) } return true @@ -342,6 +348,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream msg := []byte(`{"role":"","content":[]}`) msg, _ = sjson.SetBytes(msg, "role", role) msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems)) + msg = translatorcommon.AttachMessageCacheControl(msg, content) messageAccumulator.Append(msg) } @@ -373,6 +380,7 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", cleaned) } + anthropicTool = translatorcommon.AttachCacheControl(anthropicTool, funcDecl) anthropicTool = lowercaseClaudeToolSchemaTypes(anthropicTool) anthropicTools = append(anthropicTools, gjson.ParseBytes(anthropicTool).Value()) return true diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index f6e9ec772..d4b76f782 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -57,6 +57,7 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, toolNameMap := buildReverseMapFromClaudeOriginalToShort(rawJSON) template, _ = sjson.SetBytes(template, "model", modelName) inputItems := translatorcommon.NewRawArrayItems(rootResult.Get("messages.#").Int()) + supportsCache := translatorcommon.ModelSupportsExplicitPromptCache(modelName) // Process system messages and convert them to input content format. systemsResult := rootResult.Get("system") @@ -81,6 +82,10 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, systemResult := systemResults[i] if systemResult.Get("type").String() == "text" { appendSystemText(systemResult.Get("text").String()) + if supportsCache && len(contentItems) > 0 { + last := len(contentItems) - 1 + contentItems[last] = translatorcommon.AttachPromptCacheBreakpoint(contentItems[last], systemResult) + } } } } @@ -117,6 +122,9 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, message := []byte(`{"type":"message","role":""}`) message, _ = sjson.SetBytes(message, "role", messageRole) message, _ = sjson.SetRawBytes(message, "content", translatorcommon.JoinRawArray(contentItems)) + if supportsCache { + message = translatorcommon.AttachMessagePromptCacheBreakpoint(message, messageResult) + } inputItems = append(inputItems, message) contentItems = contentItems[:0] } @@ -181,6 +189,10 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, switch contentType { case "text": appendTextContent(messageContentResult.Get("text").String()) + if supportsCache && len(contentItems) > 0 { + last := len(contentItems) - 1 + contentItems[last] = translatorcommon.AttachPromptCacheBreakpoint(contentItems[last], messageContentResult) + } case "thinking": appendReasoningContent(messageContentResult) case "image": @@ -200,6 +212,10 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, } dataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, data) appendImageContent(dataURL) + if supportsCache && len(contentItems) > 0 { + last := len(contentItems) - 1 + contentItems[last] = translatorcommon.AttachPromptCacheBreakpoint(contentItems[last], messageContentResult) + } } } case "document": @@ -217,6 +233,10 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, } if data != "" { appendDocumentContent(fmt.Sprintf("data:%s;base64,%s", mediaType, data)) + if supportsCache && len(contentItems) > 0 { + last := len(contentItems) - 1 + contentItems[last] = translatorcommon.AttachPromptCacheBreakpoint(contentItems[last], messageContentResult) + } } case "tool_use": flushMessage() @@ -263,12 +283,18 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, toolResultContent := []byte(`{"type":"input_image","image_url":""}`) toolResultContent, _ = sjson.SetBytes(toolResultContent, "image_url", dataURL) + if supportsCache { + toolResultContent = translatorcommon.AttachPromptCacheBreakpoint(toolResultContent, contentResults[k]) + } toolResultContentItems = append(toolResultContentItems, toolResultContent) } } } else if toolResultContentType == "text" { toolResultContent := []byte(`{"type":"input_text","text":""}`) toolResultContent, _ = sjson.SetBytes(toolResultContent, "text", contentResults[k].Get("text").String()) + if supportsCache { + toolResultContent = translatorcommon.AttachPromptCacheBreakpoint(toolResultContent, contentResults[k]) + } toolResultContentItems = append(toolResultContentItems, toolResultContent) } } @@ -287,6 +313,10 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, flushMessage() } else if messageContentsResult.Type == gjson.String { appendTextContent(messageContentsResult.String()) + if supportsCache && len(contentItems) > 0 { + last := len(contentItems) - 1 + contentItems[last] = translatorcommon.AttachPromptCacheBreakpoint(contentItems[last], messageContentsResult) + } flushMessage() } } @@ -380,13 +410,22 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, // 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. - serviceTier := normalizeCodexServiceTier(rootResult.Get("service_tier")) - if speed := rootResult.Get("speed"); speed.Type == gjson.String && speed.String() == "fast" { + serviceTier := translatorcommon.NormalizeCodexServiceTier(rootResult.Get("service_tier")) + if speed := rootResult.Get("speed"); speed.Type == gjson.String && strings.ToLower(strings.TrimSpace(speed.String())) == "fast" { serviceTier = "priority" } if serviceTier != "" { template, _ = sjson.SetBytes(template, "service_tier", serviceTier) } + if v := rootResult.Get("prompt_cache_key"); v.Exists() { + template, _ = sjson.SetBytes(template, "prompt_cache_key", v.String()) + } + if v := rootResult.Get("prompt_cache_retention"); v.Exists() { + template, _ = sjson.SetBytes(template, "prompt_cache_retention", v.String()) + } + if v := rootResult.Get("prompt_cache_options"); v.Exists() && supportsCache { + template, _ = sjson.SetRawBytes(template, "prompt_cache_options", []byte(v.Raw)) + } template, _ = sjson.SetBytes(template, "stream", true) template, _ = sjson.SetBytes(template, "store", false) template, _ = sjson.SetBytes(template, "include", []string{"reasoning.encrypted_content"}) @@ -403,22 +442,6 @@ func codexClaudeTargetAcceptsGrokSignature(modelName string) bool { return strings.Contains(baseModel, "grok") } -// normalizeCodexServiceTier maps a requested service_tier to the value Codex -// accepts. "fast" and "priority" (case-insensitive, trimmed) both resolve to -// "priority"; any other value yields an empty string so the field is omitted. -func normalizeCodexServiceTier(result gjson.Result) string { - if !result.Exists() || result.Type != gjson.String { - return "" - } - - switch strings.ToLower(strings.TrimSpace(result.String())) { - case "fast", "priority": - return "priority" - default: - return "" - } -} - // shortenCodexCallIDIfNeeded keeps Claude tool IDs within the OpenAI Responses // API call_id limit while preserving a stable, low-collision mapping. func shortenCodexCallIDIfNeeded(id string) string { diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go index 9db9c069f..dc54d10fd 100644 --- a/internal/translator/codex/claude/codex_claude_request_test.go +++ b/internal/translator/codex/claude/codex_claude_request_test.go @@ -204,8 +204,10 @@ func TestConvertClaudeRequestToCodex_ServiceTier(t *testing.T) { wantExists: true, }, { - name: "Unsupported tier is omitted", + name: "Default tier passes through", serviceTierJSON: `"default"`, + want: "default", + wantExists: true, }, { name: "Non-string tier is omitted", @@ -226,7 +228,7 @@ func TestConvertClaudeRequestToCodex_ServiceTier(t *testing.T) { speedJSON: `true`, }, { - name: "Fast speed overrides unsupported Anthropic tier", + name: "Fast speed overrides auto tier", serviceTierJSON: `"auto"`, speedJSON: `"fast"`, want: "priority", diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go index 8100ceb15..7bb489866 100644 --- a/internal/translator/codex/gemini/codex_gemini_request.go +++ b/internal/translator/codex/gemini/codex_gemini_request.go @@ -43,6 +43,7 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) root := gjson.ParseBytes(rawJSON) inputItems := translatorcommon.NewRawArrayItems(root.Get("contents.#").Int()) + supportsCache := translatorcommon.ModelSupportsExplicitPromptCache(modelName) // Pre-compute tool name shortening map from declared functionDeclarations shortMap := map[string]string{} @@ -104,9 +105,22 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // Model out, _ = sjson.SetBytes(out, "model", modelName) - if serviceTier := normalizeGeminiCodexServiceTier(root.Get("service_tier")); serviceTier != "" { + serviceTier := translatorcommon.NormalizeCodexServiceTier(root.Get("service_tier")) + if speed := root.Get("speed"); speed.Type == gjson.String && strings.ToLower(strings.TrimSpace(speed.String())) == "fast" { + serviceTier = "priority" + } + if serviceTier != "" { out, _ = sjson.SetBytes(out, "service_tier", serviceTier) } + if v := root.Get("prompt_cache_key"); v.Exists() { + out, _ = sjson.SetBytes(out, "prompt_cache_key", v.String()) + } + if v := root.Get("prompt_cache_retention"); v.Exists() { + out, _ = sjson.SetBytes(out, "prompt_cache_retention", v.String()) + } + if v := root.Get("prompt_cache_options"); v.Exists() && supportsCache { + out, _ = sjson.SetRawBytes(out, "prompt_cache_options", []byte(v.Raw)) + } // System instruction -> as a user message with input_text parts sysParts := root.Get("system_instruction.parts") @@ -401,14 +415,7 @@ func codexMessageWithPart(role string, part []byte) []byte { } func normalizeGeminiCodexServiceTier(serviceTier gjson.Result) string { - if !serviceTier.Exists() || serviceTier.Type != gjson.String { - return "" - } - switch strings.ToLower(strings.TrimSpace(serviceTier.String())) { - case "priority", "fast": - return "priority" - } - return "" + return translatorcommon.NormalizeCodexServiceTier(serviceTier) } func codexContentPartFromGeminiInlineData(part gjson.Result) ([]byte, bool) { 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 307df55d4..a11da50ca 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_request.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_request.go @@ -72,6 +72,24 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b // Model out, _ = sjson.SetBytes(out, "model", modelName) + // Carry cache hints and service tier when present. prompt_cache_options is + // only valid for gpt-5.6+ / daybreak; strip it for earlier models. + supportsExplicitCache := translatorcommon.ModelSupportsExplicitPromptCache(modelName) + if v := root.Get("prompt_cache_key"); v.Exists() { + out, _ = sjson.SetBytes(out, "prompt_cache_key", v.String()) + } + if v := root.Get("prompt_cache_retention"); v.Exists() { + out, _ = sjson.SetBytes(out, "prompt_cache_retention", v.String()) + } + if v := root.Get("prompt_cache_options"); v.Exists() && supportsExplicitCache { + out, _ = sjson.SetRawBytes(out, "prompt_cache_options", []byte(v.Raw)) + } + if v := root.Get("service_tier"); v.Exists() { + if normalized := translatorcommon.NormalizeCodexServiceTier(v); normalized != "" { + out, _ = sjson.SetBytes(out, "service_tier", normalized) + } + } + // Build request-local tool metadata and name shortening map. originalToolNameMap := map[string]string{} customToolNames := map[string]struct{}{} @@ -238,6 +256,9 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b part := []byte(`{}`) part, _ = sjson.SetBytes(part, "type", partType) part, _ = sjson.SetBytes(part, "text", it.Get("text").String()) + if supportsExplicitCache { + part = translatorcommon.CopyPromptCacheBreakpoint(part, it) + } contentItems = append(contentItems, part) case "image_url": // Map image inputs to input_image for Responses API @@ -247,6 +268,9 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b if u := it.Get("image_url.url"); u.Exists() { part, _ = sjson.SetBytes(part, "image_url", u.String()) } + if supportsExplicitCache { + part = translatorcommon.CopyPromptCacheBreakpoint(part, it) + } contentItems = append(contentItems, part) } case "file": @@ -260,6 +284,9 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b if filename != "" { part, _ = sjson.SetBytes(part, "filename", filename) } + if supportsExplicitCache { + part = translatorcommon.CopyPromptCacheBreakpoint(part, it) + } contentItems = append(contentItems, part) } } @@ -274,6 +301,9 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b if audioFormat != "" { part, _ = sjson.SetBytes(part, "format", audioFormat) } + if supportsExplicitCache { + part = translatorcommon.CopyPromptCacheBreakpoint(part, it) + } contentItems = append(contentItems, part) } } 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 7edfac114..8dcc561ac 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request.go @@ -3,7 +3,6 @@ package responses import ( "bytes" "encoding/json" - "strings" translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" @@ -15,6 +14,8 @@ import ( func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte { rawJSON := inputRawJSON + supportsExplicitCache := translatorcommon.ModelSupportsExplicitPromptCache(modelName) + inputResult := util.GetGJSONBytesNoCopy(rawJSON, "input") if inputResult.Type == gjson.String { input, _ := sjson.SetBytes([]byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`), "0.content.0.text", inputResult.String()) @@ -29,7 +30,7 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, // Codex Responses rejects token limit fields, so strip them out before forwarding. rawJSON = deleteCodexRequestFields(rawJSON, "max_output_tokens", "max_completion_tokens", "temperature", "top_p") if serviceTier := gjson.GetBytes(rawJSON, "service_tier"); serviceTier.Exists() { - if normalized := normalizeCodexServiceTier(serviceTier); normalized != "" { + if normalized := translatorcommon.NormalizeCodexServiceTier(serviceTier); normalized != "" { if normalized != serviceTier.String() { rawJSON, _ = sjson.SetBytes(rawJSON, "service_tier", normalized) } @@ -38,8 +39,16 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, } } - rawJSON = deleteCodexRequestFields(rawJSON, "truncation", "prompt_cache_options") - rawJSON = stripCodexResponsesCacheBreakpoints(rawJSON) + // prompt_cache_options and per-item prompt_cache_breakpoint are only safe to + // forward for model families that explicitly support them (gpt-5.6+ / daybreak). + // Earlier Codex models reject prompt_cache_breakpoint with a 400, so we strip + // it unless the target is known to accept it. + fieldsToDelete := []string{"truncation"} + if !supportsExplicitCache { + fieldsToDelete = append(fieldsToDelete, "prompt_cache_options") + } + rawJSON = deleteCodexRequestFields(rawJSON, fieldsToDelete...) + rawJSON = maybeStripCodexResponsesCacheBreakpoints(rawJSON, !supportsExplicitCache) rawJSON = applyResponsesCompactionCompatibility(rawJSON) // Delete the user field as it is not supported by the Codex upstream. @@ -52,18 +61,6 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, return rawJSON } -func normalizeCodexServiceTier(result gjson.Result) string { - if !result.Exists() || result.Type != gjson.String { - return "" - } - switch strings.ToLower(strings.TrimSpace(result.String())) { - case "fast", "priority": - return "priority" - default: - return "" - } -} - func setCodexRequiredBool(rawJSON []byte, path string, value bool) []byte { current := gjson.GetBytes(rawJSON, path) if value && current.Type == gjson.True || !value && current.Type == gjson.False { @@ -105,14 +102,17 @@ func deleteCodexRequestFields(rawJSON []byte, paths ...string) []byte { return rawJSON } -// stripCodexResponsesCacheBreakpoints removes any "prompt_cache_breakpoint" hint -// attached to individual input[].content[] items. Some clients (e.g. GitHub -// Copilot CLI) attach this field per content item when targeting the OpenAI -// Responses format. Codex Responses rejects it outright: -// {"error":{"message":"prompt_cache_breakpoint is not supported on this model", ...}}. -// The top-level prompt_cache_options strip above does not cover this nested case. -func stripCodexResponsesCacheBreakpoints(rawJSON []byte) []byte { - if !bytes.Contains(rawJSON, []byte(`"prompt_cache_breakpoint"`)) { +// maybeStripCodexResponsesCacheBreakpoints removes any "prompt_cache_breakpoint" +// hint attached to individual input[].content[] items when shouldStrip is true. +// Some clients (e.g. GitHub Copilot CLI) attach this field per content item +// when targeting the OpenAI Responses format. Earlier Codex models reject it +// outright with: +// +// {"error":{"message":"prompt_cache_breakpoint is not supported on this model", ...}}. +// +// gpt-5.6 and later support explicit breakpoints; keep them when shouldStrip is false. +func maybeStripCodexResponsesCacheBreakpoints(rawJSON []byte, shouldStrip bool) []byte { + if !shouldStrip || !bytes.Contains(rawJSON, []byte(`"prompt_cache_breakpoint"`)) { return rawJSON } diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go index a82b5f8ad..5442d239d 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go @@ -241,7 +241,7 @@ func TestConvertOpenAIResponsesRequestToCodexReusesNormalizedPayload(t *testing. func TestConvertOpenAIResponsesRequestToCodexNormalizesRequiredFields(t *testing.T) { inputJSON := []byte(`{ - "model":"gpt-5.6", + "model":"gpt-5.4", "stream":"true", "store":true, "parallel_tool_calls":false, @@ -250,14 +250,14 @@ func TestConvertOpenAIResponsesRequestToCodexNormalizesRequiredFields(t *testing "max_completion_tokens":4096, "temperature":0.2, "top_p":0.9, - "service_tier":"standard", + "service_tier":"unknown", "truncation":"auto", "prompt_cache_options":{"mode":"implicit"}, "user":"request-owner", "input":[{"type":"message","role":"system","content":"hello"}] }`) - output := ConvertOpenAIResponsesRequestToCodex("gpt-5.6", inputJSON, true) + output := ConvertOpenAIResponsesRequestToCodex("gpt-5.4", inputJSON, true) if stream := gjson.GetBytes(output, "stream"); stream.Type != gjson.True { t.Fatalf("stream = %s, want true", stream.Raw) @@ -299,7 +299,11 @@ func TestConvertOpenAIResponsesRequestToCodex_ServiceTier(t *testing.T) { }{ {name: "priority passes through", serviceTier: "priority", want: "priority"}, {name: "fast normalizes to priority", serviceTier: "fast", want: "priority"}, - {name: "invalid tier is stripped", serviceTier: "default", want: ""}, + {name: "auto passes through", serviceTier: "auto", want: "auto"}, + {name: "default passes through", serviceTier: "default", want: "default"}, + {name: "flex passes through", serviceTier: "flex", want: "flex"}, + {name: "standard maps to default", serviceTier: "standard", want: "default"}, + {name: "unknown tier is stripped", serviceTier: "unknown", want: ""}, } for _, tt := range tests { diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_response.go b/internal/translator/codex/openai/responses/codex_openai-responses_response.go index 96bbce464..d674106ca 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_response.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_response.go @@ -15,42 +15,62 @@ import ( func ConvertCodexResponseToOpenAIResponses(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) [][]byte { if bytes.HasPrefix(rawJSON, []byte("data:")) { rawJSON = bytes.TrimSpace(rawJSON[5:]) - rawJSON = setResponsesModel(rawJSON, modelName, originalRequestRawJSON, requestRawJSON) + rawJSON = setResponsesEchoFields(rawJSON, modelName, originalRequestRawJSON, requestRawJSON) out := make([]byte, 0, len(rawJSON)+len("data: ")) out = append(out, []byte("data: ")...) out = append(out, rawJSON...) return [][]byte{out} } - return [][]byte{setResponsesModel(rawJSON, modelName, originalRequestRawJSON, requestRawJSON)} + return [][]byte{setResponsesEchoFields(rawJSON, modelName, originalRequestRawJSON, requestRawJSON)} } -func setResponsesModel(rawJSON []byte, modelName string, originalRequestRawJSON, requestRawJSON []byte) []byte { +func setResponsesEchoFields(rawJSON []byte, modelName string, originalRequestRawJSON, requestRawJSON []byte) []byte { eventType := gjson.GetBytes(rawJSON, "type").String() - if eventType != "response.created" && eventType != "response.in_progress" { + if eventType == "" { return rawJSON } - if gjson.GetBytes(rawJSON, "response.model").Exists() { + if !gjson.GetBytes(rawJSON, "response").Exists() { return rawJSON } - requestModelName := translatorcommon.RequestModelName(originalRequestRawJSON, requestRawJSON) - if requestModelName == "" { - requestModelName = modelName + // Backfill response.model for the initial events if the upstream omitted it. + if eventType == "response.created" || eventType == "response.in_progress" { + if !gjson.GetBytes(rawJSON, "response.model").Exists() { + requestModelName := translatorcommon.RequestModelName(originalRequestRawJSON, requestRawJSON) + if requestModelName == "" { + requestModelName = modelName + } + if requestModelName != "" { + rawJSON, _ = sjson.SetBytes(rawJSON, "response.model", requestModelName) + } + } } - if requestModelName == "" { - return rawJSON + + // Propagate prompt_cache_key from the request echo into the response. + // Codex Responses echoes the request model but not the prompt_cache_key, so + // we backfill it when absent to preserve client cache tracking. + if !gjson.GetBytes(rawJSON, "response.prompt_cache_key").Exists() { + req := pickRequestJSON(originalRequestRawJSON, requestRawJSON) + if v := req.Get("prompt_cache_key"); v.Exists() { + rawJSON, _ = sjson.SetBytes(rawJSON, "response.prompt_cache_key", v.String()) + } } - updated, errSet := sjson.SetBytes(rawJSON, "response.model", requestModelName) - if errSet != nil { - return rawJSON + return rawJSON +} + +func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) gjson.Result { + for _, b := range [][]byte{originalRequestRawJSON, requestRawJSON} { + if len(b) > 0 && gjson.ValidBytes(b) { + return gjson.ParseBytes(b) + } } - return updated + return gjson.Result{} } // ConvertCodexResponseToOpenAIResponsesNonStream builds a single Responses JSON // from a non-streaming OpenAI Chat Completions response. -func ConvertCodexResponseToOpenAIResponsesNonStream(_ context.Context, _ string, _, _, rawJSON []byte, _ *any) []byte { +func ConvertCodexResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { rootResult := gjson.ParseBytes(rawJSON) // Verify this is a terminal response event. responseType := rootResult.Get("type").String() @@ -58,5 +78,12 @@ func ConvertCodexResponseToOpenAIResponsesNonStream(_ context.Context, _ string, return []byte{} } responseResult := rootResult.Get("response") - return []byte(responseResult.Raw) + out := []byte(responseResult.Raw) + if !gjson.GetBytes(out, "prompt_cache_key").Exists() { + req := pickRequestJSON(originalRequestRawJSON, requestRawJSON) + if v := req.Get("prompt_cache_key"); v.Exists() { + out, _ = sjson.SetBytes(out, "prompt_cache_key", v.String()) + } + } + return out } diff --git a/internal/translator/common/cache_control.go b/internal/translator/common/cache_control.go index a7e350c27..1cc51bd25 100644 --- a/internal/translator/common/cache_control.go +++ b/internal/translator/common/cache_control.go @@ -2,6 +2,8 @@ package common import ( "fmt" + "regexp" + "strings" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -65,3 +67,123 @@ func AttachMessageCacheControl(msg []byte, src gjson.Result) []byte { out, _ = sjson.SetRawBytes(out, "content.-1", textPart) return out } + +// modelSupportsExplicitPromptCachePattern matches model families that OpenAI +// documents as supporting explicit prompt_cache_breakpoint and +// prompt_cache_options (gpt-5.6 and later, plus daybreak aliases). +// gpt-5.6 is the first family with explicit cache support; earlier families +// rely on implicit caching and may reject explicit breakpoints. +var modelSupportsExplicitPromptCachePattern = regexp.MustCompile(`^(?:gpt-5\.(?:[6-9]|[1-9][0-9])(?:-|$)|daybreak-|gpt-[6-9])`) + +// ModelSupportsExplicitPromptCache reports whether a model name indicates +// support for explicit prompt_cache_breakpoint and prompt_cache_options. +func ModelSupportsExplicitPromptCache(modelName string) bool { + return modelSupportsExplicitPromptCachePattern.MatchString(strings.ToLower(strings.TrimSpace(modelName))) +} + +// NormalizeCodexServiceTier maps a requested service_tier to a value Codex +// (OpenAI) accepts. "fast" and "priority" both resolve to "priority"; "auto", +// "default", and "flex" pass through lowercased; "standard" maps to "default". +// Unknown or non-string values return an empty string so the field is omitted. +// +// See https://platform.openai.com/docs/api-reference/chat/create#chat-create-service_tier +// and https://platform.openai.com/api/docs/guides/fast-mode. +func NormalizeCodexServiceTier(result gjson.Result) string { + if !result.Exists() || result.Type != gjson.String { + return "" + } + switch strings.ToLower(strings.TrimSpace(result.String())) { + case "fast", "priority": + return "priority" + case "auto", "default", "flex": + return strings.ToLower(strings.TrimSpace(result.String())) + case "standard": + return "default" + default: + return "" + } +} + +// CopyPromptCacheBreakpoint copies a pre-existing prompt_cache_breakpoint from +// src onto dst. Used when both source and target already speak the OpenAI/Codex +// Responses format (e.g. Chat Completions -> Responses, Responses -> Codex). +// Returns dst unchanged when prompt_cache_breakpoint is missing or not an object. +func CopyPromptCacheBreakpoint(dst []byte, src gjson.Result) []byte { + if gjson.GetBytes(dst, "prompt_cache_breakpoint").Exists() { + return dst + } + bp := src.Get("prompt_cache_breakpoint") + if !bp.Exists() || bp.Type == gjson.Null || !bp.IsObject() { + return dst + } + out, err := sjson.SetRawBytes(dst, "prompt_cache_breakpoint", []byte(bp.Raw)) + if err != nil { + return dst + } + return out +} + +// AttachPromptCacheBreakpoint maps a Claude-compatible cache_control object +// from src onto dst as an OpenAI/Codex prompt_cache_breakpoint. +// Returns dst unchanged when cache_control is missing or not an object, or when +// dst already carries a prompt_cache_breakpoint. +func AttachPromptCacheBreakpoint(dst []byte, src gjson.Result) []byte { + if gjson.GetBytes(dst, "prompt_cache_breakpoint").Exists() { + return dst + } + cc := src.Get("cache_control") + if !cc.Exists() || cc.Type == gjson.Null || !cc.IsObject() { + return dst + } + out, err := sjson.SetRawBytes(dst, "prompt_cache_breakpoint", []byte(`{"mode":"explicit"}`)) + if err != nil { + return dst + } + return out +} + +// AttachMessagePromptCacheBreakpoint applies a message-level cache_control from +// src onto the last content block of msg as an OpenAI/Codex prompt_cache_breakpoint. +// Part-level prompt_cache_breakpoint wins when the last block already has one. +// String content is promoted to a content array. +func AttachMessagePromptCacheBreakpoint(msg []byte, src gjson.Result) []byte { + cc := src.Get("cache_control") + if !cc.Exists() || cc.Type == gjson.Null || !cc.IsObject() { + return msg + } + + content := gjson.GetBytes(msg, "content") + if content.IsArray() { + arr := content.Array() + if len(arr) == 0 { + return msg + } + lastIdx := len(arr) - 1 + if arr[lastIdx].Get("prompt_cache_breakpoint").Exists() { + return msg + } + path := fmt.Sprintf("content.%d.prompt_cache_breakpoint", lastIdx) + out, err := sjson.SetRawBytes(msg, path, []byte(`{"mode":"explicit"}`)) + if err != nil { + return msg + } + return out + } + + if content.Type != gjson.String { + return msg + } + + textPart := []byte(`{"type":"text","text":""}`) + textPart, _ = sjson.SetBytes(textPart, "text", content.String()) + textPart, errSet := sjson.SetRawBytes(textPart, "prompt_cache_breakpoint", []byte(`{"mode":"explicit"}`)) + if errSet != nil { + return msg + } + out, err := sjson.SetRawBytes(msg, "content", []byte("[]")) + if err != nil { + return msg + } + out, _ = sjson.SetRawBytes(out, "content.-1", textPart) + return out +} diff --git a/internal/translator/common/cache_control_test.go b/internal/translator/common/cache_control_test.go index d9cdf6e5b..f4042136c 100644 --- a/internal/translator/common/cache_control_test.go +++ b/internal/translator/common/cache_control_test.go @@ -54,3 +54,81 @@ func TestAttachMessageCacheControl_SkipsWhenLastPartHasCacheControl(t *testing.T t.Fatalf("part-level cache_control should win; out=%s", out) } } + +func TestModelSupportsExplicitPromptCache(t *testing.T) { + cases := []struct { + model string + want bool + }{ + {"gpt-5.6", true}, + {"gpt-5.6-2025-08-01", true}, + {"gpt-5.7", true}, + {"daybreak-mini", true}, + {"gpt-5.4", false}, + {"gpt-4.1", false}, + {"", false}, + } + for _, c := range cases { + if got := ModelSupportsExplicitPromptCache(c.model); got != c.want { + t.Fatalf("ModelSupportsExplicitPromptCache(%q) = %v, want %v", c.model, got, c.want) + } + } +} + +func TestNormalizeCodexServiceTier(t *testing.T) { + cases := []struct { + in string + want string + }{ + {`"priority"`, "priority"}, + {`"fast"`, "priority"}, + {`"auto"`, "auto"}, + {`"default"`, "default"}, + {`"flex"`, "flex"}, + {`"standard"`, "default"}, + {`"unknown"`, ""}, + {`true`, ""}, + } + for _, c := range cases { + src := gjson.Parse(c.in) + if got := NormalizeCodexServiceTier(src); got != c.want { + t.Fatalf("NormalizeCodexServiceTier(%s) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestAttachPromptCacheBreakpoint_CopiesObject(t *testing.T) { + src := gjson.Parse(`{"text":"hi","cache_control":{"type":"ephemeral"}}`) + dst := []byte(`{"type":"text","text":"hi"}`) + + out := AttachPromptCacheBreakpoint(dst, src) + if got := gjson.GetBytes(out, "prompt_cache_breakpoint.mode").String(); got != "explicit" { + t.Fatalf("prompt_cache_breakpoint.mode = %q, want explicit; out=%s", got, out) + } +} + +func TestAttachPromptCacheBreakpoint_SkipsExisting(t *testing.T) { + src := gjson.Parse(`{"text":"hi","cache_control":{"type":"ephemeral"}}`) + dst := []byte(`{"type":"text","text":"hi","prompt_cache_breakpoint":{"mode":"existing"}}`) + + out := AttachPromptCacheBreakpoint(dst, src) + if got := gjson.GetBytes(out, "prompt_cache_breakpoint.mode").String(); got != "existing" { + t.Fatalf("prompt_cache_breakpoint.mode = %q, want existing; out=%s", got, out) + } +} + +func TestAttachMessagePromptCacheBreakpoint_PromotesStringContent(t *testing.T) { + src := gjson.Parse(`{"role":"user","content":"hi","cache_control":{"type":"ephemeral"}}`) + msg := []byte(`{"role":"user","content":"hi"}`) + + out := AttachMessagePromptCacheBreakpoint(msg, src) + if got := gjson.GetBytes(out, "content.0.type").String(); got != "text" { + t.Fatalf("content.0.type = %q, want text; out=%s", got, out) + } + if got := gjson.GetBytes(out, "content.0.text").String(); got != "hi" { + t.Fatalf("content.0.text = %q, want hi; out=%s", got, out) + } + if got := gjson.GetBytes(out, "content.0.prompt_cache_breakpoint.mode").String(); got != "explicit" { + t.Fatalf("content.0.prompt_cache_breakpoint.mode = %q, want explicit; out=%s", got, out) + } +} diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index 39bf176d2..5d6dafb44 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -42,6 +42,10 @@ func ConvertClaudeRequestToGeminiWithCompat(modelName string, inputRawJSON []byt func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks bool) []byte { rawJSON := inputRawJSON // Build output Gemini request JSON + // Claude cache_control markers have no direct Gemini equivalent. Gemini 2.5+ + // uses implicit context caching; explicit caching requires a separately + // created cachedContent resource. We intentionally drop cache_control from + // tools, system instructions, and message contents to avoid unsupported fields. out := []byte(`{"contents":[]}`) out, _ = sjson.SetBytes(out, "model", modelName) diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index 42f2783c3..9dd071208 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -38,6 +38,7 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream // Model mapping out, _ = sjson.SetBytes(out, "model", modelName) + supportsCache := translatorcommon.ModelSupportsExplicitPromptCache(modelName) // Max tokens if maxTokens := root.Get("max_tokens"); maxTokens.Exists() { @@ -129,7 +130,11 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream if content.IsArray() { content.ForEach(func(_, item gjson.Result) bool { if contentItem, ok := convertClaudeContentPart(item); ok { - systemContentItems = append(systemContentItems, []byte(contentItem)) + part := []byte(contentItem) + if supportsCache { + part = translatorcommon.AttachPromptCacheBreakpoint(part, item) + } + systemContentItems = append(systemContentItems, part) } return true }) @@ -155,6 +160,9 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(contentResult); ok { msgJSON := []byte(`{"role":"user","content":[{"type":"text","text":""}]}`) msgJSON, _ = sjson.SetBytes(msgJSON, "content.0.text", reminderText) + if supportsCache { + msgJSON = translatorcommon.AttachMessagePromptCacheBreakpoint(msgJSON, message) + } messageItems = append(messageItems, msgJSON) } return true @@ -190,7 +198,11 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream case "text", "image": if contentItem, ok := convertClaudeContentPart(part); ok { - contentItems = append(contentItems, []byte(contentItem)) + item := []byte(contentItem) + if supportsCache { + item = translatorcommon.AttachPromptCacheBreakpoint(item, part) + } + contentItems = append(contentItems, item) } case "tool_use": @@ -265,6 +277,9 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream msgJSON, _ = sjson.SetBytes(msgJSON, "tool_calls", toolCalls) } + if supportsCache { + msgJSON = translatorcommon.AttachMessagePromptCacheBreakpoint(msgJSON, message) + } messageItems = append(messageItems, msgJSON) } } else { @@ -275,6 +290,9 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream msgJSON, _ = sjson.SetBytes(msgJSON, "role", role) msgJSON, _ = sjson.SetRawBytes(msgJSON, "content", translatorcommon.JoinRawArray(contentItems)) + if supportsCache { + msgJSON = translatorcommon.AttachMessagePromptCacheBreakpoint(msgJSON, message) + } messageItems = append(messageItems, msgJSON) } else if hasToolResults && !hasContent { // tool_results already emitted above, no additional user message needed @@ -286,6 +304,9 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream msgJSON := []byte(`{"role":"","content":""}`) msgJSON, _ = sjson.SetBytes(msgJSON, "role", role) msgJSON, _ = sjson.SetBytes(msgJSON, "content", contentResult.String()) + if supportsCache { + msgJSON = translatorcommon.AttachMessagePromptCacheBreakpoint(msgJSON, message) + } messageItems = append(messageItems, msgJSON) }