Files
Vasanth T ab3d09d78e feat(oauth): Hugging Face + ChatGPT Codex as first-class built-in OAuth presets (#245)
* feat(oauth): add Hugging Face + ChatGPT Codex as first-class built-in OAuth providers

Adds two new built-in OAuth presets that round out the existing OpenRouter and
xAI surfaces, so the TUI `/provider` OAuth chooser and `zero auth login` work
out of the box for these providers.

Hugging Face
- Preset scopes, endpoints, and OIDC issuer are pre-filled; the operator
  supplies a "public" OAuth client_id (no secret) via
  `ZERO_OAUTH_HUGGINGFACE_CLIENT_ID` after registering an app at
  huggingface.co/settings/applications/new.
- Default flow is RFC 8628 device code, so headless/SSH is the first-class
  path (matches the existing xAI posture).
- The bearer works against the OpenAI-compatible Inference Providers router
  at `https://router.huggingface.co/v1` for hundreds of OSS models.

ChatGPT (Codex backend)
- Uses the publicly-shipped Codex CLI client identity
  (`app_EMoamEEZ73f0CkXaXp7hrann`), opt-in via `ZERO_OAUTH_ALLOW_PRESETS=1`,
  overridable via `ZERO_OAUTH_CHATGPT_*`.
- The bespoke `provideroauth.ChatGPTLogin` extracts the `chatgpt_account_id`
  claim from the OIDC ID token (added as `IDToken` on the `oauth.Token`
  struct) and stores it on `Token.Account` so the Codex provider can inject
  it as a header on every request.
- A new `internal/providers/openai/codex.go` wraps the openai provider with
  the Codex-flavored request shape: `originator: codex_cli_rs`,
  `chatgpt-account-id: <claim>`, and a branded User-Agent. The factory
  branches off the catalog id (not the provider kind) so other
  openai-compatible providers keep the standard openai path unchanged.
- The openai provider gained a `SetRequestExtra` callback so the Codex
  wrapper can inject per-request headers without coupling the openai
  provider to Codex.

CLI / TUI
- `zero auth chatgpt` is a one-shot login command (analog to
  `zero auth openrouter`); the TUI's OAuth chooser now lists both new
  providers automatically (the list is built from
  `providercatalog.OAuthProviders()`).
- The provider-wizard visible-row cap was bumped from 8 to 10 so the
  common providers (OpenAI, Anthropic, Google, Ollama, OpenRouter, the
  new Hugging Face, the new ChatGPT, Groq, …) all fit without scrolling
  the test fixtures.

Tests
- `internal/oauth/presets_test.go`: presets resolve correctly with the
  expected endpoints, scopes, and flows; env overrides win; Hugging Face
  preset stays inert without a client_id (the safety posture the existing
  xAI tests assert).
- `internal/providercatalog/oauth_test.go` + `catalog_test.go`: catalog
  now exposes 4 OAuth providers (`openrouter`, `huggingface`, `chatgpt`,
  `xai`); the `chatgpt` descriptor is `OAuth=true` with
  `OAuthDeviceFlow=false` (Codex is loopback-only) and `RequiresAuth=true`
  via an inline IIFE that sets it after the openAICompat helper.
- `internal/provideroauth/chatgpt_test.go`: ID-token claim extraction
  round-trips (happy path, missing claim, missing token, tampered JWS,
  non-string claim); the full login flow runs against a stub auth server
  and verifies the preset's client_id reaches the token endpoint.
- `internal/providers/openai/codex_test.go`: every request gets the three
  Codex headers; the 401-refresh retry path keeps them on the second
  attempt; configured baseURL wins; default originator is `codex_cli_rs`;
  branded User-Agent wins over the openai default; AccountResolver is
  consulted when AccountID is empty; the header is omitted when the
  resolver says no.

Out of scope (separate PRs): multi-account / profiles, generic
template-driven OAuth wizard, MCP / provider OAuth store unification,
subscription-proxy autoconfig.

* fix(ci): gofmt chatgpt_test.go and provider_wizard.go

The ubuntu smoke job's "Check formatting" step lists these two files as
unformatted (struct field alignment). The local gofmt run on Windows
also lists ~170 other files, but those are CRLF-vs-LF noise from
checkout and not real gofmt issues on Linux; the CI only flags the
two struct-alignment changes below.

- internal/provideroauth/chatgpt_test.go: align chatgptTestServer fields
- internal/tui/provider_wizard.go: align ChatGPTOptions literal fields

* 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.

* Addresses the Codex path shape on /responses.

The previous fix (2381197) corrected the URL and the per-request account
resolver, but the Codex provider still spoke chat-completions: it sent
{model, messages, tools, stream, stream_options, max_completion_tokens}
to /responses and parsed data: [DONE]-terminated SSE frames
(choices[].delta). The Codex backend at chatgpt.com/backend-api/codex
serves the Responses API — input items, function_call /
function_call_output, response.output_text.delta /
response.function_call_arguments.delta events — and rejects a
chat-completions body.

The Codex provider now has its own request/stream path that matches the
Responses schema:

- internal/providers/openai/codex_responses.go (new) holds the wire
  types (responsesRequest, inputItem, contentItem, responsesTool,
  responsesEvent, itemPayload, responsePayload, usagePayload), the
  request builder (system → message/role=system, user/assistant →
  message with text or content parts, prior tool calls → function_call,
  tool results → function_call_output, tools → responsesTool), the
  Responses SSE dispatcher, the tool-call accumulator
  (response.output_item.added registers the call id + name, the
  argument deltas accumulate, response.output_item.done finalizes),
  and the terminal-event handler (response.completed emits usage +
  done, response.failed surfaces the error after the usage chunk so
  token accounting is preserved).

- internal/providers/openai/codex.go: StreamCompletion now marshals a
  Responses request and dispatches via the new streamResponses
  function. The wrapped openai Provider is still constructed for its
  validated endpoint / auth / retry / idle-timeout config and its
  SetRequestExtra callback, but the openai chat-completions stream
  parser is no longer used for Codex. injectCodexHeaders still runs
  on every attempt (including the 401-refresh retry) so the Codex
  headers stay attached to the rotated bearer.

The shared providerio plumbing (auth + 401-retry, SSE scanner with
the idle watchdog, error humanization, secret redaction) is reused
verbatim so the OAuth 401-refresh and bearer-rotation paths stay
identical to the rest of the openai-compatible surface. The
stream-end fallback surfaces a StreamEventDone with
FinishReasonLength when the Codex backend closes the stream without
a response.completed (e.g. an internal truncation), so the runtime
can react instead of hanging on a phantom completion.

Tests:
- internal/providers/openai/codex_test.go:
  - newCodexTestServer now returns a minimal Responses sequence
    (response.created + response.completed); newCodexResponsesServer
    takes a custom payload sequence for richer cases.
  - The two tests with inline handlers (TestCodexProviderResolverIs-
    ConsultedOnEveryRequest, TestCodexProviderRetriesHeadersAfter401)
    use a response.completed payload on the second attempt instead of
    data: [DONE].
  - TestCodexProviderSendsResponsesRequestShape replaces the old
    TestCodexProviderSendsRequestBody and pins the Responses shape:
    the body MUST carry an `input` array of message / function_call
    / function_call_output items and `tools` of function type, and
    MUST NOT carry the chat-completions `messages` field.
  - TestCodexProviderParsesResponsesTextDeltas: response.output_text.
    delta events surface as StreamEventText in order, the
    response.completed usage chunk surfaces as StreamEventUsage
    (with input_tokens, output_tokens, cached_input_tokens, and
    reasoning_tokens from the optional details blocks), and
    StreamEventDone follows.
  - TestCodexProviderParsesResponsesToolCalls:
    response.output_item.added emits StreamEventToolCallStart with
    the right name, response.function_call_arguments.delta events
    emit StreamEventToolCallDelta in order, response.output_item.done
    emits StreamEventToolCallEnd.
  - TestCodexProviderSendsAssistantToolCallsAsInputItems: a runtime
    message with content + tool_calls serializes as BOTH the
    assistant message and a function_call item; a tool result
    serializes as a function_call_output. The expected count is 5
    items (user + assistant text + function_call + function_call_
    output + user) — earlier this test had 4, which would have
    missed the assistant message before the function_call.
  - TestCodexProviderEmitsErrorOnResponseErrorEvent: a response.error
    event surfaces as a single StreamEventError with the server's
    message and stops the scan.
  - TestCodexProviderEmitsErrorOnMalformedStream: a payload that
    parses as JSON but lacks the `type` field is reported as a
    stream error rather than silently dropped.
  - TestCodexProviderEmitsLengthFinishWhenStreamEndsWithout-
    Completion: a stream that closes without a response.completed
    surfaces a StreamEventDone with FinishReasonLength.

Verification:
- `go build ./...` clean (with the user-owned untracked files moved
  aside per the project's working-tree convention).
- `go vet ./internal/providers/...` clean.
- `go test ./internal/providers/... ./internal/providercatalog/...
  ./internal/provideroauth/... ./internal/oauth/...` PASS.
- `gofmt -l` on the three changed files clean.
2026-06-19 07:23:53 +05:30

210 lines
8.8 KiB
Go

package openai
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Codex-specific headers, lifted from the openai/codex CLI's behavior. The
// Codex backend at https://chatgpt.com/backend-api/codex requires all three on
// every request — the bearer, the `originator` that identifies the client, and
// the `chatgpt-account-id` claim that ties the bearer to a specific ChatGPT
// subscription. Drop any one and the request 401s at Cloudflare.
const (
codexDefaultOriginator = "codex_cli_rs"
codexAccountHeader = "chatgpt-account-id"
codexOriginatorHeader = "originator"
)
// CodexAccountResolver returns the `chatgpt_account_id` claim for the bearer
// that is about to be sent on a request. It is invoked once per request
// (including the 401-refresh retry) so the value can be re-derived from the
// live OAuth token rather than cached at construction time.
//
// 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).
type CodexAccountResolver func(ctx context.Context) (accountID string, ok bool, err error)
// CodexOptions configures a Codex-flavored provider. It embeds the standard
// openai.Options so every chat-completions knob (model, custom headers,
// MaxTokens, parse-think-tags, etc.) is supported unchanged. The Codex-specific
// fields below add the headers the Codex backend requires.
type CodexOptions struct {
Options
// Originator is the value of the `originator` header. Empty defaults to
// "codex_cli_rs" (the same value the openai/codex CLI ships). The Codex
// backend reads this to attribute traffic; changing it is supported but
// unusual.
Originator string
// UserAgent overrides the openai Options.UserAgent when non-empty. The
// Codex backend logs the User-Agent for diagnostics, so a "codex_cli_rs"
// / "zero" branded value is recommended.
UserAgent string
// 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 (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
// healthy connection; the cap is a safety net for the rare case the
// request hangs past the streaming idle watchdog.
RequestTimeout time.Duration
}
// 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 a
// Responses-API transport. The Codex backend at
// `https://chatgpt.com/backend-api/codex/responses` serves the OpenAI
// Responses API (not the chat-completions API), so the constructor
// overrides the endpoint AND the transport — the wrapped Provider is used
// only for its validated endpoint / auth / retry / timeout config; the
// actual request body and SSE parser live in codex_responses.go.
type CodexProvider struct {
inner *Provider
originator string
userAgent string
accountID string
accountResolve CodexAccountResolver
}
// NewCodexProvider builds a CodexProvider. It is a thin wrapper over the
// openai.New constructor plus the Codex-specific Options.SetRequestExtra
// callback that injects the Codex headers. The wrapped Provider supplies
// the validated endpoint / auth / retry / idle-timeout config; the
// request body and stream parser are Codex-specific and live in
// codex_responses.go.
func NewCodexProvider(options CodexOptions) (*CodexProvider, error) {
originator := strings.TrimSpace(options.Originator)
if originator == "" {
originator = codexDefaultOriginator
}
userAgent := strings.TrimSpace(options.UserAgent)
if userAgent == "" {
// Default to the openai Options.UserAgent (typically "zero/<ver>")
// and fall back to a Codex-branded value when the caller didn't set
// either — the Codex backend logs the User-Agent and a clearly
// branded string makes operational issues easier to triage.
userAgent = strings.TrimSpace(options.Options.UserAgent)
if userAgent == "" {
userAgent = codexDefaultOriginator
}
}
// Reuse the openai provider's transport configuration. Embed Options so
// the openai constructor sees the full struct; here we set
// SetRequestExtra below. The inner Provider is NOT used for streaming
// — the Codex provider has its own Responses-API stream path — but its
// fields (endpoint, httpClient, auth headers, idle timeout) are the
// single source of truth for the URL / auth / retry plumbing.
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.
if baseURL := strings.TrimRight(strings.TrimSpace(openaiOpts.BaseURL), "/"); baseURL != "" {
openaiOpts.Endpoint = baseURL + "/responses"
}
provider := &CodexProvider{
originator: originator,
userAgent: userAgent,
accountID: strings.TrimSpace(options.AccountID),
accountResolve: options.AccountResolver,
}
openaiOpts.SetRequestExtra = provider.injectCodexHeaders
inner, err := New(openaiOpts)
if err != nil {
return nil, fmt.Errorf("openai codex provider: %w", err)
}
provider.inner = inner
return provider, nil
}
// StreamCompletion builds a Responses-API request and dispatches it via
// the Codex-specific stream path in codex_responses.go. The request body
// is the Responses schema (input items, tools, max_output_tokens) and
// the response is parsed from the typed SSE event stream the Codex
// backend emits (response.output_text.delta, response.function_call_
// arguments.delta, response.completed, ...).
func (p *CodexProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
responsesReq, err := p.buildResponsesRequest(request)
if err != nil {
return nil, fmt.Errorf("encode codex request: %w", err)
}
body, err := json.Marshal(responsesReq)
if err != nil {
return nil, fmt.Errorf("encode codex request: %w", err)
}
events := make(chan zeroruntime.StreamEvent, 16)
go func() {
defer close(events)
p.streamResponses(ctx, body, events)
}()
return events, nil
}
// injectCodexHeaders is the SetRequestExtra callback installed on the wrapped
// openai provider. It sets the three Codex-required headers; the bearer is
// applied separately by the openai provider's auth path.
func (p *CodexProvider) injectCodexHeaders(req *http.Request) {
req.Header.Set(codexOriginatorHeader, p.originator)
if account, ok, err := p.resolveAccount(req.Context()); err == nil && ok && account != "" {
req.Header.Set(codexAccountHeader, account)
}
// Branded User-Agent overrides the openai provider's default. Set last
// so a caller that supplies a different UserAgent in custom-headers is
// still respected (the openai provider's setExtra already ran before us).
if p.userAgent != "" {
req.Header.Set("User-Agent", p.userAgent)
}
}
// resolveAccount returns the account id to inject, preferring the static
// AccountID (set at construction from the OAuth token) and falling back to the
// per-request AccountResolver. ok=false means "omit the header".
func (p *CodexProvider) resolveAccount(ctx context.Context) (string, bool, error) {
if p.accountID != "" {
return p.accountID, true, nil
}
if p.accountResolve != nil {
account, ok, err := p.accountResolve(ctx)
if err != nil {
return "", false, err
}
return account, ok, nil
}
return "", false, nil
}
// ValidateAccount is a convenience for tests/callers that want to confirm the
// account id is the right shape (non-empty, trimmed). It is a no-op helper
// rather than a constructor check so a Codex provider can be built before the
// first login completes.
func ValidateAccount(account string) error {
if strings.TrimSpace(account) == "" {
return errors.New("openai codex: account id is empty")
}
return nil
}