Files
CLIProxyAPI/internal/runtime/executor/codex_executor_request.go
이대희 3b69068e6c fix(translator): stop dropping attachments and empty turns
An attachment a translator could not represent was dropped without a word, and
a turn whose only content was that attachment went upstream as an empty
messages, parts, or input array. The model then answered as if nothing was
attached, or the upstream rejected a request that never named the attachment.

A base64 document now reaches OpenAI as a file part. A file the target cannot
receive, such as container_upload, an OpenAI file_id, or input_audio sent to
Claude, is reported by name so the route answers 400 instead of forwarding an
empty turn. Text beside it still travels.

One rule, ErrIfNothingLeft, decides when to refuse, and RegisterCheckedRequest
carries the refusal on the request envelope. Every executor that translates
checks it before calling upstream. The exported converters keep their []byte
signature, so no caller or sibling test had to change.
2026-10-07 19:21:32 +09:00

439 lines
16 KiB
Go

package executor
import (
"bytes"
"context"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
codexUserAgent = "codex-tui/0.154.0 (Mac OS 26.5.2; arm64) iTerm.app/3.6.11 (codex-tui; 0.154.0)"
codexOriginator = "codex-tui"
codexDefaultImageToolModel = "gpt-image-2"
codexResponsesLiteHeader = "X-OpenAI-Internal-Codex-Responses-Lite"
)
var dataTag = []byte("data:")
func translateCodexRequestPair(from, to sdktranslator.Format, model string, originalPayload, payload []byte, stream bool, preserveEmptyThinkingBlocks ...bool) ([]byte, []byte) {
original, body, _, _ := translateCodexRequestPairWithUpdateIntent(from, to, model, originalPayload, payload, stream, preserveEmptyThinkingBlocks...)
return original, body
}
func translateCodexRequestPairWithUpdateIntent(from, to sdktranslator.Format, model string, originalPayload, payload []byte, stream bool, preserveEmptyThinkingBlocks ...bool) ([]byte, []byte, bool, error) {
isCompat := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0]
ctx := context.Background()
translate := func(raw []byte) ([]byte, bool, error) {
if isCompat && from == sdktranslator.FormatClaude && to == sdktranslator.FormatCodex {
body, err := helps.TranslateRequestReturningError(ctx, nil, nil, from, to, model, raw, stream, true)
return body, false, err
}
translated := sdktranslator.TranslateRequestEnvelope(ctx, from, to, sdktranslator.RequestEnvelope{Format: from, Model: model, Stream: stream, Body: raw})
return translated.Body, translated.ConfigurationUpdatesChanged, translated.Err
}
if bytes.Equal(originalPayload, payload) {
body, changed, err := translate(payload)
return body, body, changed, err
}
originalTranslated, _, _ := translate(originalPayload)
body, changed, err := translate(payload)
return originalTranslated, body, changed, err
}
// PrepareRequest injects Codex credentials into the outgoing HTTP request.
func (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
if req == nil {
return nil
}
apiKey, _ := codexCreds(auth)
if strings.TrimSpace(apiKey) != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
} else {
req.Header.Del("Authorization")
}
var attrs map[string]string
if auth != nil {
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(req, attrs)
return nil
}
// HttpRequest injects Codex credentials into the request and executes it.
func (e *CodexExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
if req == nil {
return nil, fmt.Errorf("codex executor: request is nil")
}
if ctx == nil {
ctx = req.Context()
}
httpReq := req.WithContext(ctx)
if err := e.PrepareRequest(httpReq, auth); err != nil {
return nil, err
}
httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
return httpClient.Do(httpReq)
}
func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, req cliproxyexecutor.Request, rawJSON []byte, headerSets ...http.Header) (*http.Request, []byte, error) {
var headers http.Header
if len(headerSets) > 0 {
headers = headerSets[0]
}
var cache helps.CodexCache
if sourceFormatEqual(from, sdktranslator.FormatClaude) {
modelName := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String())
if modelName == "" {
modelName = thinking.ParseSuffix(req.Model).ModelName
}
cached, ok, errCache := helps.ClaudeCodePromptCache(ctx, modelName, req.Payload, headers)
if errCache != nil {
return nil, nil, errCache
}
if ok {
cache = cached
}
} else if sourceFormatEqual(from, sdktranslator.FormatOpenAIResponse) {
promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key")
if promptCacheKey.Exists() {
cache.ID = promptCacheKey.String()
}
} else if sourceFormatEqual(from, sdktranslator.FormatOpenAI) {
if promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() {
cache.ID = strings.TrimSpace(promptCacheKey.String())
}
if cache.ID == "" {
cache.ID = helps.ProviderSessionUUID("codex", req.Metadata)
}
if cache.ID == "" {
if apiKey := strings.TrimSpace(helps.APIKeyFromContext(ctx)); apiKey != "" {
cache.ID = uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:"+apiKey)).String()
}
}
}
if cache.ID == "" {
cache.ID = helps.ProviderSessionUUID("codex", req.Metadata)
}
if cache.ID != "" {
rawJSON = helps.SetStringIfDifferent(rawJSON, "prompt_cache_key", cache.ID)
}
rawJSON = helps.SanitizeCodexInputItemIDs(rawJSON)
rawJSON = helps.FinalizePayload(ctx, rawJSON)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawJSON))
if err != nil {
return nil, nil, err
}
if cache.ID != "" {
httpReq.Header.Set("Session-Id", cache.ID)
}
return httpReq, rawJSON, nil
}
func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config, clientHeaders ...http.Header) {
var ginHeaders http.Header
if len(clientHeaders) > 0 && clientHeaders[0] != nil {
ginHeaders = clientHeaders[0]
} else if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
ginHeaders = ginCtx.Request.Header
}
applyCodexHeadersFromSources(r, auth, token, stream, cfg, ginHeaders)
}
// applyModelHeaderOverrides forces models.json config.override_header onto upstream headers.
func applyModelHeaderOverrides(headers http.Header, modelName string) {
if headers == nil {
return
}
overrides := registry.ModelOverrideHeaders(modelName)
if len(overrides) == 0 {
return
}
for key, value := range overrides {
headers.Set(key, value)
}
if strings.Contains(headers.Get("User-Agent"), "Mac OS") && codexSessionHeaderValue(headers) == "" {
headers.Set("Session_id", uuid.NewString())
}
}
// applyCodexDirectImageHeaders sets Codex upstream headers for direct /images/* calls.
// Downstream client User-Agent values are not forwarded to reduce Cloudflare 1010 blocks.
func applyCodexDirectImageHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config, clientHeaders ...http.Header) {
var ginHeaders http.Header
if len(clientHeaders) > 0 && clientHeaders[0] != nil {
ginHeaders = clientHeaders[0].Clone()
ginHeaders.Del("User-Agent")
} else if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
ginHeaders = ginCtx.Request.Header.Clone()
ginHeaders.Del("User-Agent")
}
applyCodexHeadersFromSources(r, auth, token, stream, cfg, ginHeaders)
}
func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config, ginHeaders http.Header) {
r.Header.Set("Content-Type", "application/json")
if strings.TrimSpace(token) != "" {
r.Header.Set("Authorization", "Bearer "+token)
} else {
r.Header.Del("Authorization")
}
if ginHeaders != nil && ginHeaders.Get("X-Codex-Beta-Features") != "" {
r.Header.Set("X-Codex-Beta-Features", ginHeaders.Get("X-Codex-Beta-Features"))
}
misc.EnsureHeader(r.Header, ginHeaders, "Version", "")
misc.EnsureHeader(r.Header, ginHeaders, "X-Codex-Turn-Metadata", "")
misc.EnsureHeader(r.Header, ginHeaders, "X-Codex-Turn-State", "")
misc.EnsureHeader(r.Header, ginHeaders, "X-Client-Request-Id", "")
misc.EnsureHeader(r.Header, ginHeaders, "X-Codex-Window-Id", "")
misc.EnsureHeader(r.Header, ginHeaders, "Thread-Id", "")
misc.EnsureHeader(r.Header, ginHeaders, "Session-Id", "")
misc.EnsureHeader(r.Header, ginHeaders, "X-Openai-Internal-Codex-Responses-Lite", "")
cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
if stream {
r.Header.Set("Accept", "text/event-stream")
} else {
r.Header.Set("Accept", "application/json")
}
r.Header.Set("Connection", "Keep-Alive")
isAPIKey := codexAuthUsesAPIKey(auth)
if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
r.Header.Set("Originator", originator)
} else if !isAPIKey {
r.Header.Set("Originator", codexOriginator)
}
if !isAPIKey {
if auth != nil && auth.Metadata != nil {
if accountID, ok := auth.Metadata["account_id"].(string); ok {
r.Header.Set("Chatgpt-Account-Id", accountID)
}
}
}
var attrs map[string]string
if auth != nil {
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(r, attrs, ginHeaders)
applyCodexCloakingHeaders(r.Header, cfg, auth)
}
const codexRoutingHintHeader = "X-Codex-Routing-Hint"
// applyCodexRoutingHint sends the routing hint native Codex attaches to every
// ChatGPT-backend Responses request: "model=<slug>" plus ";tier=<service_tier>"
// when the body requests a tier (openai/codex rust-v0.155.0,
// codex-rs/core/src/client.rs build_routing_hint_header). Without it, a
// translated request carries service_tier=priority only in the body. Whether
// the backend needs the header to grant priority is undocumented.
//
// The model is the resolved model written to the upstream body, while the tier
// is read from the final body so payload rules cannot make the hint stale. A
// hint forwarded by a native client names its original model and is replaced.
// Operator configuration keeps precedence: when an auth "header:" rule for the
// hint resolves to a value (static, or a "$Header" reference the request
// carries), that value is sent, and callers apply models.json override_header
// afterwards. A rule that resolves to nothing falls back to the derived hint.
// API-key requests are not touched, matching native Codex, which sends no hint
// to API-key providers.
func applyCodexRoutingHint(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, baseModel string, upstreamBody []byte, clientHeaders http.Header) {
if codexAuthUsesAPIKey(auth) {
return
}
deleteHeaderCaseInsensitive(headers, codexRoutingHintHeader)
if operatorHint := codexOperatorHeaderValue(ctx, auth, clientHeaders, codexRoutingHintHeader); operatorHint != "" {
headers.Set(codexRoutingHintHeader, operatorHint)
return
}
model := strings.TrimSpace(baseModel)
if model == "" {
return
}
hint := "model=" + model
if tier := gjson.GetBytes(upstreamBody, "service_tier"); tier.Type == gjson.String {
if value := strings.TrimSpace(tier.String()); value != "" {
hint += ";tier=" + value
}
}
headers.Set(codexRoutingHintHeader, hint)
}
// codexOperatorHeaderValue returns the value the auth's "header:" rules
// resolve to for name, using the same resolver that applied them to the
// request, so dynamic references that resolve to nothing report "".
func codexOperatorHeaderValue(ctx context.Context, auth *cliproxyauth.Auth, clientHeaders http.Header, name string) string {
if auth == nil || len(auth.Attributes) == 0 {
return ""
}
resolved := (&http.Request{Header: http.Header{}}).WithContext(ctx)
util.ApplyCustomHeadersFromAttrs(resolved, auth.Attributes, clientHeaders)
return strings.TrimSpace(resolved.Header.Get(name))
}
func isCodexCloakingDisabled(cfg *config.Config, auth *cliproxyauth.Auth) bool {
if auth != nil && auth.AuthKind() == cliproxyauth.AuthKindAPIKey {
cfg = cfg.ForAPIKey()
}
if auth != nil && len(auth.Attributes) > 0 {
if val, ok := auth.Attributes[cliproxyauth.AttributeCodexDisableCloaking]; ok {
if parsed, errParse := strconv.ParseBool(strings.TrimSpace(val)); errParse == nil {
return parsed
}
}
}
if entry := resolveCodexKeyConfig(cfg, auth); entry != nil && entry.DisableCodexCloaking != nil {
return *entry.DisableCodexCloaking
}
if cfg != nil && cfg.Codex.DisableCodexCloaking {
return true
}
return false
}
func applyCodexCloakingHeaders(headers http.Header, cfg *config.Config, auth *cliproxyauth.Auth) {
if headers == nil || cfg == nil || isCodexCloakingDisabled(cfg, auth) {
return
}
headers.Set("User-Agent", codexUserAgent)
headers.Set("Originator", codexOriginator)
}
func normalizeCodexInstructions(body []byte, nativeRequest ...bool) []byte {
if len(nativeRequest) > 0 && nativeRequest[0] {
return body
}
instructions := gjson.GetBytes(body, "instructions")
if !instructions.Exists() || instructions.Type == gjson.Null {
body, _ = sjson.SetBytes(body, "instructions", "")
}
return body
}
var imageGenToolJSON = []byte(`{"type":"image_generation","output_format":"png"}`)
var imageGenToolArrayJSON = []byte(`[{"type":"image_generation","output_format":"png"}]`)
func isCodexFreePlanAuth(auth *cliproxyauth.Auth) bool {
if auth == nil || auth.Attributes == nil {
return false
}
if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
return false
}
return strings.EqualFold(strings.TrimSpace(auth.Attributes["plan_type"]), "free")
}
func isImageGenerationFunctionTool(tool gjson.Result) bool {
switch tool.Get("type").String() {
case "function":
return tool.Get("name").String() == "image_gen.imagegen"
case "namespace":
if tool.Get("name").String() != "image_gen" {
return false
}
tools := tool.Get("tools")
if !tools.IsArray() {
return false
}
for _, nestedTool := range tools.Array() {
if nestedTool.Get("type").String() == "function" && nestedTool.Get("name").String() == "imagegen" {
return true
}
}
}
return false
}
func ensureImageGenerationTool(body []byte, baseModel string, auth *cliproxyauth.Auth, headers http.Header) []byte {
if util.IsCodexResponsesLiteRequest(body, headers) {
return body
}
if strings.HasSuffix(baseModel, "spark") {
return body
}
if isCodexFreePlanAuth(auth) {
return body
}
tools := gjson.GetBytes(body, "tools")
if !tools.Exists() || !tools.IsArray() {
body, _ = sjson.SetRawBytes(body, "tools", imageGenToolArrayJSON)
return body
}
for _, t := range tools.Array() {
if t.Get("type").String() == "image_generation" || isImageGenerationFunctionTool(t) {
return body
}
}
body, _ = sjson.SetRawBytes(body, "tools.-1", imageGenToolJSON)
return body
}
func normalizeCodexParallelToolCalls(body []byte, headers http.Header) []byte {
if util.IsCodexResponsesLiteRequest(body, headers) {
body = helps.SetBoolIfDifferent(body, "parallel_tool_calls", false)
return body
}
return normalizeCodexParallelToolCallsForTools(body)
}
func normalizeCodexParallelToolCallsForTools(body []byte) []byte {
if !gjson.GetBytes(body, "parallel_tool_calls").Exists() {
return body
}
tools := gjson.GetBytes(body, "tools")
hasTools := tools.Exists() && tools.IsArray() && len(tools.Array()) > 0
if hasTools {
return body
}
body, _ = sjson.DeleteBytes(body, "parallel_tool_calls")
return body
}
func publishCodexImageToolUsage(ctx context.Context, reporter *helps.UsageReporter, body []byte, completedData []byte) {
detail, ok := helps.ParseCodexImageToolUsage(completedData)
if !ok {
return
}
reporter.EnsurePublished(ctx)
reporter.PublishAdditionalModel(ctx, codexImageGenerationToolModel(body), detail)
}
func codexImageGenerationToolModel(body []byte) string {
tools := gjson.GetBytes(body, "tools")
if tools.IsArray() {
for _, tool := range tools.Array() {
if tool.Get("type").String() != "image_generation" {
continue
}
if model := strings.TrimSpace(tool.Get("model").String()); model != "" {
return model
}
break
}
}
return codexDefaultImageToolModel
}