From dc5ca52bee6a3083bb965980b1bfe7a0256c18f4 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 1 Aug 2026 18:23:05 -0700 Subject: [PATCH 1/3] feat(providers): add SambaNova and Nebius presets --- .../docs/getting-started/quickstart.md | 2 +- .../src/content/docs/guides/providers.md | 16 +- .../docs/ja/getting-started/quickstart.md | 2 +- .../src/content/docs/ja/guides/providers.md | 16 +- .../docs/ko/getting-started/quickstart.md | 2 +- .../src/content/docs/ko/guides/providers.md | 15 +- .../docs/ru/getting-started/quickstart.md | 2 +- .../src/content/docs/ru/guides/providers.md | 16 +- .../docs/zh-cn/getting-started/quickstart.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 14 +- src/codex/catalog/provider-fetch.ts | 11 + src/providers/free-directory.ts | 4 +- src/providers/registry.ts | 47 +++ tests/fixtures/nebius-models.json | 51 +++ tests/fixtures/sambanova-models.json | 21 ++ .../provider-model-discovery-contract.test.ts | 17 + tests/provider-registry-parity.test.ts | 2 +- tests/sambanova-nebius-provider.test.ts | 304 ++++++++++++++++++ 18 files changed, 531 insertions(+), 13 deletions(-) create mode 100644 tests/fixtures/nebius-models.json create mode 100644 tests/fixtures/sambanova-models.json create mode 100644 tests/sambanova-nebius-provider.test.ts diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 1c0d8a4cd3..48518e9b12 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 70 built-in registry presets or `custom` to type a base +1. **Pick a provider** — choose one of the 72 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 4278f6c4ea..9cd410cf23 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -216,7 +216,7 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses ## 3. API-key catalog -opencodex ships 70 built-in presets: 58 key-based, eight OAuth, three local, and one default +opencodex ships 72 built-in presets: 60 key-based, eight 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; validation is provider-specific. Notable entries: @@ -252,6 +252,8 @@ free-experimentation model. | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | | Command Code | `https://api.commandcode.ai/provider/v1` | +| SambaNova Cloud | `https://api.sambanova.ai/v1` | +| Nebius Token Factory | `https://api.tokenfactory.nebius.com/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` | @@ -308,6 +310,18 @@ account-scoped and comes from the authenticated discovery endpoint after login. configured Bearer key. Create keys at [Command Code Studio](https://commandcode.ai/studio/). +**SambaNova Cloud discovery.** The preset reads SambaNova Cloud's public `/v1/models` list from the fixed API +host, preserves provider-native ids, and caps discovery at 128 KiB and 128 raw rows. Chat requests +use the configured Bearer key and disable parallel function calls, which SambaNova does not yet +support. Private SambaStudio deployment endpoints are out of scope. Create keys in +[SambaNova Cloud](https://cloud.sambanova.ai/apis). + +**Nebius Token Factory discovery.** The preset requests the authenticated verbose model catalog and +keeps only rows whose architecture produces text, excluding embedding and image-generation models. +It preserves slash-containing native ids plus reported context and input-modality metadata, and caps +discovery at 512 KiB and 512 raw rows. Dedicated deployment hosts are out of scope. Create keys in +[Nebius Token Factory](https://tokenfactory.nebius.com). + > **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 04a0633810..b46913f919 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. **プロバイダーを選択してください** — 70 の組み込みレジストリ プリセットのいずれか、または `custom` を選択してベースを入力します +1. **プロバイダーを選択してください** — 72 の組み込みレジストリ プリセットのいずれか、または `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 363f601b8f..f0d4def345 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -143,7 +143,7 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht ## 3. API キーカタログ -opencodex には組み込みプリセットが 70 個含まれています。キー方式 58、OAuth 8、ローカル 3、 +opencodex には組み込みプリセットが 72 個含まれています。キー方式 60、OAuth 8、ローカル 3、 デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 入力したキーを検証した後保存します(検証はプロバイダー固有です)。主な項目は以下のとおりです: @@ -179,6 +179,8 @@ Cline IDE/CLI のみで API からは使えません。`minimax/minimax-m2.5` | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | | Command Code | `https://api.commandcode.ai/provider/v1` | +| SambaNova Cloud | `https://api.sambanova.ai/v1` | +| Nebius Token Factory | `https://api.tokenfactory.nebius.com/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` | @@ -229,6 +231,18 @@ CLI 資格情報の取り込みも可能)。モデルカタログはアカウン discovery エンドポイントから取得します。チャットリクエストは設定済みの bearer キーを使います。 キーは [Command Code Studio](https://commandcode.ai/studio/) で作成します。 +**SambaNova Cloud の discovery:** preset は固定 API ホスト上の SambaNova Cloud の公開 `/v1/models` 一覧を読み、 +プロバイダー固有の ID を保持し、discovery を 128 KiB と raw 128 行に制限します。chat リクエストは +設定済み Bearer キーを使い、SambaNova がまだ対応していない並列 function call は無効にします。 +非公開の SambaStudio deployment endpoint は対象外です。キーは +[SambaNova Cloud](https://cloud.sambanova.ai/apis) で作成します。 + +**Nebius Token Factory の discovery:** preset は認証付きの verbose モデルカタログを取得し、architecture +が text を出力する行だけを残して embedding と image-generation モデルを除外します。スラッシュを含む +ネイティブ ID と、報告された context / input modality metadata を保持し、discovery を 512 KiB と raw +512 行に制限します。dedicated deployment のホストは対象外です。キーは +[Nebius Token Factory](https://tokenfactory.nebius.com) で作成します。 + > **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 6a718c4c1d..b9e7919487 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. **프로바이더 선택** — 내장 레지스트리 프리셋 70개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. +1. **프로바이더 선택** — 내장 레지스트리 프리셋 72개 중 하나를 고르거나 `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 5314b86660..717832841d 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -142,7 +142,7 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 -opencodex에는 빌트인 프리셋이 70개 들어 있습니다. 키 방식 58개, OAuth 8개, 로컬 3개, +opencodex에는 빌트인 프리셋이 72개 들어 있습니다. 키 방식 60개, OAuth 8개, 로컬 3개, 기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, 입력한 키를 검증한 뒤 저장합니다(검증은 프로바이더별로 다릅니다). 주요 항목은 다음과 같습니다: @@ -179,6 +179,8 @@ Cline IDE/CLI에서만 제공되며 API로는 사용할 수 없습니다. `minim | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | | Command Code | `https://api.commandcode.ai/provider/v1` | +| SambaNova Cloud | `https://api.sambanova.ai/v1` | +| Nebius Token Factory | `https://api.tokenfactory.nebius.com/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` | @@ -227,6 +229,17 @@ CLI 사용자는 `~/.commandcode/auth.json`의 로컬 CLI 자격 증명을 가 계정 단위이며 로그인 후 인증된 discovery 엔드포인트에서 가져옵니다. 채팅 요청은 설정된 bearer 키를 사용합니다. 키는 [Command Code Studio](https://commandcode.ai/studio/)에서 생성합니다. +**SambaNova Cloud 검색:** 프리셋은 고정 API 호스트의 SambaNova Cloud 공개 `/v1/models` 목록을 읽고, 프로바이더 +네이티브 ID를 보존하며 discovery를 128 KiB와 raw 행 128개로 제한합니다. chat 요청은 설정된 Bearer +키를 사용하고 SambaNova가 아직 지원하지 않는 병렬 function call은 비활성화합니다. 비공개 SambaStudio +deployment 엔드포인트는 범위에서 제외합니다. 키는 [SambaNova Cloud](https://cloud.sambanova.ai/apis)에서 생성합니다. + +**Nebius Token Factory 검색:** 프리셋은 인증된 verbose 모델 카탈로그를 요청하고 architecture가 text를 +출력하는 행만 유지해 embedding 및 image-generation 모델을 제외합니다. 슬래시가 포함된 네이티브 ID와 +보고된 context 및 input modality metadata를 보존하며 discovery를 512 KiB와 raw 행 512개로 제한합니다. +dedicated deployment 호스트는 범위에서 제외합니다. 키는 +[Nebius Token Factory](https://tokenfactory.nebius.com)에서 생성합니다. + > **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 73e905f639..f67a2c72e3 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. **Выбор провайдера** — выберите один из 70 встроенных пресетов реестра или `custom`, чтобы +1. **Выбор провайдера** — выберите один из 72 встроенных пресетов реестра или `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 bcb7c9cbb7..78adc5a79f 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -153,7 +153,7 @@ OAuth-провайдеры, чьи учётные данные содержат ## 3. Каталог API-ключей -opencodex поставляется с 70 встроенными пресетами: 58 на основе ключей, восемь OAuth, три локальных и +opencodex поставляется с 72 встроенными пресетами: 60 на основе ключей, восемь OAuth, три локальных и один пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу выдачи ключей провайдера, проверяет ключ и сохраняет его; проверка зависит от провайдера. Наиболее заметные записи: @@ -190,6 +190,8 @@ opencodex поставляется с 70 встроенными пресетам | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | | Command Code | `https://api.commandcode.ai/provider/v1` | +| SambaNova Cloud | `https://api.sambanova.ai/v1` | +| Nebius Token Factory | `https://api.tokenfactory.nebius.com/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` | @@ -242,6 +244,18 @@ endpoint в него не входят. Ключи создаются в [Hyperb аутентифицированного discovery endpoint после входа. Запросы чата используют настроенный bearer-ключ. Ключи создаются в [Command Code Studio](https://commandcode.ai/studio/). +**Discovery для SambaNova Cloud.** Пресет читает общедоступный список SambaNova Cloud `/v1/models` на +фиксированном API-хосте, сохраняет нативные id провайдера и ограничивает discovery размером 128 KiB +и 128 исходными строками. Chat-запросы используют настроенный Bearer-ключ; параллельные вызовы +функций отключены, поскольку SambaNova пока их не поддерживает. Частные endpoint развёртываний +SambaStudio не входят в область пресета. Ключи создаются в [SambaNova Cloud](https://cloud.sambanova.ai/apis). + +**Discovery для Nebius Token Factory.** Пресет запрашивает аутентифицированный verbose-каталог и +оставляет только модели, architecture которых выдаёт текст, исключая embedding и image-generation. +Он сохраняет нативные id со знаком `/`, а также заявленные context и input-modality metadata, и +ограничивает discovery размером 512 KiB и 512 исходными строками. Хосты dedicated deployment не +входят в область пресета. Ключи создаются в [Nebius Token Factory](https://tokenfactory.nebius.com). + > **Область 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 3f12c45f0d..a113197158 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 的 70 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 +1. **选择 provider** — 从内置 registry 的 72 个预设中选择一个,或选择 `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 6e4926d3ae..210891e6bb 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -131,7 +131,7 @@ Kiro 登录需要 Kiro CLI:Unix 使用 `curl -fsSL https://cli.kiro.dev/instal ## 3. API 密钥目录 -opencodex 内置 70 个预设:58 个密钥预设、8 个 OAuth 预设、3 个本地预设,以及 1 个默认的 +opencodex 内置 72 个预设:60 个密钥预设、8 个 OAuth 预设、3 个本地预设,以及 1 个默认的 ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。 验证因提供商而异。主要条目包括: @@ -167,6 +167,8 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | | Command Code | `https://api.commandcode.ai/provider/v1` | +| SambaNova Cloud | `https://api.sambanova.ai/v1` | +| Nebius Token Factory | `https://api.tokenfactory.nebius.com/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` | @@ -211,6 +213,16 @@ image、audio 和 GPU 端点不在范围内。密钥可在 [Hyperbolic](https:// `~/.commandcode/auth.json` 导入本地 CLI 凭据);模型目录按账户隔离,并在登录后从经过认证的发现 端点获取。聊天请求使用已配置的 bearer 密钥。密钥可在 [Command Code Studio](https://commandcode.ai/studio/) 创建。 +**SambaNova Cloud 发现:**该预设从固定 API 主机读取 SambaNova Cloud 的公开 `/v1/models` 列表,保留提供商原生 +模型 id,并将发现限制为 128 KiB 和 128 条原始记录。chat 请求使用已配置的 Bearer 密钥;由于 +SambaNova 尚不支持并行 function call,该能力会被禁用。私有 SambaStudio 部署端点不在范围内。 +密钥可在 [SambaNova Cloud](https://cloud.sambanova.ai/apis) 创建。 + +**Nebius Token Factory 发现:**该预设请求需要鉴权的 verbose 模型目录,仅保留 architecture 输出 text +的记录,从而排除 embedding 和 image-generation 模型。它保留含 `/` 的原生模型 id、上游报告的 context +和 input modality metadata,并将发现限制为 512 KiB 和 512 条原始记录。dedicated deployment 主机不在 +范围内。密钥可在 [Nebius Token Factory](https://tokenfactory.nebius.com) 创建。 + > **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/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index b88220c156..d968be74a2 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -736,6 +736,17 @@ function modelInputModalities( value === "text" || value === "image" || value === "audio" )); if (explicit && explicit.length > 0) return explicit; + const architecture = plainRecord(item.architecture); + const architectureModality = typeof architecture?.modality === "string" + ? normalizedMetadataString(architecture.modality, 64) + : undefined; + if (architectureModality?.includes("->")) { + const [rawInput] = architectureModality.split("->"); + const inferred = rawInput + ?.split("+") + .filter(value => value === "text" || value === "image" || value === "audio"); + if (inferred && inferred.length > 0) return [...new Set(inferred)]; + } if (capabilityRecord?.vision === false) return ["text"]; if (capabilityRecord?.vision === true || capabilities?.some(value => value === "vision" || value === "image-input")) { return ["text", "image"]; diff --git a/src/providers/free-directory.ts b/src/providers/free-directory.ts index 9d96063dd6..c3f159788c 100644 --- a/src/providers/free-directory.ts +++ b/src/providers/free-directory.ts @@ -99,7 +99,7 @@ const CONNECTABLE: Record = { ovhcloud: openAi("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", "https://console.ovhcloud.com", { keyOptional: true, modelsUrl: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models" }), pollinations: openAi("https://gen.pollinations.ai/v1", "https://pollinations.ai", { keyOptional: true }), reka: openAi("https://api.reka.ai/v1", "https://platform.reka.ai"), - sambanova: openAi("https://api.sambanova.ai/v1", "https://cloud.sambanova.ai/apis", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.sambanova.ai/cloud/docs/api-reference/models" }), + sambanova: openAi("https://api.sambanova.ai/v1", "https://cloud.sambanova.ai/apis", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.sambanova.ai/docs/en/get-started/api-keys-urls", modelsUrl: "https://api.sambanova.ai/v1/models", lastVerified: "2026-08-01" }), sparkdesk: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html" }), agnes: openAi("https://apihub.agnes-ai.com/v1", "https://agnes-ai.com"), ainative: openAi("https://api.ainative.studio/api/v1", "https://ainative.studio", { modelsUrl: "https://api.ainative.studio/api/v1/models" }), @@ -132,7 +132,7 @@ const CONNECTABLE: Record = { hyperbolic: openAi("https://api.hyperbolic.xyz/v1", "https://app.hyperbolic.xyz/settings", { verification: "official" }), longcat: openAi("https://api.longcat.chat/openai/v1", "https://longcat.chat", { verification: "official", discovery: "static", liveModels: false, models: ["LongCat-2.0"] }), monsterapi: openAi("https://api.monsterapi.ai/v1", "https://monsterapi.ai", { verification: "official" }), - nebius: openAi("https://api.tokenfactory.nebius.com/v1", "https://studio.nebius.com", { verification: "official" }), + nebius: openAi("https://api.tokenfactory.nebius.com/v1", "https://tokenfactory.nebius.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.tokenfactory.nebius.com/quickstart", lastVerified: "2026-08-01" }), novita: openAi("https://api.novita.ai/openai/v1", "https://novita.ai/settings/key-management", { supportLevel: "supported", verification: "official", modelsUrl: "https://api.novita.ai/openai/v1/models" }), nscale: openAi("https://inference.api.nscale.com/v1", "https://console.nscale.com", { verification: "official" }), nvidia: openAi("https://integrate.api.nvidia.com/v1", "https://build.nvidia.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.api.nvidia.com/nim/reference/llm-apis" }), diff --git a/src/providers/registry.ts b/src/providers/registry.ts index cd79364005..4cec83507c 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1380,6 +1380,53 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // 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. Use `ocx login command-code` for OAuth account login (imports an existing local Command Code CLI credential when present). Docs: https://commandcode.ai/docs/provider.", }, + { + id: "sambanova", + label: "SambaNova Cloud", + baseUrl: "https://api.sambanova.ai/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://cloud.sambanova.ai/apis", + liveModels: true, + preserveCustomDestination: true, + apiKeyValidation: "unknown", + // SambaNova documents this request field but does not yet support parallel function calls. + parallelToolCalls: false, + // The public catalog does not report a trustworthy per-model reasoning contract. + reasoningEfforts: [], + modelDiscovery: { + path: "models", + maxResponseBytes: 128 * 1024, + maxModels: 128, + }, + note: "SambaNova Cloud text-generation models only; private SambaStudio deployment endpoints are outside this preset.", + }, + { + id: "nebius", + label: "Nebius Token Factory", + baseUrl: "https://api.tokenfactory.nebius.com/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://tokenfactory.nebius.com", + liveModels: true, + preserveCustomDestination: true, + // The public tools guide documents single function selection, not parallel tool calls. + parallelToolCalls: false, + // Missing reasoning metadata must not promote a model to Codex's full fallback ladder. + reasoningEfforts: [], + modelDiscovery: { + path: "models", + query: { verbose: "true" }, + maxResponseBytes: 512 * 1024, + maxModels: 512, + filter: { + // Token Factory's verbose catalog also contains embeddings and image generation. + // Keep text- and vision-input rows whose output modality is text. + allOf: [{ path: ["architecture", "modality"], containsAny: ["->text"] }], + }, + }, + note: "Shared Token Factory text-output inference only; live discovery excludes embedding and image-generation rows.", + }, // 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/fixtures/nebius-models.json b/tests/fixtures/nebius-models.json new file mode 100644 index 0000000000..fa2c3bd1f0 --- /dev/null +++ b/tests/fixtures/nebius-models.json @@ -0,0 +1,51 @@ +{ + "object": "list", + "data": [ + { + "id": "meta-llama/Meta-Llama-3.1-8B-Instruct-fast", + "name": "Meta-Llama-3.1-8B-Instruct (fast)", + "object": "model", + "owned_by": "system", + "context_length": 131072, + "architecture": { + "modality": "text->text", + "tokenizer": "Other" + }, + "supported_features": ["function-calling", "json-mode"] + }, + { + "id": "Qwen/Qwen3-VL-235B-A22B-Instruct", + "name": "Qwen3 VL 235B Instruct", + "object": "model", + "owned_by": "system", + "context_length": 262144, + "architecture": { + "modality": "text+image->text", + "tokenizer": "Qwen" + }, + "supported_features": ["function-calling"] + }, + { + "id": "BAAI/bge-en-icl", + "name": "BGE English ICL", + "object": "model", + "owned_by": "system", + "context_length": 8192, + "architecture": { + "modality": "text->embedding", + "tokenizer": "Bert" + } + }, + { + "id": "black-forest-labs/FLUX.1-dev", + "name": "FLUX.1 Dev", + "object": "model", + "owned_by": "system", + "context_length": 512, + "architecture": { + "modality": "text->image", + "tokenizer": "Other" + } + } + ] +} diff --git a/tests/fixtures/sambanova-models.json b/tests/fixtures/sambanova-models.json new file mode 100644 index 0000000000..cd6b7362c3 --- /dev/null +++ b/tests/fixtures/sambanova-models.json @@ -0,0 +1,21 @@ +{ + "object": "list", + "data": [ + { + "id": "Meta-Llama-3.3-70B-Instruct", + "object": "model", + "owned_by": "sambanova", + "context_length": 131072, + "max_completion_tokens": 3072, + "sn_metadata": {} + }, + { + "id": "gpt-oss-120b", + "object": "model", + "owned_by": "sambanova", + "context_length": 131072, + "max_completion_tokens": 131072, + "sn_metadata": {} + } + ] +} diff --git a/tests/provider-model-discovery-contract.test.ts b/tests/provider-model-discovery-contract.test.ts index 80dd02fa32..ed48d66c24 100644 --- a/tests/provider-model-discovery-contract.test.ts +++ b/tests/provider-model-discovery-contract.test.ts @@ -233,6 +233,23 @@ describe("registry-owned provider model discovery", () => { })).toEqual({}); }); + test("infers Codex-safe input modalities from bounded architecture metadata", () => { + expect(catalogHintsFromModelsApiItem("example", { + id: "vision-chat", + architecture: { modality: "text+image->text" }, + })).toEqual({ inputModalities: ["text", "image"] }); + + expect(catalogHintsFromModelsApiItem("example", { + id: "unknown-input", + architecture: { modality: "text+video->text" }, + })).toEqual({ inputModalities: ["text"] }); + + expect(catalogHintsFromModelsApiItem("example", { + id: "controlled", + architecture: { modality: "text+im\u0000age->text" }, + })).toEqual({}); + }); + test("drops untrusted metadata tokens containing control characters", () => { expect(catalogHintsFromModelsApiItem("example", { id: "controlled", diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 8991253977..a3b0c74fd5 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", "commandcode", "together", "fireworks", "firepass", "moonshot", + "deepseek", "cerebras", "deepinfra", "hyperbolic", "baseten", "commandcode", "sambanova", "nebius", "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", diff --git a/tests/sambanova-nebius-provider.test.ts b/tests/sambanova-nebius-provider.test.ts new file mode 100644 index 0000000000..986cf01c45 --- /dev/null +++ b/tests/sambanova-nebius-provider.test.ts @@ -0,0 +1,304 @@ +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 { PROVIDER_REGISTRY, type ProviderRegistryEntry } 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 SAMBANOVA_FIXTURE = readFileSync(join(import.meta.dir, "fixtures/sambanova-models.json"), "utf8"); +const NEBIUS_FIXTURE = readFileSync(join(import.meta.dir, "fixtures/nebius-models.json"), "utf8"); +const originalFetch = globalThis.fetch; + +type ProviderId = "sambanova" | "nebius"; + +const PROVIDERS: Record = { + sambanova: { + baseUrl: "https://api.sambanova.ai/v1", + dashboardUrl: "https://cloud.sambanova.ai/apis", + fixture: SAMBANOVA_FIXTURE, + key: "sambanova-test-key", + modelsUrl: "https://api.sambanova.ai/v1/models", + }, + nebius: { + baseUrl: "https://api.tokenfactory.nebius.com/v1", + dashboardUrl: "https://tokenfactory.nebius.com", + fixture: NEBIUS_FIXTURE, + key: "nebius-test-key", + modelsUrl: "https://api.tokenfactory.nebius.com/v1/models?verbose=true", + }, +}; + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache("sambanova"); + clearModelCache("nebius"); +}); + +function registryEntry(id: ProviderId): ProviderRegistryEntry { + const entry = PROVIDER_REGISTRY.find(row => row.id === id); + if (!entry) throw new Error(`missing ${id} registry entry`); + return entry; +} + +function providerConfig(id: ProviderId, overrides: Partial = {}): OcxConfig { + const provider = PROVIDERS[id]; + return { + port: 10100, + defaultProvider: id, + providers: { + [id]: { + adapter: "openai-chat", + baseUrl: provider.baseUrl, + authMode: "key", + apiKey: provider.key, + liveModels: true, + // Discovery stays fixture-only; this avoids platform-specific public-DNS classification. + allowPrivateNetwork: true, + ...overrides, + }, + }, + }; +} + +describe("SambaNova and Nebius providers", () => { + test("registers fixed OpenAI transports with bounded text-output discovery", () => { + expect(registryEntry("sambanova")).toMatchObject({ + id: "sambanova", + label: "SambaNova Cloud", + adapter: "openai-chat", + baseUrl: PROVIDERS.sambanova.baseUrl, + authKind: "key", + dashboardUrl: PROVIDERS.sambanova.dashboardUrl, + liveModels: true, + preserveCustomDestination: true, + parallelToolCalls: false, + modelDiscovery: { + path: "models", + maxResponseBytes: 131_072, + maxModels: 128, + }, + }); + expect(registryEntry("sambanova").note).toContain("SambaStudio"); + + expect(registryEntry("nebius")).toMatchObject({ + id: "nebius", + label: "Nebius Token Factory", + adapter: "openai-chat", + baseUrl: PROVIDERS.nebius.baseUrl, + authKind: "key", + dashboardUrl: PROVIDERS.nebius.dashboardUrl, + liveModels: true, + preserveCustomDestination: true, + parallelToolCalls: false, + modelDiscovery: { + path: "models", + query: { verbose: "true" }, + maxResponseBytes: 524_288, + maxModels: 512, + filter: { + allOf: [{ path: ["architecture", "modality"], containsAny: ["->text"] }], + }, + }, + }); + expect(registryEntry("nebius").note).toContain("embedding and image-generation rows"); + }); + + test("derives CLI and dashboard presets without persisting registry trust policy", () => { + expect(buildInitProviders()).toEqual(deriveInitProviders()); + + for (const id of ["sambanova", "nebius"] as const) { + const entry = registryEntry(id); + const provider = PROVIDERS[id]; + expect(KEY_LOGIN_PROVIDERS[id]).toMatchObject({ + adapter: "openai-chat", + baseUrl: provider.baseUrl, + dashboardUrl: provider.dashboardUrl, + liveModels: true, + }); + expect(buildInitProviders().find(row => row.id === id)).toMatchObject({ + kind: "key", + adapter: "openai-chat", + baseUrl: provider.baseUrl, + }); + expect(deriveProviderPresets().find(row => row.id === id)).toMatchObject({ + auth: "key", + dashboardUrl: provider.dashboardUrl, + }); + + const seed = providerConfigSeed(entry); + expect(seed).toMatchObject({ + adapter: "openai-chat", + baseUrl: provider.baseUrl, + authMode: "key", + liveModels: true, + parallelToolCalls: false, + }); + expect(seed).not.toHaveProperty("modelDiscovery"); + expect(seed).not.toHaveProperty("preserveCustomDestination"); + expect(KEY_LOGIN_PROVIDERS[id]).not.toHaveProperty("modelDiscovery"); + expect(KEY_LOGIN_PROVIDERS[id]).not.toHaveProperty("preserveCustomDestination"); + } + }); + + test("lists and validates models through each registry-owned endpoint", async () => { + for (const id of ["sambanova", "nebius"] as const) { + const provider = PROVIDERS[id]; + expect(buildModelsRequest(providerConfig(id).providers[id]!, provider.key, id)).toEqual({ + url: provider.modelsUrl, + headers: { Authorization: `Bearer ${provider.key}` }, + }); + + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe(provider.modelsUrl); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${provider.key}`); + expect(init?.redirect).toBe("error"); + return new Response(provider.fixture, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + expect(await validateApiKey(id, KEY_LOGIN_PROVIDERS[id]!, provider.key)).toBe(true); + } + }); + + test("filters Nebius mixed rows and preserves model metadata and native ids", async () => { + globalThis.fetch = (async (input, init) => { + const url = String(input); + expect(new Headers(init?.headers).get("authorization")).toBeTruthy(); + expect(init?.redirect).toBe("manual"); + if (url === PROVIDERS.sambanova.modelsUrl) { + return new Response(SAMBANOVA_FIXTURE, { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url === PROVIDERS.nebius.modelsUrl) { + return new Response(NEBIUS_FIXTURE, { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`unexpected discovery URL: ${url}`); + }) as typeof fetch; + + const config = withStubbedProviderFetch({ + port: 10100, + defaultProvider: "sambanova", + providers: { + sambanova: providerConfig("sambanova").providers.sambanova!, + nebius: providerConfig("nebius").providers.nebius!, + }, + } satisfies OcxConfig); + const models = await gatherRoutedModels(config); + const sambanovaModels = models.filter(row => row.provider === "sambanova"); + const nebiusModels = models.filter(row => row.provider === "nebius"); + + expect(sambanovaModels.map(row => row.id)).toEqual([ + "gpt-oss-120b", + "Meta-Llama-3.3-70B-Instruct", + ]); + expect(sambanovaModels[1]).toMatchObject({ + owned_by: "sambanova", + contextWindow: 131_072, + }); + expect(nebiusModels.map(row => row.id)).toEqual([ + "meta-llama/Meta-Llama-3.1-8B-Instruct-fast", + "Qwen/Qwen3-VL-235B-A22B-Instruct", + ]); + expect(nebiusModels[0]).toMatchObject({ + owned_by: "system", + contextWindow: 131_072, + inputModalities: ["text"], + capabilities: ["function-calling", "json-mode"], + }); + expect(nebiusModels[1]).toMatchObject({ + contextWindow: 262_144, + inputModalities: ["text", "image"], + }); + + const selectors = [ + ["sambanova", "Meta-Llama-3.3-70B-Instruct"], + ["nebius", "meta-llama/Meta-Llama-3.1-8B-Instruct-fast"], + ] as const; + for (const [providerId, modelId] of selectors) { + expect(routeModel(config, `${providerId}/${modelId}`).modelId).toBe(modelId); + expect(routeModel(config, routedSlug(providerId, modelId)).modelId).toBe(modelId); + } + }); + + test("routes tool requests to the fixed hosts without claiming parallel tool calls", () => { + const cases = [ + ["sambanova", "Meta-Llama-3.3-70B-Instruct"], + ["nebius", "meta-llama/Meta-Llama-3.1-8B-Instruct-fast"], + ] as const; + + for (const [providerId, modelId] of cases) { + const route = routeModel(providerConfig(providerId), `${providerId}/${modelId}`); + const request = createOpenAIChatAdapter(route.provider).buildRequest({ + modelId: route.modelId, + context: { + messages: [{ role: "user", content: "ping", timestamp: 0 }], + tools: [{ + name: "ping", + description: "Return pong", + parameters: { type: "object", properties: {} }, + }], + }, + stream: true, + options: {}, + }); + const body = JSON.parse(String(request.body)) as Record; + + expect(request.url).toBe(`${PROVIDERS[providerId].baseUrl}/chat/completions`); + expect(request.headers.Authorization).toBe(`Bearer ${PROVIDERS[providerId].key}`); + expect(body.model).toBe(modelId); + expect(body.parallel_tool_calls).toBe(false); + } + }); + + test("does not retarget older same-named custom providers or adapters", () => { + for (const id of ["sambanova", "nebius"] as const) { + const customConfig = providerConfig(id, { baseUrl: "https://custom.example/v1" }); + const route = routeModel(customConfig, `${id}/custom-model`); + expect(route.provider).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://custom.example/v1", + authMode: "key", + }); + expect(buildModelsRequest(customConfig.providers[id]!, "custom-key", id)).toEqual({ + url: "https://custom.example/v1/models", + headers: { Authorization: "Bearer custom-key" }, + }); + + const customAdapter = routeModel(providerConfig(id, { + adapter: "anthropic", + baseUrl: "https://custom.example/anthropic", + }), `${id}/custom-model`); + expect(customAdapter.provider).toMatchObject({ + adapter: "anthropic", + baseUrl: "https://custom.example/anthropic", + authMode: "key", + }); + } + }); +}); From cb58400ef611dbd924ba2fd2a36ecb654655fab1 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sun, 2 Aug 2026 10:30:24 -0700 Subject: [PATCH 2/3] fix(providers): address SambaNova and Nebius review feedback --- .../src/content/docs/guides/providers.md | 8 +- .../docs/ja/getting-started/quickstart.md | 3 +- .../src/content/docs/ja/guides/providers.md | 5 +- .../src/content/docs/ko/guides/providers.md | 8 +- .../src/content/docs/ru/guides/providers.md | 6 +- .../content/docs/zh-cn/guides/providers.md | 5 +- src/codex/catalog/provider-fetch.ts | 6 +- src/providers/free-directory.ts | 7 +- src/providers/model-discovery.ts | 12 ++- src/providers/registry.ts | 4 +- .../provider-model-discovery-contract.test.ts | 26 ++++- tests/sambanova-nebius-provider.test.ts | 100 +++++++++++++++--- 12 files changed, 151 insertions(+), 39 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 9cd410cf23..b8d5e4cf1c 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -311,9 +311,11 @@ configured Bearer key. Create keys at [Command Code Studio](https://commandcode.ai/studio/). **SambaNova Cloud discovery.** The preset reads SambaNova Cloud's public `/v1/models` list from the fixed API -host, preserves provider-native ids, and caps discovery at 128 KiB and 128 raw rows. Chat requests -use the configured Bearer key and disable parallel function calls, which SambaNova does not yet -support. Private SambaStudio deployment endpoints are out of scope. Create keys in +host, preserves provider-native ids, and caps discovery at 128 KiB and 128 raw rows. Because the +catalog is unauthenticated, the CLI login flow reports the key as unverifiable instead of treating +the public response as proof. Chat requests still use the configured Bearer key and disable parallel +function calls, which SambaNova does not yet support. Private SambaStudio deployment endpoints are +out of scope. Create keys in [SambaNova Cloud](https://cloud.sambanova.ai/apis). **Nebius Token Factory discovery.** The preset requests the authenticated verbose model catalog and 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 b46913f919..01314496ca 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -13,8 +13,7 @@ ocx init `ocx init` では次の手順を説明します。 -1. **プロバイダーを選択してください** — 72 の組み込みレジストリ プリセットのいずれか、または `custom` を選択してベースを入力します -URLとアダプター。 +1. **プロバイダーを選択してください** — 72 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。 2. **API キー** — キーを貼り付けるか、`${ANTHROPIC_API_KEY}` のような環境変数を参照します。 3. **デフォルト モデル** — キー、ローカル、カスタム プロバイダーの場合は、プリセットを受け入れるか、モデル ID を入力します。 4. **プロキシ ポート** — デフォルトは `10100` です。 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index f0d4def345..7c86724377 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -232,8 +232,9 @@ discovery エンドポイントから取得します。チャットリクエス キーは [Command Code Studio](https://commandcode.ai/studio/) で作成します。 **SambaNova Cloud の discovery:** preset は固定 API ホスト上の SambaNova Cloud の公開 `/v1/models` 一覧を読み、 -プロバイダー固有の ID を保持し、discovery を 128 KiB と raw 128 行に制限します。chat リクエストは -設定済み Bearer キーを使い、SambaNova がまだ対応していない並列 function call は無効にします。 +プロバイダー固有の ID を保持し、discovery を 128 KiB と raw 128 行に制限します。カタログは認証不要のため、 +CLI の login flow は公開レスポンスをキーの有効性の証拠にせず、キーを検証不能として報告します。chat リクエストは +引き続き設定済み Bearer キーを使い、SambaNova がまだ対応していない並列 function call は無効にします。 非公開の SambaStudio deployment endpoint は対象外です。キーは [SambaNova Cloud](https://cloud.sambanova.ai/apis) で作成します。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 717832841d..7457cb1fdd 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -230,9 +230,11 @@ CLI 사용자는 `~/.commandcode/auth.json`의 로컬 CLI 자격 증명을 가 키를 사용합니다. 키는 [Command Code Studio](https://commandcode.ai/studio/)에서 생성합니다. **SambaNova Cloud 검색:** 프리셋은 고정 API 호스트의 SambaNova Cloud 공개 `/v1/models` 목록을 읽고, 프로바이더 -네이티브 ID를 보존하며 discovery를 128 KiB와 raw 행 128개로 제한합니다. chat 요청은 설정된 Bearer -키를 사용하고 SambaNova가 아직 지원하지 않는 병렬 function call은 비활성화합니다. 비공개 SambaStudio -deployment 엔드포인트는 범위에서 제외합니다. 키는 [SambaNova Cloud](https://cloud.sambanova.ai/apis)에서 생성합니다. +네이티브 ID를 보존하며 discovery를 128 KiB와 raw 행 128개로 제한합니다. 카탈로그에는 인증이 필요하지 않으므로 +CLI 로그인 흐름은 공개 응답을 키 유효성의 증거로 사용하지 않고 키를 검증할 수 없는 것으로 보고합니다. chat 요청은 +계속 설정된 Bearer 키를 사용하고 SambaNova가 아직 지원하지 않는 병렬 function call은 비활성화합니다. 비공개 +SambaStudio deployment 엔드포인트는 범위에서 제외합니다. 키는 +[SambaNova Cloud](https://cloud.sambanova.ai/apis)에서 생성합니다. **Nebius Token Factory 검색:** 프리셋은 인증된 verbose 모델 카탈로그를 요청하고 architecture가 text를 출력하는 행만 유지해 embedding 및 image-generation 모델을 제외합니다. 슬래시가 포함된 네이티브 ID와 diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 78adc5a79f..7b9c911563 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -246,8 +246,10 @@ endpoint в него не входят. Ключи создаются в [Hyperb **Discovery для SambaNova Cloud.** Пресет читает общедоступный список SambaNova Cloud `/v1/models` на фиксированном API-хосте, сохраняет нативные id провайдера и ограничивает discovery размером 128 KiB -и 128 исходными строками. Chat-запросы используют настроенный Bearer-ключ; параллельные вызовы -функций отключены, поскольку SambaNova пока их не поддерживает. Частные endpoint развёртываний +и 128 исходными строками. Каталог не требует аутентификации, поэтому процедура входа CLI сообщает, что +ключ невозможно проверить, вместо того чтобы считать публичный ответ подтверждением его действительности. +Chat-запросы по-прежнему используют настроенный Bearer-ключ; параллельные вызовы функций отключены, +поскольку SambaNova пока их не поддерживает. Частные endpoint развёртываний SambaStudio не входят в область пресета. Ключи создаются в [SambaNova Cloud](https://cloud.sambanova.ai/apis). **Discovery для Nebius Token Factory.** Пресет запрашивает аутентифицированный verbose-каталог и 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 210891e6bb..6d75353037 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -214,8 +214,9 @@ image、audio 和 GPU 端点不在范围内。密钥可在 [Hyperbolic](https:// 端点获取。聊天请求使用已配置的 bearer 密钥。密钥可在 [Command Code Studio](https://commandcode.ai/studio/) 创建。 **SambaNova Cloud 发现:**该预设从固定 API 主机读取 SambaNova Cloud 的公开 `/v1/models` 列表,保留提供商原生 -模型 id,并将发现限制为 128 KiB 和 128 条原始记录。chat 请求使用已配置的 Bearer 密钥;由于 -SambaNova 尚不支持并行 function call,该能力会被禁用。私有 SambaStudio 部署端点不在范围内。 +模型 id,并将发现限制为 128 KiB 和 128 条原始记录。该目录无需鉴权,因此 CLI 登录流程不会把公开响应 +当作密钥有效性的证明,而会将密钥报告为无法验证。chat 请求仍使用已配置的 Bearer 密钥;由于 SambaNova +尚不支持并行 function call,该能力会被禁用。私有 SambaStudio 部署端点不在范围内。 密钥可在 [SambaNova Cloud](https://cloud.sambanova.ai/apis) 创建。 **Nebius Token Factory 发现:**该预设请求需要鉴权的 verbose 模型目录,仅保留 architecture 输出 text diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index d968be74a2..545a387c35 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -741,11 +741,11 @@ function modelInputModalities( ? normalizedMetadataString(architecture.modality, 64) : undefined; if (architectureModality?.includes("->")) { - const [rawInput] = architectureModality.split("->"); + const [rawInput = ""] = architectureModality.split("->"); const inferred = rawInput - ?.split("+") + .split("+") .filter(value => value === "text" || value === "image" || value === "audio"); - if (inferred && inferred.length > 0) return [...new Set(inferred)]; + if (inferred.length > 0) return [...new Set(inferred)]; } if (capabilityRecord?.vision === false) return ["text"]; if (capabilityRecord?.vision === true || capabilities?.some(value => value === "vision" || value === "image-input")) { diff --git a/src/providers/free-directory.ts b/src/providers/free-directory.ts index c3f159788c..fd7228463d 100644 --- a/src/providers/free-directory.ts +++ b/src/providers/free-directory.ts @@ -99,7 +99,7 @@ const CONNECTABLE: Record = { ovhcloud: openAi("https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", "https://console.ovhcloud.com", { keyOptional: true, modelsUrl: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models" }), pollinations: openAi("https://gen.pollinations.ai/v1", "https://pollinations.ai", { keyOptional: true }), reka: openAi("https://api.reka.ai/v1", "https://platform.reka.ai"), - sambanova: openAi("https://api.sambanova.ai/v1", "https://cloud.sambanova.ai/apis", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.sambanova.ai/docs/en/get-started/api-keys-urls", modelsUrl: "https://api.sambanova.ai/v1/models", lastVerified: "2026-08-01" }), + sambanova: openAi("https://api.sambanova.ai/v1", "https://cloud.sambanova.ai/apis", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.sambanova.ai/docs/en/get-started/api-keys-urls", modelsUrl: "https://api.sambanova.ai/v1/models", lastVerified: "2026-08-02" }), sparkdesk: openAi("https://spark-api-open.xf-yun.com/v1", "https://console.xfyun.cn", { verification: "official", documentationUrl: "https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html" }), agnes: openAi("https://apihub.agnes-ai.com/v1", "https://agnes-ai.com"), ainative: openAi("https://api.ainative.studio/api/v1", "https://ainative.studio", { modelsUrl: "https://api.ainative.studio/api/v1/models" }), @@ -132,7 +132,7 @@ const CONNECTABLE: Record = { hyperbolic: openAi("https://api.hyperbolic.xyz/v1", "https://app.hyperbolic.xyz/settings", { verification: "official" }), longcat: openAi("https://api.longcat.chat/openai/v1", "https://longcat.chat", { verification: "official", discovery: "static", liveModels: false, models: ["LongCat-2.0"] }), monsterapi: openAi("https://api.monsterapi.ai/v1", "https://monsterapi.ai", { verification: "official" }), - nebius: openAi("https://api.tokenfactory.nebius.com/v1", "https://tokenfactory.nebius.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.tokenfactory.nebius.com/quickstart", lastVerified: "2026-08-01" }), + nebius: openAi("https://api.tokenfactory.nebius.com/v1", "https://tokenfactory.nebius.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.tokenfactory.nebius.com/quickstart", modelsUrl: "https://api.tokenfactory.nebius.com/v1/models?verbose=true", lastVerified: "2026-08-02" }), novita: openAi("https://api.novita.ai/openai/v1", "https://novita.ai/settings/key-management", { supportLevel: "supported", verification: "official", modelsUrl: "https://api.novita.ai/openai/v1/models" }), nscale: openAi("https://inference.api.nscale.com/v1", "https://console.nscale.com", { verification: "official" }), nvidia: openAi("https://integrate.api.nvidia.com/v1", "https://build.nvidia.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.api.nvidia.com/nim/reference/llm-apis" }), @@ -155,7 +155,8 @@ const LABELS: Record = { glm: "Z.AI GLM", "glm-cn": "BigModel GLM (CN)", "kilo-gateway": "Kilo Gateway", "opencode-zen": "OpenCode Zen", sealion: "SEA-LION", bytez: "Bytez", "nous-research": "Nous Research", agentrouter: "AgentRouter", ai21: "AI21", baichuan: "Baichuan", deepinfra: "DeepInfra", deepseek: "DeepSeek", doubao: "Doubao", - "freemodel-dev": "FreeModel.dev", nebius: "Nebius", novita: "Novita", nscale: "Nscale", nvidia: "NVIDIA NIM", + "freemodel-dev": "FreeModel.dev", sambanova: "SambaNova Cloud", nebius: "Nebius Token Factory", + novita: "Novita", nscale: "Nscale", nvidia: "NVIDIA NIM", publicai: "PublicAI", qoder: "Qoder", sensenova: "SenseNova", stepfun: "StepFun", vertex: "Google Vertex AI", }; diff --git a/src/providers/model-discovery.ts b/src/providers/model-discovery.ts index dc679315d5..d2bb5bbd8a 100644 --- a/src/providers/model-discovery.ts +++ b/src/providers/model-discovery.ts @@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types"; import { getProviderRegistryEntry, providerMatchesRegistryTransport, + registryEntryForProviderDestination, type ProviderModelDiscoveryFilter, type ProviderModelDiscoveryPredicate, type ProviderModelDiscoveryScalar, @@ -124,9 +125,14 @@ export function resolveProviderModelDiscovery( providerName: string, provider: Pick & Partial>, ): ResolvedProviderModelDiscovery { - const entry = providerMatchesRegistryTransport(providerName, provider) - ? getProviderRegistryEntry(providerName) - : undefined; + // The dashboard permits a canonical preset to be saved under a different name. Recover its + // registry-owned discovery policy by transport in that case. The destination helper is limited + // to exact fixed-key baseUrl + adapter matches, so custom endpoints, OAuth rows, templates, and + // overridable destinations cannot acquire another provider's discovery URL or filter. + const namedEntry = getProviderRegistryEntry(providerName); + const entry = namedEntry + ? (providerMatchesRegistryTransport(providerName, provider) ? namedEntry : undefined) + : registryEntryForProviderDestination(provider); const spec = entry?.modelDiscovery; return { ...(spec ? { spec } : {}), diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 4cec83507c..b8b95972aa 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1420,8 +1420,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ maxResponseBytes: 512 * 1024, maxModels: 512, filter: { - // Token Factory's verbose catalog also contains embeddings and image generation. - // Keep text- and vision-input rows whose output modality is text. + // Keep rows whose reported architecture output includes text (for example, + // text->text or text+image->text); embedding and image-generation rows are excluded. allOf: [{ path: ["architecture", "modality"], containsAny: ["->text"] }], }, }, diff --git a/tests/provider-model-discovery-contract.test.ts b/tests/provider-model-discovery-contract.test.ts index ed48d66c24..105f1adb2b 100644 --- a/tests/provider-model-discovery-contract.test.ts +++ b/tests/provider-model-discovery-contract.test.ts @@ -13,7 +13,11 @@ import { readBoundedDiscoveryJson, resolveProviderModelDiscovery, } from "../src/providers/model-discovery"; -import { PROVIDER_REGISTRY, type ProviderModelDiscoverySpec } from "../src/providers/registry"; +import { + PROVIDER_REGISTRY, + registryEntryForProviderDestination, + type ProviderModelDiscoverySpec, +} from "../src/providers/registry"; import { routeModel } from "../src/router"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; @@ -104,6 +108,18 @@ describe("registry-owned provider model discovery", () => { } }); + test("keeps discovery-bearing fixed key destinations unambiguous for renamed presets", () => { + for (const entry of PROVIDER_REGISTRY) { + if (!entry.modelDiscovery || entry.authKind !== "key") continue; + if (entry.allowBaseUrlOverride || /\{[^}]*\}/.test(entry.baseUrl)) continue; + expect(registryEntryForProviderDestination({ + adapter: entry.adapter, + baseUrl: entry.baseUrl, + authMode: "key", + })?.id).toBe(entry.id); + } + }); + test("derives an alternate path and query only for the canonical destination", async () => { await withTogetherDiscovery({ path: "catalog", @@ -112,6 +128,14 @@ describe("registry-owned provider model discovery", () => { const canonical = buildModelsRequest(togetherConfig().providers.together!, "secret", "together"); expect(canonical.url).toBe("https://api.together.xyz/v1/catalog?capability=chat&limit=100"); + const renamedCanonical = buildModelsRequest( + togetherConfig().providers.together!, + "secret", + "together-team", + ); + expect(renamedCanonical.url) + .toBe("https://api.together.xyz/v1/catalog?capability=chat&limit=100"); + const collidingCustom: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://custom.example/v9", diff --git a/tests/sambanova-nebius-provider.test.ts b/tests/sambanova-nebius-provider.test.ts index 986cf01c45..4193e7332c 100644 --- a/tests/sambanova-nebius-provider.test.ts +++ b/tests/sambanova-nebius-provider.test.ts @@ -12,6 +12,8 @@ import { deriveProviderPresets, providerConfigSeed, } from "../src/providers/derive"; +import { FREE_PROVIDER_DIRECTORY } from "../src/providers/free-directory"; +import { resolveProviderModelDiscovery } from "../src/providers/model-discovery"; import { PROVIDER_REGISTRY, type ProviderRegistryEntry } from "../src/providers/registry"; import { routedSlug } from "../src/providers/slug-codec"; import { routeModel } from "../src/router"; @@ -51,6 +53,7 @@ afterEach(() => { globalThis.fetch = originalFetch; clearModelCache("sambanova"); clearModelCache("nebius"); + clearModelCache("nebius-team"); }); function registryEntry(id: ProviderId): ProviderRegistryEntry { @@ -90,7 +93,9 @@ describe("SambaNova and Nebius providers", () => { dashboardUrl: PROVIDERS.sambanova.dashboardUrl, liveModels: true, preserveCustomDestination: true, + apiKeyValidation: "unknown", parallelToolCalls: false, + reasoningEfforts: [], modelDiscovery: { path: "models", maxResponseBytes: 131_072, @@ -109,6 +114,7 @@ describe("SambaNova and Nebius providers", () => { liveModels: true, preserveCustomDestination: true, parallelToolCalls: false, + reasoningEfforts: [], modelDiscovery: { path: "models", query: { verbose: "true" }, @@ -120,6 +126,16 @@ describe("SambaNova and Nebius providers", () => { }, }); expect(registryEntry("nebius").note).toContain("embedding and image-generation rows"); + expect(FREE_PROVIDER_DIRECTORY.find(row => row.id === "sambanova")).toMatchObject({ + label: "SambaNova Cloud", + lastVerified: "2026-08-02", + modelsUrl: PROVIDERS.sambanova.modelsUrl, + }); + expect(FREE_PROVIDER_DIRECTORY.find(row => row.id === "nebius")).toMatchObject({ + label: "Nebius Token Factory", + lastVerified: "2026-08-02", + modelsUrl: PROVIDERS.nebius.modelsUrl, + }); }); test("derives CLI and dashboard presets without persisting registry trust policy", () => { @@ -133,6 +149,7 @@ describe("SambaNova and Nebius providers", () => { baseUrl: provider.baseUrl, dashboardUrl: provider.dashboardUrl, liveModels: true, + ...(id === "sambanova" ? { apiKeyValidation: "unknown" } : {}), }); expect(buildInitProviders().find(row => row.id === id)).toMatchObject({ kind: "key", @@ -151,7 +168,9 @@ describe("SambaNova and Nebius providers", () => { authMode: "key", liveModels: true, parallelToolCalls: false, + reasoningEfforts: [], }); + expect(seed).not.toHaveProperty("apiKeyValidation"); expect(seed).not.toHaveProperty("modelDiscovery"); expect(seed).not.toHaveProperty("preserveCustomDestination"); expect(KEY_LOGIN_PROVIDERS[id]).not.toHaveProperty("modelDiscovery"); @@ -159,26 +178,42 @@ describe("SambaNova and Nebius providers", () => { } }); - test("lists and validates models through each registry-owned endpoint", async () => { + test("builds each registry-owned models request and validates the authenticated Nebius catalog", async () => { for (const id of ["sambanova", "nebius"] as const) { const provider = PROVIDERS[id]; expect(buildModelsRequest(providerConfig(id).providers[id]!, provider.key, id)).toEqual({ url: provider.modelsUrl, headers: { Authorization: `Bearer ${provider.key}` }, }); + } - globalThis.fetch = (async (input, init) => { - expect(String(input)).toBe(provider.modelsUrl); - expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${provider.key}`); - expect(init?.redirect).toBe("error"); - return new Response(provider.fixture, { - status: 200, - headers: { "content-type": "application/json" }, - }); - }) as typeof fetch; + const provider = PROVIDERS.nebius; + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe(provider.modelsUrl); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${provider.key}`); + expect(init?.redirect).toBe("error"); + return new Response(provider.fixture, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; - expect(await validateApiKey(id, KEY_LOGIN_PROVIDERS[id]!, provider.key)).toBe(true); - } + expect(await validateApiKey("nebius", KEY_LOGIN_PROVIDERS.nebius!, provider.key)).toBe(true); + }); + + test("does not treat SambaNova's public model catalog as proof that a key is valid", async () => { + let fetchCalled = false; + globalThis.fetch = (async () => { + fetchCalled = true; + return new Response(SAMBANOVA_FIXTURE, { status: 200 }); + }) as typeof fetch; + + expect(await validateApiKey( + "sambanova", + KEY_LOGIN_PROVIDERS.sambanova!, + PROVIDERS.sambanova.key, + )).toBe("unknown"); + expect(fetchCalled).toBe(false); }); test("filters Nebius mixed rows and preserves model metadata and native ids", async () => { @@ -220,6 +255,7 @@ describe("SambaNova and Nebius providers", () => { expect(sambanovaModels[1]).toMatchObject({ owned_by: "sambanova", contextWindow: 131_072, + reasoningEfforts: [], }); expect(nebiusModels.map(row => row.id)).toEqual([ "meta-llama/Meta-Llama-3.1-8B-Instruct-fast", @@ -230,6 +266,7 @@ describe("SambaNova and Nebius providers", () => { contextWindow: 131_072, inputModalities: ["text"], capabilities: ["function-calling", "json-mode"], + reasoningEfforts: [], }); expect(nebiusModels[1]).toMatchObject({ contextWindow: 262_144, @@ -246,6 +283,33 @@ describe("SambaNova and Nebius providers", () => { } }); + test("keeps Nebius query and text-output filtering when the preset is renamed", async () => { + const provider = PROVIDERS.nebius; + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe(provider.modelsUrl); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${provider.key}`); + expect(init?.redirect).toBe("manual"); + return new Response(provider.fixture, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const renamed = "nebius-team"; + const config = withStubbedProviderFetch({ + port: 10100, + defaultProvider: renamed, + providers: { + [renamed]: providerConfig("nebius").providers.nebius!, + }, + } satisfies OcxConfig); + const models = await gatherRoutedModels(config); + expect(models.filter(row => row.provider === renamed).map(row => row.id)).toEqual([ + "meta-llama/Meta-Llama-3.1-8B-Instruct-fast", + "Qwen/Qwen3-VL-235B-A22B-Instruct", + ]); + }); + test("routes tool requests to the fixed hosts without claiming parallel tool calls", () => { const cases = [ ["sambanova", "Meta-Llama-3.3-70B-Instruct"], @@ -265,7 +329,7 @@ describe("SambaNova and Nebius providers", () => { }], }, stream: true, - options: {}, + options: { reasoning: "high" }, }); const body = JSON.parse(String(request.body)) as Record; @@ -273,6 +337,7 @@ describe("SambaNova and Nebius providers", () => { expect(request.headers.Authorization).toBe(`Bearer ${PROVIDERS[providerId].key}`); expect(body.model).toBe(modelId); expect(body.parallel_tool_calls).toBe(false); + expect(body).not.toHaveProperty("reasoning_effort"); } }); @@ -300,5 +365,14 @@ describe("SambaNova and Nebius providers", () => { authMode: "key", }); } + + const crossPreset = providerConfig("sambanova", { + baseUrl: PROVIDERS.nebius.baseUrl, + }).providers.sambanova!; + expect(resolveProviderModelDiscovery("sambanova", crossPreset).spec).toBeUndefined(); + expect(buildModelsRequest(crossPreset, "custom-key", "sambanova")).toEqual({ + url: `${PROVIDERS.nebius.baseUrl}/models`, + headers: { Authorization: "Bearer custom-key" }, + }); }); }); From 4c9c64b184a8d8344eb59e14e94fb7646d577a01 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 6 Aug 2026 19:08:20 +0900 Subject: [PATCH 3/3] test(providers): pin the renamed-preset destination-fallback boundary The destination fallback added here lets a canonical preset saved under an unknown name recover its registry-owned discovery policy by transport. The existing coverage proved only that a renamed row picks up a path and query; it never asserted the filter, and it had no negative cases at all. Adds five tests, each driven red once against a real sabotage: - recovers path, query AND filter (literal expectations, not a re-read of the same registry row \u2014 the first formulation compared the spec against itself and stayed green when the filter was replaced) - refuses a name that matches a registry entry whose transport does not - refuses OAuth destinations reached by an unknown name - refuses non-key auth modes, templated base URLs, and overridable destinations - keeps every fallback-eligible absolute discovery URL same-origin with its own base URL The last one closes a real gap: an absolute spec.url overrides the configured base, so a cross-origin one on a fallback-eligible row would send a user's key to an origin they never configured. DeepInfra is the current instance (base /v1/openai, discovery /v1/models, same origin) and the test pins that invariant. Also corrects the OAuth narrative in all five locales: it said six presets plus Copilot while the registry has eight OAuth rows, and the command list omitted ocx login command-code. Pre-existing on dev, corrected here because this change rewrites the surrounding preset counts. --- .../src/content/docs/guides/providers.md | 3 +- .../src/content/docs/ja/guides/providers.md | 3 +- .../src/content/docs/ko/guides/providers.md | 3 +- .../src/content/docs/ru/guides/providers.md | 3 +- .../content/docs/zh-cn/guides/providers.md | 3 +- .../provider-model-discovery-contract.test.ts | 110 ++++++++++++++++++ 6 files changed, 120 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index b8d5e4cf1c..6e91356f95 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -89,7 +89,7 @@ The ChatGPT passthrough catalog also layers in the bare GPT-5.6 Sol/Terra/Luna s ## 2. Account login (OAuth) -Six provider presets use OAuth login — plus GitHub Copilot via an experimental unofficial +Seven provider presets use OAuth login — plus GitHub Copilot via an experimental unofficial device-flow bridge. opencodex stores their credentials in `~/.opencodex/auth.json` and refreshes them automatically. `chatgpt` is also accepted by the login CLI; it acquires a ChatGPT credential while creating a `forward`-mode provider entry. @@ -101,6 +101,7 @@ ocx login kimi # Moonshot Kimi ocx login kiro # import kiro-cli credentials (or token fallback) ocx login google-antigravity ocx login cursor # standalone Cursor PKCE login +ocx login command-code # Command Code browser OAuth (or import ~/.commandcode/auth.json) ocx login github-copilot # GitHub device flow → Copilot token (Copilot Pro/Business) ocx login chatgpt # standalone ChatGPT OAuth login ocx logout diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 7c86724377..426b744478 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -84,7 +84,7 @@ ChatGPT パススルーカタログには GPT-5.6 Sol/Terra/Luna の名前空間 ## 2. アカウントログイン(OAuth) -OAuth ログインを使うプロバイダープリセットは 6 つで、これに実験的な非公式デバイスフロー +OAuth ログインを使うプロバイダープリセットは 7 つで、これに実験的な非公式デバイスフロー ブリッジ経由の GitHub Copilot が加わります。認証情報は `~/.opencodex/auth.json` に保存され、 自動更新されます。ログイン CLI は `chatgpt` も受け付けます。このコマンドは ChatGPT 認証情報を 発行し `forward` モードのプロバイダーエントリを作成します。 @@ -96,6 +96,7 @@ ocx login kimi # Moonshot Kimi ocx login kiro # kiro-cli 認証情報の取り込み(トークンフォールバック対応) ocx login google-antigravity ocx login cursor # Cursor 専用 PKCE ログイン +ocx login command-code # Command Code のブラウザ OAuth (または ~/.commandcode/auth.json を取り込み) ocx login github-copilot # GitHub デバイスフロー → Copilot トークン (Copilot Pro/Business) ocx login chatgpt # 別途 ChatGPT OAuth ログイン ocx logout diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 7457cb1fdd..8f68dd1594 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -83,7 +83,7 @@ ChatGPT 패스스루 카탈로그에는 GPT-5.6 Sol/Terra/Luna의 네임스페 ## 2. 계정 로그인 (OAuth) -OAuth 로그인을 사용하는 프로바이더 프리셋은 여섯 개이며, 여기에 실험적 비공식 디바이스 플로우 +OAuth 로그인을 사용하는 프로바이더 프리셋은 일곱 개이며, 여기에 실험적 비공식 디바이스 플로우 브리지를 쓰는 GitHub Copilot이 추가됩니다. 자격 증명은 `~/.opencodex/auth.json`에 저장되고 자동으로 갱신됩니다. 로그인 CLI는 `chatgpt`도 받습니다. 이 명령은 ChatGPT 자격 증명을 발급받고 `forward` 모드 프로바이더 항목을 만듭니다. @@ -95,6 +95,7 @@ ocx login kimi # Moonshot Kimi ocx login kiro # kiro-cli 자격 증명 가져오기(토큰 폴백 지원) ocx login google-antigravity ocx login cursor # Cursor 전용 PKCE 로그인 +ocx login command-code # Command Code 브라우저 OAuth (또는 ~/.commandcode/auth.json 가져오기) ocx login github-copilot # GitHub 디바이스 플로우 → Copilot 토큰 (Copilot Pro/Business) ocx login chatgpt # 별도 ChatGPT OAuth 로그인 ocx logout diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 7b9c911563..69c7a49c9a 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -93,7 +93,7 @@ account id, OpenAI beta/originator/session — см. [Адаптеры](/ru/refe ## 2. Вход по аккаунту (OAuth) -Шесть пресетов провайдеров используют вход через OAuth — плюс GitHub Copilot через +Семь пресетов провайдеров используют вход через OAuth — плюс GitHub Copilot через экспериментальный неофициальный мост device flow. opencodex хранит их учётные данные в `~/.opencodex/auth.json` и обновляет их автоматически. CLI входа также принимает `chatgpt`: эта команда получает учётные данные ChatGPT и одновременно создаёт запись провайдера в режиме `forward`. @@ -105,6 +105,7 @@ ocx login kimi # Moonshot Kimi ocx login kiro # импорт учётных данных kiro-cli (с фолбэком на токен) ocx login google-antigravity ocx login cursor # отдельный PKCE-вход Cursor +ocx login command-code # браузерный OAuth Command Code (или импорт ~/.commandcode/auth.json) ocx login github-copilot # device flow GitHub → токен Copilot (Copilot Pro/Business) ocx login chatgpt # отдельный OAuth-вход ChatGPT ocx logout 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 6d75353037..4a3304803c 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -75,7 +75,7 @@ ChatGPT 透传目录也会加入 GPT-5.6 Sol/Terra/Luna 的裸 slug(`gpt-5.6-s ## 2. 账号登录(OAuth) -有六个提供商预设使用 OAuth 登录,另加通过实验性非官方设备流桥接的 GitHub Copilot。 +有七个提供商预设使用 OAuth 登录,另加通过实验性非官方设备流桥接的 GitHub Copilot。 opencodex 会把凭据存入 `~/.opencodex/auth.json` 并自动刷新。登录 CLI 也接受 `chatgpt`: 它会获取一份 ChatGPT 凭据,并创建一个 `forward` 模式的提供商条目。 @@ -86,6 +86,7 @@ ocx login kimi # Moonshot Kimi ocx login kiro # 导入 kiro-cli 凭据(支持令牌回退) ocx login google-antigravity ocx login cursor # 独立的 Cursor PKCE 登录 +ocx login command-code # Command Code 浏览器 OAuth(或导入 ~/.commandcode/auth.json) ocx login github-copilot # GitHub 设备流 → Copilot 令牌(Copilot Pro/Business) ocx login chatgpt # 独立的 ChatGPT OAuth 登录 ocx logout diff --git a/tests/provider-model-discovery-contract.test.ts b/tests/provider-model-discovery-contract.test.ts index 105f1adb2b..fc1c4e8f4d 100644 --- a/tests/provider-model-discovery-contract.test.ts +++ b/tests/provider-model-discovery-contract.test.ts @@ -411,4 +411,114 @@ describe("same-named custom provider preservation", () => { expect(routed.provider.baseUrl).toBe("https://api.x.ai/v1"); expect(routed.provider.authMode).toBe("oauth"); }); + + // The destination fallback added with the SambaNova/Nebius batch lets a canonical preset saved + // under an unknown name recover its registry-owned discovery policy by transport. These tests + // pin both halves of that boundary: what it must recover, and what it must refuse. Without the + // negative cases a widened matcher would look green while silently handing one provider's + // discovery contract to another row. + describe("renamed-preset destination fallback", () => { + const nebiusEntry = () => { + const entry = PROVIDER_REGISTRY.find(row => row.id === "nebius"); + if (!entry) throw new Error("missing nebius registry entry"); + return entry; + }; + + test("recovers path, query AND filter for an unknown-name canonical destination", () => { + const entry = nebiusEntry(); + const resolved = resolveProviderModelDiscovery("nebius-team", { + adapter: entry.adapter, + baseUrl: entry.baseUrl, + authMode: "key", + }); + + // Literal expectations, not a re-read of the same registry row. Comparing the resolved spec + // against `entry.modelDiscovery` would pass even if both sides changed together, which makes + // the assertion vacuous: a sabotaged filter stayed green under that formulation. + expect(resolved.spec?.path).toBe("models"); + expect(resolved.spec?.query).toEqual({ verbose: "true" }); + // The filter is the half the pre-existing renamed-preset test never asserted. A recovered + // spec without it would admit embedding and image-generation rows into the Codex catalog. + expect(resolved.spec?.filter).toEqual({ + allOf: [{ path: ["architecture", "modality"], containsAny: ["->text"] }], + }); + expect(resolved.maxResponseBytes).toBe(512 * 1024); + expect(resolved.maxModels).toBe(512); + }); + + test("refuses a name that matches a registry entry whose transport does not", () => { + // A named row is resolved by name or not at all; it must never silently fall through to a + // destination lookup and acquire some other provider's discovery policy. + const resolved = resolveProviderModelDiscovery("nebius", { + adapter: "openai-chat", + baseUrl: "https://untrusted.example/v9", + authMode: "key", + }); + + expect(resolved.spec).toBeUndefined(); + }); + + test("refuses OAuth destinations reached by an unknown name", () => { + const oauthEntry = PROVIDER_REGISTRY.find(row => row.authKind === "oauth" && row.modelDiscovery); + expect(oauthEntry).toBeDefined(); + + expect(registryEntryForProviderDestination({ + adapter: oauthEntry!.adapter, + baseUrl: oauthEntry!.baseUrl, + authMode: "key", + })?.id).not.toBe(oauthEntry!.id); + + expect(resolveProviderModelDiscovery("renamed-oauth-row", { + adapter: oauthEntry!.adapter, + baseUrl: oauthEntry!.baseUrl, + authMode: "oauth", + }).spec).toBeUndefined(); + }); + + test("refuses non-key auth modes, templated base URLs, and overridable destinations", () => { + const entry = nebiusEntry(); + + // Non-key auth mode on an otherwise exact destination match. + expect(registryEntryForProviderDestination({ + adapter: entry.adapter, + baseUrl: entry.baseUrl, + authMode: "oauth", + })).toBeUndefined(); + + for (const row of PROVIDER_REGISTRY) { + const templated = /\{[^}]*\}/.test(row.baseUrl); + if (!templated && row.allowBaseUrlOverride !== true) continue; + // Neither class identifies a single vendor route, so neither may be recovered by + // destination: a templated URL is not a real endpoint, and an overridable one is + // whatever the user pointed it at. + const match = registryEntryForProviderDestination({ + adapter: row.adapter, + baseUrl: row.baseUrl, + authMode: "key", + }); + expect(match?.id).not.toBe(row.id); + } + }); + + test("keeps every fallback-eligible absolute discovery URL same-origin with its own base URL", () => { + // An absolute spec.url overrides the configured base URL, so a cross-origin one on a + // fallback-eligible row would send a user's key to an origin they never configured. + // DeepInfra is the current instance: base /v1/openai, discovery /v1/models, same origin. + const checked: string[] = []; + + for (const entry of PROVIDER_REGISTRY) { + const url = entry.modelDiscovery?.url; + if (!url) continue; + const eligible = entry.authKind === "key" + && entry.allowBaseUrlOverride !== true + && !/\{[^}]*\}/.test(entry.baseUrl); + if (!eligible) continue; + + expect(new URL(url).origin).toBe(new URL(entry.baseUrl).origin); + checked.push(entry.id); + } + + expect(checked).toContain("deepinfra"); + }); + }); });