From 20629aed2fac67e9a1db39bd3f653e07c658b8e0 Mon Sep 17 00:00:00 2001 From: Mushikingh <164845020+mushikingh@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:19:33 +0200 Subject: [PATCH 1/4] fix(providers): route GitHub Copilot Responses-only models off chat completions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot fronts a mixed-wire catalog. The preset adapter `openai-chat` is correct for its Claude, Gemini, GPT-3.5/4 and gpt-5-mini entries, but the newer OpenAI models are Responses-only on this upstream and fail with model "gpt-5.6-sol" is not accessible via the /chat/completions endpoint gpt-5.4 hides the same problem 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. Declare the seven affected models in the registry's `modelWireDefaults`, so they resolve to `openai-responses` without every user hand-writing the same `modelAdapters` block. Unlike DeepSeek's Flash entry these are bare strings rather than inbound-scoped: the upstream serves no chat route at all for them, so a Chat Completions or Claude Code client has to be translated too instead of being kept on the wire it already speaks. Also refresh the Copilot cold-start seed from the verified live catalog — the old seed listed gpt-4.1-mini and claude-sonnet-4, neither of which it contains — so a qualified selector resolves before live discovery lands. Separately, bare OpenAI-family ids are reserved for the canonical `openai` provider, so `-m gpt-5.6-sol` cannot mean Copilot. NoEnabledOpenAiProviderError now names a qualified selector that would work when another enabled provider serves that id. Registry `modelWireDefaults` and configured `modelAdapters` keys also feed knownModelIdsForProvider, so a provider naming a model only there is selectable as `provider/model`. Verified 2026-07-30 against a live 37-entry Copilot catalog with real `codex exec` runs rather than minimal text probes: presence in GET /models proves neither, and a text-only success does not prove the model survives function tools plus reasoning_effort. --- .../src/content/docs/guides/providers.md | 41 +++++- .../src/content/docs/ja/guides/providers.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../docs/ko/reference/configuration.md | 2 +- .../content/docs/reference/configuration.md | 2 +- .../src/content/docs/ru/guides/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- src/providers/registry.ts | 30 +++- src/router.ts | 24 +++- tests/github-copilot-wire-defaults.test.ts | 134 ++++++++++++++++++ tests/router.test.ts | 13 ++ 11 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 tests/github-copilot-wire-defaults.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 2c3da6070..f030efdd5 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.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..45d101ea1 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.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..89e1336dd 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.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..9dcf0aaf3 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.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..146cb8ed1 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.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..a228db181 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1414,7 +1414,35 @@ 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.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. + modelWireDefaults: { + "gpt-5.3-codex": "openai-responses", + "gpt-5.4": "openai-responses", + "gpt-5.4-mini": "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/tests/github-copilot-wire-defaults.test.ts b/tests/github-copilot-wire-defaults.test.ts new file mode 100644 index 000000000..147459ba8 --- /dev/null +++ b/tests/github-copilot-wire-defaults.test.ts @@ -0,0 +1,134 @@ +/** + * 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.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", () => { From 17e5263f87ddcb17a321212e772200f381399523 Mon Sep 17 00:00:00 2001 From: Mushikingh <164845020+mushikingh@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:19:45 +0200 Subject: [PATCH 2/4] fix(server): keep credential recovery and sampling on routed Responses gateways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects that a registry `modelWireDefaults` entry makes reachable: putting a model on the Responses wire moves it onto code paths written for the canonical ChatGPT forward provider. Sampling params. Both translate-and-replay inbound handlers stripped max_output_tokens for ANY openai-responses route, though the intent — stated in claude-messages.ts as "routed providers keep them" — was the native ChatGPT forward path only. Gate it on `authMode === "forward"`, matching the adapter's own stripUnsupportedForwardParams condition so the two layers agree, and extract the shared block both handlers had been carrying in duplicate. temperature/top_p/stop/user stay stripped on every Responses route: `stop` is not a Responses parameter, reasoning models reject temperature/top_p, and noTemperatureModels is honored only by the openai-chat adapter, so there is no per-model filter on this wire. Credential recovery. The passthrough branch formats non-2xx immediately, while the OAuth 401 refresh and key-pool 429 rotation live only in the normal adapter loop — so a model routed onto this wire would surface a 401 a refresh fixes, or a 429 a healthy pool key absorbs, while its chat-wire siblings on the same provider recover. Add a bounded recovery before the error formatting: at most one forced refresh, then one replay per remaining pool key, both guards monotonic so a persistently failing upstream cannot loop. Nothing has streamed and the body is a replayable string, the same precondition the transient-5xx retry relies on. The failed response's socket is released before the refresh round-trip, not only before the replay, since a failed refresh returns early. The rotated credential is rebuilt through resolveProviderTransport under the SAME provider name, so it can only be addressed at that provider's validated destination, and the request is rebuilt from the rotated provider so the new credential replaces the old one everywhere it is carried. The canonical ChatGPT forward provider is excluded entirely, leaving its own pool-retry path untouched. Replays record oauth-401 / key-429 on the attempt so the request log explains why the send count grew. --- src/server/chat-completions.ts | 7 +- src/server/claude-messages.ts | 7 +- src/server/responses-wire-params.ts | 32 +++ src/server/responses/core.ts | 77 ++++++ ...ub-copilot-responses-wire-recovery.test.ts | 261 ++++++++++++++++++ 5 files changed, 374 insertions(+), 10 deletions(-) create mode 100644 src/server/responses-wire-params.ts create mode 100644 tests/github-copilot-responses-wire-recovery.test.ts 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..f2bdb6eb6 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1660,6 +1660,83 @@ 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, + ); + } 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( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider), + 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); + } + }); +}); From 2d131a9b79eaea6dee1b6e57b858ff7745a4c9c0 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:52:46 +0200 Subject: [PATCH 3/4] fix(providers): route Copilot gpt-5.4-nano and keep Kiro OAuth context on Responses recovery Add gpt-5.4-nano to the Copilot seed and modelWireDefaults so the Responses-only model is not left on openai-chat for qualified selectors, and set _kiroAuthContext on the new passthrough OAuth 401 recovery path to match the normal loop. --- docs-site/src/content/docs/guides/providers.md | 4 ++-- docs-site/src/content/docs/ja/guides/providers.md | 2 +- docs-site/src/content/docs/ko/guides/providers.md | 2 +- docs-site/src/content/docs/ru/guides/providers.md | 2 +- docs-site/src/content/docs/zh-cn/guides/providers.md | 2 +- src/providers/registry.ts | 3 ++- src/server/responses/core.ts | 3 +++ tests/github-copilot-wire-defaults.test.ts | 1 + 8 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index f030efdd5..1cd2ea311 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -97,8 +97,8 @@ Copilot's catalog is not uniformly served by `/chat/completions`. The provider a 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.5` · `gpt-5.6-luna` · `gpt-5.6-sol` · -`gpt-5.6-terra` +`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 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 45d101ea1..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 ではありません。カタログは wire が混在します: `gpt-5.3-codex`、`gpt-5.4`、`gpt-5.4-mini`、`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` のように選択してください。 | +| `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 89e1336dd..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가 아닙니다. 카탈로그는 wire가 섞여 있습니다: `gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `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`처럼 선택하세요. | +| `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/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 9dcf0aaf3..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. Каталог смешанный по wire: `gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `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`. | +| `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 146cb8ed1..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。目录混用多种 wire:`gpt-5.3-codex`、`gpt-5.4`、`gpt-5.4-mini`、`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`。 | +| `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 a228db181..60ad6cd6d 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1421,7 +1421,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ "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.5", + "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, @@ -1438,6 +1438,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ "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", diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index f2bdb6eb6..6a8050205 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1702,6 +1702,9 @@ async function handleResponsesInner( 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, { diff --git a/tests/github-copilot-wire-defaults.test.ts b/tests/github-copilot-wire-defaults.test.ts index 147459ba8..b0f9c6157 100644 --- a/tests/github-copilot-wire-defaults.test.ts +++ b/tests/github-copilot-wire-defaults.test.ts @@ -15,6 +15,7 @@ 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", From 8ea61aef3a3900826a3850266c10ef04c4864cd3 Mon Sep 17 00:00:00 2001 From: Mushikingh <164845020+mushikingh@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:00:18 +0200 Subject: [PATCH 4/4] fix(server): resolve the replay wire on the inbound the request arrived on The passthrough recovery loop rebuilt its adapter with no inbound argument, so `resolveWireProtocolOverride` fell back to its `"responses"` default instead of the inbound the request actually arrived on. A registry default scoped to a chat or anthropic inbound would then resolve one wire on the first send and a different one on the replay, putting the retried request on a protocol the original never used. This was the only one of nine call sites in the file missing the argument. Not reachable from a shipped entry today: Copilot's defaults are bare strings that apply to every inbound, and DeepSeek's is scoped to the responses inbound, which is also the fallback. It is what the next inbound-scoped default would inherit silently. Also record that gpt-5.4-nano is the one wire default not backed by a real `codex exec` run, so it does not sit under a comment claiming otherwise. --- src/providers/registry.ts | 4 ++++ src/server/responses/core.ts | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 60ad6cd6d..15c24231f 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1434,6 +1434,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // 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", diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 6a8050205..ef4c873c0 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1722,7 +1722,10 @@ async function handleResponsesInner( // 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( - resolveWireProtocolOverride(route.providerName, route.modelId, route.provider), + // `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 });