diff --git a/.env.template b/.env.template index 8ea4db1d8..f3ade807b 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.6-terra,gpt-5.6-luna,gpt-5.5 + # 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..66293af72 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.6-terra, gpt-5.6-luna, gpt-5.5] + 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..af967c377 100644 --- a/docs/guides/codex.mdx +++ b/docs/guides/codex.mdx @@ -1,130 +1,116 @@ --- 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` +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: + +| Provider | Upstream | Billing | +| -------- | -------- | ------- | +| [`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. ## 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 +To bill Codex to your ChatGPT plan, 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) +``` ```bash docker run --rm -p 8080:8080 \ -e GOMODEL_MASTER_KEY="change-me" \ - -e OPENAI_API_KEY="sk-..." \ + -e CHATGPT_API_KEY="$CHATGPT_API_KEY" \ enterpilot/gomodel ``` -## 2. Confirm the Responses API +See the [ChatGPT provider page](/providers/chatgpt) for the model list, the +token's ~10-day lifetime, and which Responses parameters that backend accepts. -Before testing Codex itself, you can optionally verify that GoModel answers a -normal Responses API request: +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. -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). +## 1. Confirm the Responses API - +Optional: check that GoModel answers a plain Responses request before involving +Codex. Use a model your configured provider actually serves. -```bash curl +```bash curl -s http://localhost:8080/v1/responses \ -H "Authorization: Bearer change-me" \ -H "Content-Type: application/json" \ - -d '{ - "model": "gpt-4.1-mini", - "input": "Reply with exactly ok", - "max_output_tokens": 16 - }' + -d '{"model": "gpt-5.6-sol", "input": "Reply with exactly ok"}' ``` -```python Python -from openai import OpenAI +The response contains `ok`. For the Python and JavaScript equivalents, see the +[Responses API reference](/advanced/responses-api). -client = OpenAI(base_url="http://localhost:8080/v1", api_key="change-me") - -response = client.responses.create( - model="gpt-4.1-mini", - input="Reply with exactly ok", - max_output_tokens=16, -) - -print(response.output_text) -``` - -```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-4.1-mini", - input: "Reply with exactly ok", - max_output_tokens: 16, -}); - -console.log(response.output_text); -``` +## 2. Configure Codex to use GoModel - - -If the gateway is wired correctly, the response will contain `ok`. - -## 3. 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 ``` - 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 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. -## 4. Run a Codex test prompt +To try it without editing your config, pass the same settings inline: ```bash -codex exec -m gpt-4.1-mini 'Reply with exactly ok and no punctuation.' +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"' \ + -m gpt-5.6-sol 'Reply with exactly ok and no punctuation.' +``` + + + 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. + + +## 3. Run a Codex test prompt + +```bash +codex exec -m gpt-5.6-sol 'Reply with exactly ok and no punctuation.' ``` The validated result was: @@ -133,87 +119,63 @@ 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. + + +## 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 -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 = "OPENAI_API_KEY" -wire_api = "responses" -``` - -## 5. 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. +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 - 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` ## 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..f3b84d963 --- /dev/null +++ b/docs/providers/chatgpt.mdx @@ -0,0 +1,123 @@ +--- +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.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.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 stay available to +Codex sessions authenticated with an OpenAI API key, through the `openai` +provider. + +## Responses API only + +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 +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. 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 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"]`. + +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. + + + 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. Declare every + model you want served: the list replaces the default inventory rather than + adding to it. + + +## 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..a3a3a7afe 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,14 @@ 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`. 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 @@ -217,6 +226,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..70d6532d7 --- /dev/null +++ b/internal/providers/chatgpt/chatgpt.go @@ -0,0 +1,199 @@ +// 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. gpt-5.4 and gpt-5.4-mini are deliberately absent: OpenAI +// 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 +// 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") +} + +// 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(unsupportedOperationCode) +} + +// 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..1b28e5262 --- /dev/null +++ b/internal/providers/chatgpt/chatgpt_test.go @@ -0,0 +1,372 @@ +package chatgpt + +import ( + "context" + "encoding/base64" + "errors" + "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.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.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. +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.6-terra", + 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) + } + // 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 +// 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.6-terra", + 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) + } +} + +// 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" + + 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_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", + }, + } + 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) { + provider := NewWithHTTPClient("", "http://example.invalid", http.DefaultClient, llmclient.Hooks{}) + _, 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") + } + 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.6-terra"}, want: []string{"gpt-5.6-terra"}}, + } + 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]) + } + } + }) + } +} + +// 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{}) + 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 + }, + } + 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) + } + // 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) + } + }) + } +} + +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..576ca201a --- /dev/null +++ b/internal/providers/chatgpt/request.go @@ -0,0 +1,72 @@ +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. +// +// 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: + return nil, core.NewInvalidRequestError("responses input is required", nil) + case string: + return []core.ResponsesInputElement{{ + Type: "message", + Role: "user", + Content: []map[string]string{{"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..66faefc86 --- /dev/null +++ b/internal/providers/chatgpt/stream.go @@ -0,0 +1,71 @@ +package chatgpt + +import ( + "bufio" + "bytes" + "io" + "net/http" + + "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. +// +// 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) + + 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"` + Message string `json:"message"` + Response *core.ResponsesResponse `json:"response"` + } + if err := json.Unmarshal(data, &event); err != nil { + continue + } + 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", http.StatusBadGateway, + "failed to read response stream: "+err.Error(), err) + } + return nil, core.NewProviderError("chatgpt", http.StatusBadGateway, + "response stream ended before completion", 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", }