Files
CLIProxyAPI/internal/runtime/executor/codex_executor_request.go
Luis Pater 48686ccc8f feat(config): preserve unknown nested fields as comments during v8 migration
- Traverse nested configuration paths to comment out unrecognized fields during v8 migration
- Remove deprecated Codex `identity-confuse` configuration option and request handling
- Clean up identity confuse mapping logic from Codex HTTP and WebSocket executors
2026-09-28 10:46:32 +08:00

437 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) {
isCompat := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0]
ctx := context.Background()
translate := func(raw []byte) ([]byte, bool) {
if isCompat && from == sdktranslator.FormatClaude && to == sdktranslator.FormatCodex {
return helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, nil, nil, from, to, model, raw, stream, true), false
}
translated := sdktranslator.TranslateRequestEnvelope(ctx, from, to, sdktranslator.RequestEnvelope{Format: from, Model: model, Stream: stream, Body: raw})
return translated.Body, translated.ConfigurationUpdatesChanged
}
if bytes.Equal(originalPayload, payload) {
body, changed := translate(payload)
return body, body, changed
}
originalTranslated, _ := translate(originalPayload)
body, changed := translate(payload)
return originalTranslated, body, changed
}
// 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)
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
}