codex

package
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 4, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

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

View Source
const (
	DefaultIssuer = "https://auth.openai.com"
	ClientID      = "app_EMoamEEZ73f0CkXaXp7hrann"
)

Protocol constants of the ChatGPT login, as used by the Codex CLI.

View Source
const (
	// DefaultBaseURL is the Codex backend root.
	DefaultBaseURL = "https://chatgpt.com/backend-api/codex"
)

Variables

View Source
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).

View Source
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.

View Source
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

func NewClient(options *Options) (*Client, error)

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

func (c *Client) Complete(ctx context.Context, request llm.Request) (llm.Response, error)

Complete drains Stream: the backend has no non-streaming mode. On a stream error the Response holds what arrived before it.

func (*Client) Models

func (c *Client) Models(ctx context.Context) ([]string, error)

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

func (c *Client) Stream(ctx context.Context, request llm.Request) (<-chan llm.Event, error)

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

func ParseLogin(raw []byte) (Tokens, error)

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

func TokensFromJWT(accessToken, refreshToken, idToken string) (Tokens, error)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL