mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-10 08:13:22 +08:00
1861 lines
64 KiB
Go
1861 lines
64 KiB
Go
package executor
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
codexauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
|
|
internalcache "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
"github.com/tiktoken-go/tokenizer"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
codexUserAgent = "codex-tui/0.135.0 (Mac OS 26.5.0; arm64) iTerm.app/3.6.10 (codex-tui; 0.135.0)"
|
|
codexOriginator = "codex-tui"
|
|
codexDefaultImageToolModel = "gpt-image-2"
|
|
)
|
|
|
|
var dataTag = []byte("data:")
|
|
var codexClaudeCodeSessionPattern = regexp.MustCompile(`_session_([a-f0-9-]+)$`)
|
|
|
|
// Streamed Codex responses may emit response.output_item.done events while leaving
|
|
// response.completed.response.output empty. Keep the stream path aligned with the
|
|
// already-patched non-stream path by reconstructing response.output from those items.
|
|
func collectCodexOutputItemDone(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback *[][]byte) {
|
|
itemResult := gjson.GetBytes(eventData, "item")
|
|
if !itemResult.Exists() || itemResult.Type != gjson.JSON {
|
|
return
|
|
}
|
|
outputIndexResult := gjson.GetBytes(eventData, "output_index")
|
|
if outputIndexResult.Exists() {
|
|
outputItemsByIndex[outputIndexResult.Int()] = []byte(itemResult.Raw)
|
|
return
|
|
}
|
|
*outputItemsFallback = append(*outputItemsFallback, []byte(itemResult.Raw))
|
|
}
|
|
|
|
func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte {
|
|
outputResult := gjson.GetBytes(eventData, "response.output")
|
|
shouldPatchOutput := (!outputResult.Exists() || !outputResult.IsArray() || len(outputResult.Array()) == 0) && (len(outputItemsByIndex) > 0 || len(outputItemsFallback) > 0)
|
|
if !shouldPatchOutput {
|
|
return eventData
|
|
}
|
|
|
|
indexes := make([]int64, 0, len(outputItemsByIndex))
|
|
for idx := range outputItemsByIndex {
|
|
indexes = append(indexes, idx)
|
|
}
|
|
sort.Slice(indexes, func(i, j int) bool {
|
|
return indexes[i] < indexes[j]
|
|
})
|
|
|
|
items := make([][]byte, 0, len(outputItemsByIndex)+len(outputItemsFallback))
|
|
for _, idx := range indexes {
|
|
items = append(items, outputItemsByIndex[idx])
|
|
}
|
|
items = append(items, outputItemsFallback...)
|
|
|
|
outputArray := []byte("[]")
|
|
if len(items) > 0 {
|
|
var buf bytes.Buffer
|
|
totalLen := 2
|
|
for _, item := range items {
|
|
totalLen += len(item)
|
|
}
|
|
if len(items) > 1 {
|
|
totalLen += len(items) - 1
|
|
}
|
|
buf.Grow(totalLen)
|
|
buf.WriteByte('[')
|
|
for i, item := range items {
|
|
if i > 0 {
|
|
buf.WriteByte(',')
|
|
}
|
|
buf.Write(item)
|
|
}
|
|
buf.WriteByte(']')
|
|
outputArray = buf.Bytes()
|
|
}
|
|
|
|
completedDataPatched, _ := sjson.SetRawBytes(eventData, "response.output", outputArray)
|
|
return completedDataPatched
|
|
}
|
|
|
|
func codexTerminalStreamContextLengthErr(eventData []byte) (statusErr, bool) {
|
|
streamErr, body, ok := codexTerminalStreamErr(eventData)
|
|
if !ok || !codexTerminalErrorIsContextLength(body) {
|
|
return statusErr{}, false
|
|
}
|
|
return streamErr, true
|
|
}
|
|
|
|
func codexTerminalStreamErr(eventData []byte) (statusErr, []byte, bool) {
|
|
eventType := gjson.GetBytes(eventData, "type").String()
|
|
var body []byte
|
|
switch eventType {
|
|
case "error":
|
|
body = codexTerminalErrorBody(eventData, "error")
|
|
if len(body) == 0 {
|
|
body = codexTerminalTopLevelErrorBody(eventData)
|
|
}
|
|
case "response.failed":
|
|
body = codexTerminalErrorBody(eventData, "response.error")
|
|
if len(body) == 0 {
|
|
body = codexTerminalErrorBody(eventData, "error")
|
|
}
|
|
default:
|
|
return statusErr{}, nil, false
|
|
}
|
|
if len(body) == 0 {
|
|
return statusErr{}, nil, false
|
|
}
|
|
if !codexTerminalStreamErrShouldHandle(body) {
|
|
return statusErr{}, nil, false
|
|
}
|
|
return newCodexStatusErr(http.StatusBadRequest, body), body, true
|
|
}
|
|
|
|
func codexTerminalStreamErrShouldHandle(body []byte) bool {
|
|
if codexTerminalErrorIsContextLength(body) {
|
|
return true
|
|
}
|
|
code, _, ok := codexStatusErrorClassification(http.StatusBadRequest, body)
|
|
return ok && code == "thinking_signature_invalid"
|
|
}
|
|
|
|
func codexTerminalErrorBody(eventData []byte, path string) []byte {
|
|
errorResult := gjson.GetBytes(eventData, path)
|
|
if !errorResult.Exists() {
|
|
return nil
|
|
}
|
|
body := []byte(`{"error":{}}`)
|
|
if errorResult.Type == gjson.JSON {
|
|
body, _ = sjson.SetRawBytes(body, "error", []byte(errorResult.Raw))
|
|
} else if message := strings.TrimSpace(errorResult.String()); message != "" {
|
|
body, _ = sjson.SetBytes(body, "error.message", message)
|
|
}
|
|
if strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) == "" {
|
|
if message := strings.TrimSpace(gjson.GetBytes(eventData, "response.error.message").String()); message != "" {
|
|
body, _ = sjson.SetBytes(body, "error.message", message)
|
|
}
|
|
}
|
|
if strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) == "" {
|
|
if code := strings.TrimSpace(gjson.GetBytes(body, "error.code").String()); code != "" {
|
|
body, _ = sjson.SetBytes(body, "error.message", code)
|
|
}
|
|
}
|
|
if strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) == "" {
|
|
if errorType := strings.TrimSpace(gjson.GetBytes(body, "error.type").String()); errorType != "" {
|
|
body, _ = sjson.SetBytes(body, "error.message", errorType)
|
|
}
|
|
}
|
|
return body
|
|
}
|
|
|
|
func codexTerminalTopLevelErrorBody(eventData []byte) []byte {
|
|
message := strings.TrimSpace(gjson.GetBytes(eventData, "message").String())
|
|
code := strings.TrimSpace(gjson.GetBytes(eventData, "code").String())
|
|
errorType := strings.TrimSpace(gjson.GetBytes(eventData, "error_type").String())
|
|
param := strings.TrimSpace(gjson.GetBytes(eventData, "param").String())
|
|
if message == "" && code == "" && errorType == "" && param == "" {
|
|
return nil
|
|
}
|
|
|
|
body := []byte(`{"error":{}}`)
|
|
if message != "" {
|
|
body, _ = sjson.SetBytes(body, "error.message", message)
|
|
}
|
|
if code != "" {
|
|
body, _ = sjson.SetBytes(body, "error.code", code)
|
|
}
|
|
if errorType != "" {
|
|
body, _ = sjson.SetBytes(body, "error.type", errorType)
|
|
}
|
|
if param != "" {
|
|
body, _ = sjson.SetBytes(body, "error.param", param)
|
|
}
|
|
if strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) == "" {
|
|
if code != "" {
|
|
body, _ = sjson.SetBytes(body, "error.message", code)
|
|
} else if errorType != "" {
|
|
body, _ = sjson.SetBytes(body, "error.message", errorType)
|
|
}
|
|
}
|
|
return body
|
|
}
|
|
|
|
func codexTerminalErrorIsContextLength(body []byte) bool {
|
|
errorCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()))
|
|
message := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.message").String()))
|
|
return errorCode == "context_length_exceeded" ||
|
|
errorCode == "context_too_large" ||
|
|
strings.Contains(message, "context window") ||
|
|
strings.Contains(message, "context length") ||
|
|
strings.Contains(message, "too many tokens")
|
|
}
|
|
|
|
// CodexExecutor is a stateless executor for Codex (OpenAI Responses API entrypoint).
|
|
// If api_key is unavailable on auth, it falls back to legacy via ClientAdapter.
|
|
type CodexExecutor struct {
|
|
cfg *config.Config
|
|
}
|
|
|
|
func NewCodexExecutor(cfg *config.Config) *CodexExecutor { return &CodexExecutor{cfg: cfg} }
|
|
|
|
func (e *CodexExecutor) Identifier() string { return "codex" }
|
|
|
|
func translateCodexRequestPair(from, to sdktranslator.Format, model string, originalPayload, payload []byte, stream bool) ([]byte, []byte) {
|
|
if bytes.Equal(originalPayload, payload) {
|
|
body := sdktranslator.TranslateRequest(from, to, model, payload, stream)
|
|
return body, body
|
|
}
|
|
originalTranslated := sdktranslator.TranslateRequest(from, to, model, originalPayload, stream)
|
|
body := sdktranslator.TranslateRequest(from, to, model, payload, stream)
|
|
return originalTranslated, body
|
|
}
|
|
|
|
type codexReasoningReplayScope struct {
|
|
modelName string
|
|
sessionKey string
|
|
}
|
|
|
|
func (s codexReasoningReplayScope) valid() bool {
|
|
return strings.TrimSpace(s.modelName) != "" && strings.TrimSpace(s.sessionKey) != ""
|
|
}
|
|
|
|
func applyCodexReasoningReplayCache(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, body []byte) ([]byte, codexReasoningReplayScope) {
|
|
scope := codexReasoningReplayScopeFromRequest(ctx, from, req, opts, body)
|
|
if !scope.valid() {
|
|
return body, scope
|
|
}
|
|
items, ok := internalcache.GetCodexReasoningReplayItems(scope.modelName, scope.sessionKey)
|
|
if !ok {
|
|
return body, scope
|
|
}
|
|
items = filterCodexReasoningReplayItemsForInput(body, items)
|
|
if len(items) == 0 {
|
|
return body, scope
|
|
}
|
|
updated, ok := insertCodexReasoningReplayItems(body, items)
|
|
if !ok {
|
|
return body, scope
|
|
}
|
|
return updated, scope
|
|
}
|
|
|
|
func codexReasoningReplayScopeFromRequest(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, body []byte) codexReasoningReplayScope {
|
|
if !codexReasoningReplayEnabledForSource(from) {
|
|
return codexReasoningReplayScope{}
|
|
}
|
|
return codexReasoningReplayScope{
|
|
modelName: thinking.ParseSuffix(req.Model).ModelName,
|
|
sessionKey: codexReasoningReplaySessionKey(ctx, from, req, opts, body),
|
|
}
|
|
}
|
|
|
|
func codexReasoningReplayEnabledForSource(from sdktranslator.Format) bool {
|
|
return sourceFormatEqual(from, sdktranslator.FormatClaude)
|
|
}
|
|
|
|
func sourceFormatEqual(from, want sdktranslator.Format) bool {
|
|
return strings.EqualFold(strings.TrimSpace(from.String()), want.String())
|
|
}
|
|
|
|
func codexClaudeCodeReplaySessionKey(payload []byte) string {
|
|
sessionID := extractClaudeCodeSessionIDForCodexReplay(payload)
|
|
if sessionID == "" {
|
|
return ""
|
|
}
|
|
return "claude:" + sessionID
|
|
}
|
|
|
|
func codexClaudeCodePromptCacheStorageKey(req cliproxyexecutor.Request) string {
|
|
sessionID := extractClaudeCodeSessionIDForCodexReplay(req.Payload)
|
|
if sessionID == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%s-claude:%s", req.Model, sessionID)
|
|
}
|
|
|
|
func codexClaudeCodePromptCache(req cliproxyexecutor.Request) (helps.CodexCache, bool) {
|
|
key := codexClaudeCodePromptCacheStorageKey(req)
|
|
if key == "" {
|
|
return helps.CodexCache{}, false
|
|
}
|
|
if cache, ok := helps.GetCodexCache(key); ok {
|
|
return cache, true
|
|
}
|
|
cache := helps.CodexCache{
|
|
ID: uuid.New().String(),
|
|
Expire: time.Now().Add(1 * time.Hour),
|
|
}
|
|
helps.SetCodexCache(key, cache)
|
|
return cache, true
|
|
}
|
|
|
|
func extractClaudeCodeSessionIDForCodexReplay(payload []byte) string {
|
|
if len(payload) == 0 {
|
|
return ""
|
|
}
|
|
userID := gjson.GetBytes(payload, "metadata.user_id").String()
|
|
if userID == "" {
|
|
return ""
|
|
}
|
|
if matches := codexClaudeCodeSessionPattern.FindStringSubmatch(userID); len(matches) >= 2 {
|
|
return matches[1]
|
|
}
|
|
if len(userID) > 0 && userID[0] == '{' {
|
|
return gjson.Get(userID, "session_id").String()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func codexReasoningReplaySessionKey(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, body []byte) string {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if value := metadataString(opts.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); value != "" {
|
|
return "execution:" + value
|
|
}
|
|
if value := metadataString(req.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); value != "" {
|
|
return "execution:" + value
|
|
}
|
|
if value := codexReasoningReplaySessionKeyFromPayload(body); value != "" {
|
|
return value
|
|
}
|
|
if value := codexReasoningReplaySessionKeyFromPayload(req.Payload); value != "" {
|
|
return value
|
|
}
|
|
if value := codexReasoningReplaySessionKeyFromHeaders(opts.Headers); value != "" {
|
|
return value
|
|
}
|
|
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
|
if value := codexReasoningReplaySessionKeyFromHeaders(ginCtx.Request.Header); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
if sourceFormatEqual(from, sdktranslator.FormatClaude) {
|
|
return codexClaudeCodeReplaySessionKey(req.Payload)
|
|
}
|
|
if sourceFormatEqual(from, sdktranslator.FormatOpenAI) {
|
|
if apiKey := strings.TrimSpace(helps.APIKeyFromContext(ctx)); apiKey != "" {
|
|
return "prompt-cache:" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:"+apiKey)).String()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func metadataString(metadata map[string]any, key string) string {
|
|
if len(metadata) == 0 {
|
|
return ""
|
|
}
|
|
raw, ok := metadata[key]
|
|
if !ok || raw == nil {
|
|
return ""
|
|
}
|
|
switch v := raw.(type) {
|
|
case string:
|
|
return strings.TrimSpace(v)
|
|
case []byte:
|
|
return strings.TrimSpace(string(v))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func codexReasoningReplaySessionKeyFromPayload(payload []byte) string {
|
|
if len(payload) == 0 {
|
|
return ""
|
|
}
|
|
if promptCacheKey := strings.TrimSpace(gjson.GetBytes(payload, "prompt_cache_key").String()); promptCacheKey != "" {
|
|
return "prompt-cache:" + promptCacheKey
|
|
}
|
|
if windowID := strings.TrimSpace(gjson.GetBytes(payload, "client_metadata.x-codex-window-id").String()); windowID != "" {
|
|
return "window:" + windowID
|
|
}
|
|
if turnMetadata := strings.TrimSpace(gjson.GetBytes(payload, "client_metadata.x-codex-turn-metadata").String()); turnMetadata != "" {
|
|
return codexReasoningReplaySessionKeyFromTurnMetadata(turnMetadata)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func codexReasoningReplaySessionKeyFromHeaders(headers http.Header) string {
|
|
if headers == nil {
|
|
return ""
|
|
}
|
|
if turnMetadata := strings.TrimSpace(headers.Get("X-Codex-Turn-Metadata")); turnMetadata != "" {
|
|
if key := codexReasoningReplaySessionKeyFromTurnMetadata(turnMetadata); key != "" {
|
|
return key
|
|
}
|
|
}
|
|
if windowID := strings.TrimSpace(headerValueCaseInsensitive(headers, "X-Codex-Window-Id")); windowID != "" {
|
|
return "window:" + windowID
|
|
}
|
|
for _, headerName := range []string{"Session_id", "session_id", "Session-Id"} {
|
|
if value := strings.TrimSpace(headerValueCaseInsensitive(headers, headerName)); value != "" {
|
|
return "session-id:" + value
|
|
}
|
|
}
|
|
if conversationID := strings.TrimSpace(headerValueCaseInsensitive(headers, "Conversation_id")); conversationID != "" {
|
|
return "conversation_id:" + conversationID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func codexReasoningReplaySessionKeyFromTurnMetadata(turnMetadata string) string {
|
|
if promptCacheKey := strings.TrimSpace(gjson.Get(turnMetadata, "prompt_cache_key").String()); promptCacheKey != "" {
|
|
return "prompt-cache:" + promptCacheKey
|
|
}
|
|
if windowID := strings.TrimSpace(gjson.Get(turnMetadata, "window_id").String()); windowID != "" {
|
|
return "window:" + windowID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func codexInputHasValidReasoningEncryptedContent(body []byte) bool {
|
|
input := gjson.GetBytes(body, "input")
|
|
if !input.IsArray() {
|
|
return false
|
|
}
|
|
for _, item := range input.Array() {
|
|
if strings.TrimSpace(item.Get("type").String()) != "reasoning" {
|
|
continue
|
|
}
|
|
encryptedContent := item.Get("encrypted_content")
|
|
if encryptedContent.Type != gjson.String {
|
|
continue
|
|
}
|
|
if _, err := signature.InspectGPTReasoningSignature(encryptedContent.String()); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func filterCodexReasoningReplayItemsForInput(body []byte, items [][]byte) [][]byte {
|
|
input := gjson.GetBytes(body, "input")
|
|
if !input.IsArray() {
|
|
return nil
|
|
}
|
|
|
|
hasInputReasoning := codexInputHasValidReasoningEncryptedContent(body)
|
|
existingCalls := make(map[string]bool)
|
|
existingOutputs := make(map[string]bool)
|
|
for _, inputItem := range input.Array() {
|
|
itemType := strings.TrimSpace(inputItem.Get("type").String())
|
|
if itemType == "function_call_output" || itemType == "custom_tool_call_output" {
|
|
callID := strings.TrimSpace(inputItem.Get("call_id").String())
|
|
if callID != "" {
|
|
for _, candidate := range codexReplayComparableCallIDs(callID) {
|
|
existingOutputs[candidate] = true
|
|
}
|
|
}
|
|
}
|
|
for _, key := range codexReplayToolCallKeys(inputItem) {
|
|
existingCalls[key] = true
|
|
}
|
|
}
|
|
|
|
filtered := make([][]byte, 0, len(items))
|
|
for _, item := range items {
|
|
itemResult := gjson.ParseBytes(item)
|
|
switch strings.TrimSpace(itemResult.Get("type").String()) {
|
|
case "reasoning":
|
|
if hasInputReasoning {
|
|
continue
|
|
}
|
|
case "function_call", "custom_tool_call":
|
|
keys := codexReplayToolCallKeys(itemResult)
|
|
if len(keys) == 0 || codexReplayAnyToolCallKeyExists(existingCalls, keys) {
|
|
continue
|
|
}
|
|
// Only inject if there is a matching output in the request
|
|
hasMatchingOutput := false
|
|
callID := strings.TrimSpace(itemResult.Get("call_id").String())
|
|
if callID != "" {
|
|
for _, candidate := range codexReplayComparableCallIDs(callID) {
|
|
if existingOutputs[candidate] {
|
|
hasMatchingOutput = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !hasMatchingOutput {
|
|
continue
|
|
}
|
|
for _, key := range keys {
|
|
existingCalls[key] = true
|
|
}
|
|
default:
|
|
continue
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func insertCodexReasoningReplayItems(body []byte, replayItems [][]byte) ([]byte, bool) {
|
|
input := gjson.GetBytes(body, "input")
|
|
if !input.IsArray() || len(replayItems) == 0 {
|
|
return body, false
|
|
}
|
|
inputItems := input.Array()
|
|
insertIndex := codexReasoningReplayInsertIndex(inputItems, replayItems)
|
|
replayItems = codexAlignReasoningReplayToolCallIDs(inputItems, replayItems)
|
|
items := make([]string, 0, len(inputItems)+len(replayItems))
|
|
for i, inputItem := range inputItems {
|
|
if i == insertIndex {
|
|
for _, replayItem := range replayItems {
|
|
items = append(items, string(replayItem))
|
|
}
|
|
}
|
|
items = append(items, inputItem.Raw)
|
|
}
|
|
if insertIndex == len(inputItems) {
|
|
for _, replayItem := range replayItems {
|
|
items = append(items, string(replayItem))
|
|
}
|
|
}
|
|
updated, err := sjson.SetRawBytes(body, "input", []byte("["+strings.Join(items, ",")+"]"))
|
|
if err != nil {
|
|
return body, false
|
|
}
|
|
return updated, true
|
|
}
|
|
|
|
func codexReasoningReplayInsertIndex(inputItems []gjson.Result, replayItems [][]byte) int {
|
|
replayCallIDs := make(map[string]bool)
|
|
for _, replayItem := range replayItems {
|
|
itemResult := gjson.ParseBytes(replayItem)
|
|
itemType := strings.TrimSpace(itemResult.Get("type").String())
|
|
if itemType != "function_call" && itemType != "custom_tool_call" {
|
|
continue
|
|
}
|
|
for _, callID := range codexReplayComparableCallIDs(itemResult.Get("call_id").String()) {
|
|
replayCallIDs[callID] = true
|
|
}
|
|
}
|
|
if len(replayCallIDs) > 0 {
|
|
for index, inputItem := range inputItems {
|
|
itemType := strings.TrimSpace(inputItem.Get("type").String())
|
|
if itemType != "function_call_output" && itemType != "custom_tool_call_output" {
|
|
continue
|
|
}
|
|
callID := strings.TrimSpace(inputItem.Get("call_id").String())
|
|
if callID == "" || replayCallIDs[callID] {
|
|
return index
|
|
}
|
|
}
|
|
}
|
|
for index := len(inputItems) - 1; index >= 0; index-- {
|
|
inputItem := inputItems[index]
|
|
if strings.TrimSpace(inputItem.Get("type").String()) == "message" && strings.TrimSpace(inputItem.Get("role").String()) == "assistant" {
|
|
return index
|
|
}
|
|
}
|
|
for index, inputItem := range inputItems {
|
|
if shouldInsertCodexReasoningReplayBefore(inputItem) {
|
|
return index
|
|
}
|
|
}
|
|
return len(inputItems)
|
|
}
|
|
|
|
func codexAlignReasoningReplayToolCallIDs(inputItems []gjson.Result, replayItems [][]byte) [][]byte {
|
|
outputCallIDs := codexReplayOutputCallIDs(inputItems)
|
|
if len(outputCallIDs) == 0 {
|
|
return replayItems
|
|
}
|
|
|
|
aligned := make([][]byte, 0, len(replayItems))
|
|
for _, replayItem := range replayItems {
|
|
itemResult := gjson.ParseBytes(replayItem)
|
|
itemType := strings.TrimSpace(itemResult.Get("type").String())
|
|
if itemType != "function_call" && itemType != "custom_tool_call" {
|
|
aligned = append(aligned, replayItem)
|
|
continue
|
|
}
|
|
|
|
callID := strings.TrimSpace(itemResult.Get("call_id").String())
|
|
outputCallID := ""
|
|
for _, candidate := range codexReplayComparableCallIDs(callID) {
|
|
if value := outputCallIDs[candidate]; value != "" {
|
|
outputCallID = value
|
|
break
|
|
}
|
|
}
|
|
if outputCallID == "" || outputCallID == callID {
|
|
aligned = append(aligned, replayItem)
|
|
continue
|
|
}
|
|
|
|
updated, err := sjson.SetBytes(replayItem, "call_id", outputCallID)
|
|
if err != nil {
|
|
aligned = append(aligned, replayItem)
|
|
continue
|
|
}
|
|
aligned = append(aligned, updated)
|
|
}
|
|
return aligned
|
|
}
|
|
|
|
func codexReplayOutputCallIDs(inputItems []gjson.Result) map[string]string {
|
|
outputCallIDs := make(map[string]string)
|
|
for _, inputItem := range inputItems {
|
|
itemType := strings.TrimSpace(inputItem.Get("type").String())
|
|
if itemType != "function_call_output" && itemType != "custom_tool_call_output" {
|
|
continue
|
|
}
|
|
callID := strings.TrimSpace(inputItem.Get("call_id").String())
|
|
if callID == "" {
|
|
continue
|
|
}
|
|
for _, candidate := range codexReplayComparableCallIDs(callID) {
|
|
outputCallIDs[candidate] = callID
|
|
}
|
|
}
|
|
return outputCallIDs
|
|
}
|
|
|
|
func shouldInsertCodexReasoningReplayBefore(item gjson.Result) bool {
|
|
if strings.TrimSpace(item.Get("type").String()) != "message" {
|
|
return true
|
|
}
|
|
switch strings.TrimSpace(item.Get("role").String()) {
|
|
case "developer", "system":
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func codexReplayToolCallKeys(item gjson.Result) []string {
|
|
itemType := strings.TrimSpace(item.Get("type").String())
|
|
if itemType != "function_call" && itemType != "custom_tool_call" {
|
|
return nil
|
|
}
|
|
callIDs := codexReplayComparableCallIDs(item.Get("call_id").String())
|
|
if len(callIDs) == 0 {
|
|
return nil
|
|
}
|
|
keys := make([]string, 0, len(callIDs))
|
|
for _, callID := range callIDs {
|
|
keys = append(keys, itemType+":"+callID)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func codexReplayAnyToolCallKeyExists(existing map[string]bool, keys []string) bool {
|
|
for _, key := range keys {
|
|
if existing[key] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func codexReplayComparableCallIDs(callID string) []string {
|
|
callID = strings.TrimSpace(callID)
|
|
if callID == "" {
|
|
return nil
|
|
}
|
|
|
|
claudeVisibleCallID := shortenCodexReplayCallIDIfNeeded(util.SanitizeClaudeToolID(callID))
|
|
if claudeVisibleCallID == "" || claudeVisibleCallID == callID {
|
|
return []string{callID}
|
|
}
|
|
return []string{callID, claudeVisibleCallID}
|
|
}
|
|
|
|
func shortenCodexReplayCallIDIfNeeded(id string) string {
|
|
const limit = 64
|
|
if len(id) <= limit {
|
|
return id
|
|
}
|
|
|
|
sum := sha256.Sum256([]byte(id))
|
|
suffix := "_" + hex.EncodeToString(sum[:8])
|
|
prefixLen := limit - len(suffix)
|
|
if prefixLen <= 0 {
|
|
return suffix[len(suffix)-limit:]
|
|
}
|
|
return id[:prefixLen] + suffix
|
|
}
|
|
|
|
func cacheCodexReasoningReplayFromCompleted(scope codexReasoningReplayScope, completedData []byte) {
|
|
if !scope.valid() {
|
|
return
|
|
}
|
|
output := gjson.GetBytes(completedData, "response.output")
|
|
if !output.IsArray() {
|
|
return
|
|
}
|
|
items := make([][]byte, 0, len(output.Array()))
|
|
for _, item := range output.Array() {
|
|
switch strings.TrimSpace(item.Get("type").String()) {
|
|
case "reasoning", "function_call", "custom_tool_call":
|
|
items = append(items, []byte(item.Raw))
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
if !internalcache.CacheCodexReasoningReplayItems(scope.modelName, scope.sessionKey, items) {
|
|
internalcache.DeleteCodexReasoningReplayItem(scope.modelName, scope.sessionKey)
|
|
}
|
|
}
|
|
|
|
func clearCodexReasoningReplayOnInvalidSignature(scope codexReasoningReplayScope, statusCode int, body []byte) {
|
|
if !scope.valid() {
|
|
return
|
|
}
|
|
code, _, ok := codexStatusErrorClassification(statusCode, body)
|
|
if ok && code == "thinking_signature_invalid" {
|
|
internalcache.DeleteCodexReasoningReplayItem(scope.modelName, scope.sessionKey)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
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) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
|
if opts.Alt == "responses/compact" {
|
|
return e.executeCompact(ctx, auth, req, opts)
|
|
}
|
|
if isCodexOpenAIImageRequest(opts) {
|
|
return e.executeOpenAIImage(ctx, auth, req, opts)
|
|
}
|
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
|
|
apiKey, baseURL := codexCreds(auth)
|
|
if baseURL == "" {
|
|
baseURL = "https://chatgpt.com/backend-api/codex"
|
|
}
|
|
|
|
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
|
|
defer reporter.TrackFailure(ctx, &err)
|
|
|
|
from := opts.SourceFormat
|
|
to := sdktranslator.FromString("codex")
|
|
originalPayloadSource := req.Payload
|
|
if len(opts.OriginalRequest) > 0 {
|
|
originalPayloadSource = opts.OriginalRequest
|
|
}
|
|
originalPayload := originalPayloadSource
|
|
originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false)
|
|
|
|
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
|
|
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
|
|
requestPath := helps.PayloadRequestPath(opts)
|
|
body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
|
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
|
body, _ = sjson.SetBytes(body, "stream", true)
|
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
|
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
|
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
|
body, _ = sjson.DeleteBytes(body, "stream_options")
|
|
body = normalizeCodexInstructions(body)
|
|
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
|
|
body = ensureImageGenerationTool(body, baseModel, auth)
|
|
}
|
|
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body)
|
|
body, replayScope := applyCodexReasoningReplayCache(ctx, from, req, opts, body)
|
|
reporter.SetTranslatedReasoningEffort(body, to.String())
|
|
|
|
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
|
var identityState codexIdentityConfuseState
|
|
httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
|
|
applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
|
|
var authID, authLabel, authType, authValue string
|
|
if auth != nil {
|
|
authID = auth.ID
|
|
authLabel = auth.Label
|
|
authType, authValue = auth.AccountInfo()
|
|
}
|
|
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
|
|
URL: url,
|
|
Method: http.MethodPost,
|
|
Headers: httpReq.Header.Clone(),
|
|
Body: upstreamBody,
|
|
Provider: e.Identifier(),
|
|
AuthID: authID,
|
|
AuthLabel: authLabel,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
|
|
httpClient = reporter.TrackHTTPClient(httpClient)
|
|
httpResp, err := httpClient.Do(httpReq)
|
|
if err != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
|
return resp, err
|
|
}
|
|
defer func() {
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("codex executor: close response body error: %v", errClose)
|
|
}
|
|
}()
|
|
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(httpResp.Body)
|
|
b = applyCodexIdentityConfuseResponsePayload(b, identityState)
|
|
clearCodexReasoningReplayOnInvalidSignature(replayScope, httpResp.StatusCode, b)
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
|
|
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
|
err = newCodexStatusErr(httpResp.StatusCode, b)
|
|
return resp, err
|
|
}
|
|
data, err := io.ReadAll(httpResp.Body)
|
|
if err != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
|
return resp, err
|
|
}
|
|
upstreamData := applyCodexIdentityConfuseResponsePayload(data, identityState)
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, upstreamData)
|
|
|
|
lines := bytes.Split(upstreamData, []byte("\n"))
|
|
outputItemsByIndex := make(map[int64][]byte)
|
|
var outputItemsFallback [][]byte
|
|
for _, line := range lines {
|
|
if !bytes.HasPrefix(line, dataTag) {
|
|
continue
|
|
}
|
|
|
|
eventData := bytes.TrimSpace(line[5:])
|
|
eventType := gjson.GetBytes(eventData, "type").String()
|
|
|
|
if streamErr, terminalBody, ok := codexTerminalStreamErr(eventData); ok {
|
|
clearCodexReasoningReplayOnInvalidSignature(replayScope, streamErr.StatusCode(), terminalBody)
|
|
err = streamErr
|
|
return resp, err
|
|
}
|
|
|
|
if eventType == "response.output_item.done" {
|
|
itemResult := gjson.GetBytes(eventData, "item")
|
|
if !itemResult.Exists() || itemResult.Type != gjson.JSON {
|
|
continue
|
|
}
|
|
outputIndexResult := gjson.GetBytes(eventData, "output_index")
|
|
if outputIndexResult.Exists() {
|
|
outputItemsByIndex[outputIndexResult.Int()] = []byte(itemResult.Raw)
|
|
} else {
|
|
outputItemsFallback = append(outputItemsFallback, []byte(itemResult.Raw))
|
|
}
|
|
continue
|
|
}
|
|
|
|
if eventType != "response.completed" {
|
|
continue
|
|
}
|
|
|
|
if detail, ok := helps.ParseCodexUsage(eventData); ok {
|
|
reporter.Publish(ctx, detail)
|
|
}
|
|
publishCodexImageToolUsage(ctx, reporter, body, eventData)
|
|
|
|
completedData := eventData
|
|
outputResult := gjson.GetBytes(completedData, "response.output")
|
|
shouldPatchOutput := (!outputResult.Exists() || !outputResult.IsArray() || len(outputResult.Array()) == 0) && (len(outputItemsByIndex) > 0 || len(outputItemsFallback) > 0)
|
|
if shouldPatchOutput {
|
|
completedDataPatched := completedData
|
|
completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output", []byte(`[]`))
|
|
|
|
indexes := make([]int64, 0, len(outputItemsByIndex))
|
|
for idx := range outputItemsByIndex {
|
|
indexes = append(indexes, idx)
|
|
}
|
|
sort.Slice(indexes, func(i, j int) bool {
|
|
return indexes[i] < indexes[j]
|
|
})
|
|
for _, idx := range indexes {
|
|
completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output.-1", outputItemsByIndex[idx])
|
|
}
|
|
for _, item := range outputItemsFallback {
|
|
completedDataPatched, _ = sjson.SetRawBytes(completedDataPatched, "response.output.-1", item)
|
|
}
|
|
completedData = completedDataPatched
|
|
}
|
|
cacheCodexReasoningReplayFromCompleted(replayScope, completedData)
|
|
|
|
var param any
|
|
clientCompletedData := applyCodexIdentityExposeResponsePayload(completedData, identityState)
|
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, clientCompletedData, ¶m)
|
|
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
|
|
return resp, nil
|
|
}
|
|
err = statusErr{code: 408, msg: "stream error: stream disconnected before completion: stream closed before response.completed"}
|
|
return resp, err
|
|
}
|
|
|
|
func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
|
|
apiKey, baseURL := codexCreds(auth)
|
|
if baseURL == "" {
|
|
baseURL = "https://chatgpt.com/backend-api/codex"
|
|
}
|
|
|
|
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
|
|
defer reporter.TrackFailure(ctx, &err)
|
|
|
|
from := opts.SourceFormat
|
|
to := sdktranslator.FromString("openai-response")
|
|
originalPayloadSource := req.Payload
|
|
if len(opts.OriginalRequest) > 0 {
|
|
originalPayloadSource = opts.OriginalRequest
|
|
}
|
|
originalPayload := originalPayloadSource
|
|
originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, false)
|
|
|
|
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
|
|
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
|
|
requestPath := helps.PayloadRequestPath(opts)
|
|
body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
|
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
|
body, _ = sjson.DeleteBytes(body, "stream")
|
|
body = normalizeCodexInstructions(body)
|
|
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
|
|
body = ensureImageGenerationTool(body, baseModel, auth)
|
|
}
|
|
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body)
|
|
reporter.SetTranslatedReasoningEffort(body, to.String())
|
|
|
|
url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
|
|
var identityState codexIdentityConfuseState
|
|
httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg)
|
|
applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
|
|
var authID, authLabel, authType, authValue string
|
|
if auth != nil {
|
|
authID = auth.ID
|
|
authLabel = auth.Label
|
|
authType, authValue = auth.AccountInfo()
|
|
}
|
|
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
|
|
URL: url,
|
|
Method: http.MethodPost,
|
|
Headers: httpReq.Header.Clone(),
|
|
Body: upstreamBody,
|
|
Provider: e.Identifier(),
|
|
AuthID: authID,
|
|
AuthLabel: authLabel,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
|
|
httpClient = reporter.TrackHTTPClient(httpClient)
|
|
httpResp, err := httpClient.Do(httpReq)
|
|
if err != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
|
return resp, err
|
|
}
|
|
defer func() {
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("codex executor: close response body error: %v", errClose)
|
|
}
|
|
}()
|
|
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(httpResp.Body)
|
|
b = applyCodexIdentityConfuseResponsePayload(b, identityState)
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
|
|
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
|
err = newCodexStatusErr(httpResp.StatusCode, b)
|
|
return resp, err
|
|
}
|
|
data, err := io.ReadAll(httpResp.Body)
|
|
if err != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
|
return resp, err
|
|
}
|
|
upstreamData := applyCodexIdentityConfuseResponsePayload(data, identityState)
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, upstreamData)
|
|
reporter.Publish(ctx, helps.ParseOpenAIUsage(upstreamData))
|
|
reporter.EnsurePublished(ctx)
|
|
var param any
|
|
clientData := applyCodexIdentityExposeResponsePayload(upstreamData, identityState)
|
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, clientData, ¶m)
|
|
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
|
|
return resp, nil
|
|
}
|
|
|
|
func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
|
|
if opts.Alt == "responses/compact" {
|
|
return nil, statusErr{code: http.StatusBadRequest, msg: "streaming not supported for /responses/compact"}
|
|
}
|
|
if isCodexOpenAIImageRequest(opts) {
|
|
return e.executeOpenAIImageStream(ctx, auth, req, opts)
|
|
}
|
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
|
|
apiKey, baseURL := codexCreds(auth)
|
|
if baseURL == "" {
|
|
baseURL = "https://chatgpt.com/backend-api/codex"
|
|
}
|
|
|
|
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
|
|
defer reporter.TrackFailure(ctx, &err)
|
|
|
|
from := opts.SourceFormat
|
|
to := sdktranslator.FromString("codex")
|
|
originalPayloadSource := req.Payload
|
|
if len(opts.OriginalRequest) > 0 {
|
|
originalPayloadSource = opts.OriginalRequest
|
|
}
|
|
originalPayload := originalPayloadSource
|
|
originalTranslated, body := translateCodexRequestPair(from, to, baseModel, originalPayload, req.Payload, true)
|
|
|
|
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
|
|
requestPath := helps.PayloadRequestPath(opts)
|
|
body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
|
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
|
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
|
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
|
body, _ = sjson.DeleteBytes(body, "stream_options")
|
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
|
body = normalizeCodexInstructions(body)
|
|
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
|
|
body = ensureImageGenerationTool(body, baseModel, auth)
|
|
}
|
|
body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body)
|
|
body, replayScope := applyCodexReasoningReplayCache(ctx, from, req, opts, body)
|
|
reporter.SetTranslatedReasoningEffort(body, to.String())
|
|
|
|
url := strings.TrimSuffix(baseURL, "/") + "/responses"
|
|
var identityState codexIdentityConfuseState
|
|
httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
|
|
applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
|
|
var authID, authLabel, authType, authValue string
|
|
if auth != nil {
|
|
authID = auth.ID
|
|
authLabel = auth.Label
|
|
authType, authValue = auth.AccountInfo()
|
|
}
|
|
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
|
|
URL: url,
|
|
Method: http.MethodPost,
|
|
Headers: httpReq.Header.Clone(),
|
|
Body: upstreamBody,
|
|
Provider: e.Identifier(),
|
|
AuthID: authID,
|
|
AuthLabel: authLabel,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
|
|
httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0)
|
|
httpClient = reporter.TrackHTTPClient(httpClient)
|
|
httpResp, err := httpClient.Do(httpReq)
|
|
if err != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
|
return nil, err
|
|
}
|
|
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
data, readErr := io.ReadAll(httpResp.Body)
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("codex executor: close response body error: %v", errClose)
|
|
}
|
|
if readErr != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, readErr)
|
|
return nil, readErr
|
|
}
|
|
data = applyCodexIdentityConfuseResponsePayload(data, identityState)
|
|
clearCodexReasoningReplayOnInvalidSignature(replayScope, httpResp.StatusCode, data)
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
|
|
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
|
|
err = newCodexStatusErr(httpResp.StatusCode, data)
|
|
return nil, err
|
|
}
|
|
out := make(chan cliproxyexecutor.StreamChunk)
|
|
go func() {
|
|
defer close(out)
|
|
defer func() {
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("codex executor: close response body error: %v", errClose)
|
|
}
|
|
}()
|
|
scanner := bufio.NewScanner(httpResp.Body)
|
|
scanner.Buffer(nil, 52_428_800) // 50MB
|
|
var param any
|
|
outputItemsByIndex := make(map[int64][]byte)
|
|
var outputItemsFallback [][]byte
|
|
for scanner.Scan() {
|
|
line := applyCodexIdentityConfuseResponsePayload(scanner.Bytes(), identityState)
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
|
|
translatedLine := bytes.Clone(line)
|
|
|
|
if bytes.HasPrefix(line, dataTag) {
|
|
data := bytes.TrimSpace(line[5:])
|
|
if streamErr, terminalBody, ok := codexTerminalStreamErr(data); ok {
|
|
clearCodexReasoningReplayOnInvalidSignature(replayScope, streamErr.StatusCode(), terminalBody)
|
|
helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
|
|
reporter.PublishFailure(ctx, streamErr)
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Err: streamErr}:
|
|
case <-ctx.Done():
|
|
}
|
|
return
|
|
}
|
|
switch gjson.GetBytes(data, "type").String() {
|
|
case "response.output_item.done":
|
|
collectCodexOutputItemDone(data, outputItemsByIndex, &outputItemsFallback)
|
|
case "response.completed":
|
|
if detail, ok := helps.ParseCodexUsage(data); ok {
|
|
reporter.Publish(ctx, detail)
|
|
}
|
|
publishCodexImageToolUsage(ctx, reporter, body, data)
|
|
data = patchCodexCompletedOutput(data, outputItemsByIndex, outputItemsFallback)
|
|
cacheCodexReasoningReplayFromCompleted(replayScope, data)
|
|
translatedLine = append([]byte("data: "), data...)
|
|
}
|
|
}
|
|
|
|
translatedLine = applyCodexIdentityExposeResponsePayload(translatedLine, identityState)
|
|
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, translatedLine, ¶m)
|
|
for i := range chunks {
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if errScan := scanner.Err(); errScan != nil {
|
|
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
|
|
reporter.PublishFailure(ctx, errScan)
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
}()
|
|
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
|
|
}
|
|
|
|
func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
|
|
from := opts.SourceFormat
|
|
to := sdktranslator.FromString("codex")
|
|
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
|
|
|
|
body, err := thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
|
|
if err != nil {
|
|
return cliproxyexecutor.Response{}, err
|
|
}
|
|
|
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
|
body, _ = sjson.DeleteBytes(body, "previous_response_id")
|
|
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
|
|
body, _ = sjson.DeleteBytes(body, "safety_identifier")
|
|
body, _ = sjson.DeleteBytes(body, "stream_options")
|
|
body, _ = sjson.SetBytes(body, "stream", false)
|
|
body = normalizeCodexInstructions(body)
|
|
|
|
enc, err := tokenizerForCodexModel(baseModel)
|
|
if err != nil {
|
|
return cliproxyexecutor.Response{}, fmt.Errorf("codex executor: tokenizer init failed: %w", err)
|
|
}
|
|
|
|
count, err := countCodexInputTokens(enc, body)
|
|
if err != nil {
|
|
return cliproxyexecutor.Response{}, fmt.Errorf("codex executor: token counting failed: %w", err)
|
|
}
|
|
|
|
usageJSON := fmt.Sprintf(`{"response":{"usage":{"input_tokens":%d,"output_tokens":0,"total_tokens":%d}}}`, count, count)
|
|
translated := sdktranslator.TranslateTokenCount(ctx, to, from, count, []byte(usageJSON))
|
|
return cliproxyexecutor.Response{Payload: translated}, nil
|
|
}
|
|
|
|
func tokenizerForCodexModel(model string) (tokenizer.Codec, error) {
|
|
sanitized := strings.ToLower(strings.TrimSpace(model))
|
|
switch {
|
|
case sanitized == "":
|
|
return tokenizer.Get(tokenizer.Cl100kBase)
|
|
case strings.HasPrefix(sanitized, "gpt-5"):
|
|
return tokenizer.ForModel(tokenizer.GPT5)
|
|
case strings.HasPrefix(sanitized, "gpt-4.1"):
|
|
return tokenizer.ForModel(tokenizer.GPT41)
|
|
case strings.HasPrefix(sanitized, "gpt-4o"):
|
|
return tokenizer.ForModel(tokenizer.GPT4o)
|
|
case strings.HasPrefix(sanitized, "gpt-4"):
|
|
return tokenizer.ForModel(tokenizer.GPT4)
|
|
case strings.HasPrefix(sanitized, "gpt-3.5"), strings.HasPrefix(sanitized, "gpt-3"):
|
|
return tokenizer.ForModel(tokenizer.GPT35Turbo)
|
|
default:
|
|
return tokenizer.Get(tokenizer.Cl100kBase)
|
|
}
|
|
}
|
|
|
|
func countCodexInputTokens(enc tokenizer.Codec, body []byte) (int64, error) {
|
|
if enc == nil {
|
|
return 0, fmt.Errorf("encoder is nil")
|
|
}
|
|
if len(body) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
root := gjson.ParseBytes(body)
|
|
var segments []string
|
|
|
|
if inst := strings.TrimSpace(root.Get("instructions").String()); inst != "" {
|
|
segments = append(segments, inst)
|
|
}
|
|
|
|
inputItems := root.Get("input")
|
|
if inputItems.IsArray() {
|
|
arr := inputItems.Array()
|
|
for i := range arr {
|
|
item := arr[i]
|
|
switch item.Get("type").String() {
|
|
case "message":
|
|
content := item.Get("content")
|
|
if content.IsArray() {
|
|
parts := content.Array()
|
|
for j := range parts {
|
|
part := parts[j]
|
|
if text := strings.TrimSpace(part.Get("text").String()); text != "" {
|
|
segments = append(segments, text)
|
|
}
|
|
}
|
|
}
|
|
case "function_call":
|
|
if name := strings.TrimSpace(item.Get("name").String()); name != "" {
|
|
segments = append(segments, name)
|
|
}
|
|
if args := strings.TrimSpace(item.Get("arguments").String()); args != "" {
|
|
segments = append(segments, args)
|
|
}
|
|
case "function_call_output":
|
|
if out := strings.TrimSpace(item.Get("output").String()); out != "" {
|
|
segments = append(segments, out)
|
|
}
|
|
default:
|
|
if text := strings.TrimSpace(item.Get("text").String()); text != "" {
|
|
segments = append(segments, text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
tools := root.Get("tools")
|
|
if tools.IsArray() {
|
|
tarr := tools.Array()
|
|
for i := range tarr {
|
|
tool := tarr[i]
|
|
if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
|
|
segments = append(segments, name)
|
|
}
|
|
if desc := strings.TrimSpace(tool.Get("description").String()); desc != "" {
|
|
segments = append(segments, desc)
|
|
}
|
|
if params := tool.Get("parameters"); params.Exists() {
|
|
val := params.Raw
|
|
if params.Type == gjson.String {
|
|
val = params.String()
|
|
}
|
|
if trimmed := strings.TrimSpace(val); trimmed != "" {
|
|
segments = append(segments, trimmed)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
textFormat := root.Get("text.format")
|
|
if textFormat.Exists() {
|
|
if name := strings.TrimSpace(textFormat.Get("name").String()); name != "" {
|
|
segments = append(segments, name)
|
|
}
|
|
if schema := textFormat.Get("schema"); schema.Exists() {
|
|
val := schema.Raw
|
|
if schema.Type == gjson.String {
|
|
val = schema.String()
|
|
}
|
|
if trimmed := strings.TrimSpace(val); trimmed != "" {
|
|
segments = append(segments, trimmed)
|
|
}
|
|
}
|
|
}
|
|
|
|
text := strings.Join(segments, "\n")
|
|
if text == "" {
|
|
return 0, nil
|
|
}
|
|
|
|
count, err := enc.Count(text)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return int64(count), nil
|
|
}
|
|
|
|
func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
|
log.Debugf("codex executor: refresh called")
|
|
if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
|
|
return refreshed, err
|
|
}
|
|
if auth == nil {
|
|
return nil, statusErr{code: 500, msg: "codex executor: auth is nil"}
|
|
}
|
|
var refreshToken string
|
|
if auth.Metadata != nil {
|
|
if v, ok := auth.Metadata["refresh_token"].(string); ok && v != "" {
|
|
refreshToken = v
|
|
}
|
|
}
|
|
if refreshToken == "" {
|
|
return auth, nil
|
|
}
|
|
svc := codexauth.NewCodexAuthWithProxyURL(e.cfg, auth.ProxyURL)
|
|
td, err := svc.RefreshTokensWithRetry(ctx, refreshToken, 3)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if auth.Metadata == nil {
|
|
auth.Metadata = make(map[string]any)
|
|
}
|
|
auth.Metadata["id_token"] = td.IDToken
|
|
auth.Metadata["access_token"] = td.AccessToken
|
|
if td.RefreshToken != "" {
|
|
auth.Metadata["refresh_token"] = td.RefreshToken
|
|
}
|
|
if td.AccountID != "" {
|
|
auth.Metadata["account_id"] = td.AccountID
|
|
}
|
|
auth.Metadata["email"] = td.Email
|
|
// Use unified key in files
|
|
auth.Metadata["expired"] = td.Expire
|
|
auth.Metadata["type"] = "codex"
|
|
now := time.Now().Format(time.RFC3339)
|
|
auth.Metadata["last_refresh"] = now
|
|
return auth, nil
|
|
}
|
|
|
|
type codexIdentityConfuseState struct {
|
|
enabled bool
|
|
authID string
|
|
originalPromptCacheKey string
|
|
promptCacheKey string
|
|
turnIDs []codexIdentityReplacement
|
|
}
|
|
|
|
type codexIdentityReplacement struct {
|
|
original string
|
|
confused string
|
|
}
|
|
|
|
func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, userPayload []byte, rawJSON []byte) (*http.Request, []byte, codexIdentityConfuseState, error) {
|
|
var cache helps.CodexCache
|
|
if sourceFormatEqual(from, sdktranslator.FormatClaude) {
|
|
if cached, ok := codexClaudeCodePromptCache(req); 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 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 != "" {
|
|
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID)
|
|
}
|
|
var identityState codexIdentityConfuseState
|
|
rawJSON, identityState = applyCodexIdentityConfuseBody(e.cfg, auth, userPayload, rawJSON)
|
|
if identityState.promptCacheKey != "" {
|
|
cache.ID = identityState.promptCacheKey
|
|
}
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawJSON))
|
|
if err != nil {
|
|
return nil, nil, codexIdentityConfuseState{}, err
|
|
}
|
|
if cache.ID != "" {
|
|
httpReq.Header.Set("Session_id", cache.ID)
|
|
}
|
|
return httpReq, rawJSON, identityState, nil
|
|
}
|
|
|
|
func applyCodexIdentityConfuseBody(cfg *config.Config, auth *cliproxyauth.Auth, userPayload []byte, rawJSON []byte) ([]byte, codexIdentityConfuseState) {
|
|
if !codexIdentityConfuseEnabled(cfg) || auth == nil || strings.TrimSpace(auth.ID) == "" || len(rawJSON) == 0 {
|
|
return rawJSON, codexIdentityConfuseState{}
|
|
}
|
|
|
|
state := codexIdentityConfuseState{enabled: true, authID: strings.TrimSpace(auth.ID)}
|
|
if promptCacheKey := strings.TrimSpace(gjson.GetBytes(userPayload, "prompt_cache_key").String()); promptCacheKey != "" {
|
|
state.originalPromptCacheKey = promptCacheKey
|
|
state.promptCacheKey = codexIdentityConfuseUUID(auth.ID, "prompt-cache", promptCacheKey)
|
|
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", state.promptCacheKey)
|
|
}
|
|
if installationID := strings.TrimSpace(gjson.GetBytes(userPayload, "client_metadata.x-codex-installation-id").String()); installationID != "" {
|
|
rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-installation-id", codexIdentityConfuseUUID(auth.ID, "installation", installationID))
|
|
}
|
|
if turnMetadata := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.x-codex-turn-metadata").String()); turnMetadata != "" {
|
|
rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-turn-metadata", applyCodexTurnMetadataIdentityConfuse(turnMetadata, &state))
|
|
}
|
|
if state.promptCacheKey != "" {
|
|
if windowID := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.x-codex-window-id").String()); windowID != "" {
|
|
rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-window-id", state.promptCacheKey+":0")
|
|
}
|
|
}
|
|
|
|
return rawJSON, state
|
|
}
|
|
|
|
func applyCodexIdentityConfuseHeaders(headers http.Header, state *codexIdentityConfuseState) {
|
|
if headers == nil {
|
|
return
|
|
}
|
|
if state == nil || !state.enabled {
|
|
return
|
|
}
|
|
|
|
if rawTurnMetadata := strings.TrimSpace(headers.Get("X-Codex-Turn-Metadata")); rawTurnMetadata != "" {
|
|
headers.Set("X-Codex-Turn-Metadata", applyCodexTurnMetadataIdentityConfuse(rawTurnMetadata, state))
|
|
}
|
|
if state.promptCacheKey == "" {
|
|
return
|
|
}
|
|
|
|
setCodexSessionHeaderCasePreserved(headers, "Session_id", state.promptCacheKey)
|
|
if headerValueCaseInsensitive(headers, "Conversation_id") != "" {
|
|
setHeaderCasePreserved(headers, "Conversation_id", state.promptCacheKey)
|
|
}
|
|
headers.Set("X-Client-Request-Id", state.promptCacheKey)
|
|
headers.Set("Thread-Id", state.promptCacheKey)
|
|
headers.Set("X-Codex-Window-Id", state.promptCacheKey+":0")
|
|
}
|
|
|
|
func applyCodexTurnMetadataIdentityConfuse(rawTurnMetadata string, state *codexIdentityConfuseState) string {
|
|
updatedTurnMetadata := rawTurnMetadata
|
|
if state == nil || !state.enabled {
|
|
return updatedTurnMetadata
|
|
}
|
|
if state.promptCacheKey != "" && gjson.Get(rawTurnMetadata, "prompt_cache_key").Exists() {
|
|
updatedTurnMetadata, _ = sjson.Set(updatedTurnMetadata, "prompt_cache_key", state.promptCacheKey)
|
|
} else if state.promptCacheKey != "" && state.originalPromptCacheKey != "" {
|
|
updatedTurnMetadata = strings.ReplaceAll(updatedTurnMetadata, state.originalPromptCacheKey, state.promptCacheKey)
|
|
}
|
|
if turnID := strings.TrimSpace(gjson.Get(rawTurnMetadata, "turn_id").String()); turnID != "" {
|
|
updatedTurnMetadata, _ = sjson.Set(updatedTurnMetadata, "turn_id", state.confuseTurnID(turnID))
|
|
}
|
|
if state.promptCacheKey != "" && gjson.Get(rawTurnMetadata, "window_id").Exists() {
|
|
updatedTurnMetadata, _ = sjson.Set(updatedTurnMetadata, "window_id", state.promptCacheKey+":0")
|
|
}
|
|
return updatedTurnMetadata
|
|
}
|
|
|
|
func applyCodexIdentityConfuseResponsePayload(payload []byte, state codexIdentityConfuseState) []byte {
|
|
payload = replaceCodexIdentityResponsePayload(payload, state.originalPromptCacheKey, state.promptCacheKey)
|
|
for _, turnID := range state.turnIDs {
|
|
payload = replaceCodexIdentityResponsePayload(payload, turnID.original, turnID.confused)
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func applyCodexIdentityExposeResponsePayload(payload []byte, state codexIdentityConfuseState) []byte {
|
|
payload = replaceCodexIdentityResponsePayload(payload, state.promptCacheKey, state.originalPromptCacheKey)
|
|
for _, turnID := range state.turnIDs {
|
|
payload = replaceCodexIdentityResponsePayload(payload, turnID.confused, turnID.original)
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func (state *codexIdentityConfuseState) confuseTurnID(turnID string) string {
|
|
turnID = strings.TrimSpace(turnID)
|
|
if state == nil || !state.enabled || strings.TrimSpace(state.authID) == "" || turnID == "" {
|
|
return turnID
|
|
}
|
|
for _, replacement := range state.turnIDs {
|
|
if replacement.original == turnID || replacement.confused == turnID {
|
|
return replacement.confused
|
|
}
|
|
}
|
|
confusedTurnID := codexIdentityConfuseUUID(state.authID, "turn", turnID)
|
|
state.turnIDs = append(state.turnIDs, codexIdentityReplacement{original: turnID, confused: confusedTurnID})
|
|
return confusedTurnID
|
|
}
|
|
|
|
func replaceCodexIdentityResponsePayload(payload []byte, from string, to string) []byte {
|
|
from = strings.TrimSpace(from)
|
|
to = strings.TrimSpace(to)
|
|
if len(payload) == 0 || from == "" || to == "" || from == to || !bytes.Contains(payload, []byte(from)) {
|
|
return payload
|
|
}
|
|
return bytes.ReplaceAll(payload, []byte(from), []byte(to))
|
|
}
|
|
|
|
func codexIdentityConfuseEnabled(cfg *config.Config) bool {
|
|
if cfg == nil || !cfg.Codex.IdentityConfuse {
|
|
return false
|
|
}
|
|
strategy := strings.ToLower(strings.TrimSpace(cfg.Routing.Strategy))
|
|
return cfg.Routing.SessionAffinity || strategy == "fill-first" || strategy == "fillfirst" || strategy == "ff"
|
|
}
|
|
|
|
func codexIdentityConfuseUUID(authID string, kind string, value string) string {
|
|
name := strings.Join([]string{"cli-proxy-api", "codex", "identity-confuse", kind, strings.TrimSpace(authID), strings.TrimSpace(value)}, ":")
|
|
return uuid.NewSHA1(uuid.NameSpaceOID, []byte(name)).String()
|
|
}
|
|
|
|
func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config) {
|
|
r.Header.Set("Content-Type", "application/json")
|
|
r.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
var ginHeaders http.Header
|
|
if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
|
ginHeaders = ginCtx.Request.Header
|
|
}
|
|
|
|
if 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-Client-Request-Id", "")
|
|
cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
|
|
ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
|
|
|
|
if strings.Contains(r.Header.Get("User-Agent"), "Mac OS") {
|
|
misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString())
|
|
}
|
|
|
|
if stream {
|
|
r.Header.Set("Accept", "text/event-stream")
|
|
} else {
|
|
r.Header.Set("Accept", "application/json")
|
|
}
|
|
r.Header.Set("Connection", "Keep-Alive")
|
|
|
|
isAPIKey := false
|
|
if auth != nil && auth.Attributes != nil {
|
|
if v := strings.TrimSpace(auth.Attributes["api_key"]); v != "" {
|
|
isAPIKey = true
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
|
|
func newCodexStatusErr(statusCode int, body []byte) statusErr {
|
|
errCode := statusCode
|
|
if isCodexModelCapacityError(body) {
|
|
errCode = http.StatusTooManyRequests
|
|
}
|
|
body = classifyCodexStatusError(errCode, body)
|
|
err := statusErr{code: errCode, msg: string(body)}
|
|
if retryAfter := parseCodexRetryAfter(errCode, body, time.Now()); retryAfter != nil {
|
|
err.retryAfter = retryAfter
|
|
}
|
|
return err
|
|
}
|
|
|
|
func classifyCodexStatusError(statusCode int, body []byte) []byte {
|
|
code, errType, ok := codexStatusErrorClassification(statusCode, body)
|
|
if !ok {
|
|
return body
|
|
}
|
|
message := gjson.GetBytes(body, "error.message").String()
|
|
if message == "" {
|
|
message = gjson.GetBytes(body, "message").String()
|
|
}
|
|
if message == "" {
|
|
message = strings.TrimSpace(string(body))
|
|
}
|
|
if message == "" {
|
|
message = http.StatusText(statusCode)
|
|
}
|
|
out := []byte(`{"error":{}}`)
|
|
out, _ = sjson.SetBytes(out, "error.message", message)
|
|
out, _ = sjson.SetBytes(out, "error.type", errType)
|
|
out, _ = sjson.SetBytes(out, "error.code", code)
|
|
return out
|
|
}
|
|
|
|
func codexStatusErrorClassification(statusCode int, body []byte) (code string, errType string, ok bool) {
|
|
errorMessage := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.message").String()))
|
|
if errorMessage == "" {
|
|
errorMessage = strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "message").String()))
|
|
}
|
|
lower := strings.ToLower(strings.TrimSpace(string(body)))
|
|
upstreamCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()))
|
|
upstreamType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.type").String()))
|
|
isInvalidRequest := upstreamType == "" || upstreamType == "invalid_request_error"
|
|
|
|
switch {
|
|
case statusCode == http.StatusRequestEntityTooLarge || upstreamCode == "context_length_exceeded" || upstreamCode == "context_too_large" || isInvalidRequest && (strings.Contains(errorMessage, "context length") || strings.Contains(errorMessage, "context_length") || strings.Contains(errorMessage, "maximum context") || strings.Contains(errorMessage, "too many tokens")):
|
|
return "context_too_large", "invalid_request_error", true
|
|
case strings.Contains(lower, "invalid signature in thinking block") || strings.Contains(lower, "invalid_encrypted_content"):
|
|
return "thinking_signature_invalid", "invalid_request_error", true
|
|
case upstreamCode == "previous_response_not_found" || strings.Contains(lower, "previous_response_not_found") || strings.Contains(lower, "previous_response_id") && strings.Contains(lower, "not found"):
|
|
return "previous_response_not_found", "invalid_request_error", true
|
|
case statusCode == http.StatusUnauthorized || upstreamType == "authentication_error" || upstreamCode == "invalid_api_key" || strings.Contains(lower, "invalid or expired token") || strings.Contains(lower, "refresh_token_reused"):
|
|
return "auth_unavailable", "authentication_error", true
|
|
default:
|
|
return "", "", false
|
|
}
|
|
}
|
|
|
|
func normalizeCodexInstructions(body []byte) []byte {
|
|
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 ensureImageGenerationTool(body []byte, baseModel string, auth *cliproxyauth.Auth) []byte {
|
|
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" {
|
|
return body
|
|
}
|
|
}
|
|
body, _ = sjson.SetRawBytes(body, "tools.-1", imageGenToolJSON)
|
|
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
|
|
}
|
|
|
|
func isCodexModelCapacityError(errorBody []byte) bool {
|
|
if len(errorBody) == 0 {
|
|
return false
|
|
}
|
|
candidates := []string{
|
|
gjson.GetBytes(errorBody, "error.message").String(),
|
|
gjson.GetBytes(errorBody, "message").String(),
|
|
string(errorBody),
|
|
}
|
|
for _, candidate := range candidates {
|
|
lower := strings.ToLower(strings.TrimSpace(candidate))
|
|
if lower == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(lower, "selected model is at capacity") ||
|
|
strings.Contains(lower, "model is at capacity. please try a different model") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time.Duration {
|
|
if statusCode != http.StatusTooManyRequests || len(errorBody) == 0 {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(gjson.GetBytes(errorBody, "error.type").String()) != "usage_limit_reached" {
|
|
return nil
|
|
}
|
|
if resetsAt := gjson.GetBytes(errorBody, "error.resets_at").Int(); resetsAt > 0 {
|
|
resetAtTime := time.Unix(resetsAt, 0)
|
|
if resetAtTime.After(now) {
|
|
retryAfter := resetAtTime.Sub(now)
|
|
return &retryAfter
|
|
}
|
|
}
|
|
if resetsInSeconds := gjson.GetBytes(errorBody, "error.resets_in_seconds").Int(); resetsInSeconds > 0 {
|
|
retryAfter := time.Duration(resetsInSeconds) * time.Second
|
|
return &retryAfter
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
|
|
if a == nil {
|
|
return "", ""
|
|
}
|
|
if a.Attributes != nil {
|
|
apiKey = a.Attributes["api_key"]
|
|
baseURL = a.Attributes["base_url"]
|
|
}
|
|
if apiKey == "" && a.Metadata != nil {
|
|
if v, ok := a.Metadata["access_token"].(string); ok {
|
|
apiKey = v
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (e *CodexExecutor) resolveCodexConfig(auth *cliproxyauth.Auth) *config.CodexKey {
|
|
if auth == nil || e.cfg == nil {
|
|
return nil
|
|
}
|
|
var attrKey, attrBase string
|
|
if auth.Attributes != nil {
|
|
attrKey = strings.TrimSpace(auth.Attributes["api_key"])
|
|
attrBase = strings.TrimSpace(auth.Attributes["base_url"])
|
|
}
|
|
for i := range e.cfg.CodexKey {
|
|
entry := &e.cfg.CodexKey[i]
|
|
cfgKey := strings.TrimSpace(entry.APIKey)
|
|
cfgBase := strings.TrimSpace(entry.BaseURL)
|
|
if attrKey != "" && attrBase != "" {
|
|
if strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) {
|
|
return entry
|
|
}
|
|
continue
|
|
}
|
|
if attrKey != "" && strings.EqualFold(cfgKey, attrKey) {
|
|
if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) {
|
|
return entry
|
|
}
|
|
}
|
|
if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) {
|
|
return entry
|
|
}
|
|
}
|
|
if attrKey != "" {
|
|
for i := range e.cfg.CodexKey {
|
|
entry := &e.cfg.CodexKey[i]
|
|
if strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) {
|
|
return entry
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|