mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* 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.
210 lines
8.8 KiB
Go
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
|
|
}
|