diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 2c3da6070..1cd2ea311 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -88,7 +88,46 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install | bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1' | iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport, and account-filtered model discovery. | -| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | +| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. The catalog is mixed-wire — see [GitHub Copilot model wires](#github-copilot-model-wires). | + +### GitHub Copilot model wires + +Copilot's catalog is not uniformly served by `/chat/completions`. The provider adapter stays +`openai-chat`, which is correct for the Claude, Gemini, GPT-3.5/4 and `gpt-5-mini` entries, but the +newer OpenAI models answer only on the Responses API. opencodex routes those over +`openai-responses` automatically: + +`gpt-5.3-codex` · `gpt-5.4` · `gpt-5.4-mini` · `gpt-5.4-nano` · `gpt-5.5` · `gpt-5.6-luna` · +`gpt-5.6-sol` · `gpt-5.6-terra` + +Without this, a chat-completions request fails with +`model "…" is not accessible via the /chat/completions endpoint`. `gpt-5.4` is subtler: a text-only +request succeeds and only a real Codex request fails, because Codex supplies function tools plus a +reasoning effort (`Function tools with reasoning_effort are not supported … use /v1/responses`). + +Unlike the DeepSeek default, which applies only to a Responses inbound, these apply to every +inbound: Copilot serves no chat route at all for them, so a Chat Completions or Claude Code client +is translated onto Responses too. + +An explicit [`modelAdapters`](/reference/configuration/) entry always wins, so you can move a model +back onto chat completions — or route a new one onto Responses before the built-in map catches up: + +```json +{ + "providers": { + "github-copilot": { + "modelAdapters": { "gpt-5.4": "openai-chat" } + } + } +} +``` + +Bare OpenAI model names such as `gpt-5.6-sol` are reserved for the canonical `openai` provider, so +Copilot models must be selected with the provider prefix: + +```bash +codex -m github-copilot/gpt-5.6-sol -c 'model_reasoning_effort="medium"' +``` For the canonical Kimi Coding Plan presets (`kimi` account login and `kimi-code` API key), opencodex forwards only a caller-supplied stable `prompt_cache_key` to the Chat Completions request; diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index b47146a0c..9d33da40c 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -85,7 +85,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは、インストール済みでサインインした `kiro-cli` セッションを取り込みます(Unix では `curl -fsSL https://cli.kiro.dev/install | bash`、Windows PowerShell では `irm 'https://cli.kiro.dev/install.ps1' | iex` でインストールしてから `kiro-cli login` を実行)。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | -| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。 | +| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。カタログは wire が混在します: `gpt-5.3-codex`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5.5`、`gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra` は Chat Completions では `model "…" is not accessible via the /chat/completions endpoint` で失敗するため、opencodex がすべての inbound で `openai-responses` にルーティングします。`gpt-5.4` はテキストのみの要求なら成功しますが、Codex が function tool と reasoning effort を伴うと失敗します。その他のモデルは `openai-chat` のままで、明示的な `modelAdapters` が常に優先されます。bare な OpenAI 名は canonical `openai` 専用なので `github-copilot/gpt-5.6-sol` のように選択してください。 | 正規の Kimi Coding Plan プリセット(`kimi` アカウントログインと `kimi-code` API key)では、 opencodex は呼び出し元が指定した安定した `prompt_cache_key` だけを Chat Completions リクエストへ diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 968014ce2..7c767e828 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -85,7 +85,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 설치하고 로그인한 `kiro-cli` 세션을 가져옵니다(Unix에서는 `curl -fsSL https://cli.kiro.dev/install | bash`, Windows PowerShell에서는 `irm 'https://cli.kiro.dev/install.ps1' | iex`로 설치한 뒤 `kiro-cli login` 실행). **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | -| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. | +| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. 카탈로그는 wire가 섞여 있습니다: `gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`는 Chat Completions에서 `model "…" is not accessible via the /chat/completions endpoint`로 실패하므로 opencodex가 모든 inbound에서 `openai-responses`로 라우팅합니다. `gpt-5.4`는 텍스트 전용 요청은 성공하지만 Codex가 function tool과 reasoning effort를 함께 보내면 실패합니다. 나머지 모델은 `openai-chat` 그대로이며, 명시적인 `modelAdapters` 항목이 항상 우선합니다. bare OpenAI 이름은 canonical `openai` 전용이므로 `github-copilot/gpt-5.6-sol`처럼 선택하세요. | 정식 Kimi Coding Plan 프리셋(`kimi` 계정 로그인과 `kimi-code` API key)의 경우, opencodex는 호출자가 제공한 안정적인 `prompt_cache_key`만 Chat Completions 요청으로 전달하며 직접 생성하지 diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index cb947d866..cfb23c68a 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -194,7 +194,7 @@ timing side channel을 막기 위해 상수 시간(`timingSafeEqual`)으로 비 | `modelReasoningEfforts?` | `Record` | 모델별 reasoning 레이블. 빈 배열은 해당 모델의 effort control을 숨깁니다. | | `modelSupportsReasoningSummaries?` | `Record` | 모델별 reasoning summary capability. 모델 값을 `false`로 두면 summary 지원을 알리지 않고 `openai-responses` 요청 전에 summary-delivery 필드를 제거합니다. | | `modelReasoningSummaryDelivery?` | `Record` | 모델별 Responses delivery enum입니다. 설정된 모델은 summary 지원을 유지하며 기존 `stream_options.reasoning_summary_delivery` 값만 바꿉니다. 같은 모델의 summary capability를 `false`로 설정할 수 없습니다. | -| `modelAdapters?` | `Record` | 여러 wire를 쓰는 모델이 한 게이트웨이에 섞여 있을 때의 모델별 wire 지정. 키는 upstream native 모델 ID이고 값은 `openai-chat` 또는 `openai-responses`만 허용합니다. `web_search` 같은 hosted tool 때문에 한 모델만 Responses API가 필요할 때 씁니다. upstream이 wire를 고정한 모델과 canonical ChatGPT forward provider에서는 override가 거부됩니다. | +| `modelAdapters?` | `Record` | 여러 wire를 쓰는 모델이 한 게이트웨이에 섞여 있을 때의 모델별 wire 지정. 키는 upstream native 모델 ID이고 값은 `openai-chat` 또는 `openai-responses`만 허용합니다. `web_search` 같은 hosted tool 때문에 한 모델만 Responses API가 필요할 때 씁니다. upstream이 wire를 고정한 모델과 canonical ChatGPT forward provider에서는 override가 거부됩니다. 일부 preset은 검증된 mixed-wire 경로를 자동 선택하는 registry 기본값을 가집니다(DeepSeek은 `deepseek-v4-flash`를 native Responses로, GitHub Copilot은 Responses 전용 GPT-5.3+ 모델을 모든 inbound에서 그렇게 보냅니다). 여기에 명시한 항목은 provider 전체 adapter와 같은 값이더라도 항상 우선하며, 모델을 기본값에서 빼는 방법입니다. | | `reasoningEffortMap?` | `Record` | 프로바이더 단위 reasoning 레이블 wire alias. 업스트림이 다른 값을 요구할 때만 사용합니다. | | `modelReasoningEffortMap?` | `Record>` | 모델별 reasoning 레이블 wire alias. | | `noReasoningModels?` | `string[]` | reasoning/thinking 파라미터를 거부하는 모델. 어댑터가 `reasoning_effort`를 제거합니다. | diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 0050fa718..6da28e9ac 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -339,7 +339,7 @@ or bind the forward explicitly to loopback (`ssh -L 127.0.0.1:20100:localhost:10 | `modelReasoningEfforts?` | `Record` | Model-specific reasoning labels. An empty list hides the effort control for that model. | | `modelSupportsReasoningSummaries?` | `Record` | Model-specific reasoning-summary capability. Set a model to `false` to stop advertising summaries and strip summary-delivery fields before an `openai-responses` request. | | `modelReasoningSummaryDelivery?` | `Record` | Model-specific Responses delivery enum. A configured model stays summary-capable and only an existing `stream_options.reasoning_summary_delivery` is rewritten; do not combine it with a `false` summary capability for the same model. | -| `modelAdapters?` | `Record` | Per-model wire override for a gateway that fronts models speaking different wires. Keys are upstream native model ids; values must be `openai-chat` or `openai-responses`. Built-in registry defaults may select a verified mixed-wire route automatically (the DeepSeek preset sends `deepseek-v4-flash` over native Responses); an explicit entry wins and can opt a model back out. Models the upstream pins to a single wire, and the canonical ChatGPT forward provider, reject overrides. | +| `modelAdapters?` | `Record` | Per-model wire override for a gateway that fronts models speaking different wires. Keys are upstream native model ids; values must be `openai-chat` or `openai-responses`. Built-in registry defaults may select a verified mixed-wire route automatically (the DeepSeek preset sends `deepseek-v4-flash` over native Responses; the GitHub Copilot preset sends its Responses-only GPT-5.3+ models that way on every inbound); an explicit entry wins and can opt a model back out. Models the upstream pins to a single wire, and the canonical ChatGPT forward provider, reject overrides. | | `reasoningEffortMap?` | `Record` | Provider-wide wire aliases for reasoning labels. Use only when the upstream expects a different value. | | `modelReasoningEffortMap?` | `Record>` | Model-specific wire aliases for reasoning labels. | | `noReasoningModels?` | `string[]` | Models that reject a reasoning/thinking param — the adapter drops `reasoning_effort` for them. | diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 8bf6be3bc..8dfe368d3 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -91,7 +91,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Первый вход импортирует существующую сессию после установки Kiro CLI (в Unix: `curl -fsSL https://cli.kiro.dev/install | bash`; в Windows PowerShell: `irm 'https://cli.kiro.dev/install.ps1' | iex`; затем выполните `kiro-cli login`). **Добавить аккаунт** выполняет выход из `kiro-cli`, запускает новый вход через браузер, переключает аккаунт самого `kiro-cli` и сохраняет метаданные профиля отдельно для каждого аккаунта. Существующие аккаунты OpenCodex сохраняются; при отмене или сбое восстанавливается предыдущая сессия `kiro-cli`. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | -| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | +| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. Каталог смешанный по wire: `gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra` падают на Chat Completions с `model "…" is not accessible via the /chat/completions endpoint`, поэтому opencodex маршрутизирует их через `openai-responses` на любом inbound. У `gpt-5.4` текстовый запрос проходит, но запрос Codex с function tools и reasoning effort — нет. Остальные модели остаются на `openai-chat`, а явная запись `modelAdapters` всегда важнее. Голые имена OpenAI зарезервированы за каноническим `openai`, выбирайте `github-copilot/gpt-5.6-sol`. | Для канонических пресетов Kimi Coding Plan (вход через аккаунт `kimi` и API-ключ `kimi-code`) opencodex передаёт в запрос Chat Completions только стабильный `prompt_cache_key`, предоставленный diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index c86149aa5..b14791f3d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -79,7 +79,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(Unix 使用 `curl -fsSL https://cli.kiro.dev/install | bash`;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1' | iex`;然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、HTTP/2 传输和按账号筛选的模型发现。 | -| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | +| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。目录混用多种 wire:`gpt-5.3-codex`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.4-nano`、`gpt-5.5`、`gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra` 在 Chat Completions 上会返回 `model "…" is not accessible via the /chat/completions endpoint`,因此 opencodex 会在所有 inbound 上将它们路由到 `openai-responses`。`gpt-5.4` 的纯文本请求可以成功,但 Codex 携带 function tools 和 reasoning effort 的请求会失败。其余模型保持 `openai-chat`,显式的 `modelAdapters` 始终优先。裸 OpenAI 名称保留给 canonical `openai`,请使用 `github-copilot/gpt-5.6-sol`。 | 对于规范的 Kimi Coding Plan 预设(`kimi` 账号登录和 `kimi-code` API key),opencodex 只会把调用方提供的稳定 `prompt_cache_key` 转发到 Chat Completions 请求,绝不自行生成。Kimi diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 48ad865eb..15c24231f 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1414,7 +1414,40 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ featured: false, dashboardUrl: "https://github.com/settings/copilot", liveModels: true, - models: ["gpt-4o", "gpt-4.1", "gpt-4.1-mini", "claude-sonnet-4", "gemini-2.5-pro"], + // Seed refreshed 2026-07-30 from a live 37-entry Copilot catalog; embeddings and the + // internal `trajectory-compaction` entry are omitted because they are not chat models. + models: [ + "claude-haiku-4.5", "claude-opus-4.5", "claude-opus-4.6", "claude-opus-4.7", + "claude-opus-4.8", "claude-sonnet-4.5", "claude-sonnet-4.6", "claude-sonnet-5", + "gemini-2.5-pro", "gemini-3-flash-preview", "gemini-3.1-pro-preview", + "gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-5-mini", + "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.5", + "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra", + ], + // Copilot's catalog is mixed-wire: `openai-chat` above is right for the Claude, Gemini, + // GPT-3.5/4 and gpt-5-mini entries, but these answer ONLY on the Responses API and fail + // chat completions with `model "…" is not accessible via the /chat/completions endpoint`. + // gpt-5.4 hides it behind a passing smoke test — a text-only chat request succeeds and + // only a real Codex request fails, because Codex supplies function tools plus a reasoning + // effort. Unlike DeepSeek's Flash entry these are bare strings, not inbound-scoped: the + // upstream serves no chat route at all for them, so every inbound must be translated + // rather than kept on the wire the client happened to speak. + // Verified 2026-07-30 with real `codex exec` runs, not minimal text probes: presence in + // GET /models proves neither. Recheck when Copilot changes models. + // `gpt-5.4-nano` is the one exception to that sentence: it was absent from the captured + // catalog, so it rests on GitHub's published model list plus the family pattern (every + // GPT-5.3+ entry Copilot serves is Responses-only; only the gpt-4o/4.1/gpt-5-mini tier + // answers chat). Confirm it on a plan that exposes it, or drop it. + modelWireDefaults: { + "gpt-5.3-codex": "openai-responses", + "gpt-5.4": "openai-responses", + "gpt-5.4-mini": "openai-responses", + "gpt-5.4-nano": "openai-responses", + "gpt-5.5": "openai-responses", + "gpt-5.6-luna": "openai-responses", + "gpt-5.6-sol": "openai-responses", + "gpt-5.6-terra": "openai-responses", + }, defaultModel: "gpt-4o", note: "Experimental unofficial Copilot bridge. Logs in via GitHub device flow using the public VS Code OAuth client id, then exchanges for a short-lived Copilot API token (copilot_internal). Requires an active Copilot subscription. GitHub may tighten or revoke this path; do not send confidential material you would not paste into Copilot Chat.", }, diff --git a/src/router.ts b/src/router.ts index 0c562240c..a487bb376 100644 --- a/src/router.ts +++ b/src/router.ts @@ -53,6 +53,10 @@ export function knownModelIdsForProvider(provName: string, prov: OcxProviderConf registry?.modelDefaultReasoningEfforts, registry?.modelReasoningEffortMap, registry?.modelMaxOutputTokens, + registry?.modelWireDefaults, + // A user who names a model in `modelAdapters` has asserted it exists on this provider, + // so it is selectable as `provider/model` even when no seed or cache lists it. + prov.modelAdapters, ]) { for (const id of Object.keys(map ?? {})) ids.add(id); } @@ -306,15 +310,29 @@ function activeProviderEntries(config: OcxConfig): [string, OcxProviderConfig][] } export class NoEnabledOpenAiProviderError extends Error { - constructor(modelId: string) { + constructor(modelId: string, alternatives: string[] = []) { super( `Model ${modelId} requires the canonical openai provider. ` - + `Run: ocx provider add openai && ocx sync && ocx restart`, + + `Run: ocx provider add openai && ocx sync && ocx restart` + // A bare OpenAI-family id is reserved for the canonical provider, so a gateway that + // also serves it (GitHub Copilot, OpenRouter, ...) is only reachable through a + // qualified selector. Name one instead of leaving the user to guess. + + (alternatives.length > 0 + ? `. Or select it on a provider that serves it: ${alternatives.map(name => `${name}/${modelId}`).join(", ")}` + : ""), ); this.name = "NoEnabledOpenAiProviderError"; } } +/** Enabled non-canonical providers whose known model ids include this bare OpenAI-family id. */ +function providersServingModelId(config: OcxConfig, modelId: string): string[] { + return activeProviderEntries(config) + .filter(([provName, prov]) => provName !== OPENAI_CODEX_PROVIDER_ID + && knownModelIdsForProvider(provName, prov).includes(modelId)) + .map(([provName]) => provName); +} + // Codex uses a small number of control-plane model ids that are not part of the public GPT/o // naming families. Keep this exact: a broad `codex-*` rule could capture a third-party model. const CODEX_INTERNAL_OPENAI_MODELS = new Set(["codex-auto-review"]); @@ -373,7 +391,7 @@ function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: bo if (isBareOpenAiFamilyModel(modelId)) { const provider = config.providers[OPENAI_CODEX_PROVIDER_ID]; if (provider && provider.disabled !== true) return routeResult(OPENAI_CODEX_PROVIDER_ID, provider, modelId); - throw new NoEnabledOpenAiProviderError(modelId); + throw new NoEnabledOpenAiProviderError(modelId, providersServingModelId(config, modelId)); } for (const [provName, prov] of activeProviderEntries(config)) { diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 2e22dbea3..3cee776ff 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -20,6 +20,7 @@ import { resolveClientRetryAfter } from "../lib/retry-after"; import { estimateTokens } from "../lib/token-estimate"; import { routeModel } from "../router"; import { resolveWireProtocolOverride } from "./adapter-resolve"; +import { stripUnsupportedResponsesSamplingParams } from "./responses-wire-params"; import type { OcxConfig } from "../types"; import { readJsonRequestBody } from "./request-decompress"; import { @@ -122,11 +123,7 @@ async function handleChatCompletionsWithBudget( directRoute = route.codexAccountMode === "direct"; // ChatGPT backend rejects store:true and unsupported sampling knobs. internalBody.store = false; - delete internalBody.max_output_tokens; - delete internalBody.temperature; - delete internalBody.top_p; - delete internalBody.stop; - delete internalBody.user; + stripUnsupportedResponsesSamplingParams(internalBody, route.provider); } else if (internalBody.store === undefined) { internalBody.store = false; } diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 4ad6e48fa..65194dd8b 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -27,6 +27,7 @@ import { clearableDeadline, idleDeadline } from "../lib/abort"; import { estimateTokens } from "../lib/token-estimate"; import { routeModel } from "../router"; import { resolveWireProtocolOverride } from "./adapter-resolve"; +import { stripUnsupportedResponsesSamplingParams } from "./responses-wire-params"; import type { OcxConfig } from "../types"; import { readJsonRequestBody } from "./request-decompress"; import { addFinalRequestLog, httpStatusForTerminalStatus, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log"; @@ -633,11 +634,7 @@ async function handleClaudeMessagesWithBudget( route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic"); if (route.provider.adapter === "openai-responses") { nativeRoute = true; - delete internalBody.max_output_tokens; - delete internalBody.temperature; - delete internalBody.top_p; - delete internalBody.stop; - delete internalBody.user; + stripUnsupportedResponsesSamplingParams(internalBody, route.provider); } // Estimated-usage adapters (cursor/kiro) report no per-turn input tokens; stash a // request-side estimate so the log's in:0 rows get a floor. NEVER set this for diff --git a/src/server/responses-wire-params.ts b/src/server/responses-wire-params.ts new file mode 100644 index 000000000..83f5e4aab --- /dev/null +++ b/src/server/responses-wire-params.ts @@ -0,0 +1,32 @@ +import type { OcxProviderConfig } from "../types"; + +/** + * Shape a translated Responses body for the wire the route actually uses. + * + * Shared by both translate-and-replay inbound handlers (Chat Completions and Anthropic + * Messages). They previously carried identical copies of this block, which is how the two + * drift apart. + * + * `store` is deliberately not handled here — only the Chat Completions handler has a + * non-Responses default for it. + */ +export function stripUnsupportedResponsesSamplingParams( + body: Record, + provider: Pick, +): void { + // Forward mode is the only route that 400s on max_output_tokens ("Unsupported parameter", + // verified live 2026-07-11), and the adapter's own stripUnsupportedForwardParams already + // drops it there on the same condition. A routed Responses gateway (GitHub Copilot, + // api.openai.com, ...) honors the field, and dropping it there silently ignores the + // caller's max_tokens. Reachable since a registry `modelWireDefaults` entry can put a + // chat/anthropic inbound onto the Responses wire. + if (provider.authMode === "forward") delete body.max_output_tokens; + // temperature/top_p/stop/user stay stripped on every Responses route: `stop` is not a + // Responses parameter at all, reasoning models reject temperature/top_p, and the + // passthrough adapter relays the body verbatim — `noTemperatureModels` and friends are + // honored only by the openai-chat adapter, so there is no per-model filter here. + delete body.temperature; + delete body.top_p; + delete body.stop; + delete body.user; +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index fabbc6ad8..ef4c873c0 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1660,6 +1660,89 @@ async function handleResponsesInner( } } } + // Credential recovery for ROUTED Responses gateways (GitHub Copilot, key-pool gateways). + // The normal adapter loop refreshes an OAuth token on 401 and rotates a pool key on 429, + // but this branch formats non-2xx immediately — so a model a registry `modelWireDefaults` + // entry puts on the Responses wire would surface a 401 that a refresh fixes, or a 429 a + // healthy key absorbs, while its chat-wire siblings on the same provider recover. Nothing + // has streamed yet and the body is a replayable string, the same precondition the + // transient-5xx retry above relies on. The canonical ChatGPT forward provider is excluded: + // it owns the pool-retry path above, and its credential must never be swapped here. + if (!upstreamResponse.ok && !isCanonicalOpenAiForwardProvider(route.provider)) { + // Widened from the narrowed passthrough type: only buildRequest is needed here, and + // the rebuilt adapter for the rotated credential is a plain ProviderAdapter. + let passthroughAdapter: Pick = adapter; + let oauth401Replayed = false; + // Bounded: at most one token refresh, then one replay per remaining pool key. Both + // guards are monotonic, so a persistently-401/429 upstream cannot loop here. + for (;;) { + let nextProvider: OcxProviderConfig | undefined; + // Recorded on the replay's send so the request log explains WHY the send count grew, + // exactly as the normal loop's rebuildAndRefetch does. + let recoveryKind: AttemptRecoveryKind | undefined; + if (upstreamResponse.status === 401 && isOAuth401ReplayProvider && sentOAuthSnapshot && !oauth401Replayed) { + oauth401Replayed = true; + recoveryKind = "oauth-401"; + // Release the rejected response's socket before the refresh round-trip, not just + // before the replay — a failed refresh returns without reaching the shared cancel. + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + let refreshed: OAuthAccessSnapshot; + try { + refreshed = await forceRefreshOAuthAccessSnapshot(sentOAuthSnapshot); + } catch (err) { + upstream.abort(); + return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err)); + } + sentOAuthSnapshot = refreshed; + // Rebuild the transport from the SAME provider name, so a refreshed credential can + // only ever be addressed at that provider's own validated destination. + nextProvider = resolveProviderTransport( + route.providerName, + { ...route.provider, apiKey: refreshed.accessToken }, + parsed.options.promptCacheKey, + route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined, + ); + if (route.providerName === "kiro") { + parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; + } + } else if (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) { + recoveryKind = "key-429"; + nextProvider = rotateProviderTransportOn429(config, route.providerName, route.provider, { + retryAfter: upstreamResponse.headers.get("retry-after"), + now: Date.now(), + attemptedKey: route.provider.apiKey, + promptCacheKey: parsed.options.promptCacheKey, + }) ?? undefined; + } + if (!nextProvider) break; + // Release the failed response's socket before replaying; unread bodies otherwise + // linger until runtime cleanup (one per rotated key under a rate-limit storm). + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + route.provider = nextProvider; + // Rebuild the request from the rotated provider so the new credential replaces the + // old one everywhere the request carries it — the adapter closes over the provider. + passthroughAdapter = resolveAdapter( + // `inboundWire` must match the original resolve: an inbound-scoped registry default + // would otherwise pick a different wire here and replay the request on a protocol + // the first send never used. + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + request = await passthroughAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); + // Keep the request log's attempt count honest: a replay is a second upstream send, + // exactly as the transient-5xx retry above records one. + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recoveryKind); + try { + upstreamResponse = await fetchWithHeaderTimeout(request.url, { + method: request.method, headers: request.headers, body: request.body, + }, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider)); + } catch (err) { + return transportFailureResponse(err); + } + if (upstreamResponse.ok) break; + } + } + const headers = sanitizePassthroughHeaders(upstreamResponse.headers); const resolvedModel = headers.get("openai-model")?.trim(); if (resolvedModel) logCtx.resolvedModel = resolvedModel; diff --git a/tests/github-copilot-responses-wire-recovery.test.ts b/tests/github-copilot-responses-wire-recovery.test.ts new file mode 100644 index 000000000..4119edd26 --- /dev/null +++ b/tests/github-copilot-responses-wire-recovery.test.ts @@ -0,0 +1,261 @@ +/** + * Routing a model onto `openai-responses` moves it from the normal adapter loop into the + * passthrough branch, which formats non-2xx immediately. Without matching recovery there, + * a Copilot model on the Responses wire would surface a 401 that a token refresh fixes, or + * a 429 a healthy pool key absorbs, while its chat-wire siblings on the SAME provider still + * recover — and the compat handlers would strip sampling params meant only for the native + * ChatGPT forward route. Codex review on PR #746. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { saveCredential } from "../src/oauth/store"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; + +const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"; +const GITHUB_USER_URL = "https://api.github.com/user"; +const COPILOT_RESPONSES_URL = "https://api.githubcopilot.com/v1/responses"; +/** Routed onto the Responses wire by the registry's modelWireDefaults. */ +const RESPONSES_MODEL = "github-copilot/gpt-5.6-sol"; + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; +let originalFetch: typeof fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-copilot-wire-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-copilot-wire-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +function copilotConfig(provider: Record): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "github-copilot", + providers: { + "github-copilot": { + adapter: "openai-chat", + baseUrl: "https://api.githubcopilot.com", + ...provider, + }, + }, + } as OcxConfig; +} + +function responsesBody(text: string): string { + return JSON.stringify({ + id: "resp_copilot", + object: "response", + status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text }] }], + usage: { input_tokens: 3, output_tokens: 2 }, + }); +} + +function post(server: ReturnType, body: Record): Promise { + return originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: RESPONSES_MODEL, input: "hello", stream: false, ...body }), + }); +} + +describe("Copilot models routed onto the Responses wire keep provider recovery", () => { + test("an OAuth 401 refreshes the Copilot token and replays once", async () => { + saveCredential("github-copilot", { + access: "stale-copilot-token", + refresh: "github-access-token", + expires: Date.now() + 3_600_000, + accountId: "copilot-test-account", + source: "oauth", + apiBaseUrl: "https://api.githubcopilot.com", + }); + saveConfig(copilotConfig({ authMode: "oauth" })); + + const upstreamAuth: string[] = []; + let exchanges = 0; + const statuses = [401, 200]; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === GITHUB_USER_URL) { + return new Response(JSON.stringify({ id: 4242, login: "copilot-tester" }), { + headers: { "content-type": "application/json" }, + }); + } + if (url === COPILOT_TOKEN_URL) { + exchanges += 1; + return new Response(JSON.stringify({ + token: "fresh-copilot-token", + expires_at: Math.floor(Date.now() / 1000) + 1800, + endpoints: { api: "https://api.githubcopilot.com" }, + }), { headers: { "content-type": "application/json" } }); + } + if (url === COPILOT_RESPONSES_URL) { + upstreamAuth.push(new Headers(init?.headers).get("authorization") ?? ""); + if (statuses.shift() === 401) { + return new Response(JSON.stringify({ error: { message: "token expired" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + return new Response(responsesBody("ok after refresh"), { headers: { "content-type": "application/json" } }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const server = startServer(0); + try { + const response = await post(server, {}); + expect(response.status).toBe(200); + // Exactly one forced exchange, and the replay carried the refreshed token. + expect(exchanges).toBeGreaterThanOrEqual(1); + expect(upstreamAuth).toHaveLength(2); + expect(upstreamAuth[0]).not.toBe(upstreamAuth[1]); + expect(upstreamAuth[1]).toContain("fresh-copilot-token"); + } finally { + server.stop(true); + } + }); + + test("a repeated 401 replays only once and propagates the second failure", async () => { + saveCredential("github-copilot", { + access: "stale-copilot-token", + refresh: "github-access-token", + expires: Date.now() + 3_600_000, + accountId: "copilot-test-account", + source: "oauth", + apiBaseUrl: "https://api.githubcopilot.com", + }); + saveConfig(copilotConfig({ authMode: "oauth" })); + + let upstreamCalls = 0; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === GITHUB_USER_URL) { + return new Response(JSON.stringify({ id: 4242, login: "copilot-tester" }), { + headers: { "content-type": "application/json" }, + }); + } + if (url === COPILOT_TOKEN_URL) { + return new Response(JSON.stringify({ + token: "fresh-copilot-token", + expires_at: Math.floor(Date.now() / 1000) + 1800, + endpoints: { api: "https://api.githubcopilot.com" }, + }), { headers: { "content-type": "application/json" } }); + } + if (url === COPILOT_RESPONSES_URL) { + upstreamCalls += 1; + return new Response(JSON.stringify({ error: { message: "still rejected" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const server = startServer(0); + try { + const response = await post(server, {}); + expect(response.status).toBe(401); + expect(upstreamCalls).toBe(2); + } finally { + server.stop(true); + } + }); + + test("a key-pool 429 rotates to the next key and replays", async () => { + // Copilot supports a key-auth override (allowKeyAuthOverride), so an apiKeyPool is + // reachable on this provider — and pool rotation must survive the wire change. + saveConfig(copilotConfig({ + authMode: "key", + apiKey: "copilot-key-1", + apiKeyPool: [ + { id: "k1", key: "copilot-key-1" }, + { id: "k2", key: "copilot-key-2" }, + ], + })); + + const upstreamAuth: string[] = []; + const statuses = [429, 200]; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === COPILOT_RESPONSES_URL) { + upstreamAuth.push(new Headers(init?.headers).get("authorization") ?? ""); + if (statuses.shift() === 429) { + return new Response(JSON.stringify({ error: { message: "rate limited" } }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + } + return new Response(responsesBody("ok after rotation"), { headers: { "content-type": "application/json" } }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const server = startServer(0); + try { + const response = await post(server, {}); + expect(response.status).toBe(200); + expect(upstreamAuth).toHaveLength(2); + expect(upstreamAuth[0]).toContain("copilot-key-1"); + expect(upstreamAuth[1]).toContain("copilot-key-2"); + } finally { + server.stop(true); + } + }); + + test("a routed Responses gateway keeps max_output_tokens from a Chat Completions client", async () => { + saveConfig(copilotConfig({ authMode: "key", apiKey: "copilot-key-1" })); + + let captured: Record = {}; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === COPILOT_RESPONSES_URL) { + captured = JSON.parse(String(init?.body ?? "{}")) as Record; + return new Response(responsesBody("ok"), { headers: { "content-type": "application/json" } }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: RESPONSES_MODEL, + messages: [{ role: "user", content: "hi" }], + max_tokens: 321, + temperature: 0.4, + stop: ["END"], + }), + }); + expect(response.status).toBe(200); + // The caller's ceiling must survive: only the native ChatGPT forward route 400s on it. + expect(captured.max_output_tokens).toBe(321); + // These stay stripped on every Responses route — `stop` is not a Responses parameter + // and reasoning models reject temperature/top_p, with no per-model filter on this wire. + expect(captured.temperature).toBeUndefined(); + expect(captured.stop).toBeUndefined(); + } finally { + server.stop(true); + } + }); +}); diff --git a/tests/github-copilot-wire-defaults.test.ts b/tests/github-copilot-wire-defaults.test.ts new file mode 100644 index 000000000..b0f9c6157 --- /dev/null +++ b/tests/github-copilot-wire-defaults.test.ts @@ -0,0 +1,135 @@ +/** + * GitHub Copilot fronts a mixed-wire catalog: the preset adapter `openai-chat` is right + * for the Claude/Gemini/GPT-4 entries, but the GPT-5.3+ OpenAI models are Responses-only + * and fail on chat completions — either outright (`is not accessible via the + * /chat/completions endpoint`) or, for gpt-5.4, only once a real Codex request carries + * function tools plus a reasoning effort. Without a registry default every user has to + * hand-write the same `modelAdapters` map. + */ +import { describe, expect, test } from "bun:test"; +import { resolveWireProtocolOverride } from "../src/server/adapter-resolve"; +import { getProviderRegistryEntry, PROVIDER_REGISTRY, providerModelWireDefault } from "../src/providers/registry"; +import { MODEL_ADAPTER_OVERRIDE_ALLOWED, type OcxProviderConfig } from "../src/types"; + +const RESPONSES_ONLY = [ + "gpt-5.3-codex", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.5", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", +]; + +const CHAT_MODELS = [ + "claude-sonnet-5", + "claude-opus-4.8", + "gemini-3.1-pro-preview", + "gpt-4o", + "gpt-4.1", + "gpt-5-mini", +]; + +function copilot(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "openai-chat", + baseUrl: "https://api.githubcopilot.com", + authMode: "oauth", + ...overrides, + } as OcxProviderConfig; +} + +function wireFor(modelId: string, provider = copilot(), inbound: "responses" | "chat" | "anthropic" = "responses"): string { + return resolveWireProtocolOverride("github-copilot", modelId, provider, inbound).adapter; +} + +describe("github-copilot registry per-model wire defaults", () => { + test("Responses-only models resolve to openai-responses with no user config", () => { + for (const model of RESPONSES_ONLY) { + expect(wireFor(model)).toBe("openai-responses"); + } + }); + + test("the rest of the catalog keeps the provider-wide chat wire", () => { + for (const model of CHAT_MODELS) { + expect(wireFor(model)).toBe("openai-chat"); + } + }); + + test("the default applies on every inbound, unlike an inbound-scoped entry", () => { + // Copilot serves no chat route at all for these, so a Chat or Anthropic client must be + // translated onto Responses too — the bare-string form, not DeepSeek's `{wire, inbound}`. + for (const inbound of ["responses", "chat", "anthropic"] as const) { + expect(wireFor("gpt-5.6-sol", copilot(), inbound)).toBe("openai-responses"); + } + }); + + test("an explicit modelAdapters entry overrides the registry default", () => { + // Naming the provider-wide adapter is how a user opts a model back out, so it must + // not fall through to the default the way an unconfigured model does. + const pinnedToChat = copilot({ modelAdapters: { "gpt-5.4": "openai-chat" } }); + expect(wireFor("gpt-5.4", pinnedToChat)).toBe("openai-chat"); + + const pinnedToResponses = copilot({ modelAdapters: { "gpt-4o": "openai-responses" } }); + expect(wireFor("gpt-4o", pinnedToResponses)).toBe("openai-responses"); + }); + + test("an out-of-allow-list override falls back to the registry default", () => { + const handEdited = copilot({ modelAdapters: { "gpt-5.5": "cursor" } }); + expect(wireFor("gpt-5.5", handEdited)).toBe("openai-responses"); + }); + + test("resolving twice is stable", () => { + const once = resolveWireProtocolOverride("github-copilot", "gpt-5.6-sol", copilot()); + const twice = resolveWireProtocolOverride("github-copilot", "gpt-5.6-sol", once); + expect(once.adapter).toBe("openai-responses"); + expect(twice.adapter).toBe("openai-responses"); + }); + + test("credentials and destination survive the wire swap", () => { + const provider = copilot({ headers: { "Copilot-Integration-Id": "vscode-chat" } }); + const resolved = resolveWireProtocolOverride("github-copilot", "gpt-5.6-sol", provider); + + expect(provider.adapter).toBe("openai-chat"); // input not mutated + expect(resolved.authMode).toBe("oauth"); + expect(resolved.baseUrl).toBe("https://api.githubcopilot.com"); + expect(resolved.headers).toEqual({ "Copilot-Integration-Id": "vscode-chat" }); + }); + + test("a provider moved off an OpenAI-shaped wire is left alone", () => { + expect(wireFor("gpt-5.6-sol", copilot({ adapter: "anthropic" }))).toBe("anthropic"); + expect(providerModelWireDefault( + "github-copilot", + copilot({ adapter: "anthropic" }), + "gpt-5.6-sol", + MODEL_ADAPTER_OVERRIDE_ALLOWED, + "responses", + )).toBeUndefined(); + }); + + test("the default does not leak into unrelated providers", () => { + const other = { adapter: "openai-chat", baseUrl: "https://gateway.example/v1", authMode: "key" } as OcxProviderConfig; + expect(resolveWireProtocolOverride("localmodels", "gpt-5.6-sol", other).adapter).toBe("openai-chat"); + }); + + test("the map lives on the canonical registry entry", () => { + const entry = getProviderRegistryEntry("github-copilot"); + expect(Object.keys(entry?.modelWireDefaults ?? {}).sort()).toEqual([...RESPONSES_ONLY].sort()); + }); +}); + +describe("registry model-wire-default invariants", () => { + test("every wire default names a seeded model and an allowed wire", () => { + // A default for a model missing from the seed is unreachable by qualified selector + // until live discovery lands — exactly the cold-start case the seed exists for. + for (const entry of PROVIDER_REGISTRY) { + for (const [model, declared] of Object.entries(entry.modelWireDefaults ?? {})) { + const wire = typeof declared === "string" ? declared : declared.wire; + expect(MODEL_ADAPTER_OVERRIDE_ALLOWED.has(wire)).toBe(true); + expect(entry.models ?? []).toContain(model); + expect(model).toBe(model.trim().toLowerCase()); + } + } + }); +}); diff --git a/tests/router.test.ts b/tests/router.test.ts index 14a263b67..b9f45248d 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -175,6 +175,19 @@ describe("routeModel registry effort defaults", () => { const unavailable = { ...base, providers: { "openai-proxy": base.providers["openai-proxy"] } }; expect(() => routeModel(unavailable, "gpt-5.5")).toThrow(/ocx provider add openai/); expect(() => routeModel(unavailable, "codex-auto-review")).toThrow(NoEnabledOpenAiProviderError); + // A bare OpenAI-family id is reserved for the canonical provider, so a gateway that + // also serves it is only reachable qualified — name it instead of leaving the user + // to guess that `openai-proxy/gpt-5.5` is the working selector. + expect(() => routeModel(unavailable, "gpt-5.5")).toThrow(/openai-proxy\/gpt-5\.5/); + // A provider that only names the model in modelAdapters still counts as serving it. + const viaAdapters = { + ...base, + providers: { + gw: { adapter: "openai-chat", baseUrl: "https://gw.example/v1", modelAdapters: { "gpt-5.5": "openai-responses" } }, + }, + } as OcxConfig; + expect(() => routeModel(viaAdapters, "gpt-5.5")).toThrow(/gw\/gpt-5\.5/); + expect(routeModel(viaAdapters, "gw/gpt-5.5")).toMatchObject({ providerName: "gw", modelId: "gpt-5.5" }); }); test("rejects legacy chatgpt namespaces even when configured", () => {