From 3a51a4cbaa9fbf02d51cbd282b7eaf8d75b84c49 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 20 Aug 2026 04:37:59 +0200 Subject: [PATCH 01/11] feat(chatgpt): route Codex traffic through a ChatGPT subscription Adds a `chatgpt` provider that calls the Codex backend behind a ChatGPT plan, so Codex can run through GoModel while staying billed to the subscription instead of an OpenAI Platform key. The upstream speaks a narrow Responses dialect: streaming only, `store: false`, and a strict parameter allowlist that rejects temperature, top_p, max_output_tokens, previous_response_id, truncation, metadata, user, and service_tier. The provider builds the body from an explicit allowlist, wraps string inputs in the required message list, and collapses the upstream stream for non-streaming callers, so the gateway's OpenAI-compatible surface is unchanged. --- .env.template | 9 + README.md | 5 +- config/config.example.yaml | 8 + docs/docs.json | 1 + docs/guides/codex.mdx | 150 ++++++++----- docs/providers/chatgpt.mdx | 93 ++++++++ docs/providers/overview.mdx | 8 + internal/providers/chatgpt/auth.go | 38 ++++ internal/providers/chatgpt/chatgpt.go | 190 ++++++++++++++++ internal/providers/chatgpt/chatgpt_test.go | 245 +++++++++++++++++++++ internal/providers/chatgpt/request.go | 68 ++++++ internal/providers/chatgpt/stream.go | 58 +++++ run/providers.go | 2 + run/providers_test.go | 2 +- 14 files changed, 821 insertions(+), 56 deletions(-) create mode 100644 docs/providers/chatgpt.mdx create mode 100644 internal/providers/chatgpt/auth.go create mode 100644 internal/providers/chatgpt/chatgpt.go create mode 100644 internal/providers/chatgpt/chatgpt_test.go create mode 100644 internal/providers/chatgpt/request.go create mode 100644 internal/providers/chatgpt/stream.go diff --git a/.env.template b/.env.template index 8ea4db1d8..6f460c86f 100644 --- a/.env.template +++ b/.env.template @@ -427,6 +427,15 @@ # OPENAI_SESSION_STICKY_KEYS=false # OPENAI_BASE_URL=https://api.openai.com/v1 +# ChatGPT subscription (Codex backend, /v1/responses only) +# Billed against your ChatGPT plan instead of OpenAI Platform credit. The key is +# the access token from `codex login`; it expires roughly every 10 days. +# CHATGPT_API_KEY=$(jq -r .tokens.access_token ~/.codex/auth.json) +# CHATGPT_API_KEY= +# CHATGPT_BASE_URL=https://chatgpt.com/backend-api/codex +# Optional model override; defaults to the models a ChatGPT plan can call. +# CHATGPT_MODELS=gpt-5.6-sol,gpt-5.5,gpt-5.4 + # Anthropic # Accepts a Console API key (sk-ant-api...) or a Claude subscription OAuth # token from `claude setup-token` (sk-ant-oat...; Claude Code traffic only). diff --git a/README.md b/README.md index c6afb5827..ee234f5d4 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,9 @@ GoModel supports OpenAI, Anthropic, Cohere, Google Gemini, Vertex AI, DeepSeek, Groq, Fireworks AI, Meta (Muse Spark), OpenRouter, Z.ai, xAI (Grok), Alibaba Cloud Model Studio (Bailian), Kilo AI, MiniMax, Xiaomi MiMo, OpenCode Go, Azure OpenAI, Oracle, Ollama, SGLang, vLLM, llm-d, Amazon Bedrock Runtime, Amazon -Bedrock Mantle, and all OpenAI-compatible providers. Voice: ElevenLabs -(text-to-speech and speech-to-text). +Bedrock Mantle, and all OpenAI-compatible providers. Subscription-billed: +ChatGPT (the Codex backend) and Claude. Voice: ElevenLabs (text-to-speech and +speech-to-text). See the [Providers Overview](https://gomodel.enterpilot.io/docs/providers/overview?utm_source=readme) for the full per-provider feature matrix (chat, `/responses`, embeddings, files, batches, diff --git a/config/config.example.yaml b/config/config.example.yaml index 785bee4b1..b1f6aa666 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -337,6 +337,14 @@ providers: type: anthropic api_key: "sk-ant-..." + # ChatGPT subscription (Codex backend). Serves /v1/responses only and is + # billed against your ChatGPT plan. The key is the access token from + # `codex login`: jq -r .tokens.access_token ~/.codex/auth.json + chatgpt: + type: chatgpt + api_key: "${CHATGPT_API_KEY}" + # models: [gpt-5.6-sol, gpt-5.5, gpt-5.4] + cohere: type: cohere api_key: "${COHERE_API_KEY}" diff --git a/docs/docs.json b/docs/docs.json index f348e58c6..def02d552 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -166,6 +166,7 @@ "providers/overview", "providers/key-rotation", "providers/anthropic", + "providers/chatgpt", "providers/cohere", "providers/gemini", "providers/deepseek", diff --git a/docs/guides/codex.mdx b/docs/guides/codex.mdx index e49b450aa..d533c433b 100644 --- a/docs/guides/codex.mdx +++ b/docs/guides/codex.mdx @@ -1,33 +1,57 @@ --- title: "GoModel & Codex" -description: "Route OpenAI Codex through GoModel's Responses API: run the gateway with Docker, point Codex at it with a master key, and verify the setup." +description: "Put GoModel between Codex and your models: keep billing on your ChatGPT subscription, or route Codex to any other provider." icon: "code-xml" -keywords: ["Codex", "OpenAI Codex", "coding agent", "setup guide"] +keywords: ["Codex", "OpenAI Codex", "ChatGPT subscription", "coding agent", "setup guide"] --- GoModel is a good fit for Codex because Codex already targets the OpenAI Responses API. -Flow: +`Codex -> GoModel -> upstream` -`Codex -> GoModel -> upstream model provider` +Two ways to run it: + +| Mode | Upstream | Billing | +| ---- | -------- | ------- | +| **Subscription** | The Codex backend behind your ChatGPT plan | ChatGPT subscription quota | +| **Bring your own provider** | OpenAI, DeepSeek, Anthropic, … | That provider's API credit | + +Either way Codex talks to GoModel with a GoModel master key, so every request +shows up in the dashboard. ## Before you start - Install Codex on your machine. - Choose a GoModel master key, for example `change-me`. -- Make sure GoModel has the upstream provider key for the models you want to use. - - You can keep using Codex with a ChatGPT subscription sign-in, but GoModel - still needs a gateway credential from Codex and an upstream provider key of - its own. In this guide, `OPENAI_API_KEY=change-me` is the GoModel master key - that Codex sends to GoModel, not your OpenAI Platform key. - + + -## 1. Run GoModel +Sign in once so Codex writes its token file, then hand that token to GoModel: -Start GoModel with a master key and an OpenAI provider key: +```bash +codex login +export CHATGPT_API_KEY=$(jq -r .tokens.access_token ~/.codex/auth.json) +``` + +Run GoModel with it: + +```bash +docker run --rm -p 8080:8080 \ + -e GOMODEL_MASTER_KEY="change-me" \ + -e CHATGPT_API_KEY="$CHATGPT_API_KEY" \ + enterpilot/gomodel +``` + +Usage is billed against your ChatGPT plan. See the +[ChatGPT provider page](/providers/chatgpt) for the model list, the token's +~10-day lifetime, and which Responses parameters the backend accepts. + + + + +Make sure GoModel has the upstream provider key for the models you want: ```bash docker run --rm -p 8080:8080 \ @@ -36,14 +60,22 @@ docker run --rm -p 8080:8080 \ enterpilot/gomodel ``` -## 2. Confirm the Responses API + + You can stay signed into Codex with ChatGPT while using this mode, but the + custom provider still needs its own gateway credential and GoModel still needs + an upstream provider key. + + + + + +## 1. Confirm the Responses API Before testing Codex itself, you can optionally verify that GoModel answers a normal Responses API request: -This step is optional. If you are sure you have configured a valid -`OPENAI_API_KEY` in GoModel, you can skip it and go straight to -[step 3](#3-configure-codex-to-use-gomodel). +This step is optional. If you are sure GoModel has a working credential, skip +it and go straight to [step 2](#2-configure-codex-to-use-gomodel). @@ -52,7 +84,7 @@ curl -s http://localhost:8080/v1/responses \ -H "Authorization: Bearer change-me" \ -H "Content-Type: application/json" \ -d '{ - "model": "gpt-4.1-mini", + "model": "gpt-5.6-sol", "input": "Reply with exactly ok", "max_output_tokens": 16 }' @@ -64,7 +96,7 @@ from openai import OpenAI client = OpenAI(base_url="http://localhost:8080/v1", api_key="change-me") response = client.responses.create( - model="gpt-4.1-mini", + model="gpt-5.6-sol", input="Reply with exactly ok", max_output_tokens=16, ) @@ -81,7 +113,7 @@ const client = new OpenAI({ }); const response = await client.responses.create({ - model: "gpt-4.1-mini", + model: "gpt-5.6-sol", input: "Reply with exactly ok", max_output_tokens: 16, }); @@ -91,40 +123,52 @@ console.log(response.output_text); -If the gateway is wired correctly, the response will contain `ok`. +If the gateway is wired correctly, the response will contain `ok`. Use a model +your configured provider actually serves — `gpt-5.6-sol` on a ChatGPT +subscription, or e.g. `gpt-4.1-mini` on an OpenAI Platform key. -## 3. Configure Codex to use GoModel +## 2. Configure Codex to use GoModel -Use a Responses-based provider in your Codex config file: +Add a Responses-based provider to `~/.codex/config.toml`: ```toml model_provider = "gomodel" -model = "gpt-4.1-mini" +model = "gpt-5.6-sol" # or any model your GoModel serves [model_providers.gomodel] name = "GoModel" base_url = "http://localhost:8080/v1" -env_key = "OPENAI_API_KEY" +env_key = "GOMODEL_API_KEY" wire_api = "responses" ``` Then export the GoModel master key for that provider: ```bash -export OPENAI_API_KEY=change-me +export GOMODEL_API_KEY=change-me +``` + +To try it without editing your config, pass the same settings inline: + +```bash +GOMODEL_API_KEY=change-me codex exec \ + -c model_provider=gomodel \ + -c 'model_providers.gomodel.base_url="http://localhost:8080/v1"' \ + -c 'model_providers.gomodel.env_key="GOMODEL_API_KEY"' \ + -c 'model_providers.gomodel.wire_api="responses"' \ + -m gpt-5.6-sol 'Reply with exactly ok and no punctuation.' ``` - Codex `0.122.0` did not use the `OPENAI_BASE_URL` environment variable in - local validation. Use the provider config above, or set `openai_base_url` in - Codex config if you intentionally want to override the built-in OpenAI - provider. + Codex ignores `OPENAI_BASE_URL`. Use the provider config above, or set + `openai_base_url` in Codex config if you intentionally want to override the + built-in OpenAI provider. -## 4. Run a Codex test prompt +## 3. Run a Codex test prompt ```bash -codex exec -m gpt-4.1-mini 'Reply with exactly ok and no punctuation.' +codex exec -m gpt-5.6-sol 'Reply with exactly ok and no punctuation.' ``` The validated result was: @@ -133,6 +177,13 @@ The validated result was: ok ``` + + Codex 0.147.0 logs `failed to refresh available models: missing field + "models"` at startup. It calls its own catalogue endpoint, which GoModel + answers with the standard OpenAI `/v1/models` shape. The message is cosmetic + — Codex falls back and the session works. + + ## DeepSeek V4 Codex sends `POST /v1/responses`. DeepSeek exposes chat completions instead of @@ -171,11 +222,11 @@ model = "deepseek-v4-pro" [model_providers.gomodel] name = "GoModel" base_url = "http://localhost:8080/v1" -env_key = "OPENAI_API_KEY" +env_key = "GOMODEL_API_KEY" wire_api = "responses" ``` -## 5. Check the traffic in GoModel +## 4. Check the traffic in GoModel Open the GoModel dashboard audit logs: @@ -189,31 +240,24 @@ your GoModel traffic and usage. - the recommended integration path is Codex custom provider -> standard `http://localhost:8080/v1` -- Codex custom provider mode sends `POST /v1/responses` -- Codex `0.122.0` sends an uncompressed JSON request body in this path, so the +- Codex custom provider mode sends `POST /v1/responses` as plain JSON, so the old `--disable enable_request_compression` workaround is no longer required -- ChatGPT subscription sign-in can coexist with the custom provider, but the - custom provider still requires the configured `env_key` +- Codex still requires the provider's `env_key`, even when signed in with + ChatGPT — that variable carries the GoModel master key, not an OpenAI key ## References - OpenAI Codex discussion: [Deprecating `chat/completions` support in Codex](https://github.com/openai/codex/discussions/7782) - OpenAI Codex repository: [openai/codex](https://github.com/openai/codex) -## Validated on April 21, 2026 - -This guide was validated against: - -- a local GoModel instance on `http://localhost:8080` -- Codex CLI `0.122.0` +## Validated on August 20, 2026 -Local validation confirmed: +This guide was validated against a local GoModel instance and Codex CLI +`0.147.0`. Local validation confirmed: -- `POST /v1/responses` returned `200 OK` with `curl` -- `codex exec` returned `ok` through `Codex -> GoModel -> OpenAI-compatible upstream` -- Codex sent plain JSON to `POST /v1/responses`; no `Content-Encoding: zstd` - header was present -- a ChatGPT-signed-in Codex session worked with the custom `gomodel` provider - when `OPENAI_API_KEY` was set to the GoModel master key -- the same custom provider failed without `OPENAI_API_KEY`, because the provider - `env_key` is still required +- `codex exec` returned `ok` through `Codex -> GoModel -> ChatGPT subscription` +- `POST /v1/responses` returned `200 OK` for both streaming and non-streaming + callers +- Codex sent plain JSON; no `Content-Encoding: zstd` header was present +- the custom `gomodel` provider failed without its `env_key`, because Codex + still requires that variable when signed in with ChatGPT diff --git a/docs/providers/chatgpt.mdx b/docs/providers/chatgpt.mdx new file mode 100644 index 000000000..4b8185411 --- /dev/null +++ b/docs/providers/chatgpt.mdx @@ -0,0 +1,93 @@ +--- +title: "ChatGPT subscription" +description: "Route Responses API traffic through a ChatGPT subscription instead of an OpenAI Platform API key." +icon: "message-circle" +keywords: ["ChatGPT", "Codex", "subscription", "OAuth", "Responses API", "provider setup"] +--- + +The `chatgpt` provider calls the Codex backend that ships with a ChatGPT +subscription (Plus, Pro, Business, or Enterprise). Usage is billed against the +subscription's quota, not an OpenAI Platform API key. + +Pair it with the [Codex guide](/guides/codex) to run +`Codex -> GoModel -> ChatGPT subscription`. + +## Configure + +The credential is the access token from your Codex sign-in: + +```bash +CHATGPT_API_KEY=$(jq -r .tokens.access_token ~/.codex/auth.json) +``` + +Or in `config.yaml`: + +```yaml +providers: + chatgpt: + type: chatgpt + api_key: "${CHATGPT_API_KEY}" +``` + +Run `codex login` first if `~/.codex/auth.json` does not exist yet. GoModel +derives the ChatGPT account ID from the token itself, so nothing else is +needed. + + + The token expires roughly every 10 days. Re-export it (the Codex CLI + refreshes the file whenever it runs) or run `codex login` again when requests + start returning 401. + + +## Models + +The Codex backend has no model-listing endpoint, so GoModel ships the +inventory a ChatGPT subscription can call: + +```text +gpt-5.6-sol, gpt-5.5, gpt-5.4 +``` + +Override it when your plan serves a different set: + +```bash +CHATGPT_MODELS=gpt-5.6-sol,gpt-5.4 +``` + +A model outside the plan's set is rejected upstream with `The '' model +is not supported when using Codex with a ChatGPT account`. + +## Responses API only + +The Codex backend serves `/responses` and nothing else. `/v1/chat/completions` +and `/v1/embeddings` return an error for `chatgpt` models — use +`/v1/responses`, which is what Codex and the OpenAI SDKs send anyway. + +The backend also validates against a strict parameter allowlist. GoModel +adapts requests rather than failing them, so callers keep using the standard +Responses API: + +| Request field | Behavior | +| ------------- | -------- | +| `stream`, `store` | Pinned to `true` / `false` — the backend rejects anything else | +| `input` as a string | Wrapped into the message list the backend requires | +| `instructions`, `tools`, `tool_choice`, `parallel_tool_calls`, `reasoning`, `text`, `include` | Forwarded | +| `temperature`, `top_p`, `max_output_tokens`, `previous_response_id`, `truncation`, `metadata`, `user`, `service_tier`, `top_logprobs` | Dropped — unsupported upstream | + +Because the backend streams only, a non-streaming `POST /v1/responses` is +served by streaming upstream and returning the final response object. Clients +see a normal non-streaming response. + +## Limits + +Subscription quota is separate from API credit. When it is exhausted the +gateway relays a `429`: + +```json +{"error":{"message":"The usage limit has been reached","type":"rate_limit_error"}} +``` + + + OpenAI authorizes these tokens for Codex traffic. Use this provider to route + your own Codex usage through GoModel, not as a general-purpose inference API. + diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index 684c3b3ae..e0e6936c0 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -41,6 +41,7 @@ support, not every individual model capability exposed by an upstream provider. | Provider | Credential | Example Model | Chat | `/responses` | Embed | Files | Batches | Passthru | Guide | | -------- | ---------- | ------------- | :--: | :----------: | :---: | :---: | :-----: | :------: | ----- | | OpenAI | `OPENAI_API_KEY` | `gpt-5.5` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | +| ChatGPT subscription | `CHATGPT_API_KEY` (Codex sign-in token) | `gpt-5.6-sol` | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | [ChatGPT subscription](/providers/chatgpt) | | Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-20250514` | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | [Anthropic](/providers/anthropic) | | Cohere | `COHERE_API_KEY` | `command-a-plus-05-2026` | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | [Cohere](/providers/cohere) | | Google Gemini | `GEMINI_API_KEY` | `gemini-3.7-flash` | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | [Google Gemini](/providers/gemini) | @@ -127,6 +128,11 @@ support, not every individual model capability exposed by an upstream provider. `qwen3.7-max`, override with `OPENCODE_GO_MESSAGES_MODELS`) are sent to the Anthropic-native endpoint. Set `OPENCODE_GO_API_KEY`; the base URL defaults to `https://opencode.ai/zen/go/v1`. +- **ChatGPT subscription** — serves `/v1/responses` only, billed against the + ChatGPT plan's quota rather than API credit. The upstream accepts a strict + parameter allowlist and streams only; GoModel adapts requests and collapses + the stream for non-streaming callers. Set `CHATGPT_API_KEY` to the access + token from `codex login`. - **Kimi Code** — no per-token pricing; quota refreshes weekly and is also constrained by a rolling 5-hour window. Usage-cost tracking reports zero for Kimi Code, so `cost` load-balancing cannot price it; prefer conservative @@ -217,6 +223,8 @@ These are the providers most users hit friction on: - **OpenCode Go** — OpenCode Zen splits models across OpenAI-style `/chat/completions` and Anthropic-native `/messages`; GoModel routes per model (the `/messages`-only set is maintained manually, default `qwen3.7-max`). +- **ChatGPT subscription** — a Responses-only upstream with a strict parameter + allowlist, used to put GoModel between Codex and a ChatGPT plan. Every provider page shows three launch forms in one CodeGroup: Docker with diff --git a/internal/providers/chatgpt/auth.go b/internal/providers/chatgpt/auth.go new file mode 100644 index 000000000..c9a2cd2cf --- /dev/null +++ b/internal/providers/chatgpt/auth.go @@ -0,0 +1,38 @@ +package chatgpt + +import ( + "encoding/base64" + "strings" + + "github.com/goccy/go-json" +) + +// accountIDClaim is the JWT claim the ChatGPT access token carries the +// subscription's account ID in. Codex sends that ID as chatgpt-account-id, so +// deriving it from the token keeps the provider single-credential. +const accountIDClaim = "https://api.openai.com/auth" + +// accountIDFromToken extracts the ChatGPT account ID from an access token. +// It returns "" for anything that is not a JWT with that claim; the header is +// optional, so an unreadable token is not an error here. +func accountIDFromToken(token string) string { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "" + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "" + } + var claims map[string]json.RawMessage + if err := json.Unmarshal(payload, &claims); err != nil { + return "" + } + var auth struct { + AccountID string `json:"chatgpt_account_id"` + } + if err := json.Unmarshal(claims[accountIDClaim], &auth); err != nil { + return "" + } + return auth.AccountID +} diff --git a/internal/providers/chatgpt/chatgpt.go b/internal/providers/chatgpt/chatgpt.go new file mode 100644 index 000000000..a796d9b31 --- /dev/null +++ b/internal/providers/chatgpt/chatgpt.go @@ -0,0 +1,190 @@ +// Package chatgpt routes Responses API traffic to the ChatGPT Codex backend, +// billed against a ChatGPT subscription instead of an OpenAI Platform API key. +// +// The upstream is the endpoint the Codex CLI itself calls when signed in with +// ChatGPT. It speaks a deliberately narrow dialect of the Responses API — a +// strict parameter allowlist, streaming only, no stored responses — so all the +// adaptation lives in request.go and stream.go and never leaks into GoModel's +// OpenAI-compatible surface. +package chatgpt + +import ( + "context" + "io" + "net/http" + "strings" + "time" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" + "github.com/enterpilot/gomodel/internal/providers" +) + +// defaultBaseURL is the Codex backend served to ChatGPT subscribers. Override +// with CHATGPT_BASE_URL. +const defaultBaseURL = "https://chatgpt.com/backend-api/codex" + +// defaultModels lists the models a ChatGPT subscription may call through the +// Codex backend. The backend exposes no /models endpoint, so the inventory is +// declared here and overridden with CHATGPT_MODELS when a plan serves a +// different set. +var defaultModels = []string{"gpt-5.6-sol", "gpt-5.5", "gpt-5.4"} + +// Registration provides factory registration for the ChatGPT subscription +// provider. The "chatgpt" type derives CHATGPT_API_KEY, CHATGPT_BASE_URL, and +// CHATGPT_MODELS by convention. +var Registration = providers.Registration{ + Type: "chatgpt", + New: New, + Discovery: providers.DiscoveryConfig{ + DefaultBaseURL: defaultBaseURL, + }, +} + +// Provider implements the core.Provider interface for the ChatGPT Codex +// backend. Only the Responses surface is served: the upstream has no chat +// completions, embeddings, or models endpoint, and advertising them would +// route traffic that can only fail. +type Provider struct { + client *llmclient.Client + keys *providers.Keyring + models []string +} + +var _ core.Provider = (*Provider)(nil) + +// New creates a new ChatGPT subscription provider. +func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { + p := &Provider{ + keys: opts.Keyring(cfg.APIKey), + models: resolveModels(opts.Models), + } + p.client = llmclient.New(llmclient.Config{ + ProviderName: "chatgpt", + BaseURL: providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL), + Retry: opts.Resilience.Retry, + Hooks: opts.Hooks, + CircuitBreaker: opts.Resilience.CircuitBreaker, + }, nil) + return p +} + +// NewWithHTTPClient creates a new ChatGPT provider with a custom HTTP client. +// If httpClient is nil, http.DefaultClient is used. +func NewWithHTTPClient(apiKey string, baseURL string, httpClient *http.Client, hooks llmclient.Hooks) *Provider { + if httpClient == nil { + httpClient = http.DefaultClient + } + clientCfg := llmclient.DefaultConfig("chatgpt", providers.ResolveBaseURL(baseURL, defaultBaseURL)) + clientCfg.Hooks = hooks + return &Provider{ + client: llmclient.NewWithHTTPClient(httpClient, clientCfg, nil), + keys: providers.NewKeyring(apiKey), + models: resolveModels(nil), + } +} + +// resolveModels returns the operator-configured inventory when present, +// otherwise the shipped default. +func resolveModels(configured []string) []string { + if len(configured) > 0 { + return configured + } + return defaultModels +} + +// SetBaseURL overrides the upstream endpoint. +func (p *Provider) SetBaseURL(url string) { p.client.SetBaseURL(url) } + +// ListModels returns the declared inventory. The Codex backend has no /models +// endpoint, so nothing is fetched upstream. +func (p *Provider) ListModels(_ context.Context) (*core.ModelsResponse, error) { + created := time.Now().Unix() + models := make([]core.Model, 0, len(p.models)) + for _, id := range p.models { + models = append(models, core.Model{ID: id, Object: "model", OwnedBy: "chatgpt", Created: created}) + } + return &core.ModelsResponse{Object: "list", Data: models}, nil +} + +// Responses serves a non-streaming request by collapsing the upstream stream: +// the Codex backend rejects `stream: false`, so GoModel streams on the client's +// behalf and returns the final response object. +func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + stream, err := p.StreamResponses(ctx, req) + if err != nil { + return nil, err + } + defer func() { _ = stream.Close() }() + resp, err := collapseResponsesStream(stream) + if err != nil { + return nil, err + } + core.EnsureModel(&resp.Model, req.Model) + return resp, nil +} + +// StreamResponses forwards the request to the Codex backend and returns its SSE +// stream unchanged. +func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { + if req == nil { + return nil, core.NewInvalidRequestError("responses request is required", nil) + } + body, err := newUpstreamRequest(req) + if err != nil { + return nil, err + } + headers, err := authHeaders(p.keys.NextForContext(ctx)) + if err != nil { + return nil, err + } + stream, err := p.client.DoStream(ctx, llmclient.Request{ + Method: http.MethodPost, + Endpoint: "/responses", + Operation: llmclient.OperationChat, + Model: req.Model, + Stream: true, + Body: body, + Headers: headers, + }) + if err != nil { + return nil, err + } + return providers.EnsureResponsesDone(stream), nil +} + +// ChatCompletion is unsupported: the ChatGPT Codex backend serves only the +// Responses API. Clients reach these models through /v1/responses. +func (p *Provider) ChatCompletion(_ context.Context, _ *core.ChatRequest) (*core.ChatResponse, error) { + return nil, unsupported("chat completions") +} + +// StreamChatCompletion is unsupported for the same reason as ChatCompletion. +func (p *Provider) StreamChatCompletion(_ context.Context, _ *core.ChatRequest) (io.ReadCloser, error) { + return nil, unsupported("chat completions") +} + +// Embeddings is unsupported: the Codex backend exposes no embeddings endpoint. +func (p *Provider) Embeddings(_ context.Context, _ *core.EmbeddingRequest) (*core.EmbeddingResponse, error) { + return nil, unsupported("embeddings") +} + +func unsupported(surface string) error { + return core.NewInvalidRequestError( + "chatgpt serves only the Responses API; "+surface+" are not available on a ChatGPT subscription", nil) +} + +// authHeaders builds the per-request credential headers. The account ID is +// derived from the token itself, so a subscription needs no extra configuration. +func authHeaders(token string) (http.Header, error) { + token = strings.TrimSpace(token) + if token == "" { + return nil, core.NewAuthenticationError("chatgpt", + "missing ChatGPT access token; set CHATGPT_API_KEY to the token from your Codex sign-in") + } + headers := http.Header{"Authorization": []string{"Bearer " + token}} + if accountID := accountIDFromToken(token); accountID != "" { + headers.Set("chatgpt-account-id", accountID) + } + return headers, nil +} diff --git a/internal/providers/chatgpt/chatgpt_test.go b/internal/providers/chatgpt/chatgpt_test.go new file mode 100644 index 000000000..d569d08d9 --- /dev/null +++ b/internal/providers/chatgpt/chatgpt_test.go @@ -0,0 +1,245 @@ +package chatgpt + +import ( + "context" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/goccy/go-json" + + "github.com/enterpilot/gomodel/internal/core" + "github.com/enterpilot/gomodel/internal/llmclient" + "github.com/enterpilot/gomodel/internal/providers" +) + +// codexSSE is a minimal Codex-backend stream: one text delta and the terminal +// response.completed envelope. +const codexSSE = "event: response.created\n" + + `data: {"type":"response.created","response":{"id":"resp_1","object":"response","status":"in_progress","model":"gpt-5.4"}}` + "\n\n" + + "event: response.output_text.delta\n" + + `data: {"type":"response.output_text.delta","delta":"ok"}` + "\n\n" + + "event: response.completed\n" + + `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.4","output":[{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":4,"output_tokens":1,"total_tokens":5}}}` + "\n\n" + + "data: [DONE]\n\n" + +// tokenWithAccount builds an unsigned JWT carrying the ChatGPT account claim. +func tokenWithAccount(t *testing.T, accountID string) string { + t.Helper() + payload, err := json.Marshal(map[string]any{ + accountIDClaim: map[string]string{"chatgpt_account_id": accountID}, + "exp": 1787235658, + }) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + enc := base64.RawURLEncoding.EncodeToString + return enc([]byte(`{"alg":"none"}`)) + "." + enc(payload) + ".sig" +} + +func TestRegistration_TypeIsChatGPT(t *testing.T) { + if Registration.Type != "chatgpt" { + t.Errorf("Registration.Type = %q, want %q", Registration.Type, "chatgpt") + } + if Registration.New == nil { + t.Error("Registration.New should not be nil") + } + if Registration.Discovery.DefaultBaseURL != defaultBaseURL { + t.Errorf("DefaultBaseURL = %q, want %q", Registration.Discovery.DefaultBaseURL, defaultBaseURL) + } +} + +// TestStreamResponses_SendsCodexDialect locks the wire contract: the ChatGPT +// Codex backend requires stream/store pinned, rejects public Responses +// parameters it does not implement, and needs a list-shaped input. +func TestStreamResponses_SendsCodexDialect(t *testing.T) { + var gotPath string + var gotHeader http.Header + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotHeader = r.Header.Clone() + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("decode body: %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, codexSSE) + })) + defer srv.Close() + + token := tokenWithAccount(t, "acct-123") + provider := NewWithHTTPClient(token, srv.URL, srv.Client(), llmclient.Hooks{}) + + temperature := 0.7 + maxTokens := 128 + stream, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{ + Model: "gpt-5.4", + Input: "Reply with exactly ok", + Instructions: "You are Codex.", + Temperature: &temperature, + MaxOutputTokens: &maxTokens, + PreviousResponseID: "resp_prev", + Truncation: "auto", + User: "someone", + Metadata: map[string]string{"a": "b"}, + Include: []string{"reasoning.encrypted_content"}, + Reasoning: &core.Reasoning{Effort: "low"}, + }) + if err != nil { + t.Fatalf("StreamResponses: %v", err) + } + defer func() { _ = stream.Close() }() + if _, err := io.ReadAll(stream); err != nil { + t.Fatalf("read stream: %v", err) + } + + if gotPath != "/responses" { + t.Errorf("path = %q, want /responses", gotPath) + } + if got := gotHeader.Get("Authorization"); got != "Bearer "+token { + t.Errorf("Authorization header not forwarded") + } + if got := gotHeader.Get("chatgpt-account-id"); got != "acct-123" { + t.Errorf("chatgpt-account-id = %q, want acct-123", got) + } + + if gotBody["stream"] != true { + t.Errorf("stream = %v, want true", gotBody["stream"]) + } + if gotBody["store"] != false { + t.Errorf("store = %v, want false", gotBody["store"]) + } + if gotBody["instructions"] != "You are Codex." { + t.Errorf("instructions = %v", gotBody["instructions"]) + } + for _, field := range []string{"temperature", "max_output_tokens", "previous_response_id", "truncation", "user", "metadata", "top_p", "service_tier"} { + if _, ok := gotBody[field]; ok { + t.Errorf("%s must not be sent to the Codex backend", field) + } + } + input, ok := gotBody["input"].([]any) + if !ok || len(input) != 1 { + t.Fatalf("input = %#v, want a one-element list", gotBody["input"]) + } + msg, _ := input[0].(map[string]any) + if msg["role"] != "user" || msg["type"] != "message" { + t.Errorf("input[0] = %#v, want a user message", msg) + } +} + +// TestResponses_CollapsesUpstreamStream covers the non-streaming path: the +// backend refuses stream:false, so GoModel streams and returns the final object. +func TestResponses_CollapsesUpstreamStream(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, codexSSE) + })) + defer srv.Close() + + provider := NewWithHTTPClient("token", srv.URL, srv.Client(), llmclient.Hooks{}) + resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{ + Model: "gpt-5.4", + Input: []core.ResponsesInputElement{{Type: "message", Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatalf("Responses: %v", err) + } + if resp.Status != "completed" || resp.ID != "resp_1" { + t.Errorf("resp = %+v, want completed resp_1", resp) + } + if len(resp.Output) != 1 || len(resp.Output[0].Content) != 1 || resp.Output[0].Content[0].Text != "ok" { + t.Errorf("output = %+v, want a single 'ok' text item", resp.Output) + } + if resp.Usage == nil || resp.Usage.TotalTokens != 5 { + t.Errorf("usage = %+v, want total_tokens 5", resp.Usage) + } +} + +func TestResponses_EmptyStreamIsAnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: [DONE]\n\n") + })) + defer srv.Close() + + provider := NewWithHTTPClient("token", srv.URL, srv.Client(), llmclient.Hooks{}) + if _, err := provider.Responses(context.Background(), &core.ResponsesRequest{Model: "gpt-5.4", Input: "hi"}); err == nil { + t.Fatal("expected an error for a stream with no response envelope") + } +} + +func TestStreamResponses_RequiresToken(t *testing.T) { + provider := NewWithHTTPClient("", "http://example.invalid", http.DefaultClient, llmclient.Hooks{}) + _, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{Model: "gpt-5.4", Input: "hi"}) + if err == nil { + t.Fatal("expected an authentication error without a token") + } + if !strings.Contains(err.Error(), "CHATGPT_API_KEY") { + t.Errorf("error = %q, want it to name CHATGPT_API_KEY", err) + } +} + +func TestListModels(t *testing.T) { + tests := []struct { + name string + configured []string + want []string + }{ + {name: "defaults", want: defaultModels}, + {name: "configured override", configured: []string{"gpt-5.4"}, want: []string{"gpt-5.4"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + provider := New(providers.ProviderConfig{APIKey: "token"}, providers.ProviderOptions{Models: tc.configured}) + resp, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels: %v", err) + } + if len(resp.Data) != len(tc.want) { + t.Fatalf("got %d models, want %d", len(resp.Data), len(tc.want)) + } + for i, model := range resp.Data { + if model.ID != tc.want[i] { + t.Errorf("model[%d] = %q, want %q", i, model.ID, tc.want[i]) + } + } + }) + } +} + +func TestUnsupportedSurfaces(t *testing.T) { + provider := New(providers.ProviderConfig{APIKey: "token"}, providers.ProviderOptions{}) + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{Model: "gpt-5.4"}); err == nil { + t.Error("ChatCompletion should be unsupported") + } + if _, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{Model: "gpt-5.4"}); err == nil { + t.Error("StreamChatCompletion should be unsupported") + } + if _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{Model: "gpt-5.4"}); err == nil { + t.Error("Embeddings should be unsupported") + } +} + +func TestAccountIDFromToken(t *testing.T) { + tests := []struct { + name string + token string + want string + }{ + {name: "chatgpt token", token: tokenWithAccount(t, "acct-9"), want: "acct-9"}, + {name: "not a jwt", token: "sk-plain-key", want: ""}, + {name: "jwt without claim", token: "e30.e30.sig", want: ""}, + {name: "undecodable payload", token: "e30.!!!.sig", want: ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := accountIDFromToken(tc.token); got != tc.want { + t.Errorf("accountIDFromToken() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/providers/chatgpt/request.go b/internal/providers/chatgpt/request.go new file mode 100644 index 000000000..b5be3d966 --- /dev/null +++ b/internal/providers/chatgpt/request.go @@ -0,0 +1,68 @@ +package chatgpt + +import ( + "github.com/enterpilot/gomodel/internal/core" +) + +// upstreamRequest is the request body the ChatGPT Codex backend accepts. +// +// The backend validates against a strict allowlist and rejects any field +// outside it — including ones the public Responses API supports (temperature, +// top_p, max_output_tokens, previous_response_id, truncation, metadata, user, +// service_tier). Building the body from this struct rather than filtering the +// incoming request keeps that contract explicit and stops new gateway fields +// from silently breaking every request. +type upstreamRequest struct { + Model string `json:"model"` + Input any `json:"input"` + Instructions string `json:"instructions,omitempty"` + Tools []map[string]any `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + Reasoning *core.Reasoning `json:"reasoning,omitempty"` + Text any `json:"text,omitempty"` + Include []string `json:"include,omitempty"` + // Stream and Store are pinned: the backend rejects `stream: false` and + // `store: true` outright. + Stream bool `json:"stream"` + Store bool `json:"store"` +} + +// newUpstreamRequest adapts a gateway Responses request to the Codex backend +// dialect, dropping unsupported parameters rather than failing the request. +func newUpstreamRequest(req *core.ResponsesRequest) (*upstreamRequest, error) { + input, err := normalizeInput(req.Input) + if err != nil { + return nil, err + } + return &upstreamRequest{ + Model: req.Model, + Input: input, + Instructions: req.Instructions, + Tools: req.Tools, + ToolChoice: req.ToolChoice, + ParallelToolCalls: req.ParallelToolCalls, + Reasoning: req.Reasoning, + Text: req.Text, + Include: req.Include, + Stream: true, + Store: false, + }, nil +} + +// normalizeInput wraps a bare string prompt in the message list the backend +// requires; array inputs pass through untouched. +func normalizeInput(input any) (any, error) { + switch v := input.(type) { + case nil: + return nil, core.NewInvalidRequestError("responses input is required", nil) + case string: + return []core.ResponsesInputElement{{ + Type: "message", + Role: "user", + Content: []core.ContentPart{{Type: "input_text", Text: v}}, + }}, nil + default: + return v, nil + } +} diff --git a/internal/providers/chatgpt/stream.go b/internal/providers/chatgpt/stream.go new file mode 100644 index 000000000..963a2bce9 --- /dev/null +++ b/internal/providers/chatgpt/stream.go @@ -0,0 +1,58 @@ +package chatgpt + +import ( + "bufio" + "bytes" + "io" + + "github.com/goccy/go-json" + + "github.com/enterpilot/gomodel/internal/core" +) + +// maxSSELineBytes caps a single SSE data line. Reasoning summaries and +// encrypted reasoning blobs are large, so the default bufio limit is too small. +const maxSSELineBytes = 8 << 20 + +// collapseResponsesStream reads a Responses SSE stream and returns the response +// object carried by its terminal event. The Codex backend streams only, so this +// is how GoModel answers a non-streaming /v1/responses call against it. +func collapseResponsesStream(stream io.Reader) (*core.ResponsesResponse, error) { + scanner := bufio.NewScanner(stream) + scanner.Buffer(make([]byte, 0, 64<<10), maxSSELineBytes) + + var final *core.ResponsesResponse + for scanner.Scan() { + data, ok := bytes.CutPrefix(bytes.TrimSpace(scanner.Bytes()), []byte("data:")) + if !ok { + continue + } + data = bytes.TrimSpace(data) + if len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { + continue + } + var event struct { + Type string `json:"type"` + Response *core.ResponsesResponse `json:"response"` + } + if err := json.Unmarshal(data, &event); err != nil { + continue + } + // Keep the last response envelope seen: completed and failed both carry + // the full object, and an incomplete stream should still report what + // the upstream last knew. + if event.Response != nil { + final = event.Response + } + if event.Type == "response.completed" || event.Type == "response.failed" { + break + } + } + if err := scanner.Err(); err != nil { + return nil, core.NewProviderError("chatgpt", 502, "failed to read response stream: "+err.Error(), err) + } + if final == nil { + return nil, core.NewEmptyProviderResponseError("chatgpt") + } + return final, nil +} diff --git a/run/providers.go b/run/providers.go index 9354813c7..f5974173b 100644 --- a/run/providers.go +++ b/run/providers.go @@ -9,6 +9,7 @@ import ( "github.com/enterpilot/gomodel/internal/providers/bailian" "github.com/enterpilot/gomodel/internal/providers/bedrock" "github.com/enterpilot/gomodel/internal/providers/bedrockmantle" + "github.com/enterpilot/gomodel/internal/providers/chatgpt" "github.com/enterpilot/gomodel/internal/providers/chutes" "github.com/enterpilot/gomodel/internal/providers/cohere" "github.com/enterpilot/gomodel/internal/providers/deepseek" @@ -53,6 +54,7 @@ func defaultProviderFactory(cfg *config.Config) *providers.ProviderFactory { factory.Add(anthropic.Registration) factory.Add(bedrock.Registration) factory.Add(bedrockmantle.Registration) + factory.Add(chatgpt.Registration) factory.Add(chutes.Registration) factory.Add(cohere.Registration) factory.Add(deepseek.Registration) diff --git a/run/providers_test.go b/run/providers_test.go index f39ba6bcc..23fdadb52 100644 --- a/run/providers_test.go +++ b/run/providers_test.go @@ -175,7 +175,7 @@ var credentialPayloadFields = []string{ func TestDefaultProviderFactoryRegistersAllProviderTypes(t *testing.T) { expected := []string{ - "anthropic", "azure", "bailian", "bedrock", "bedrock-mantle", "chutes", "cohere", "deepseek", "elevenlabs", + "anthropic", "azure", "bailian", "bedrock", "bedrock-mantle", "chatgpt", "chutes", "cohere", "deepseek", "elevenlabs", "fireworks", "gemini", "groq", "hetzner", "kilo", "kimicode", "llamacpp", "llmd", "meta", "minimax", "ollama", "openai", "opencode_go", "openrouter", "oracle", "sglang", "vertex", "vllm", "xai", "xiaomi", "zai", } From dbc873f6e8a539a28add9724905a533d02dc2996 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 20 Aug 2026 11:51:23 +0200 Subject: [PATCH 02/11] fix(chatgpt): require a terminal event before collapsing a stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the new provider. collapseResponsesStream kept the last envelope it saw, so a stream that ended early — a dropped connection, or an `error` event — was served to a non-streaming caller as an empty but successful response. Only the terminal lifecycle events now produce a response; anything else is an error. A terminal response.failed or response.incomplete stays a response, matching what the Responses API returns for a non-streaming call. Also drops gpt-5.4 from the default inventory: OpenAI withdraws it and gpt-5.4-mini from ChatGPT-authenticated Codex on 2026-08-31. gpt-5.6-terra and gpt-5.6-luna replace it, both verified against the live backend. The guide's inline `codex exec` example was missing the required provider `name` field. --- .env.template | 2 +- config/config.example.yaml | 2 +- docs/guides/codex.mdx | 1 + docs/providers/chatgpt.mdx | 9 ++- internal/providers/chatgpt/chatgpt.go | 5 +- internal/providers/chatgpt/chatgpt_test.go | 75 ++++++++++++++++++---- internal/providers/chatgpt/stream.go | 41 ++++++++---- 7 files changed, 101 insertions(+), 34 deletions(-) diff --git a/.env.template b/.env.template index 6f460c86f..f3ade807b 100644 --- a/.env.template +++ b/.env.template @@ -434,7 +434,7 @@ # CHATGPT_API_KEY= # CHATGPT_BASE_URL=https://chatgpt.com/backend-api/codex # Optional model override; defaults to the models a ChatGPT plan can call. -# CHATGPT_MODELS=gpt-5.6-sol,gpt-5.5,gpt-5.4 +# CHATGPT_MODELS=gpt-5.6-sol,gpt-5.6-terra,gpt-5.6-luna,gpt-5.5 # Anthropic # Accepts a Console API key (sk-ant-api...) or a Claude subscription OAuth diff --git a/config/config.example.yaml b/config/config.example.yaml index b1f6aa666..66293af72 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -343,7 +343,7 @@ providers: chatgpt: type: chatgpt api_key: "${CHATGPT_API_KEY}" - # models: [gpt-5.6-sol, gpt-5.5, gpt-5.4] + # models: [gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5] cohere: type: cohere diff --git a/docs/guides/codex.mdx b/docs/guides/codex.mdx index d533c433b..898c7999b 100644 --- a/docs/guides/codex.mdx +++ b/docs/guides/codex.mdx @@ -153,6 +153,7 @@ To try it without editing your config, pass the same settings inline: ```bash GOMODEL_API_KEY=change-me codex exec \ -c model_provider=gomodel \ + -c 'model_providers.gomodel.name="GoModel"' \ -c 'model_providers.gomodel.base_url="http://localhost:8080/v1"' \ -c 'model_providers.gomodel.env_key="GOMODEL_API_KEY"' \ -c 'model_providers.gomodel.wire_api="responses"' \ diff --git a/docs/providers/chatgpt.mdx b/docs/providers/chatgpt.mdx index 4b8185411..6d4be8606 100644 --- a/docs/providers/chatgpt.mdx +++ b/docs/providers/chatgpt.mdx @@ -45,17 +45,20 @@ The Codex backend has no model-listing endpoint, so GoModel ships the inventory a ChatGPT subscription can call: ```text -gpt-5.6-sol, gpt-5.5, gpt-5.4 +gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5 ``` Override it when your plan serves a different set: ```bash -CHATGPT_MODELS=gpt-5.6-sol,gpt-5.4 +CHATGPT_MODELS=gpt-5.6-sol,gpt-5.6-terra ``` A model outside the plan's set is rejected upstream with `The '' model -is not supported when using Codex with a ChatGPT account`. +is not supported when using Codex with a ChatGPT account`. `gpt-5.4` and +`gpt-5.4-mini` left ChatGPT-authenticated Codex on 2026-08-31 — use +`gpt-5.6-terra` and `gpt-5.6-luna` instead. Both remain available to Codex +sessions authenticated with an OpenAI API key, through the `openai` provider. ## Responses API only diff --git a/internal/providers/chatgpt/chatgpt.go b/internal/providers/chatgpt/chatgpt.go index a796d9b31..ef0fc47f8 100644 --- a/internal/providers/chatgpt/chatgpt.go +++ b/internal/providers/chatgpt/chatgpt.go @@ -27,8 +27,9 @@ const defaultBaseURL = "https://chatgpt.com/backend-api/codex" // defaultModels lists the models a ChatGPT subscription may call through the // Codex backend. The backend exposes no /models endpoint, so the inventory is // declared here and overridden with CHATGPT_MODELS when a plan serves a -// different set. -var defaultModels = []string{"gpt-5.6-sol", "gpt-5.5", "gpt-5.4"} +// different set. gpt-5.4 and gpt-5.4-mini are deliberately absent: OpenAI +// withdrew them from ChatGPT-authenticated Codex on 2026-08-31. +var defaultModels = []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"} // Registration provides factory registration for the ChatGPT subscription // provider. The "chatgpt" type derives CHATGPT_API_KEY, CHATGPT_BASE_URL, and diff --git a/internal/providers/chatgpt/chatgpt_test.go b/internal/providers/chatgpt/chatgpt_test.go index d569d08d9..d6a01627f 100644 --- a/internal/providers/chatgpt/chatgpt_test.go +++ b/internal/providers/chatgpt/chatgpt_test.go @@ -19,11 +19,11 @@ import ( // codexSSE is a minimal Codex-backend stream: one text delta and the terminal // response.completed envelope. const codexSSE = "event: response.created\n" + - `data: {"type":"response.created","response":{"id":"resp_1","object":"response","status":"in_progress","model":"gpt-5.4"}}` + "\n\n" + + `data: {"type":"response.created","response":{"id":"resp_1","object":"response","status":"in_progress","model":"gpt-5.6-terra"}}` + "\n\n" + "event: response.output_text.delta\n" + `data: {"type":"response.output_text.delta","delta":"ok"}` + "\n\n" + "event: response.completed\n" + - `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.4","output":[{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":4,"output_tokens":1,"total_tokens":5}}}` + "\n\n" + + `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.6-terra","output":[{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":4,"output_tokens":1,"total_tokens":5}}}` + "\n\n" + "data: [DONE]\n\n" // tokenWithAccount builds an unsigned JWT carrying the ChatGPT account claim. @@ -77,7 +77,7 @@ func TestStreamResponses_SendsCodexDialect(t *testing.T) { temperature := 0.7 maxTokens := 128 stream, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{ - Model: "gpt-5.4", + Model: "gpt-5.6-terra", Input: "Reply with exactly ok", Instructions: "You are Codex.", Temperature: &temperature, @@ -142,7 +142,7 @@ func TestResponses_CollapsesUpstreamStream(t *testing.T) { provider := NewWithHTTPClient("token", srv.URL, srv.Client(), llmclient.Hooks{}) resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{ - Model: "gpt-5.4", + Model: "gpt-5.6-terra", Input: []core.ResponsesInputElement{{Type: "message", Role: "user", Content: "hi"}}, }) if err != nil { @@ -159,22 +159,71 @@ func TestResponses_CollapsesUpstreamStream(t *testing.T) { } } -func TestResponses_EmptyStreamIsAnError(t *testing.T) { +// TestResponses_IncompleteStreamIsAnError guards the non-streaming path against +// serving a truncated stream as an empty but successful answer: only a terminal +// lifecycle event may produce a response. +func TestResponses_IncompleteStreamIsAnError(t *testing.T) { + created := "event: response.created\n" + + `data: {"type":"response.created","response":{"id":"resp_1","object":"response","status":"in_progress","model":"gpt-5.6-terra"}}` + "\n\n" + + tests := []struct { + name string + body string + want string + }{ + {name: "no events at all", body: "data: [DONE]\n\n", want: "ended before completion"}, + {name: "stream cut after response.created", body: created, want: "ended before completion"}, + { + name: "upstream error event", + body: created + "event: error\n" + + `data: {"type":"error","code":"server_error","message":"upstream exploded"}` + "\n\n", + want: "upstream exploded", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, tc.body) + })) + defer srv.Close() + + provider := NewWithHTTPClient("token", srv.URL, srv.Client(), llmclient.Hooks{}) + resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{Model: "gpt-5.6-terra", Input: "hi"}) + if err == nil { + t.Fatalf("expected an error, got response %+v", resp) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error = %q, want it to mention %q", err, tc.want) + } + }) + } +} + +// TestResponses_TerminalFailureIsReturnedAsAResponse mirrors what the Responses +// API returns for a non-streaming call: a failed generation is a response whose +// status says so, not a transport error. +func TestResponses_TerminalFailureIsReturnedAsAResponse(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") - _, _ = io.WriteString(w, "data: [DONE]\n\n") + _, _ = io.WriteString(w, "event: response.failed\n"+ + `data: {"type":"response.failed","response":{"id":"resp_1","object":"response","status":"failed","model":"gpt-5.6-terra","error":{"code":"server_error","message":"boom"}}}`+"\n\n") })) defer srv.Close() provider := NewWithHTTPClient("token", srv.URL, srv.Client(), llmclient.Hooks{}) - if _, err := provider.Responses(context.Background(), &core.ResponsesRequest{Model: "gpt-5.4", Input: "hi"}); err == nil { - t.Fatal("expected an error for a stream with no response envelope") + resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{Model: "gpt-5.6-terra", Input: "hi"}) + if err != nil { + t.Fatalf("Responses: %v", err) + } + if resp.Status != "failed" || resp.Error == nil || resp.Error.Message != "boom" { + t.Errorf("resp = %+v, want a failed response carrying the upstream error", resp) } } func TestStreamResponses_RequiresToken(t *testing.T) { provider := NewWithHTTPClient("", "http://example.invalid", http.DefaultClient, llmclient.Hooks{}) - _, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{Model: "gpt-5.4", Input: "hi"}) + _, err := provider.StreamResponses(context.Background(), &core.ResponsesRequest{Model: "gpt-5.6-terra", Input: "hi"}) if err == nil { t.Fatal("expected an authentication error without a token") } @@ -190,7 +239,7 @@ func TestListModels(t *testing.T) { want []string }{ {name: "defaults", want: defaultModels}, - {name: "configured override", configured: []string{"gpt-5.4"}, want: []string{"gpt-5.4"}}, + {name: "configured override", configured: []string{"gpt-5.6-terra"}, want: []string{"gpt-5.6-terra"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -213,13 +262,13 @@ func TestListModels(t *testing.T) { func TestUnsupportedSurfaces(t *testing.T) { provider := New(providers.ProviderConfig{APIKey: "token"}, providers.ProviderOptions{}) - if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{Model: "gpt-5.4"}); err == nil { + if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{Model: "gpt-5.6-terra"}); err == nil { t.Error("ChatCompletion should be unsupported") } - if _, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{Model: "gpt-5.4"}); err == nil { + if _, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{Model: "gpt-5.6-terra"}); err == nil { t.Error("StreamChatCompletion should be unsupported") } - if _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{Model: "gpt-5.4"}); err == nil { + if _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{Model: "gpt-5.6-terra"}); err == nil { t.Error("Embeddings should be unsupported") } } diff --git a/internal/providers/chatgpt/stream.go b/internal/providers/chatgpt/stream.go index 963a2bce9..66faefc86 100644 --- a/internal/providers/chatgpt/stream.go +++ b/internal/providers/chatgpt/stream.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "io" + "net/http" "github.com/goccy/go-json" @@ -17,11 +18,15 @@ const maxSSELineBytes = 8 << 20 // collapseResponsesStream reads a Responses SSE stream and returns the response // object carried by its terminal event. The Codex backend streams only, so this // is how GoModel answers a non-streaming /v1/responses call against it. +// +// Only a terminal lifecycle event produces a response. A stream that stops +// early — a dropped connection, or an `error` event — is an error rather than +// the last in-progress envelope, which would otherwise be served as an empty +// but successful answer. func collapseResponsesStream(stream io.Reader) (*core.ResponsesResponse, error) { scanner := bufio.NewScanner(stream) scanner.Buffer(make([]byte, 0, 64<<10), maxSSELineBytes) - var final *core.ResponsesResponse for scanner.Scan() { data, ok := bytes.CutPrefix(bytes.TrimSpace(scanner.Bytes()), []byte("data:")) if !ok { @@ -33,26 +38,34 @@ func collapseResponsesStream(stream io.Reader) (*core.ResponsesResponse, error) } var event struct { Type string `json:"type"` + Message string `json:"message"` Response *core.ResponsesResponse `json:"response"` } if err := json.Unmarshal(data, &event); err != nil { continue } - // Keep the last response envelope seen: completed and failed both carry - // the full object, and an incomplete stream should still report what - // the upstream last knew. - if event.Response != nil { - final = event.Response - } - if event.Type == "response.completed" || event.Type == "response.failed" { - break + switch event.Type { + // The three terminal lifecycle events all carry the full object. + // failed and incomplete are reported to the caller as a normal + // response whose status says so, matching what the Responses API + // returns for a non-streaming call. + case "response.completed", "response.failed", "response.incomplete": + if event.Response == nil { + return nil, core.NewEmptyProviderResponseError("chatgpt") + } + return event.Response, nil + case "error": + message := event.Message + if message == "" { + message = "upstream reported a stream error" + } + return nil, core.NewProviderError("chatgpt", http.StatusBadGateway, message, nil) } } if err := scanner.Err(); err != nil { - return nil, core.NewProviderError("chatgpt", 502, "failed to read response stream: "+err.Error(), err) - } - if final == nil { - return nil, core.NewEmptyProviderResponseError("chatgpt") + return nil, core.NewProviderError("chatgpt", http.StatusBadGateway, + "failed to read response stream: "+err.Error(), err) } - return final, nil + return nil, core.NewProviderError("chatgpt", http.StatusBadGateway, + "response stream ended before completion", nil) } From 399206371fa6a91f466a5bc1697e6118ea75424c Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 20 Aug 2026 12:11:16 +0200 Subject: [PATCH 03/11] test(chatgpt): cover the response.incomplete terminal event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the missing case alongside response.failed, and renames the truncated-stream test so it is not confused with it: a stream that stops early is an error, while a response.incomplete event is a legitimate response. Also states the gpt-5.4 Codex retirement in the future tense — it takes effect on August 31, 2026. --- docs/providers/chatgpt.mdx | 2 +- internal/providers/chatgpt/chatgpt.go | 2 +- internal/providers/chatgpt/chatgpt_test.go | 82 ++++++++++++++++------ 3 files changed, 63 insertions(+), 23 deletions(-) diff --git a/docs/providers/chatgpt.mdx b/docs/providers/chatgpt.mdx index 6d4be8606..56bf3d632 100644 --- a/docs/providers/chatgpt.mdx +++ b/docs/providers/chatgpt.mdx @@ -56,7 +56,7 @@ CHATGPT_MODELS=gpt-5.6-sol,gpt-5.6-terra A model outside the plan's set is rejected upstream with `The '' model is not supported when using Codex with a ChatGPT account`. `gpt-5.4` and -`gpt-5.4-mini` left ChatGPT-authenticated Codex on 2026-08-31 — use +`gpt-5.4-mini` leave ChatGPT-authenticated Codex on August 31, 2026 — use `gpt-5.6-terra` and `gpt-5.6-luna` instead. Both remain available to Codex sessions authenticated with an OpenAI API key, through the `openai` provider. diff --git a/internal/providers/chatgpt/chatgpt.go b/internal/providers/chatgpt/chatgpt.go index ef0fc47f8..272645a87 100644 --- a/internal/providers/chatgpt/chatgpt.go +++ b/internal/providers/chatgpt/chatgpt.go @@ -28,7 +28,7 @@ const defaultBaseURL = "https://chatgpt.com/backend-api/codex" // Codex backend. The backend exposes no /models endpoint, so the inventory is // declared here and overridden with CHATGPT_MODELS when a plan serves a // different set. gpt-5.4 and gpt-5.4-mini are deliberately absent: OpenAI -// withdrew them from ChatGPT-authenticated Codex on 2026-08-31. +// withdraws them from ChatGPT-authenticated Codex on 2026-08-31. var defaultModels = []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"} // Registration provides factory registration for the ChatGPT subscription diff --git a/internal/providers/chatgpt/chatgpt_test.go b/internal/providers/chatgpt/chatgpt_test.go index d6a01627f..dab149d1f 100644 --- a/internal/providers/chatgpt/chatgpt_test.go +++ b/internal/providers/chatgpt/chatgpt_test.go @@ -159,10 +159,11 @@ func TestResponses_CollapsesUpstreamStream(t *testing.T) { } } -// TestResponses_IncompleteStreamIsAnError guards the non-streaming path against -// serving a truncated stream as an empty but successful answer: only a terminal -// lifecycle event may produce a response. -func TestResponses_IncompleteStreamIsAnError(t *testing.T) { +// TestResponses_TruncatedStreamIsAnError guards the non-streaming path against +// serving a stream that stopped early as an empty but successful answer: only a +// terminal lifecycle event may produce a response. Not to be confused with the +// response.incomplete terminal event, which is a legitimate response. +func TestResponses_TruncatedStreamIsAnError(t *testing.T) { created := "event: response.created\n" + `data: {"type":"response.created","response":{"id":"resp_1","object":"response","status":"in_progress","model":"gpt-5.6-terra"}}` + "\n\n" @@ -200,25 +201,64 @@ func TestResponses_IncompleteStreamIsAnError(t *testing.T) { } } -// TestResponses_TerminalFailureIsReturnedAsAResponse mirrors what the Responses -// API returns for a non-streaming call: a failed generation is a response whose -// status says so, not a transport error. -func TestResponses_TerminalFailureIsReturnedAsAResponse(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - _, _ = io.WriteString(w, "event: response.failed\n"+ - `data: {"type":"response.failed","response":{"id":"resp_1","object":"response","status":"failed","model":"gpt-5.6-terra","error":{"code":"server_error","message":"boom"}}}`+"\n\n") - })) - defer srv.Close() - - provider := NewWithHTTPClient("token", srv.URL, srv.Client(), llmclient.Hooks{}) - resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{Model: "gpt-5.6-terra", Input: "hi"}) - if err != nil { - t.Fatalf("Responses: %v", err) +// TestResponses_NonSuccessTerminalIsReturnedAsAResponse mirrors what the +// Responses API returns for a non-streaming call: a generation that failed or +// stopped short is a response whose status says so, not a transport error. +func TestResponses_NonSuccessTerminalIsReturnedAsAResponse(t *testing.T) { + tests := []struct { + name string + event string + payload string + wantStatus string + }{ + { + name: "failed", + event: "response.failed", + payload: `{"type":"response.failed","response":{"id":"resp_1","object":"response","status":"failed","model":"gpt-5.6-terra","error":{"code":"server_error","message":"boom"}}}`, + wantStatus: "failed", + }, + { + name: "incomplete", + event: "response.incomplete", + payload: `{"type":"response.incomplete","response":{"id":"resp_1","object":"response","status":"incomplete","model":"gpt-5.6-terra","output":[{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}]}}`, + wantStatus: "incomplete", + }, } - if resp.Status != "failed" || resp.Error == nil || resp.Error.Message != "boom" { - t.Errorf("resp = %+v, want a failed response carrying the upstream error", resp) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "event: "+tc.event+"\ndata: "+tc.payload+"\n\n") + })) + defer srv.Close() + + provider := NewWithHTTPClient("token", srv.URL, srv.Client(), llmclient.Hooks{}) + resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{Model: "gpt-5.6-terra", Input: "hi"}) + if err != nil { + t.Fatalf("Responses: %v", err) + } + if resp.Status != tc.wantStatus { + t.Errorf("status = %q, want %q", resp.Status, tc.wantStatus) + } + }) } + t.Run("failed carries the upstream error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "event: response.failed\n"+ + `data: {"type":"response.failed","response":{"id":"resp_1","object":"response","status":"failed","model":"gpt-5.6-terra","error":{"code":"server_error","message":"boom"}}}`+"\n\n") + })) + defer srv.Close() + + provider := NewWithHTTPClient("token", srv.URL, srv.Client(), llmclient.Hooks{}) + resp, err := provider.Responses(context.Background(), &core.ResponsesRequest{Model: "gpt-5.6-terra", Input: "hi"}) + if err != nil { + t.Fatalf("Responses: %v", err) + } + if resp.Error == nil || resp.Error.Message != "boom" { + t.Errorf("resp.Error = %+v, want the upstream error", resp.Error) + } + }) } func TestStreamResponses_RequiresToken(t *testing.T) { From c405edebbb270533f4ad53997acb76237b7584a2 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 20 Aug 2026 14:51:54 +0200 Subject: [PATCH 04/11] fix(chatgpt): send input_text parts and report unsupported surfaces as 501 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while exercising the provider across every gateway surface with curl. A bare string prompt was wrapped with core.ContentPart, which marshals to the Chat Completions shape and rewrote "input_text" to "text" on the wire. The Responses API spells input content "input_text", so the convenience path — the one the docs' curl example uses — sent a part the spec does not define. List inputs were unaffected; they pass through untouched. Chat completions and embeddings reported a capability gap as 400 invalid_request_error, which reads as "your request was malformed". They now return 501, matching the unsupported_response_operation shape the router already uses for provider capability gaps. Also documents that reported cost is not real spend: these model IDs exist on the OpenAI Platform too, so the registry prices flat-rate subscription traffic at API rates, which budgets and cost-based routing then act on. --- docs/providers/chatgpt.mdx | 15 +++++++ docs/providers/overview.mdx | 5 ++- internal/providers/chatgpt/chatgpt.go | 9 ++++- internal/providers/chatgpt/chatgpt_test.go | 47 ++++++++++++++++++---- internal/providers/chatgpt/request.go | 6 ++- 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/docs/providers/chatgpt.mdx b/docs/providers/chatgpt.mdx index 56bf3d632..ef3fdd5c7 100644 --- a/docs/providers/chatgpt.mdx +++ b/docs/providers/chatgpt.mdx @@ -81,6 +81,21 @@ Because the backend streams only, a non-streaming `POST /v1/responses` is served by streaming upstream and returning the final response object. Clients see a normal non-streaming response. +## Reported cost is not real spend + +Subscription usage is flat-rate, but these model IDs also exist on the OpenAI +Platform, so GoModel's model registry attaches their per-token API prices. +Usage records and dashboard totals for `chatgpt` therefore show a dollar figure +that corresponds to no actual charge. + + + Anything that consumes those figures acts on them: a **budget** can reject + `chatgpt` traffic for "spending" money the subscription never charges, and + `cost`-based load balancing will price it as API traffic. Scope budgets to a + [user path](/features/user-path) that excludes subscription traffic, or leave + budgets off for it. + + ## Limits Subscription quota is separate from API credit. When it is exhausted the diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index e0e6936c0..a3a3a7afe 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -132,7 +132,10 @@ support, not every individual model capability exposed by an upstream provider. ChatGPT plan's quota rather than API credit. The upstream accepts a strict parameter allowlist and streams only; GoModel adapts requests and collapses the stream for non-streaming callers. Set `CHATGPT_API_KEY` to the access - token from `codex login`. + token from `codex login`. Reported cost is not real spend: these model IDs + also exist on the OpenAI Platform, so the registry attaches their per-token + prices to flat-rate subscription traffic — keep budgets and `cost` load + balancing off it. - **Kimi Code** — no per-token pricing; quota refreshes weekly and is also constrained by a rolling 5-hour window. Usage-cost tracking reports zero for Kimi Code, so `cost` load-balancing cannot price it; prefer conservative diff --git a/internal/providers/chatgpt/chatgpt.go b/internal/providers/chatgpt/chatgpt.go index 272645a87..104d8737a 100644 --- a/internal/providers/chatgpt/chatgpt.go +++ b/internal/providers/chatgpt/chatgpt.go @@ -170,9 +170,14 @@ func (p *Provider) Embeddings(_ context.Context, _ *core.EmbeddingRequest) (*cor return nil, unsupported("embeddings") } +// unsupported reports a surface the upstream does not implement. It mirrors the +// 501 unsupported_response_operation shape the router already uses for provider +// capability gaps, so callers can tell "this provider cannot do that" apart +// from "your request was malformed". func unsupported(surface string) error { - return core.NewInvalidRequestError( - "chatgpt serves only the Responses API; "+surface+" are not available on a ChatGPT subscription", nil) + return core.NewInvalidRequestErrorWithStatus(http.StatusNotImplemented, + "chatgpt serves only the Responses API; "+surface+" are not available on a ChatGPT subscription", + nil).WithCode("unsupported_provider_operation") } // authHeaders builds the per-request credential headers. The account ID is diff --git a/internal/providers/chatgpt/chatgpt_test.go b/internal/providers/chatgpt/chatgpt_test.go index dab149d1f..b63e2b42e 100644 --- a/internal/providers/chatgpt/chatgpt_test.go +++ b/internal/providers/chatgpt/chatgpt_test.go @@ -3,6 +3,7 @@ package chatgpt import ( "context" "encoding/base64" + "errors" "io" "net/http" "net/http/httptest" @@ -129,6 +130,16 @@ func TestStreamResponses_SendsCodexDialect(t *testing.T) { if msg["role"] != "user" || msg["type"] != "message" { t.Errorf("input[0] = %#v, want a user message", msg) } + // The Responses API spells input content "input_text"; core.ContentPart + // would have rewritten it to the Chat Completions "text". + parts, ok := msg["content"].([]any) + if !ok || len(parts) != 1 { + t.Fatalf("content = %#v, want one part", msg["content"]) + } + part, _ := parts[0].(map[string]any) + if part["type"] != "input_text" || part["text"] != "Reply with exactly ok" { + t.Errorf("content[0] = %#v, want an input_text part", part) + } } // TestResponses_CollapsesUpstreamStream covers the non-streaming path: the @@ -300,16 +311,38 @@ func TestListModels(t *testing.T) { } } +// TestUnsupportedSurfaces checks that surfaces the Codex backend does not +// implement report a capability gap (501) rather than a malformed request. func TestUnsupportedSurfaces(t *testing.T) { provider := New(providers.ProviderConfig{APIKey: "token"}, providers.ProviderOptions{}) - if _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{Model: "gpt-5.6-terra"}); err == nil { - t.Error("ChatCompletion should be unsupported") - } - if _, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{Model: "gpt-5.6-terra"}); err == nil { - t.Error("StreamChatCompletion should be unsupported") + calls := map[string]func() error{ + "ChatCompletion": func() error { + _, err := provider.ChatCompletion(context.Background(), &core.ChatRequest{Model: "gpt-5.6-terra"}) + return err + }, + "StreamChatCompletion": func() error { + _, err := provider.StreamChatCompletion(context.Background(), &core.ChatRequest{Model: "gpt-5.6-terra"}) + return err + }, + "Embeddings": func() error { + _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{Model: "gpt-5.6-terra"}) + return err + }, } - if _, err := provider.Embeddings(context.Background(), &core.EmbeddingRequest{Model: "gpt-5.6-terra"}); err == nil { - t.Error("Embeddings should be unsupported") + for name, call := range calls { + t.Run(name, func(t *testing.T) { + err := call() + if err == nil { + t.Fatal("expected an unsupported-surface error") + } + var gatewayErr *core.GatewayError + if !errors.As(err, &gatewayErr) { + t.Fatalf("error = %T, want *core.GatewayError", err) + } + if gatewayErr.StatusCode != http.StatusNotImplemented { + t.Errorf("status = %d, want %d", gatewayErr.StatusCode, http.StatusNotImplemented) + } + }) } } diff --git a/internal/providers/chatgpt/request.go b/internal/providers/chatgpt/request.go index b5be3d966..576ca201a 100644 --- a/internal/providers/chatgpt/request.go +++ b/internal/providers/chatgpt/request.go @@ -52,6 +52,10 @@ func newUpstreamRequest(req *core.ResponsesRequest) (*upstreamRequest, error) { // normalizeInput wraps a bare string prompt in the message list the backend // requires; array inputs pass through untouched. +// +// The content part is a literal rather than a core.ContentPart: that type +// marshals to the Chat Completions shape, rewriting "input_text" to "text", +// which is not how the Responses API spells input content. func normalizeInput(input any) (any, error) { switch v := input.(type) { case nil: @@ -60,7 +64,7 @@ func normalizeInput(input any) (any, error) { return []core.ResponsesInputElement{{ Type: "message", Role: "user", - Content: []core.ContentPart{{Type: "input_text", Text: v}}, + Content: []map[string]string{{"type": "input_text", "text": v}}, }}, nil default: return v, nil From 88406be787df0943548075424e9fe7d095c64000 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 20 Aug 2026 15:07:48 +0200 Subject: [PATCH 05/11] docs(chatgpt): show how to correct the model metadata collision These model IDs also exist on the OpenAI Platform, so registry enrichment attaches their per-token prices and advertises modes: ["chat", "responses"] for a provider that answers 501 to chat completions. Declaring the models with explicit metadata corrects the /v1/models listing. It does not correct usage records: cost tracking resolves pricing separately from the metadata the registry serves, so budgets and cost-based routing still act on API-rate figures for flat-rate subscription traffic. Says so plainly rather than implying the override is a complete fix. --- docs/providers/chatgpt.mdx | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/providers/chatgpt.mdx b/docs/providers/chatgpt.mdx index ef3fdd5c7..9af549611 100644 --- a/docs/providers/chatgpt.mdx +++ b/docs/providers/chatgpt.mdx @@ -88,12 +88,33 @@ Platform, so GoModel's model registry attaches their per-token API prices. Usage records and dashboard totals for `chatgpt` therefore show a dollar figure that corresponds to no actual charge. +The same collision makes `/v1/models` advertise `modes: ["chat", "responses"]` +for these models, though chat completions return 501 here. Declare the models +explicitly to correct both: + +```yaml +providers: + chatgpt: + type: chatgpt + api_key: "${CHATGPT_API_KEY}" + models: + - id: gpt-5.6-sol + metadata: + modes: ["responses"] + pricing: + input_per_mtok: 0 + output_per_mtok: 0 + cached_input_per_mtok: 0 +``` + - Anything that consumes those figures acts on them: a **budget** can reject - `chatgpt` traffic for "spending" money the subscription never charges, and - `cost`-based load balancing will price it as API traffic. Scope budgets to a - [user path](/features/user-path) that excludes subscription traffic, or leave - budgets off for it. + That override corrects what `/v1/models` advertises, but **not** what usage + records: cost tracking resolves pricing separately and still bills at + registry rates. Anything consuming those figures acts on them — a **budget** + can reject `chatgpt` traffic for "spending" money the subscription never + charges, and `cost`-based load balancing will price it as API traffic. Scope + budgets to a [user path](/features/user-path) that excludes subscription + traffic, or leave budgets off for it. ## Limits From 3bf40f3b17f4d00bd2119f9865d2565980f4bcaf Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 20 Aug 2026 15:10:29 +0200 Subject: [PATCH 06/11] test(chatgpt): pin the unsupported-operation error code The 501 status was asserted but the code was not, leaving the programmatic half of the contract free to drift. Extracts the literal into a named constant so the provider and its test share one source. --- internal/providers/chatgpt/chatgpt.go | 13 ++++++++----- internal/providers/chatgpt/chatgpt_test.go | 5 +++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/internal/providers/chatgpt/chatgpt.go b/internal/providers/chatgpt/chatgpt.go index 104d8737a..70d6532d7 100644 --- a/internal/providers/chatgpt/chatgpt.go +++ b/internal/providers/chatgpt/chatgpt.go @@ -170,14 +170,17 @@ func (p *Provider) Embeddings(_ context.Context, _ *core.EmbeddingRequest) (*cor return nil, unsupported("embeddings") } -// unsupported reports a surface the upstream does not implement. It mirrors the -// 501 unsupported_response_operation shape the router already uses for provider -// capability gaps, so callers can tell "this provider cannot do that" apart -// from "your request was malformed". +// unsupportedOperationCode marks a surface the upstream does not implement. It +// mirrors the router's unsupported_response_operation code, which covers +// response operations specifically, so callers can tell "this provider cannot +// do that" apart from "your request was malformed". +const unsupportedOperationCode = "unsupported_provider_operation" + +// unsupported reports a surface the Codex backend does not serve. func unsupported(surface string) error { return core.NewInvalidRequestErrorWithStatus(http.StatusNotImplemented, "chatgpt serves only the Responses API; "+surface+" are not available on a ChatGPT subscription", - nil).WithCode("unsupported_provider_operation") + nil).WithCode(unsupportedOperationCode) } // authHeaders builds the per-request credential headers. The account ID is diff --git a/internal/providers/chatgpt/chatgpt_test.go b/internal/providers/chatgpt/chatgpt_test.go index b63e2b42e..1b28e5262 100644 --- a/internal/providers/chatgpt/chatgpt_test.go +++ b/internal/providers/chatgpt/chatgpt_test.go @@ -342,6 +342,11 @@ func TestUnsupportedSurfaces(t *testing.T) { if gatewayErr.StatusCode != http.StatusNotImplemented { t.Errorf("status = %d, want %d", gatewayErr.StatusCode, http.StatusNotImplemented) } + // The code is the programmatic half of the contract: callers + // branch on it to tell a capability gap from a bad request. + if gatewayErr.Code == nil || *gatewayErr.Code != unsupportedOperationCode { + t.Errorf("code = %v, want %q", gatewayErr.Code, unsupportedOperationCode) + } }) } } From 91673b3b334e6995558436ab377f04eaaa167e13 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 20 Aug 2026 15:25:54 +0200 Subject: [PATCH 07/11] docs(codex): drop the false choice between subscription and "your own provider" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide framed the integration as two modes, but steps 1-4 were identical for both and the "bring your own provider" tab only ever demonstrated OPENAI_API_KEY — it was the generic quickstart in a Codex-shaped wrapper. The two labels were also on different axes: one described billing, the other ownership, while both paths configure an ordinary GoModel provider. States the one real decision instead — which provider serves the model, and so what pays for it — and notes that everything downstream is the same either way. Also moves the DeepSeek section out from between steps 3 and 4 so the numbered walkthrough is contiguous, and keeps the env_key gotcha at the point where env_key is actually configured rather than repeating it three times. --- docs/guides/codex.mdx | 78 +++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 48 deletions(-) diff --git a/docs/guides/codex.mdx b/docs/guides/codex.mdx index 898c7999b..a6db0c507 100644 --- a/docs/guides/codex.mdx +++ b/docs/guides/codex.mdx @@ -10,33 +10,30 @@ Responses API. `Codex -> GoModel -> upstream` -Two ways to run it: +Codex talks to GoModel with a GoModel master key, so every request shows up in +the dashboard. The one choice to make is which provider serves the model, +because that decides what pays for it: -| Mode | Upstream | Billing | -| ---- | -------- | ------- | -| **Subscription** | The Codex backend behind your ChatGPT plan | ChatGPT subscription quota | -| **Bring your own provider** | OpenAI, DeepSeek, Anthropic, … | That provider's API credit | +| Provider | Upstream | Billing | +| -------- | -------- | ------- | +| `chatgpt` | The Codex backend behind your ChatGPT plan | ChatGPT subscription quota | +| `openai`, `deepseek`, `anthropic`, … | That provider's API | That provider's API credit | -Either way Codex talks to GoModel with a GoModel master key, so every request -shows up in the dashboard. +Everything after this point is the same either way. ## Before you start - Install Codex on your machine. - Choose a GoModel master key, for example `change-me`. - - - -Sign in once so Codex writes its token file, then hand that token to GoModel: +To bill Codex to your ChatGPT plan, sign in once so Codex writes its token +file, then hand that token to GoModel: ```bash codex login export CHATGPT_API_KEY=$(jq -r .tokens.access_token ~/.codex/auth.json) ``` -Run GoModel with it: - ```bash docker run --rm -p 8080:8080 \ -e GOMODEL_MASTER_KEY="change-me" \ @@ -44,30 +41,11 @@ docker run --rm -p 8080:8080 \ enterpilot/gomodel ``` -Usage is billed against your ChatGPT plan. See the -[ChatGPT provider page](/providers/chatgpt) for the model list, the token's -~10-day lifetime, and which Responses parameters the backend accepts. - - - - -Make sure GoModel has the upstream provider key for the models you want: - -```bash -docker run --rm -p 8080:8080 \ - -e GOMODEL_MASTER_KEY="change-me" \ - -e OPENAI_API_KEY="sk-..." \ - enterpilot/gomodel -``` - - - You can stay signed into Codex with ChatGPT while using this mode, but the - custom provider still needs its own gateway credential and GoModel still needs - an upstream provider key. - +See the [ChatGPT provider page](/providers/chatgpt) for the model list, the +token's ~10-day lifetime, and which Responses parameters that backend accepts. - - +To bill it to API credit instead, configure any other provider as usual — for +example `-e OPENAI_API_KEY="sk-..."` — and use one of its models in step 2. ## 1. Confirm the Responses API @@ -148,6 +126,12 @@ Then export the GoModel master key for that provider: export GOMODEL_API_KEY=change-me ``` + + Codex requires this variable even when you are signed in with ChatGPT, and it + carries the GoModel master key — not an OpenAI key. Without it the provider + fails to start. + + To try it without editing your config, pass the same settings inline: ```bash @@ -185,6 +169,16 @@ ok — Codex falls back and the session works. +## 4. Check the traffic in GoModel + +Open the GoModel dashboard audit logs: + +[http://localhost:8080/admin/dashboard/audit](http://localhost:8080/admin/dashboard/audit) + +This lets you confirm that Codex is reaching GoModel and inspect the full +request and response trail. From the same dashboard, you can keep following +your GoModel traffic and usage. + ## DeepSeek V4 Codex sends `POST /v1/responses`. DeepSeek exposes chat completions instead of @@ -227,24 +221,12 @@ env_key = "GOMODEL_API_KEY" wire_api = "responses" ``` -## 4. Check the traffic in GoModel - -Open the GoModel dashboard audit logs: - -[http://localhost:8080/admin/dashboard/audit](http://localhost:8080/admin/dashboard/audit) - -This lets you confirm that Codex is reaching GoModel and inspect the full -request and response trail. From the same dashboard, you can keep following -your GoModel traffic and usage. - ## Current status - the recommended integration path is Codex custom provider -> standard `http://localhost:8080/v1` - Codex custom provider mode sends `POST /v1/responses` as plain JSON, so the old `--disable enable_request_compression` workaround is no longer required -- Codex still requires the provider's `env_key`, even when signed in with - ChatGPT — that variable carries the GoModel master key, not an OpenAI key ## References From f64206d29686f99211e1c1d53fa84f4d93522726 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 20 Aug 2026 15:30:57 +0200 Subject: [PATCH 08/11] docs(codex): link the provider pages and cut duplicated setup The guide had grown while gaining the subscription path. Trims it back without losing coverage: - the provider table now links each option to its page, and "any other" to the providers overview, so the guide points at detail instead of restating it - the optional verification step keeps curl and links the Responses API reference for the Python and JavaScript forms, which were generic SDK usage rather than anything Codex-specific - that example no longer passes max_output_tokens: the chatgpt provider strips it, so on the subscription path it taught a cap that does not apply - the DeepSeek section defers its reasoning-effort and encrypted-reasoning detail to the pages that own it Net 38 lines shorter than before the subscription path was added. --- docs/guides/codex.mdx | 99 ++++++++----------------------------------- 1 file changed, 17 insertions(+), 82 deletions(-) diff --git a/docs/guides/codex.mdx b/docs/guides/codex.mdx index a6db0c507..af967c377 100644 --- a/docs/guides/codex.mdx +++ b/docs/guides/codex.mdx @@ -16,8 +16,8 @@ because that decides what pays for it: | Provider | Upstream | Billing | | -------- | -------- | ------- | -| `chatgpt` | The Codex backend behind your ChatGPT plan | ChatGPT subscription quota | -| `openai`, `deepseek`, `anthropic`, … | That provider's API | That provider's API credit | +| [`chatgpt`](/providers/chatgpt) | The Codex backend behind your ChatGPT plan | ChatGPT subscription quota | +| `openai`, [`deepseek`](/providers/deepseek), [`anthropic`](/providers/anthropic), [any other](/providers/overview) | That provider's API | That provider's API credit | Everything after this point is the same either way. @@ -49,61 +49,18 @@ example `-e OPENAI_API_KEY="sk-..."` — and use one of its models in step 2. ## 1. Confirm the Responses API -Before testing Codex itself, you can optionally verify that GoModel answers a -normal Responses API request: +Optional: check that GoModel answers a plain Responses request before involving +Codex. Use a model your configured provider actually serves. -This step is optional. If you are sure GoModel has a working credential, skip -it and go straight to [step 2](#2-configure-codex-to-use-gomodel). - - - -```bash curl +```bash curl -s http://localhost:8080/v1/responses \ -H "Authorization: Bearer change-me" \ -H "Content-Type: application/json" \ - -d '{ - "model": "gpt-5.6-sol", - "input": "Reply with exactly ok", - "max_output_tokens": 16 - }' -``` - -```python Python -from openai import OpenAI - -client = OpenAI(base_url="http://localhost:8080/v1", api_key="change-me") - -response = client.responses.create( - model="gpt-5.6-sol", - input="Reply with exactly ok", - max_output_tokens=16, -) - -print(response.output_text) + -d '{"model": "gpt-5.6-sol", "input": "Reply with exactly ok"}' ``` -```javascript JavaScript -import OpenAI from "openai"; - -const client = new OpenAI({ - baseURL: "http://localhost:8080/v1", - apiKey: "change-me", -}); - -const response = await client.responses.create({ - model: "gpt-5.6-sol", - input: "Reply with exactly ok", - max_output_tokens: 16, -}); - -console.log(response.output_text); -``` - - - -If the gateway is wired correctly, the response will contain `ok`. Use a model -your configured provider actually serves — `gpt-5.6-sol` on a ChatGPT -subscription, or e.g. `gpt-4.1-mini` on an OpenAI Platform key. +The response contains `ok`. For the Python and JavaScript equivalents, see the +[Responses API reference](/advanced/responses-api). ## 2. Configure Codex to use GoModel @@ -181,45 +138,23 @@ your GoModel traffic and usage. ## DeepSeek V4 -Codex sends `POST /v1/responses`. DeepSeek exposes chat completions instead of -a native Responses API, so configure the first-class DeepSeek provider and let -GoModel translate the request. +Codex sends `POST /v1/responses`, which DeepSeek does not serve natively. Use +`type: deepseek` rather than `type: openai`: the DeepSeek provider translates +`/responses` to `/chat/completions`, while the generic OpenAI provider forwards +it upstream unchanged. ```yaml providers: deepseek: type: deepseek - base_url: "https://api.deepseek.com" api_key: "${DEEPSEEK_API_KEY}" ``` -If you previously configured DeepSeek as `type: openai`, change it to -`type: deepseek` for Codex. The generic OpenAI provider forwards `/responses` -upstream, while the DeepSeek provider translates `/responses` to -`/chat/completions`. - -See the [DeepSeek guide](/providers/deepseek) for the full reasoning effort -mapping table (DeepSeek V4 only accepts `high` and `max`, so GoModel maps -`low` and `medium` up to `high`). - -Codex attaches `include: ["reasoning.encrypted_content"]` to every Responses -request. GoModel ignores it on chat-translated providers, so the response -carries no encrypted reasoning items. See -[Responses compatibility](/advanced/responses-compatibility) for the full -feature matrix. - -Then use the DeepSeek model name in Codex: - -```toml -model_provider = "gomodel" -model = "deepseek-v4-pro" - -[model_providers.gomodel] -name = "GoModel" -base_url = "http://localhost:8080/v1" -env_key = "GOMODEL_API_KEY" -wire_api = "responses" -``` +Then set `model = "deepseek-v4-pro"` in the Codex config from step 2. See the +[DeepSeek page](/providers/deepseek) for the reasoning-effort mapping, and +[Responses compatibility](/advanced/responses-compatibility) for what +chat-translated providers drop — including the `reasoning.encrypted_content` +Codex asks for on every request. ## Current status From fffa0aca621c590f24fa198474be58ccec05293d Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 20 Aug 2026 15:35:02 +0200 Subject: [PATCH 09/11] docs(chatgpt): link the metadata reference instead of inlining it The cost caveat had grown into the longest section on the page, with a config block that duplicates the escape hatch /advanced/model-metadata already documents. Links there instead. Corrects an overstatement while trimming: the wrong `modes` on GET /v1/models is cosmetic, since modes drive dashboard grouping rather than routing. Pricing is the part that matters, because it feeds cost tracking, budgets, and cost load balancing. Also names the 501 that chat completions and embeddings return, splits the gpt-5.4 retirement out of the model-rejection paragraph, and points the parameter table at the Responses compatibility matrix. --- docs/providers/chatgpt.mdx | 55 +++++++++++++++----------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/docs/providers/chatgpt.mdx b/docs/providers/chatgpt.mdx index 9af549611..ef52b9957 100644 --- a/docs/providers/chatgpt.mdx +++ b/docs/providers/chatgpt.mdx @@ -55,10 +55,12 @@ CHATGPT_MODELS=gpt-5.6-sol,gpt-5.6-terra ``` A model outside the plan's set is rejected upstream with `The '' model -is not supported when using Codex with a ChatGPT account`. `gpt-5.4` and -`gpt-5.4-mini` leave ChatGPT-authenticated Codex on August 31, 2026 — use -`gpt-5.6-terra` and `gpt-5.6-luna` instead. Both remain available to Codex -sessions authenticated with an OpenAI API key, through the `openai` provider. +is not supported when using Codex with a ChatGPT account`. + +`gpt-5.4` and `gpt-5.4-mini` leave ChatGPT-authenticated Codex on August 31, +2026; use `gpt-5.6-terra` and `gpt-5.6-luna` instead. Both stay available to +Codex sessions authenticated with an OpenAI API key, through the `openai` +provider. ## Responses API only @@ -79,42 +81,29 @@ Responses API: Because the backend streams only, a non-streaming `POST /v1/responses` is served by streaming upstream and returning the final response object. Clients -see a normal non-streaming response. +see a normal non-streaming response. See +[Responses compatibility](/advanced/responses-compatibility) for how the +gateway's Responses surface behaves across providers. ## Reported cost is not real spend Subscription usage is flat-rate, but these model IDs also exist on the OpenAI -Platform, so GoModel's model registry attaches their per-token API prices. -Usage records and dashboard totals for `chatgpt` therefore show a dollar figure -that corresponds to no actual charge. - -The same collision makes `/v1/models` advertise `modes: ["chat", "responses"]` -for these models, though chat completions return 501 here. Declare the models -explicitly to correct both: +Platform, so the model catalog attaches their per-token API prices. Usage +records and dashboard totals for `chatgpt` show a figure that corresponds to no +actual charge, and the same collision makes `GET /v1/models` advertise +`modes: ["chat", "responses"]`. -```yaml -providers: - chatgpt: - type: chatgpt - api_key: "${CHATGPT_API_KEY}" - models: - - id: gpt-5.6-sol - metadata: - modes: ["responses"] - pricing: - input_per_mtok: 0 - output_per_mtok: 0 - cached_input_per_mtok: 0 -``` +The modes are cosmetic — they drive dashboard grouping, not routing. Pricing is +not: it feeds [cost tracking](/features/cost-tracking), budgets, and `cost` +load balancing. - That override corrects what `/v1/models` advertises, but **not** what usage - records: cost tracking resolves pricing separately and still bills at - registry rates. Anything consuming those figures acts on them — a **budget** - can reject `chatgpt` traffic for "spending" money the subscription never - charges, and `cost`-based load balancing will price it as API traffic. Scope - budgets to a [user path](/features/user-path) that excludes subscription - traffic, or leave budgets off for it. + A **budget** can therefore reject `chatgpt` traffic for "spending" money the + subscription never charges. Scope budgets to a + [user path](/features/user-path) that excludes subscription traffic, or leave + budgets off for it. Declaring per-model `pricing` and `modes: ["responses"]` + in [model metadata](/advanced/model-metadata) corrects what `/v1/models` + advertises, but usage records still price at catalog rates. ## Limits From 45089352e3eab9f9a0a5b52101aae8f2bdb67a69 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 20 Aug 2026 15:35:20 +0200 Subject: [PATCH 10/11] docs(chatgpt): name the 501 that unsupported surfaces return "Return an error" left the caller guessing; the status is the actionable part. --- docs/providers/chatgpt.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/providers/chatgpt.mdx b/docs/providers/chatgpt.mdx index ef52b9957..77d4d3675 100644 --- a/docs/providers/chatgpt.mdx +++ b/docs/providers/chatgpt.mdx @@ -64,9 +64,9 @@ provider. ## Responses API only -The Codex backend serves `/responses` and nothing else. `/v1/chat/completions` -and `/v1/embeddings` return an error for `chatgpt` models — use -`/v1/responses`, which is what Codex and the OpenAI SDKs send anyway. +The Codex backend serves `/responses` and nothing else, so +`/v1/chat/completions` and `/v1/embeddings` answer `501` for `chatgpt` models. +Use `/v1/responses`, which is what Codex sends anyway. The backend also validates against a strict parameter allowlist. GoModel adapts requests rather than failing them, so callers keep using the standard From 436f9fef5ba35c9f79f8acbb358803100d2315af Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 20 Aug 2026 15:39:35 +0200 Subject: [PATCH 11/11] docs(chatgpt): warn that a declared model list replaces the inventory Declaring models to fix their metadata drops every model left out: with one of the four declared, GET /v1/models returns only that one. Says so where the metadata override is recommended. --- docs/providers/chatgpt.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/providers/chatgpt.mdx b/docs/providers/chatgpt.mdx index 77d4d3675..f3b84d963 100644 --- a/docs/providers/chatgpt.mdx +++ b/docs/providers/chatgpt.mdx @@ -103,7 +103,9 @@ load balancing. [user path](/features/user-path) that excludes subscription traffic, or leave budgets off for it. Declaring per-model `pricing` and `modes: ["responses"]` in [model metadata](/advanced/model-metadata) corrects what `/v1/models` - advertises, but usage records still price at catalog rates. + advertises, but usage records still price at catalog rates. Declare every + model you want served: the list replaces the default inventory rather than + adding to it. ## Limits