fix(codex): hit the /responses path and resolve account id per request

Addresses the two findings in anandh8x's CHANGES_REQUESTED review on #245.

The Codex provider previously routed the chat-completions body to
{baseURL}/chat/completions, which is not the path the Codex backend serves
(chatgpt.com/backend-api/codex/responses, the Responses API). Add an
Endpoint field to openai.Options so a flavor can target a sibling path on
the same host; the Codex constructor sets it to {baseURL}/responses and
falls back to the openai transport unchanged for everything else.

The factory was also passing the OAuth token's Account field as a static
AccountID at construction time. A refresh that rotates the token (and
its account claim) would leave a stale value on the wire. Drop the
static AccountID from the factory wiring; the per-request
AccountResolver (which reads the live store) is now the sole source, so
a refresh takes effect on the next outgoing request.

Tests:
- codex_test.go: handler mounted on /responses; expected path updated;
  TestCodexProviderResolverIsConsultedOnEveryRequest pins the per-request
  resolver behavior (a refresh-style sequence of calls picks up the new
  account id on the second attempt).
- factory_test.go: expected request path updated to /responses.
This commit is contained in:
Vasanthdev2004 committed 2026-06-18 20:38:08 +05:30
1 parent a824bf4a8e
commit 2381197d71
5 files changed
+150 -28

No files matched your search

+7 -6
View File
@@ -252,11 +252,11 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
// newCodexProvider builds a Codex-flavored openai provider for the chatgpt
// catalog. The Codex headers (`originator`, `chatgpt-account-id`) are
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is
// resolved dynamically from the stored OAuth token's Account field so a
// refresh that rotates the token (rare, but possible) keeps the right id
// on the wire.
// resolved dynamically from the stored OAuth token's Account field on
// every request so a refresh that rotates the token (and its account
// claim) takes effect immediately — a static AccountID captured at
// construction would go stale on the first refresh.
func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile, options Options) (zeroruntime.Provider, error) {
codexAccount := codexAccountFromStore(profile.Name)
resolver := openai.CodexAccountResolver(func(ctx context.Context) (string, bool, error) {
account := codexAccountFromStore(profile.Name)
if account == "" {
@@ -281,8 +281,9 @@ func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile,
// The chatgpt catalog overrides this with the Codex baseURL
// (https://chatgpt.com/backend-api/codex) when the user does not
// pin one, so this branch only needs to handle a user-supplied
// override.
AccountID: codexAccount,
// override. The codex provider's constructor derives the
// `/responses` endpoint from BaseURL, so the factory stays out of
// the path.
AccountResolver: resolver,
})
}
+6 -4
View File
@@ -291,10 +291,12 @@ func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
if transport.request == nil {
t.Fatal("HTTP client was not used")
}
// The chatgpt catalog's baseURL is the Codex backend. With no override,
// the Codex provider's stream hits that URL on /chat/completions.
if !strings.HasSuffix(transport.request.URL.Path, "/chat/completions") {
t.Fatalf("request URL path = %q, want .../chat/completions", transport.request.URL.Path)
// The chatgpt catalog's baseURL is the Codex backend. The Codex
// provider targets the Responses API at `{baseURL}/responses`, not
// `/chat/completions` (a chat-completions body on this path would 404
// or be misrouted by the Codex gateway).
if !strings.HasSuffix(transport.request.URL.Path, "/responses") {
t.Fatalf("request URL path = %q, want .../responses", transport.request.URL.Path)
}
wantHost := "chatgpt.com"
if !strings.Contains(transport.request.URL.Host, wantHost) {
+26 -8
View File
@@ -47,13 +47,21 @@ type CodexOptions struct {
// Codex backend logs the User-Agent for diagnostics, so a "codex_cli_rs"
// / "zero" branded value is recommended.
UserAgent string
// AccountID is the static `chatgpt-account-id` to inject on every
// request. When set, the AccountResolver is not consulted. The factory
// wires this from the OAuth token's `Account` field.
// AccountID is a static `chatgpt-account-id` that bypasses the resolver.
// Leave empty in production wiring so the AccountResolver is consulted on
// every request — that path reads the live OAuth token from the store and
// survives a refresh that rotates the bearer (and its account claim).
// The field exists for tests that want a pinned value without standing up
// a resolver.
AccountID string
// AccountResolver, when set, returns the account id dynamically per
// request. It is used as a fallback when AccountID is empty. Most callers
// (the factory) use AccountID instead; the resolver is for tests.
// request (including the 401-refresh retry). The factory wires this so a
// refresh that updates the stored token's Account field takes effect on
// the next outgoing request without restarting the agent.
//
// ok=false means "no account id known" — the Codex provider simply omits
// the header in that case (the request will 401, but that's recoverable:
// the user re-auths and the next login persists a fresh id).
AccountResolver CodexAccountResolver
// RequestTimeout caps each outbound Codex request. 0 => 60s. The Codex
// backend is hosted behind Cloudflare, so a few seconds is plenty for a
@@ -65,9 +73,11 @@ type CodexOptions struct {
// CodexProvider is the Codex-flavored variant of the openai provider. It is
// a thin shim that adds the Codex-specific request headers
// (`originator`, `chatgpt-account-id`, branded `User-Agent`) on top of the
// generic OpenAI chat-completions transport. The Codex backend accepts the
// same `/chat/completions` body shape today, so the request body is
// byte-for-byte the same as the openai provider.
// generic OpenAI chat-completions transport. The Codex backend speaks the
// Responses API at `{baseURL}/responses` (not `/chat/completions`), so the
// constructor overrides the endpoint the wrapped openai provider would
// otherwise default to. The request body is byte-for-byte the same
// chat-completions shape today; the path difference is the only divergence.
type CodexProvider struct {
inner *Provider
originator string
@@ -100,6 +110,14 @@ func NewCodexProvider(options CodexOptions) (*CodexProvider, error) {
// constructor sees the full struct; here we set SetRequestExtra below.
openaiOpts := options.Options
openaiOpts.UserAgent = userAgent
// The Codex backend serves the Responses API at `{baseURL}/responses`,
// not `/chat/completions`. Override the endpoint the openai transport
// would otherwise default to so the Codex provider hits the right path.
// (The chat-completions request body is still accepted; only the path
// diverges.)
if baseURL := strings.TrimRight(strings.TrimSpace(openaiOpts.BaseURL), "/"); baseURL != "" {
openaiOpts.Endpoint = baseURL + "/responses"
}
provider := &CodexProvider{
originator: originator,
+91 -7
View File
@@ -27,11 +27,13 @@ type codexRequest struct {
}
// newCodexTestServer returns an httptest.Server that records each request's
// Codex headers and writes a minimal [DONE] SSE stream.
// Codex headers and writes a minimal [DONE] SSE stream. The Codex provider
// targets `{BaseURL}/responses` (the Responses API on the chatgpt backend),
// so the handler is mounted on that path.
func newCodexTestServer(t *testing.T, rec *codexRequest) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/chat/completions", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/responses", func(w http.ResponseWriter, r *http.Request) {
rec.method = r.Method
rec.path = r.URL.Path
rec.originator = r.Header.Get(codexOriginatorHeader)
@@ -89,8 +91,8 @@ func TestCodexProviderSetsExpectedHeaders(t *testing.T) {
if rec.method != http.MethodPost {
t.Fatalf("method = %q, want POST", rec.method)
}
if rec.path != "/chat/completions" {
t.Fatalf("path = %q, want /chat/completions", rec.path)
if rec.path != "/responses" {
t.Fatalf("path = %q, want /responses", rec.path)
}
if rec.originator != "codex_cli_rs" {
t.Fatalf("originator = %q, want codex_cli_rs", rec.originator)
@@ -112,9 +114,9 @@ func TestCodexProviderSetsExpectedHeaders(t *testing.T) {
func TestCodexProviderUsesConfiguredBaseURL(t *testing.T) {
var rec codexRequest
// Run the test server on a custom path to confirm the request goes
// through {BaseURL}/chat/completions, not a hard-coded host.
// through {BaseURL}/responses, not a hard-coded host.
mux := http.NewServeMux()
customPath := "/api/v1/codex/chat/completions"
customPath := "/api/v1/codex/responses"
var hit atomic.Int32
mux.HandleFunc(customPath, func(w http.ResponseWriter, r *http.Request) {
hit.Add(1)
@@ -251,6 +253,88 @@ func TestCodexProviderAccountResolverIsUsedWhenAccountIDEmpty(t *testing.T) {
}
}
func TestCodexProviderResolverIsConsultedOnEveryRequest(t *testing.T) {
// The factory wires the AccountResolver from the OAuth store so a refresh
// that updates the stored token's Account field takes effect on the next
// outgoing request — not just the first. This test asserts the resolver
// runs once per request (and that the second request picks up the
// account id the resolver starts returning on call #2).
var hits atomic.Int32
var rec1, rec2 codexRequest
mux := http.NewServeMux()
mux.HandleFunc("/responses", func(w http.ResponseWriter, r *http.Request) {
n := hits.Add(1)
var rec *codexRequest
if n == 1 {
rec = &rec1
} else {
rec = &rec2
}
rec.path = r.URL.Path
rec.originator = r.Header.Get(codexOriginatorHeader)
rec.accountID = r.Header.Get(codexAccountHeader)
if n == 1 {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: [DONE]\n\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
resolverCalls := atomic.Int32{}
provider, err := NewCodexProvider(CodexOptions{
Options: Options{
BaseURL: srv.URL,
Model: "gpt-5",
// Wire an OAuth resolver so the openai provider's 401-retry
// path runs. The Codex resolver (AccountResolver) is what
// this test actually exercises.
OAuthResolver: func(_ context.Context, _ bool) (string, string, bool, error) {
return "Authorization", "Bearer oauth-tok", true, nil
},
},
// Static AccountID intentionally empty so the resolver is the
// source. The first call returns ok=false (no account); the second
// call (after the 401 refresh) returns the new id.
AccountResolver: func(_ context.Context) (string, bool, error) {
n := resolverCalls.Add(1)
if n == 1 {
return "", false, nil
}
return "acc-from-store", true, nil
},
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
drainCodexEvents(t, stream)
if hits.Load() != 2 {
t.Fatalf("server hit %d times, want 2 (initial + retry)", hits.Load())
}
if resolverCalls.Load() != 2 {
t.Fatalf("resolver called %d times, want 2 (once per request, including the 401 retry)", resolverCalls.Load())
}
if rec1.accountID != "" {
t.Fatalf("first attempt account id = %q, want empty (resolver returned ok=false on call 1)", rec1.accountID)
}
if rec2.accountID != "acc-from-store" {
t.Fatalf("retry account id = %q, want acc-from-store (resolver must re-run on every request)", rec2.accountID)
}
}
func TestCodexProviderOmitsAccountIDWhenResolverSaysNo(t *testing.T) {
var rec codexRequest
srv := newCodexTestServer(t, &rec)
@@ -319,7 +403,7 @@ func TestCodexProviderRetriesHeadersAfter401(t *testing.T) {
var hits atomic.Int32
var rec1, rec2 codexRequest
mux := http.NewServeMux()
mux.HandleFunc("/chat/completions", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/responses", func(w http.ResponseWriter, r *http.Request) {
n := hits.Add(1)
var rec *codexRequest
if n == 1 {
+20 -3
View File
@@ -25,8 +25,14 @@ const defaultStreamIdleTimeout = 90 * time.Second
// Options configures an OpenAI-compatible chat completions provider.
type Options struct {
APIKey string
BaseURL string
APIKey string
BaseURL string
// Endpoint, when non-empty, overrides the default `{BaseURL}/chat/completions`
// request URL. Used by flavors (notably the Codex provider) that speak a
// different path on the same host — e.g. `/responses` on the ChatGPT Codex
// backend. When empty, the openai provider falls back to its default
// chat-completions path so every existing caller is unchanged.
Endpoint string
Model string
AuthHeader string
AuthScheme string
@@ -60,6 +66,7 @@ type Options struct {
type Provider struct {
apiKey string
baseURL string
endpoint string
model string
authHeader string
authScheme string
@@ -90,6 +97,15 @@ func New(options Options) (*Provider, error) {
return nil, fmt.Errorf("invalid OpenAI base URL: %w", err)
}
// Endpoint overrides the default chat-completions path. When empty we
// append `/chat/completions` to the (trimmed) baseURL. When non-empty we
// trust the caller's value verbatim so a flavor (Codex) can target a
// sibling path on the same host without re-implementing the transport.
endpoint := strings.TrimSpace(options.Endpoint)
if endpoint == "" {
endpoint = baseURL + "/chat/completions"
}
httpClient := options.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
@@ -108,6 +124,7 @@ func New(options Options) (*Provider, error) {
return &Provider{
apiKey: options.APIKey,
baseURL: baseURL,
endpoint: endpoint,
model: model,
authHeader: strings.TrimSpace(options.AuthHeader),
authScheme: strings.TrimSpace(options.AuthScheme),
@@ -143,7 +160,7 @@ func (provider *Provider) StreamCompletion(
}
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
endpoint := provider.baseURL + "/chat/completions"
endpoint := provider.endpoint
// streamCtx lets the idle watchdog abort an in-flight body read by cancelling
// the request, rather than closing response.Body directly (which would race