diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 9db9cd110..fa39fef46 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` walks you through: -1. **Pick a provider** — choose one of the 68 built-in registry presets or `custom` to type a base +1. **Pick a provider** — choose one of the 69 built-in registry presets or `custom` to type a base URL and adapter. 2. **API key** — paste a key, or reference an environment variable like `${ANTHROPIC_API_KEY}`. 3. **Default model** — for key, local, and custom providers, accept the preset or enter a model id. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 597c31e9c..5fce1c79c 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -190,9 +190,10 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses ## 3. API-key catalog -opencodex ships 68 built-in presets: 57 key-based, seven OAuth, three local, and the default +opencodex ships 69 built-in presets: 58 key-based, seven OAuth, three local, and one default ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard, -validates the key, and stores it. Notable entries: +validates the key, and stores it; validation is provider-specific, and Command Code's public +catalog reports keys as unverifiable. Notable entries: **ClinePass** uses a Cline API key with the [official subscription catalog](https://docs.cline.bot/getting-started/clinepass) and [Chat Completions endpoint](https://docs.cline.bot/api/chat-completions), operated by Cline Bot Inc. under @@ -225,6 +226,7 @@ free-experimentation model. | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | +| Command Code | `https://api.commandcode.ai/provider/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | | Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | @@ -273,6 +275,13 @@ slash-containing native model ids, and caps live discovery at 256 KiB and 256 ra serverless text and vision-language chat only; Hyperbolic's separate image, audio, and GPU endpoints are out of scope. Create keys at [Hyperbolic](https://app.hyperbolic.ai). +**Command Code discovery.** The preset reads Command Code's public `/provider/v1/models` list from +the fixed Provider API host, preserves provider-native ids, and caps discovery at 256 KiB and 256 raw +rows. The model catalog is unauthenticated, so the CLI login flow reports the key as unverifiable +instead of a false positive. Chat requests use the configured Bearer key; API access requires the +Provider plan, and CLI auth bridging for Go/Pro subscriptions is not yet available. Create keys at +[Command Code Studio](https://commandcode.ai/studio/). + > **Baseten scope:** The preset covers Baseten's shared [Model APIs](https://docs.baseten.co/inference/model-apis/overview) > only. Use a personal [API key](https://docs.baseten.co/organization/api-keys) for local use, or a team key > with **Call Model APIs** access for shared/production use. Dedicated Truss `predict` endpoints use different diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index f129296e9..41b7e8343 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` では次の手順を説明します。 -1. **プロバイダーを選択してください** — 68 の組み込みレジストリ プリセットのいずれか、または `custom` を選択してベースを入力します +1. **プロバイダーを選択してください** — 69 の組み込みレジストリ プリセットのいずれか、または `custom` を選択してベースを入力します URLとアダプター。 2. **API キー** — キーを貼り付けるか、`${ANTHROPIC_API_KEY}` のような環境変数を参照します。 3. **デフォルト モデル** — キー、ローカル、カスタム プロバイダーの場合は、プリセットを受け入れるか、モデル ID を入力します。 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 6acd78b84..d55a8a843 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -119,9 +119,10 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht ## 3. API キーカタログ -opencodex には組み込みプリセットが 68 個含まれています。キー方式 57、OAuth 7、ローカル 3、 +opencodex には組み込みプリセットが 69 個含まれています。キー方式 58、OAuth 7、ローカル 3、 デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 -入力したキーを検証した後保存します。主な項目は以下のとおりです: +入力したキーを検証した後保存します(検証はプロバイダー固有で、Command Code の公開カタログはキーを +検証不能として報告します)。主な項目は以下のとおりです: **ClinePass** は Cline API キーで[公式サブスクリプションカタログ](https://docs.cline.bot/getting-started/clinepass)と [Chat Completions エンドポイント](https://docs.cline.bot/api/chat-completions)に接続します。運営主体は @@ -154,6 +155,7 @@ Cline IDE/CLI のみで API からは使えません。`minimax/minimax-m2.5` | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | +| Command Code | `https://api.commandcode.ai/provider/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | | Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | @@ -196,6 +198,13 @@ Volcengine Agent Plan は `openai-responses` アダプターでネイティブ R vision-language chat のみを対象とし、別系統の image、audio、GPU endpoint は対象外です。キーは [Hyperbolic](https://app.hyperbolic.ai) で作成します。 +**Command Code の discovery:** preset は Command Code の公開 `/provider/v1/models` リストを固定の +Provider API ホストから読み、スラッシュを含むネイティブモデル ID を保持し、live discovery を +256 KiB と raw 256 行に制限します。モデルカタログは未認証のため、CLI ログインフローはキーを +誤って有効と報告せず、検証不能として報告します。チャットリクエストは設定済みの bearer キーを使います。 +API アクセスには Provider プランが必要で、Go/Pro サブスクリプション向けの CLI 認証ブリッジはまだ利用できません。 +キーは [Command Code Studio](https://commandcode.ai/studio/) で作成します。 + > **Baseten の対象範囲:** このプリセットは Baseten の共有 [Model APIs](https://docs.baseten.co/inference/model-apis/overview) > のみを対象とします。ローカル利用では個人の [API キー](https://docs.baseten.co/organization/api-keys)を、 > 共有/本番利用では **Call Model APIs** 権限を持つチームキーを使用してください。専用 Truss `predict` diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md index 64b5e44eb..9692a44e7 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init`은 다음 과정을 안내합니다: -1. **프로바이더 선택** — 내장 레지스트리 프리셋 68개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. +1. **프로바이더 선택** — 내장 레지스트리 프리셋 69개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. 2. **API 키** — 키를 붙여넣거나 `${ANTHROPIC_API_KEY}` 같은 환경 변수를 참조합니다. 3. **기본 모델** — 키, 로컬, custom 프로바이더에서는 프리셋을 그대로 쓰거나 모델 ID를 직접 입력합니다. 4. **프록시 포트** — 기본값은 `10100`입니다. diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 09d81ce80..ce1609aba 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -119,9 +119,10 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 -opencodex에는 빌트인 프리셋이 68개 들어 있습니다. 키 방식 57개, OAuth 7개, 로컬 3개, +opencodex에는 빌트인 프리셋이 69개 들어 있습니다. 키 방식 58개, OAuth 7개, 로컬 3개, 기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, -입력한 키를 검증한 뒤 저장합니다. 주요 항목은 다음과 같습니다: +입력한 키를 검증한 뒤 저장합니다(검증은 프로바이더별로 다르며, Command Code의 공개 카탈로그는 키를 +검증 불가로 보고합니다). 주요 항목은 다음과 같습니다: **ClinePass**는 Cline API 키로 [공식 구독 카탈로그](https://docs.cline.bot/getting-started/clinepass)와 [Chat Completions 엔드포인트](https://docs.cline.bot/api/chat-completions)에 연결합니다. 운영 주체는 @@ -155,6 +156,7 @@ Cline IDE/CLI에서만 제공되며 API로는 사용할 수 없습니다. `minim | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | +| Command Code | `https://api.commandcode.ai/provider/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | | Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | @@ -196,6 +198,13 @@ Bearer API 키를 사용합니다. registry가 소유하는 DeepInfra 모델 목 대상으로 하며 별도 image, audio, GPU 엔드포인트는 범위에서 제외합니다. 키는 [Hyperbolic](https://app.hyperbolic.ai)에서 생성합니다. +**Command Code 검색:** 프리셋은 Command Code의 공개 `/provider/v1/models` 목록을 고정된 Provider API +호스트에서 읽고, 슬래시가 포함된 네이티브 모델 ID를 보존하며 live discovery를 256 KiB와 raw 행 +256개로 제한합니다. 모델 카탈로그는 인증이 없으므로 CLI 로그인 흐름은 키를 유효하다고 잘못 보고하지 +않고 검증 불가로 보고합니다. 채팅 요청은 설정된 bearer 키를 사용하며, API 액세스에는 Provider 플랜이 +필요하고 Go/Pro 구독자용 CLI 인증 브리지는 아직 제공되지 않습니다. 키는 +[Command Code Studio](https://commandcode.ai/studio/)에서 생성합니다. + > **Baseten 범위:** 이 프리셋은 Baseten의 공유 [Model APIs](https://docs.baseten.co/inference/model-apis/overview)만 > 지원합니다. 로컬 사용에는 개인 [API 키](https://docs.baseten.co/organization/api-keys)를, 공유/프로덕션 > 사용에는 **Call Model APIs** 권한이 있는 팀 키를 사용하세요. 전용 Truss `predict` 엔드포인트는 diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md index 7f1d20abe..ee54838ea 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` проведёт вас по следующим шагам: -1. **Выбор провайдера** — выберите один из 68 встроенных пресетов реестра или `custom`, чтобы +1. **Выбор провайдера** — выберите один из 69 встроенных пресетов реестра или `custom`, чтобы ввести базовый URL и адаптер вручную. 2. **API-ключ** — вставьте ключ или сошлитесь на переменную окружения вида `${ANTHROPIC_API_KEY}`. 3. **Модель по умолчанию** — для провайдеров с ключом, локальных и `custom` примите значение из diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 8f882b30f..2ef2dfdb1 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -126,9 +126,10 @@ OAuth-провайдеры, чьи учётные данные содержат ## 3. Каталог API-ключей -opencodex поставляется с 68 встроенными пресетами: 57 на основе ключей, семь OAuth, три локальных и -пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу -выдачи ключей провайдера, проверяет ключ и сохраняет его. Наиболее заметные записи: +opencodex поставляется с 69 встроенными пресетами: 58 на основе ключей, семь OAuth, три локальных и +один пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу +выдачи ключей провайдера, проверяет ключ и сохраняет его; проверка зависит от провайдера, а публичный +каталог Command Code сообщает ключ как непроверенный. Наиболее заметные записи: **ClinePass** подключается с помощью Cline API key к [официальному каталогу подписки](https://docs.cline.bot/getting-started/clinepass) и [Chat Completions endpoint](https://docs.cline.bot/api/chat-completions). Оператор — Cline Bot Inc., указанный в @@ -161,6 +162,7 @@ opencodex поставляется с 68 встроенными пресетам | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | +| Command Code | `https://api.commandcode.ai/provider/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | | Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | @@ -205,6 +207,13 @@ Volcengine Agent Plan использует нативную конечную т строками. Он охватывает только serverless text и vision-language chat; отдельные image, audio и GPU endpoint в него не входят. Ключи создаются в [Hyperbolic](https://app.hyperbolic.ai). +**Discovery для Command Code.** Пресет читает публичный список `/provider/v1/models` с фиксированного +хоста Provider API, сохраняет нативные id моделей со знаком `/` и ограничивает live discovery размером +256 KiB и 256 исходными строками. Каталог моделей не требует аутентификации, поэтому CLI-флоу входа +сообщает ключ как непроверенный, а не как ложноположительно действительный. Запросы чата используют +настроенный bearer-ключ; для доступа к API требуется план Provider, а CLI-мост аутентификации для +подписок Go/Pro пока недоступен. Ключи создаются в [Command Code Studio](https://commandcode.ai/studio/). + > **Область Baseten:** пресет поддерживает только общие [Model APIs](https://docs.baseten.co/inference/model-apis/overview) > Baseten. Для локальной работы используйте личный [API-ключ](https://docs.baseten.co/organization/api-keys), > а для общего/промышленного использования — командный ключ с правом **Call Model APIs**. Выделенные конечные точки Truss `predict` используют другие хосты и diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md index 02178fd28..0544b8f63 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` 会引导你完成: -1. **选择 provider** — 从内置 registry 的 68 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 +1. **选择 provider** — 从内置 registry 的 69 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 2. **API key** — 粘贴一个 key,或引用一个环境变量,例如 `${ANTHROPIC_API_KEY}`。 3. **默认模型** — 对于 key、本地和 custom provider,接受预设值或输入模型 id。 4. **代理端口** — 默认为 `10100`。 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 b5706f765..2680857dc 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -111,9 +111,9 @@ Kiro 登录需要 Kiro CLI:Unix 使用 `curl -fsSL https://cli.kiro.dev/instal ## 3. API 密钥目录 -opencodex 内置 68 个预设:57 个密钥预设、7 个 OAuth 预设、3 个本地预设,以及默认的 +opencodex 内置 69 个预设:58 个密钥预设、7 个 OAuth 预设、3 个本地预设,以及 1 个默认的 ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。 -主要条目包括: +验证因提供商而异,Command Code 的公开目录会将密钥报告为无法验证。主要条目包括: **ClinePass** 使用 Cline API 密钥连接[官方订阅目录](https://docs.cline.bot/getting-started/clinepass)和 [Chat Completions 端点](https://docs.cline.bot/api/chat-completions)。运营主体是 @@ -146,6 +146,7 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | +| Command Code | `https://api.commandcode.ai/provider/v1` | | Together | `https://api.together.xyz/v1` | | Fireworks | `https://api.fireworks.ai/inference/v1` | | Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` | @@ -184,6 +185,12 @@ OpenAI Chat Completions 提供商。registry 固定的 DeepInfra 模型列表 UR 并将实时发现限制为 256 KiB 和 256 条原始记录。它仅覆盖 serverless text 与 vision-language chat;独立的 image、audio 和 GPU 端点不在范围内。密钥可在 [Hyperbolic](https://app.hyperbolic.ai) 创建。 +**Command Code 发现:**该预设从固定的 Provider API 主机读取 Command Code 公开的 +`/provider/v1/models` 列表,保留含 `/` 的原生模型 id,并将实时发现限制为 256 KiB 和 256 条原始记录。 +模型目录无需认证,因此 CLI 登录流程会将密钥报告为无法验证,而不是误报为有效。聊天请求使用已配置的 +bearer 密钥;API 访问需要 Provider 套餐,Go/Pro 订阅用户的 CLI 认证桥接尚不可用。 +密钥可在 [Command Code Studio](https://commandcode.ai/studio/) 创建。 + > **Baseten 范围:**该预设仅覆盖 Baseten 的共享 [Model APIs](https://docs.baseten.co/inference/model-apis/overview)。 > 本地使用可选择个人 [API 密钥](https://docs.baseten.co/organization/api-keys);共享或生产用途请使用具备 > **Call Model APIs** 权限的团队密钥。 diff --git a/src/oauth/key-providers.ts b/src/oauth/key-providers.ts index 2168f5cc5..150a80b2f 100644 --- a/src/oauth/key-providers.ts +++ b/src/oauth/key-providers.ts @@ -52,6 +52,10 @@ export async function validateApiKey( key: string, ): Promise { try { + // A public model catalog cannot prove that the supplied key is valid. Returning unknown keeps + // the best-effort login flow available without persisting a false-positive validation result. + if (provider.apiKeyValidation === "unknown") return "unknown"; + if (provider.adapter === "anthropic") { const base = provider.baseUrl.replace(/\/v1\/?$/, ""); const res = await fetch(`${base}/v1/messages`, { diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 7f2da82fc..ba4fc6244 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -6,6 +6,7 @@ export interface DerivedKeyLoginProvider { baseUrl: string; responsesPath?: string; adapter: string; + apiKeyValidation?: "unknown"; apiKeyTransport?: OcxProviderConfig["apiKeyTransport"]; dashboardUrl: string; models?: string[]; @@ -161,6 +162,7 @@ export function deriveKeyLoginMap(): Record { baseUrl: entry.baseUrl, ...(entry.responsesPath ? { responsesPath: entry.responsesPath } : {}), adapter: entry.adapter, + ...(entry.apiKeyValidation !== undefined ? { apiKeyValidation: entry.apiKeyValidation } : {}), ...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}), dashboardUrl: entry.dashboardUrl, ...(entry.models ? { models: [...entry.models] } : {}), diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 5dff08a15..91d3ad07d 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -110,6 +110,11 @@ export interface ProviderRegistryEntry { allowKeyAuthOverride?: boolean; allowPrivateNetworkByDefault?: boolean; keyOptional?: boolean; + /** + * Registry-only key-login policy for public model catalogs that cannot authenticate a key. + * The dashboard flow then reports the key as unverifiable instead of a false positive. + */ + apiKeyValidation?: "unknown"; /** * Free-tier pricing (no paid subscription required). Distinct from `keyOptional`: * free tiers may still require an API key (e.g. NVIDIA NIM free credits). @@ -1168,6 +1173,32 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ }, note: "Shared Model APIs only (personal API key, or team key with Call Model APIs access); dedicated Truss predict endpoints are outside this preset.", }, + { + id: "commandcode", + label: "Command Code", + adapter: "openai-chat", + baseUrl: "https://api.commandcode.ai/provider/v1", + authKind: "key", + dashboardUrl: "https://commandcode.ai/studio/", + liveModels: true, + preserveCustomDestination: true, + defaultModel: "deepseek/deepseek-v4-flash", + // The default is also the cold-start seed: live discovery failure must not empty the catalog + // for a freshly configured provider with no stale cache (issue #308 pattern). + models: ["deepseek/deepseek-v4-flash"], + // The public model catalog is unauthenticated, so a Bearer probe cannot prove key validity. + apiKeyValidation: "unknown", + // The public catalog reports ids/context windows only; no trustworthy reasoning contract. + reasoningEfforts: [], + modelDiscovery: { + path: "models", + maxResponseBytes: 256 * 1024, + maxModels: 256, + }, + // Verified 2026-08-03: public /provider/v1/models returns 51 rows; /chat/completions returns + // 401 UNAUTHORIZED without a Bearer key. Primary source: https://commandcode.ai/docs/provider. + note: "Command Code Provider API (OpenAI-compatible); API access requires the Provider plan. CLI auth bridging for Go/Pro subscriptions is not yet available. Docs: https://commandcode.ai/docs/provider.", + }, // FREEZE 2026-07-10: exact serverless ids remain auth-gated/unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. { id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys" }, { id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" }, diff --git a/tests/commandcode-provider.test.ts b/tests/commandcode-provider.test.ts new file mode 100644 index 000000000..db037e378 --- /dev/null +++ b/tests/commandcode-provider.test.ts @@ -0,0 +1,225 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import { gatherRoutedModels } from "../src/codex/catalog"; +import { clearModelCache } from "../src/codex/model-cache"; +import { buildInitProviders } from "../src/cli/init"; +import { buildModelsRequest } from "../src/oauth"; +import { KEY_LOGIN_PROVIDERS, validateApiKey } from "../src/oauth/key-providers"; +import { + deriveInitProviders, + deriveProviderPresets, + providerConfigSeed, +} from "../src/providers/derive"; +import { providerModelDiscoverySpecError } from "../src/providers/model-discovery"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { routedSlug } from "../src/providers/slug-codec"; +import { routeModel } from "../src/router"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; + +const FIXTURE = readFileSync(join(import.meta.dir, "fixtures/commandcode-models.json"), "utf8"); +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache("commandcode"); +}); + +function commandcodeEntry() { + const entry = PROVIDER_REGISTRY.find(row => row.id === "commandcode"); + if (!entry) throw new Error("missing Command Code registry entry"); + return entry; +} + +function commandcodeConfig(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "commandcode", + providers: { + commandcode: { + adapter: "openai-chat", + baseUrl: "https://api.commandcode.ai/provider/v1", + authMode: "key", + apiKey: "cmd-test-key", + liveModels: true, + // The discovery fetch itself is fixture-only; this opt-in keeps the test focused on + // the provider contract in environments that resolve public hosts differently. + allowPrivateNetwork: true, + ...overrides, + }, + }, + }; +} + +describe("Command Code provider", () => { + test("registers the fixed Provider API transport and bounded discovery policy", () => { + const entry = commandcodeEntry(); + expect(entry).toMatchObject({ + id: "commandcode", + label: "Command Code", + adapter: "openai-chat", + baseUrl: "https://api.commandcode.ai/provider/v1", + authKind: "key", + dashboardUrl: "https://commandcode.ai/studio/", + liveModels: true, + preserveCustomDestination: true, + defaultModel: "deepseek/deepseek-v4-flash", + apiKeyValidation: "unknown", + reasoningEfforts: [], + modelDiscovery: { + path: "models", + maxResponseBytes: 256 * 1024, + maxModels: 256, + }, + }); + expect(providerModelDiscoverySpecError(entry.modelDiscovery!)).toBeNull(); + // The public catalog lists ids/context windows only, so the only static seed is the default + // model itself — it stays callable when live discovery fails on a fresh config (no cache). + expect(entry.models).toEqual(["deepseek/deepseek-v4-flash"]); + }); + + test("derives key-login, init, and dashboard presets without persisting trust policy", () => { + expect(KEY_LOGIN_PROVIDERS.commandcode).toMatchObject({ + label: "Command Code", + adapter: "openai-chat", + baseUrl: "https://api.commandcode.ai/provider/v1", + dashboardUrl: "https://commandcode.ai/studio/", + liveModels: true, + defaultModel: "deepseek/deepseek-v4-flash", + apiKeyValidation: "unknown", + models: ["deepseek/deepseek-v4-flash"], + }); + expect(buildInitProviders()).toEqual(deriveInitProviders()); + expect(buildInitProviders().find(row => row.id === "commandcode")).toMatchObject({ + kind: "key", + adapter: "openai-chat", + baseUrl: "https://api.commandcode.ai/provider/v1", + defaultModel: "deepseek/deepseek-v4-flash", + }); + expect(deriveProviderPresets().find(row => row.id === "commandcode")).toMatchObject({ + auth: "key", + dashboardUrl: "https://commandcode.ai/studio/", + defaultModel: "deepseek/deepseek-v4-flash", + }); + + const seed = providerConfigSeed(commandcodeEntry()); + expect(seed).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://api.commandcode.ai/provider/v1", + authMode: "key", + liveModels: true, + reasoningEfforts: [], + defaultModel: "deepseek/deepseek-v4-flash", + models: ["deepseek/deepseek-v4-flash"], + }); + expect(seed).not.toHaveProperty("modelDiscovery"); + expect(seed).not.toHaveProperty("preserveCustomDestination"); + expect(seed).not.toHaveProperty("apiKeyValidation"); + }); + + test("reports key validity as unknown instead of trusting the public catalog", async () => { + globalThis.fetch = (() => { + throw new Error("validateApiKey must not probe a public catalog"); + }) as typeof fetch; + + expect(await validateApiKey( + "commandcode", + KEY_LOGIN_PROVIDERS.commandcode!, + "cmd-validation-key", + )).toBe("unknown"); + }); + + test("lists models through the fixed public discovery endpoint with the bearer key", () => { + const request = buildModelsRequest( + commandcodeConfig().providers.commandcode!, + "cmd-model-list-key", + "commandcode", + ); + expect(request).toEqual({ + url: "https://api.commandcode.ai/provider/v1/models", + headers: { Authorization: "Bearer cmd-model-list-key" }, + }); + }); + + test("routes chat completions to the Provider API with namespaced ids intact", () => { + const route = routeModel( + commandcodeConfig(), + "commandcode/deepseek/deepseek-v4-flash", + ); + // No explicit parallel-tool-calls claim is persisted for Command Code; the openai-chat + // adapter applies its default-on wire behavior (see below) unless a provider opts out. + expect(route.provider.parallelToolCalls).toBeUndefined(); + expect(route.modelId).toBe("deepseek/deepseek-v4-flash"); + + const request = createOpenAIChatAdapter(route.provider).buildRequest({ + modelId: route.modelId, + context: { + messages: [{ role: "user", content: "ping", timestamp: 0 }], + tools: [{ name: "lookup", description: "Lookup", parameters: { type: "object" } }], + }, + stream: true, + options: {}, + }); + const body = JSON.parse(String(request.body)) as Record; + + expect(request.url).toBe("https://api.commandcode.ai/provider/v1/chat/completions"); + expect(request.headers.Authorization).toBe("Bearer cmd-test-key"); + expect(body.model).toBe("deepseek/deepseek-v4-flash"); + expect(body.parallel_tool_calls).toBe(true); + }); + + test("discovers the live catalog with context windows and preserves slash ids", async () => { + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe("https://api.commandcode.ai/provider/v1/models"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer cmd-test-key"); + expect(init?.redirect).toBe("manual"); + return new Response(FIXTURE, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const config = withStubbedProviderFetch(commandcodeConfig()); + const models = (await gatherRoutedModels(config)).filter(row => row.provider === "commandcode"); + // Full public catalog snapshot: 51 rows, including the free-tier entries. + expect(models).toHaveLength(51); + expect(models.map(row => row.id)).toContain("deepseek/deepseek-v4-flash"); + expect(models.map(row => row.id)).toContain("moonshotai/Kimi-K2.7-Code"); + expect(models.map(row => row.id)).toContain("poolside/laguna-s-2.1-free"); + + const deepseek = models.find(row => row.id === "deepseek/deepseek-v4-flash")!; + expect(deepseek.contextWindow).toBe(1_000_000); + expect(deepseek.owned_by).toBe("command-code"); + expect(deepseek.reasoningEfforts).toEqual([]); + + const haiku = models.find(row => row.id === "claude-haiku-4-5-20251001")!; + expect(haiku.contextWindow).toBe(200_000); + expect(haiku.inputModalities).toBeUndefined(); + + const sol = models.find(row => row.id === "gpt-5.6-sol")!; + expect(sol.contextWindow).toBe(1_050_000); + + expect(routedSlug("commandcode", deepseek.id)).toBe("commandcode/deepseek-deepseek-v4-flash"); + expect(routeModel(config, "commandcode/deepseek/deepseek-v4-flash").modelId) + .toBe("deepseek/deepseek-v4-flash"); + expect(routeModel(config, "commandcode/deepseek-deepseek-v4-flash").modelId) + .toBe("deepseek/deepseek-v4-flash"); + }); + + test("keeps the default model callable when live discovery fails on a fresh config", async () => { + globalThis.fetch = (async () => new Response("upstream down", { + status: 500, + headers: { "content-type": "text/plain" }, + })) as typeof fetch; + + const config = withStubbedProviderFetch(commandcodeConfig()); + const models = (await gatherRoutedModels(config)).filter(row => row.provider === "commandcode"); + + // No stale cache exists on a fresh config, so the seeded default must survive the failure. + expect(models.map(row => row.id)).toEqual(["deepseek/deepseek-v4-flash"]); + expect(routeModel(config, "commandcode/deepseek/deepseek-v4-flash").modelId) + .toBe("deepseek/deepseek-v4-flash"); + }); +}); diff --git a/tests/fixtures/commandcode-models.json b/tests/fixtures/commandcode-models.json new file mode 100644 index 000000000..06b974135 --- /dev/null +++ b/tests/fixtures/commandcode-models.json @@ -0,0 +1 @@ +{"object":"list","data":[{"id":"claude-sonnet-5","object":"model","created":1785728568,"owned_by":"command-code","name":"Claude Sonnet 5","context_length":1000000},{"id":"claude-sonnet-4-6","object":"model","created":1785728568,"owned_by":"command-code","name":"Claude Sonnet 4.6","context_length":1000000},{"id":"claude-fable-5","object":"model","created":1785728568,"owned_by":"command-code","name":"Claude Fable 5","context_length":1000000},{"id":"claude-opus-5","object":"model","created":1785728568,"owned_by":"command-code","name":"Claude Opus 5","context_length":1000000},{"id":"claude-opus-4-8","object":"model","created":1785728568,"owned_by":"command-code","name":"Claude Opus 4.8","context_length":1000000},{"id":"claude-opus-4-7","object":"model","created":1785728568,"owned_by":"command-code","name":"Claude Opus 4.7","context_length":1000000},{"id":"claude-haiku-4-5-20251001","object":"model","created":1785728568,"owned_by":"command-code","name":"Claude Haiku 4.5","context_length":200000},{"id":"gpt-5.6-sol","object":"model","created":1785728568,"owned_by":"command-code","name":"GPT-5.6 Sol","context_length":1050000},{"id":"gpt-5.6-terra","object":"model","created":1785728568,"owned_by":"command-code","name":"GPT-5.6 Terra","context_length":1050000},{"id":"gpt-5.6-luna","object":"model","created":1785728568,"owned_by":"command-code","name":"GPT-5.6 Luna","context_length":1050000},{"id":"gpt-5.5","object":"model","created":1785728568,"owned_by":"command-code","name":"GPT-5.5","context_length":200000},{"id":"gpt-5.4","object":"model","created":1785728568,"owned_by":"command-code","name":"GPT-5.4","context_length":400000},{"id":"gpt-5.3-codex","object":"model","created":1785728568,"owned_by":"command-code","name":"GPT-5.3 Codex","context_length":400000},{"id":"gpt-5.4-mini","object":"model","created":1785728568,"owned_by":"command-code","name":"GPT-5.4 Mini","context_length":400000},{"id":"deepseek/deepseek-v4-pro","object":"model","created":1785728568,"owned_by":"command-code","name":"DeepSeek V4 Pro","context_length":1000000},{"id":"deepseek/deepseek-v4-flash","object":"model","created":1785728568,"owned_by":"command-code","name":"DeepSeek V4 Flash","context_length":1000000},{"id":"moonshotai/Kimi-K3","object":"model","created":1785728568,"owned_by":"command-code","name":"Kimi K3","context_length":1000000},{"id":"moonshotai/Kimi-K2.7-Code","object":"model","created":1785728568,"owned_by":"command-code","name":"Kimi K2.7 Code","context_length":256000},{"id":"moonshotai/Kimi-K2.7-Code-Highspeed","object":"model","created":1785728568,"owned_by":"command-code","name":"Kimi K2.7 Code HighSpeed","context_length":262000},{"id":"moonshotai/Kimi-K2.6","object":"model","created":1785728568,"owned_by":"command-code","name":"Kimi K2.6","context_length":256000},{"id":"moonshotai/Kimi-K2.5","object":"model","created":1785728568,"owned_by":"command-code","name":"Kimi K2.5","context_length":256000},{"id":"zai-org/GLM-5.2","object":"model","created":1785728568,"owned_by":"command-code","name":"GLM-5.2","context_length":1000000},{"id":"zai-org/GLM-5.2-Fast","object":"model","created":1785728568,"owned_by":"command-code","name":"GLM-5.2 Fast","context_length":1000000},{"id":"zai-org/GLM-5.1","object":"model","created":1785728568,"owned_by":"command-code","name":"GLM-5.1","context_length":200000},{"id":"zai-org/GLM-5","object":"model","created":1785728568,"owned_by":"command-code","name":"GLM-5","context_length":200000},{"id":"MiniMaxAI/MiniMax-M3","object":"model","created":1785728568,"owned_by":"command-code","name":"MiniMax M3","context_length":1000000},{"id":"MiniMaxAI/MiniMax-M2.7","object":"model","created":1785728568,"owned_by":"command-code","name":"MiniMax M2.7","context_length":200000},{"id":"MiniMaxAI/MiniMax-M2.5","object":"model","created":1785728568,"owned_by":"command-code","name":"MiniMax M2.5","context_length":200000},{"id":"xiaomi/mimo-v2.5-pro","object":"model","created":1785728568,"owned_by":"command-code","name":"MiMo V2.5 Pro","context_length":1000000},{"id":"xiaomi/mimo-v2.5","object":"model","created":1785728568,"owned_by":"command-code","name":"MiMo V2.5","context_length":1000000},{"id":"Qwen/Qwen3.6-Max-Preview","object":"model","created":1785728568,"owned_by":"command-code","name":"Qwen 3.6 Max Preview","context_length":200000},{"id":"Qwen/Qwen3.6-Plus","object":"model","created":1785728568,"owned_by":"command-code","name":"Qwen 3.6 Plus","context_length":200000},{"id":"Qwen/Qwen3.7-Max","object":"model","created":1785728568,"owned_by":"command-code","name":"Qwen 3.7 Max","context_length":1000000},{"id":"Qwen/Qwen3.7-Plus","object":"model","created":1785728568,"owned_by":"command-code","name":"Qwen 3.7 Plus","context_length":1000000},{"id":"Qwen/Qwen3.7-Flash","object":"model","created":1785728568,"owned_by":"command-code","name":"Qwen 3.7 Flash","context_length":1000000},{"id":"Qwen/Qwen3.8-Max","object":"model","created":1785728568,"owned_by":"command-code","name":"Qwen 3.8 Max","context_length":1000000},{"id":"stepfun/Step-3.7-Flash","object":"model","created":1785728568,"owned_by":"command-code","name":"Step 3.7 Flash","context_length":256000},{"id":"stepfun/Step-3.5-Flash","object":"model","created":1785728568,"owned_by":"command-code","name":"Step 3.5 Flash","context_length":1000000},{"id":"tencent/hy3-paid","object":"model","created":1785728568,"owned_by":"command-code","name":"Tencent Hy3","context_length":262144},{"id":"google/gemini-3.6-flash","object":"model","created":1785728568,"owned_by":"command-code","name":"Gemini 3.6 Flash","context_length":1000000},{"id":"google/gemini-3.5-flash","object":"model","created":1785728568,"owned_by":"command-code","name":"Gemini 3.5 Flash","context_length":1000000},{"id":"google/gemini-3.5-flash-lite","object":"model","created":1785728568,"owned_by":"command-code","name":"Gemini 3.5 Flash Lite","context_length":1000000},{"id":"google/gemini-3.1-flash-lite","object":"model","created":1785728568,"owned_by":"command-code","name":"Gemini 3.1 Flash Lite","context_length":1000000},{"id":"sakana/fugu-ultra","object":"model","created":1785728568,"owned_by":"command-code","name":"Fugu Ultra","context_length":1000000},{"id":"nvidia/nemotron-3-ultra-550b-a55b","object":"model","created":1785728568,"owned_by":"command-code","name":"Nemotron 3 Ultra","context_length":1000000},{"id":"thinkingmachines/inkling","object":"model","created":1785728568,"owned_by":"command-code","name":"Inkling","context_length":256000},{"id":"thinkingmachines/inkling-small","object":"model","created":1785728568,"owned_by":"command-code","name":"Inkling Small","context_length":1000000},{"id":"poolside/laguna-s-2.1-free","object":"model","created":1785728568,"owned_by":"command-code","name":"Laguna S 2.1","context_length":256000},{"id":"inclusionai/ling-3.0-flash-free","object":"model","created":1785728568,"owned_by":"command-code","name":"Ling 3.0 Flash","context_length":256000},{"id":"meta/muse-spark-1.1","object":"model","created":1785728568,"owned_by":"command-code","name":"Muse Spark 1.1","context_length":1048576},{"id":"xai/grok-4.5","object":"model","created":1785728568,"owned_by":"command-code","name":"Grok 4.5","context_length":500000}]} \ No newline at end of file diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index e9e80cf33..a1be64d29 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -31,7 +31,7 @@ function nativeTemplate(): Record { const EXPECTED_KEY_PROVIDER_IDS = [ "anthropic-apikey", "openai-apikey", "umans", "opencode-go", "neuralwatt", "openrouter", "cline-pass", "cline", "orcarouter", "bizrouter", "groq", "google", "google-vertex", "azure-openai", - "deepseek", "cerebras", "deepinfra", "hyperbolic", "baseten", "together", "fireworks", "firepass", "moonshot", + "deepseek", "cerebras", "deepinfra", "hyperbolic", "baseten", "commandcode", "together", "fireworks", "firepass", "moonshot", "huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan", "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", @@ -815,6 +815,37 @@ describe("provider registry parity", () => { // so the request still reaches BizRouter as `openai/gpt-5.6-sol`. expect(entries.find(e => e.slug === "bizrouter/openai-gpt-5.6-sol")).toBeTruthy(); }); + + test("the Command Code preset seeds a usable live-discovery provider", () => { + const commandcode = PROVIDER_REGISTRY.find(entry => entry.id === "commandcode"); + expect(commandcode).toBeTruthy(); + expect(commandcode?.adapter).toBe("openai-chat"); + expect(commandcode?.authKind).toBe("key"); + expect(commandcode?.baseUrl).toBe("https://api.commandcode.ai/provider/v1"); + expect(commandcode?.liveModels).toBe(true); + // The default must be a real id in the public catalog that live discovery can return. + expect(commandcode?.defaultModel).toBe("deepseek/deepseek-v4-flash"); + // The public /models endpoint is unauthenticated, so key validation must stay honest. + expect(commandcode?.apiKeyValidation).toBe("unknown"); + + const seed = providerConfigSeed(commandcode!); + expect(seed.baseUrl).toBe("https://api.commandcode.ai/provider/v1"); + expect(seed.adapter).toBe("openai-chat"); + expect(seed.defaultModel).toBe("deepseek/deepseek-v4-flash"); + expect(seed).not.toHaveProperty("apiKeyValidation"); + + // Vendor-namespaced ids pass through unchanged: rewriting them would break upstream routing. + const model = applyProviderConfigHints("commandcode", seed, { + id: "deepseek/deepseek-v4-flash", + provider: "commandcode", + }); + expect(model.id).toBe("deepseek/deepseek-v4-flash"); + + const entries = buildCatalogEntries(nativeTemplate() as never, [], [model]); + // The catalog slug flattens the vendor separator, but the routed model id itself is untouched, + // so the request still reaches Command Code as `deepseek/deepseek-v4-flash`. + expect(entries.find(e => e.slug === "commandcode/deepseek-deepseek-v4-flash")).toBeTruthy(); + }); }); describe("free-provider directory isolation", () => {