Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions internal/runtime/executor/codex_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
}

url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
httpReq, err := e.cacheHelper(ctx, from, url, req, body, true)
if err != nil {
return resp, err
}
Expand Down Expand Up @@ -216,14 +216,10 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A

requestedModel := payloadRequestedModel(opts, req.Model)
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
body, _ = sjson.SetBytes(body, "model", baseModel)
body, _ = sjson.DeleteBytes(body, "stream")
if !gjson.GetBytes(body, "instructions").Exists() {
body, _ = sjson.SetBytes(body, "instructions", "")
}
body = normalizeCodexCompactRequestBody(body, baseModel)

url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
httpReq, err := e.cacheHelper(ctx, from, url, req, body, false)
if err != nil {
return resp, err
}
Expand Down Expand Up @@ -317,7 +313,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}

url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
httpReq, err := e.cacheHelper(ctx, from, url, req, body, true)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -596,7 +592,32 @@ func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*
return auth, nil
}

func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, req cliproxyexecutor.Request, rawJSON []byte) (*http.Request, error) {
func normalizeCodexCompactRequestBody(body []byte, baseModel string) []byte {
if len(body) == 0 {
return body
}

normalizedSource := sdktranslator.TranslateRequest(sdktranslator.FromString("openai-response"), sdktranslator.FromString("codex"), baseModel, body, false)
if !gjson.ValidBytes(normalizedSource) {
normalizedSource = body
}

normalized := []byte(`{}`)
normalized, _ = sjson.SetBytes(normalized, "model", baseModel)
for _, field := range []string{"input", "instructions", "previous_response_id"} {
value := gjson.GetBytes(normalizedSource, field)
if !value.Exists() {
continue
}
normalized, _ = sjson.SetRawBytes(normalized, field, []byte(value.Raw))
}
if !gjson.GetBytes(normalized, "instructions").Exists() {
normalized, _ = sjson.SetBytes(normalized, "instructions", "")
}
return normalized
}

func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, req cliproxyexecutor.Request, rawJSON []byte, includePromptCacheKey bool) (*http.Request, error) {
var cache codexCache
if from == "claude" {
userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
Expand All @@ -622,7 +643,7 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form
}
}

if cache.ID != "" {
if includePromptCacheKey && cache.ID != "" {
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawJSON))
Expand Down
4 changes: 2 additions & 2 deletions internal/runtime/executor/codex_executor_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
}
url := "https://example.com/responses"

httpReq, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, req, rawJSON)
httpReq, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, req, rawJSON, true)
if err != nil {
t.Fatalf("cacheHelper error: %v", err)
}
Expand All @@ -49,7 +49,7 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
t.Fatalf("Session_id = %q, want %q", gotSession, expectedKey)
}

httpReq2, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, req, rawJSON)
httpReq2, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, req, rawJSON, true)
if err != nil {
t.Fatalf("cacheHelper error (second call): %v", err)
}
Expand Down
118 changes: 118 additions & 0 deletions internal/runtime/executor/codex_executor_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ type codexCapturedRequest struct {
Path string
Accept string
Authorization string
Originator string
AccountID string
Version string
SessionID string
Body []byte
Expand Down Expand Up @@ -143,6 +145,8 @@ func TestCodexExecutorExecuteCompact_LocalServer_RequestShapeAndPayload(t *testi
Path: r.URL.Path,
Accept: r.Header.Get("Accept"),
Authorization: r.Header.Get("Authorization"),
Originator: r.Header.Get("Originator"),
AccountID: r.Header.Get("Chatgpt-Account-Id"),
Body: body,
}

Expand Down Expand Up @@ -185,6 +189,107 @@ func TestCodexExecutorExecuteCompact_LocalServer_RequestShapeAndPayload(t *testi
}
}

func TestCodexExecutorExecuteCompact_LocalServer_AuthBackedNormalizesRequest(t *testing.T) {
t.Parallel()

var captured codexCapturedRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
captured = codexCapturedRequest{
Path: r.URL.Path,
Accept: r.Header.Get("Accept"),
Authorization: r.Header.Get("Authorization"),
Originator: r.Header.Get("Originator"),
AccountID: r.Header.Get("Chatgpt-Account-Id"),
SessionID: r.Header.Get("Session_id"),
Body: body,
}

w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"id":"resp_compact_oauth","object":"response","status":"completed","usage":{"input_tokens":2,"output_tokens":3,"total_tokens":5}}`)
}))
defer server.Close()

req := cliproxyexecutor.Request{
Model: "gpt-5.4",
Payload: []byte(`{
"model":"gpt-5.4",
"instructions":"keep only compact fields",
"input":[
{"type":"message","role":"system","content":[{"type":"input_text","text":"be precise"}]},
{"type":"message","role":"user","content":[{"type":"input_text","text":"compact me"}]}
],
"store":true,
"stream":true,
"temperature":0.3,
"top_p":0.7,
"functions":[{"name":"lookup_issue","parameters":{"type":"object"}}],
"function_call":{"name":"lookup_issue"},
"context_management":[{"type":"compaction","compact_threshold":12000}],
"previous_response_id":"resp_prev_123",
"prompt_cache_key":"compact-seed",
"user":"integration-test"
}`),
}

executor := NewCodexExecutor(&config.Config{})
resp, err := executor.Execute(
context.Background(),
newCodexOAuthTestAuth(server.URL, "oauth-token", "chatgpt-acc"),
req,
cliproxyexecutor.Options{
SourceFormat: sdktranslator.FromString("openai-response"),
Alt: "responses/compact",
OriginalRequest: req.Payload,
},
)
if err != nil {
t.Fatalf("Execute() compact oauth error = %v", err)
}

if captured.Path != "/responses/compact" {
t.Fatalf("path = %q, want %q", captured.Path, "/responses/compact")
}
if captured.Accept != "application/json" {
t.Fatalf("Accept = %q, want %q", captured.Accept, "application/json")
}
if captured.Authorization != "Bearer oauth-token" {
t.Fatalf("Authorization = %q, want %q", captured.Authorization, "Bearer oauth-token")
}
if captured.Originator != "codex_cli_rs" {
t.Fatalf("Originator = %q, want %q", captured.Originator, "codex_cli_rs")
}
if captured.AccountID != "chatgpt-acc" {
t.Fatalf("Chatgpt-Account-Id = %q, want %q", captured.AccountID, "chatgpt-acc")
}
if captured.SessionID != "compact-seed" {
t.Fatalf("Session_id = %q, want %q", captured.SessionID, "compact-seed")
}
if gotModel := gjson.GetBytes(captured.Body, "model").String(); gotModel != "gpt-5.4" {
t.Fatalf("request model = %q, want %q", gotModel, "gpt-5.4")
}
if gotRole := gjson.GetBytes(captured.Body, "input.0.role").String(); gotRole != "developer" {
t.Fatalf("request input[0].role = %q, want %q", gotRole, "developer")
}
if gotText := gjson.GetBytes(captured.Body, "input.1.content.0.text").String(); gotText != "compact me" {
t.Fatalf("request input[1] text = %q, want %q", gotText, "compact me")
}
if gotInstructions := gjson.GetBytes(captured.Body, "instructions").String(); gotInstructions != "keep only compact fields" {
t.Fatalf("request instructions = %q, want %q", gotInstructions, "keep only compact fields")
}
if gotPrev := gjson.GetBytes(captured.Body, "previous_response_id").String(); gotPrev != "resp_prev_123" {
t.Fatalf("previous_response_id = %q, want %q", gotPrev, "resp_prev_123")
}
for _, field := range []string{"store", "stream", "temperature", "top_p", "functions", "function_call", "context_management", "prompt_cache_key", "user", "parallel_tool_calls", "include"} {
if got := gjson.GetBytes(captured.Body, field); got.Exists() {
t.Fatalf("%s should be removed for compact path, got %s", field, got.Raw)
}
}
if string(resp.Payload) != `{"id":"resp_compact_oauth","object":"response","status":"completed","usage":{"input_tokens":2,"output_tokens":3,"total_tokens":5}}` {
t.Fatalf("payload = %s", string(resp.Payload))
}
}

func TestCodexExecutorExecute_LocalServer_Parses429RetryAfter(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -614,6 +719,19 @@ func newCodexTestAuth(baseURL, apiKey string) *cliproxyauth.Auth {
}
}

func newCodexOAuthTestAuth(baseURL, accessToken, accountID string) *cliproxyauth.Auth {
return &cliproxyauth.Auth{
Attributes: map[string]string{
"base_url": baseURL,
},
Metadata: map[string]any{
"email": "user@example.com",
"access_token": accessToken,
"account_id": accountID,
},
}
}

func newCodexResponsesRequest(prompt string) cliproxyexecutor.Request {
return cliproxyexecutor.Request{
Model: "gpt-5.4",
Expand Down
85 changes: 85 additions & 0 deletions sdk/api/handlers/openai/openai_responses_compact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@ package openai
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/gin-gonic/gin"
internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/tidwall/gjson"
)

type compactCaptureExecutor struct {
Expand Down Expand Up @@ -118,3 +122,84 @@ func TestOpenAIResponsesCompactExecute(t *testing.T) {
t.Fatalf("body = %s", resp.Body.String())
}
}

func TestOpenAIResponsesCompactExecute_CodexAuthManagerPath(t *testing.T) {
gin.SetMode(gin.TestMode)

var gotPath string
var gotAuthorization string
var gotBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuthorization = r.Header.Get("Authorization")
body, _ := io.ReadAll(r.Body)
gotBody = body

w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"resp_compact_auth","object":"response","status":"completed","usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`))
}))
defer server.Close()

cfg := &internalconfig.Config{
CodexKey: []internalconfig.CodexKey{
{
APIKey: "compact-key",
BaseURL: server.URL,
Models: []internalconfig.CodexModel{
{Name: "gpt-5-codex", Alias: "compact-alias"},
},
},
},
}

manager := coreauth.NewManager(nil, nil, nil)
manager.SetConfig(cfg)
manager.RegisterExecutor(runtimeexecutor.NewCodexExecutor(cfg))

auth := &coreauth.Auth{
ID: "codex-compact-auth",
Provider: "codex",
Status: coreauth.StatusActive,
Attributes: map[string]string{
"api_key": "compact-key",
"base_url": server.URL,
},
}
if _, err := manager.Register(context.Background(), auth); err != nil {
t.Fatalf("Register auth: %v", err)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "compact-alias"}})
manager.RefreshSchedulerEntry(auth.ID)
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})

base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses/compact", h.Compact)

req := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", strings.NewReader(`{"model":"compact-alias","input":"hello","stream":false}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)

if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
}
if gotPath != "/responses/compact" {
t.Fatalf("path = %q, want %q", gotPath, "/responses/compact")
}
if gotAuthorization != "Bearer compact-key" {
t.Fatalf("Authorization = %q, want %q", gotAuthorization, "Bearer compact-key")
}
if gotModel := gjson.GetBytes(gotBody, "model").String(); gotModel != "gpt-5-codex" {
t.Fatalf("upstream model = %q, want %q", gotModel, "gpt-5-codex")
}
if gotStream := gjson.GetBytes(gotBody, "stream"); gotStream.Exists() {
t.Fatalf("upstream stream should be removed for compact path, got %s", gotStream.Raw)
}
if gotID := gjson.Get(resp.Body.String(), "id").String(); gotID != "resp_compact_auth" {
t.Fatalf("response id = %q, want %q", gotID, "resp_compact_auth")
}
}
Loading