mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-28 10:35:44 +08:00
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.
439 lines
16 KiB
Go
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
|
|
}
|