mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
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:
1 parent
a824bf4a8e
commit
2381197d71
5 files changed
+150
-28
No files matched your search
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user