Documentation
¶
Overview ¶
Package codex implements llm.Client against the Codex backend of a ChatGPT subscription (https://chatgpt.com/backend-api/codex/responses), the way the Codex CLI uses it. It bills the subscription instead of an API key.
The backend speaks the OpenAI Responses API with a few rules: it only streams, never stores, and rejects max_output_tokens. Supported: system messages (as instructions), user and assistant text, image input and structured output (llm.Request.ResponseFormat). Tools, tool results, audio input and Thinking are not supported and fail with llm.ErrInvalidRequest; MaximumTokens, Temperature, StopSequences, Modalities and ImageConfiguration are ignored.
Log in with StartDeviceLogin + PollDeviceLogin (or ParseLogin), wrap the login in a TokenSource that persists every refresh, and pass it in Options.
Index ¶
Examples ¶
Constants ¶
const ( DefaultIssuer = "https://auth.openai.com" ClientID = "app_EMoamEEZ73f0CkXaXp7hrann" )
Protocol constants of the ChatGPT login, as used by the Codex CLI.
const (
// DefaultBaseURL is the Codex backend root.
DefaultBaseURL = "https://chatgpt.com/backend-api/codex"
)
Variables ¶
var ErrDeviceLoginDisabled = errors.New("aikido/codex: device-code login is not enabled")
ErrDeviceLoginDisabled means the issuer does not offer device-code login (it answered 404 to the user-code request).
var ErrReauthenticationRequired = errors.New("aikido/codex: ChatGPT login expired or revoked, log in again")
ErrReauthenticationRequired means the login is gone for good (refresh token reused, expired or revoked): the user has to log in again. Retrying cannot help.
var ErrRefreshInterrupted = errors.New("aikido/codex: refresh interrupted, the refresh token may have rotated; " +
"if the next refresh fails with refresh token reuse, sign in again")
ErrRefreshInterrupted means a refresh got no complete answer (transport failure, cut response, a 200 without tokens, the refresh's own timeout): the issuer may have rotated the refresh token, and the rotated one is lost. The cause is part of the message only: the error matches neither llm.ErrServerError nor a context error, so no retry helper (llm.CollectWithRetry, llm.CompleteWithRetry, llm.IsTransientServerError) replays the refresh. The next Token call refreshes with the previous token again; if that fails with ErrReauthenticationRequired, the user has to sign in again. No client can do better: the issuer offers no way to recover a rotated token whose answer did not arrive.
Functions ¶
This section is empty.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is a Codex-backend implementation of llm.Client. It is safe for concurrent use.
func NewClient ¶
NewClient builds a Client. It returns an error if options is nil or lacks Tokens or Originator. options is copied; later changes do not reach the Client.
Example ¶
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
"github.com/govengers/aikido/llm"
"github.com/govengers/aikido/llm/codex"
)
func main() {
// A stand-in for the Codex backend; leave BaseURL empty in real use.
backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(writer, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hello!\"}\n\n"+
"data: {\"type\":\"response.completed\",\"response\":{}}\n\n")
}))
defer backend.Close()
// The login comes from StartDeviceLogin + PollDeviceLogin or ParseLogin.
tokens, err := codex.NewTokenSource(&codex.TokenSourceOptions{
Tokens: codex.Tokens{AccessToken: "access", RefreshToken: "refresh", ExpiresAt: time.Now().Add(time.Hour)},
OnRefresh: func(context.Context, codex.Tokens) error {
return nil // persist the rotated login here
},
})
if err != nil {
fmt.Println(err)
return
}
client, err := codex.NewClient(&codex.Options{Tokens: tokens, Originator: "my-app", BaseURL: backend.URL})
if err != nil {
fmt.Println(err)
return
}
response, err := client.Complete(context.Background(), llm.Request{
Model: "gpt-5.5",
Messages: []llm.Message{{Role: llm.RoleUser, Content: "Say hello."}},
})
if err != nil {
fmt.Println(err)
return
}
fmt.Println(response.Text, response.FinishReason)
}
Output: Hello! stop
func (*Client) Complete ¶
Complete drains Stream: the backend has no non-streaming mode. On a stream error the Response holds what arrived before it.
func (*Client) Models ¶
Models lists the model ids the subscription offers, in the backend's order, hidden ones left out. Errors wrap the llm sentinels; a catalog over 8 MiB is an error.
func (*Client) Stream ¶
Stream sends one request and returns its events; EventEnd comes last, then the channel closes. The caller must drain the channel or cancel ctx. 429 and 5xx at stream start are retried, mid-stream errors and a failed token refresh are not (a replayed refresh token can revoke the login, see TokenSource). Errors wrap the llm sentinels (llm.ErrInvalidRequest for unsupported request features).
type DeviceCode ¶
type DeviceCode struct {
DeviceAuthorizationID string
UserCode string
VerificationURL string
// Interval is the pause between two polls, at least three seconds.
Interval time.Duration
ExpiresAt time.Time
}
DeviceCode is a started device login: show VerificationURL and UserCode to the user, then call PollDeviceLogin.
func StartDeviceLogin ¶
func StartDeviceLogin(ctx context.Context, options *LoginOptions) (DeviceCode, error)
StartDeviceLogin asks the issuer for a device code. A nil options uses the defaults. It returns ErrDeviceLoginDisabled if the issuer has no device login, and an error wrapping an llm sentinel for other failed statuses.
type LoginOptions ¶
type LoginOptions struct {
// Issuer overrides DefaultIssuer (tests).
Issuer string
// HTTPClient overrides a client with a 30 s timeout.
HTTPClient *http.Client
}
LoginOptions configure the login endpoints. A nil *LoginOptions and zero fields use the defaults. A login endpoint answer over 1 MiB is an error wrapping llm.ErrServerError.
type Options ¶
type Options struct {
// Tokens is required.
Tokens *TokenSource
// Originator is required: OpenAI asks third-party clients to identify
// themselves (e.g. "casa-mapa").
Originator string
// UserAgent defaults to Originator + "/aikido".
UserAgent string
// BaseURL overrides DefaultBaseURL (tests).
BaseURL string
// HTTPClient overrides a client without timeout; pass a ctx deadline.
HTTPClient *http.Client
}
Options configure the Client.
type TokenSource ¶
type TokenSource struct {
// contains filtered or unexported fields
}
TokenSource hands out a valid access token, refreshing it when it is about to expire. It is safe for concurrent use. Use one TokenSource per login and process: the mutex keeps exactly one refresh in flight, since a second refresh with the same (rotated) refresh token would revoke the login.
Known limit: a refresh whose answer does not arrive (ErrRefreshInterrupted) cannot be repaired client-side. One Token call never retries a refresh; the next call tries the previous refresh token once more, which either works (the issuer did not rotate) or fails with ErrReauthenticationRequired, and then only a new sign-in helps.
func NewTokenSource ¶
func NewTokenSource(options *TokenSourceOptions) (*TokenSource, error)
NewTokenSource wraps a login. It returns an error if options is nil or the login has no RefreshToken, since such a login cannot be kept valid.
func (*TokenSource) Token ¶
func (s *TokenSource) Token(ctx context.Context) (Tokens, error)
Token returns a login whose access token is valid for at least a few more minutes. A refresh that fails for good returns an error wrapping ErrReauthenticationRequired; one without a complete answer an error wrapping ErrRefreshInterrupted (see TokenSource). If persisting a refreshed login fails the error is returned, but the new login is kept in memory, so the next call succeeds without refreshing.
type TokenSourceOptions ¶
type TokenSourceOptions struct {
// Tokens is the login to keep valid, required to carry a RefreshToken.
Tokens Tokens
// OnRefresh, optional, receives every rotated login and should persist it;
// aikido never writes tokens anywhere.
OnRefresh func(ctx context.Context, tokens Tokens) error
// Login configures the refresh endpoint; the zero value uses the defaults.
Login LoginOptions
}
TokenSourceOptions configure a TokenSource.
type Tokens ¶
type Tokens struct {
AccessToken string
RefreshToken string
IDToken string
// AccountID and PlanType come from the access token's claims.
AccountID string
PlanType string
// ExpiresAt is the access token's exp claim.
ExpiresAt time.Time
}
Tokens is one ChatGPT login. RefreshToken rotates on every refresh and is single-use: the caller must persist what OnRefresh hands over, and must not share it with another process (a replayed refresh token revokes the login).
func ParseLogin ¶
ParseLogin reads a Codex CLI ~/.codex/auth.json. Only ChatGPT logins with an unexpired access token are accepted. Note the refresh token is then shared with that CLI: whichever side refreshes first logs the other out.
func PollDeviceLogin ¶
func PollDeviceLogin(ctx context.Context, options *LoginOptions, deviceCode DeviceCode) (Tokens, error)
PollDeviceLogin waits until the user confirmed the code (or it expired, or ctx ended), then exchanges it for tokens. A nil options uses the defaults. It returns ctx.Err() when ctx ends first.
func TokensFromJWT ¶
TokensFromJWT builds Tokens from a token response, reading account, plan and expiry from the access token. The signature is not verified: the token only travels between OpenAI and the caller. It fails when accessToken is not a JWT with a JSON payload.