diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index b4a9573aec3..fd372abf865 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 69 built-in presets: 58 key-based, seven OAuth, three local, and one default +opencodex ships 70 built-in presets: 59 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; validation is provider-specific, and Command Code's public catalog reports keys as unverifiable. Notable entries: @@ -249,6 +249,7 @@ free-experimentation model. | MiniMax · MiniMax (CN) | `https://api.minimax.io/v1` · `https://api.minimaxi.com/v1` | | DeepSeek | `https://api.deepseek.com` | | Cerebras | `https://api.cerebras.ai/v1` | +| Apertis | `https://api.apertis.ai/v1` | | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | @@ -270,6 +271,12 @@ free-experimentation model. | Cloudflare AI Gateway | `https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic` | | …and more | opencode zen, Vercel AI Gateway, Venice, NanoGPT, Synthetic, Qianfan, Alibaba, Parallel, ZenMux, LiteLLM | +**Apertis discovery.** Apertis exposes an OpenAI-compatible API at +[`https://api.apertis.ai/v1`](https://docs.apertis.ai/api/), uses Bearer API keys, and returns a +key/plan-scoped live catalog from [`GET /v1/models`](https://docs.apertis.ai/api/utilities/models/). +Do not freeze a model id from the catalog; availability can vary by key type and plan. Apertis's +public site identifies the operator as STIMA AI LLC and publishes its [Terms of Service](https://apertis.ai/terms). + Most use the `openai-chat` adapter with a bearer key; a few that expose only an Anthropic-compatible endpoint (e.g. **Xiaomi MiMo**) use the `anthropic` adapter (`x-api-key`). Volcengine Agent Plan uses its native Responses endpoint through `openai-responses`. diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 48f04235c9f..4f792700164 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 には組み込みプリセットが 69 個含まれています。キー方式 58、OAuth 7、ローカル 3、 +opencodex には組み込みプリセットが 70 個含まれています。キー方式 59、OAuth 7、ローカル 3、 デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 入力したキーを検証した後保存します(検証はプロバイダー固有で、Command Code の公開カタログはキーを 検証不能として報告します)。主な項目は以下のとおりです: @@ -176,6 +176,7 @@ Cline IDE/CLI のみで API からは使えません。`minimax/minimax-m2.5` | MiniMax · MiniMax (CN) | `https://api.minimax.io/v1` · `https://api.minimaxi.com/v1` | | DeepSeek | `https://api.deepseek.com` | | Cerebras | `https://api.cerebras.ai/v1` | +| Apertis | `https://api.apertis.ai/v1` | | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index ae29734994e..a8f0204bc36 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에는 빌트인 프리셋이 69개 들어 있습니다. 키 방식 58개, OAuth 7개, 로컬 3개, +opencodex에는 빌트인 프리셋이 70개 들어 있습니다. 키 방식 59개, OAuth 7개, 로컬 3개, 기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, 입력한 키를 검증한 뒤 저장합니다(검증은 프로바이더별로 다르며, Command Code의 공개 카탈로그는 키를 검증 불가로 보고합니다). 주요 항목은 다음과 같습니다: @@ -176,6 +176,7 @@ Cline IDE/CLI에서만 제공되며 API로는 사용할 수 없습니다. `minim | MiniMax · MiniMax (CN) | `https://api.minimax.io/v1` · `https://api.minimaxi.com/v1` | | DeepSeek | `https://api.deepseek.com` | | Cerebras | `https://api.cerebras.ai/v1` | +| Apertis | `https://api.apertis.ai/v1` | | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 4f912949a6e..ee923c3e83d 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 поставляется с 69 встроенными пресетами: 58 на основе ключей, семь OAuth, три локальных и +opencodex поставляется с 70 встроенными пресетами: 59 на основе ключей, семь OAuth, три локальных и один пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу выдачи ключей провайдера, проверяет ключ и сохраняет его; проверка зависит от провайдера, а публичный каталог Command Code сообщает ключ как непроверенный. Наиболее заметные записи: @@ -186,6 +186,7 @@ opencodex поставляется с 69 встроенными пресетам | MiniMax · MiniMax (CN) | `https://api.minimax.io/v1` · `https://api.minimaxi.com/v1` | | DeepSeek | `https://api.deepseek.com` | | Cerebras | `https://api.cerebras.ai/v1` | +| Apertis | `https://api.apertis.ai/v1` | | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | 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 e8ce7f5d105..1a5d6887513 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 内置 69 个预设:58 个密钥预设、7 个 OAuth 预设、3 个本地预设,以及 1 个默认的 +opencodex 内置 70 个预设:59 个密钥预设、7 个 OAuth 预设、3 个本地预设,以及 1 个默认的 ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。 验证因提供商而异,Command Code 的公开目录会将密钥报告为无法验证。主要条目包括: @@ -163,6 +163,7 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 | MiniMax · MiniMax (CN) | `https://api.minimax.io/v1` · `https://api.minimaxi.com/v1` | | DeepSeek | `https://api.deepseek.com` | | Cerebras | `https://api.cerebras.ai/v1` | +| Apertis | `https://api.apertis.ai/v1` | | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | diff --git a/gui/package.json b/gui/package.json index 1556402dc37..0139de1563a 100644 --- a/gui/package.json +++ b/gui/package.json @@ -6,11 +6,11 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "lint": "eslint .", + "lint": "bun --bun eslint .", "test": "bun test tests", - "lint:i18n": "eslint src/pages src/components src/App.tsx src/ui.tsx", - "doctor": "npx --yes react-doctor@0.9.3 --verbose --scope changed --base origin/main --no-telemetry", - "doctor:full": "npx --yes react-doctor@0.9.3 --verbose --scope full --no-telemetry", + "lint:i18n": "bun --bun eslint src/pages src/components src/App.tsx src/ui.tsx", + "doctor": "npm exec --yes --package=typescript@6.0.2 --package=react-doctor@0.9.3 -- react-doctor --verbose --scope changed --base origin/main --no-telemetry", + "doctor:full": "npm exec --yes --package=typescript@6.0.2 --package=react-doctor@0.9.3 -- react-doctor --verbose --scope full --no-telemetry", "preview": "vite preview" }, "dependencies": { diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index c0e1d0677cf..3c33ff52fb8 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -71,6 +71,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { xiaomi: "Xiaomi", cursor: "Cursor", deepseek: "DeepSeek", + apertis: "Apertis", github: "GitHub", "github-copilot": "GitHub Copilot", "gitlab-duo": "GitLab Duo", diff --git a/openspec/changes/apertis-reference-directory/.openspec.yaml b/openspec/changes/apertis-reference-directory/.openspec.yaml new file mode 100644 index 00000000000..e08b5f89a2a --- /dev/null +++ b/openspec/changes/apertis-reference-directory/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-03 diff --git a/openspec/changes/apertis-reference-directory/README.md b/openspec/changes/apertis-reference-directory/README.md new file mode 100644 index 00000000000..3d7dd8d9436 --- /dev/null +++ b/openspec/changes/apertis-reference-directory/README.md @@ -0,0 +1,4 @@ +# apertis-reference-directory + +Restore Apertis as a canonical OpenAI-compatible API-key provider with authenticated live model +discovery, while keeping the maintainer evidence and security-review boundary explicit. diff --git a/openspec/changes/apertis-reference-directory/design.md b/openspec/changes/apertis-reference-directory/design.md new file mode 100644 index 00000000000..5bf7e5a39f5 --- /dev/null +++ b/openspec/changes/apertis-reference-directory/design.md @@ -0,0 +1,84 @@ +## Context + +Canonical registry entries seed provider configuration, expose API-key setup, enrich saved +providers, and feed routing and the shared Codex catalog. Apertis's official API documentation +currently describes the OpenAI-compatible base URL `https://api.apertis.ai/v1`, Bearer +authentication, and an authenticated `/v1/models` endpoint whose result depends on the API key +type/plan. Apertis's public website identifies STIMA AI LLC as the operator and describes +multi-provider routing. + +The upstream repository requires additional private routing/resale authorization and maintainer +security review before a credential-destination preset can merge. This change therefore implements +the requested canonical behavior and records that external gate honestly; it does not infer private +authorization from public product or API documentation. + +## Goals / Non-Goals + +**Goals:** + +- Register Apertis as a canonical `authKind: "key"`, `adapter: "openai-chat"` provider. +- Seed the documented fixed base URL and dashboard URL into CLI/key-login/dashboard flows. +- Validate keys and discover model ids through the authenticated OpenAI-shaped `/models` path. +- Route chat-completion requests to the canonical host while preserving same-named custom + providers' own adapter, destination, and credentials. +- Keep model availability live and key-scoped rather than encoding a speculative static catalog. +- Keep public evidence links and the private maintainer authorization gate separate. + +**Non-Goals:** + +- Add a new adapter, Responses wire, image/audio integration, or provider-specific request + semantics beyond the existing OpenAI Chat adapter. +- Freeze model ids, context windows, prices, or a default model from the mutable live catalog. +- Claim that public endpoint/terms/legal-entity evidence proves private routing/resale authorization. +- Send credentials, post maintainer messages, push the branch, merge, or deploy as part of local + implementation. + +## Decisions + +- **Extend the existing registry-derived provider flow.** This keeps CLI init, key-login, + dashboard presets, routing, and catalog derivation on one source of truth instead of adding an + Apertis-specific path. +- **Use live discovery with the default `/models` resolution.** The configured base URL already + ends in `/v1`, so the existing discovery and key-validation flow produces `/v1/models`, sends a + Bearer key, and applies the repository's bounded response/row guards. A registry-specific + discovery policy is unnecessary for the documented endpoint. +- **Set `preserveCustomDestination: true`.** A saved provider named `apertis` with a custom + adapter/base URL must not be silently retargeted when the canonical preset is introduced. +- **Do not add static models or a default model.** Official documentation says availability varies + by key type and plan; live discovery is the faithful contract and avoids stale claims. +- **Use the existing GUI display-name classification without inventing an icon asset.** The provider + remains discoverable and correctly labeled while the default unknown-provider icon fallback stays + intact. +- **Keep the evidence gate outside runtime behavior.** Public API/terms/legal-entity links are + recorded in source/docs; private routing/resale authorization remains a maintainer review input. +- **Keep GUI validation runtimes explicit.** ESLint continues to use Bun's runtime bridge, while + React Doctor uses npm exec with pinned TypeScript and React Doctor packages so Bun cannot omit + React Doctor's TypeScript peer. + +## Risks / Trade-offs + +- [A user key and traffic reach a multi-provider gateway] → fixed-host routing, explicit public + provenance, `preserveCustomDestination`, and maintainer/security review are required before merge. +- [The live catalog changes by key/plan] → no static model list/default is claimed; discovery is + bounded and fixture-tested. +- [A same-named custom provider could be disabled in GUI classification] → the canonical entry's + destination-preservation contract and regression test keep custom adapter/base URL controls intact. +- [Public docs may be mistaken for private authorization] → the change records public evidence and + the unresolved routing/resale gate separately. +- [A GUI validation command could lose its local TypeScript peer under Bun] → ESLint scripts use + Bun's runtime bridge and React Doctor scripts use npm exec with explicit TypeScript and React + Doctor packages; direct external invocations remain outside this repository contract. + +## Migration Plan + +Fresh Apertis setup derives the canonical fixed destination. Existing saved `apertis` providers +retain their configured adapter, base URL, and key when they do not match the canonical transport. +Rolling back removes the preset surfaces but does not rewrite or delete existing provider config; +explicit custom routes remain user-owned. + +## Open Questions + +- Which maintainer-designated private channel will record the routing/resale authorization, named + maintenance owner, and verification date required for merge? +- After the current head is reviewed, does the maintainer require any provider-specific capability + exclusions beyond the existing `openai-chat` contract? diff --git a/openspec/changes/apertis-reference-directory/proposal.md b/openspec/changes/apertis-reference-directory/proposal.md new file mode 100644 index 00000000000..1ac53282b1a --- /dev/null +++ b/openspec/changes/apertis-reference-directory/proposal.md @@ -0,0 +1,46 @@ +## Why + +Apertis publishes an OpenAI-compatible API at `https://api.apertis.ai/v1`, uses Bearer API keys, +and exposes a key-scoped live `/v1/models` catalog. The current PR intentionally removed the +canonical entry and therefore no longer meets the requested outcome of adding Apertis as a model +provider. + +## What Changes + +- Restore Apertis as a canonical API-key provider in the registry-derived CLI, dashboard, key-login, + routing, and model-discovery surfaces. +- Use the documented fixed base URL and authenticated live model discovery without freezing a + mutable model list or claiming a default model. +- Preserve same-named custom providers' adapter, destination, and key boundary. +- Add focused coverage for derivation, Bearer validation, live discovery, chat routing, and custom + provider preservation. +- Sync the provider catalog documentation and translated tables/counts. +- Retain the existing Bun-based GUI lint runtime fix and make React Doctor's TypeScript peer + resolution explicit for Bun-launched validation. +- Keep the maintainer-required routing/resale authorization and security review as an explicit + pre-merge gate; public endpoint/terms/legal-entity evidence must not be presented as proof of + private authorization. + +## Capabilities + +### New Capabilities + +- `provider-canonical-integration`: expose Apertis as a canonical key provider with authenticated + live model discovery and fixed-host routing. +- `gui-lint-runtime`: execute GUI lint and React Doctor validation with runtimes that preserve + the repository's local TypeScript tooling. + +### Modified Capabilities + + + +## Impact + +- `src/providers/registry.ts`, registry derivation, key validation, GUI provider classification, + and provider parity gain the `apertis` canonical entry. +- `tests/apertis-provider.test.ts`, the discovery fixture, and registry parity tests prove the + provider contract without contacting the live service. +- English and translated `docs-site` provider guides list the new endpoint and updated counts. +- `gui/package.json` lint commands continue to use Bun's runtime bridge, while React Doctor + commands use npm's explicit package resolution instead of a Bun-translated `npx` launch. diff --git a/openspec/changes/apertis-reference-directory/specs/gui-lint-runtime/spec.md b/openspec/changes/apertis-reference-directory/specs/gui-lint-runtime/spec.md new file mode 100644 index 00000000000..b58dc2effe6 --- /dev/null +++ b/openspec/changes/apertis-reference-directory/specs/gui-lint-runtime/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: GUI lint scripts load the local TypeScript ESLint plugin +The GUI package's `lint` and `lint:i18n` scripts SHALL execute ESLint through Bun's runtime bridge +so that the checked-in ESLint configuration and its local TypeScript plugin load successfully. + +#### Scenario: Full GUI lint command +- **WHEN** a contributor runs the GUI `lint` script from the repository validation path +- **THEN** ESLint evaluates the configured GUI files without an unknown-TypeScript-extension error + +#### Scenario: Focused GUI i18n lint command +- **WHEN** a contributor runs the GUI `lint:i18n` script +- **THEN** ESLint evaluates the configured UI paths with the same local plugin available + +### Requirement: React Doctor scripts resolve their TypeScript peer explicitly +The GUI package's `doctor` and `doctor:full` scripts SHALL invoke React Doctor through npm +with the pinned TypeScript and React Doctor packages so that Bun's `npx` alias cannot omit the +TypeScript peer required by React Doctor. + +#### Scenario: Changed GUI React Doctor command +- **WHEN** a contributor runs the GUI `doctor` script from the repository validation path +- **THEN** React Doctor evaluates the changed GUI scope and resolves its TypeScript peer + successfully + +#### Scenario: Full GUI React Doctor command +- **WHEN** a contributor runs the GUI `doctor:full` script +- **THEN** React Doctor evaluates the full GUI scope with the same explicit TypeScript peer + resolution diff --git a/openspec/changes/apertis-reference-directory/specs/provider-canonical-integration/spec.md b/openspec/changes/apertis-reference-directory/specs/provider-canonical-integration/spec.md new file mode 100644 index 00000000000..9a8b0a8a4ba --- /dev/null +++ b/openspec/changes/apertis-reference-directory/specs/provider-canonical-integration/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Apertis is a canonical API-key provider +The provider registry SHALL expose Apertis as a canonical key provider with label `Apertis`, the +`openai-chat` adapter, base URL `https://api.apertis.ai/v1`, the documented dashboard URL, and live +model discovery enabled. The registry SHALL NOT freeze a static model list or default model for +the mutable key-scoped catalog. + +#### Scenario: Canonical setup surfaces derive Apertis +- **WHEN** CLI init, key-login, dashboard preset, and provider catalog surfaces are derived +- **THEN** each surface contains `apertis` with key authentication, the fixed base URL, and live discovery + +#### Scenario: Live catalog remains the source of available models +- **WHEN** a fresh Apertis provider is created without an explicit model list +- **THEN** the saved provider enables live discovery and does not claim a static model list or default model + +### Requirement: Apertis key validation and discovery use the authenticated models endpoint +The key-login flow SHALL call `GET https://api.apertis.ai/v1/models` with `Authorization: Bearer ` +and SHALL treat an OpenAI-shaped model list as the live catalog. A 401 or 403 response SHALL reject +the key; other non-success or transport failures SHALL remain unknown rather than becoming a false +positive. Discovery SHALL use the repository's bounded response, row, and model-id guards. + +#### Scenario: Valid key returns an OpenAI-shaped catalog +- **WHEN** the models endpoint returns a bounded `{ "object": "list", "data": [{ "id": "..." }] }` response +- **THEN** key validation succeeds and discovery exposes the returned model ids under `apertis/` + +#### Scenario: Unauthorized key is rejected +- **WHEN** the models endpoint returns HTTP 401 or 403 +- **THEN** key validation returns false and does not persist the key as valid + +#### Scenario: Uncertain upstream failure is not a false positive +- **WHEN** the models endpoint times out, returns a transport error, or returns another non-success status +- **THEN** key validation returns unknown and discovery does not cache an untrusted catalog + +### Requirement: Apertis routing preserves the configured custom boundary +Requests for canonical Apertis models SHALL use the `openai-chat` adapter, Bearer authentication, +and the canonical `/chat/completions` destination. A manually configured provider named `apertis` +whose adapter or destination differs from the canonical preset SHALL retain its own adapter, base URL, +model id, and key boundary. + +#### Scenario: Canonical chat request uses the Apertis host +- **WHEN** a request selects `apertis/` from a canonical provider config +- **THEN** the request targets `https://api.apertis.ai/v1/chat/completions` with the configured Bearer key and model id + +#### Scenario: Same-named custom provider is not retargeted +- **WHEN** a saved `apertis` provider has a custom adapter or base URL +- **THEN** routing and model discovery use that saved adapter/destination instead of the canonical Apertis host + +### Requirement: Canonical-provider evidence remains explicit and review-gated +The change record and PR SHALL distinguish public endpoint, API, terms, and legal-entity evidence from +the maintainer-required private routing/resale authorization. The change SHALL NOT claim verified, +merge-ready, merged, or deployed status until a maintainer records the required authorization, +maintenance owner, verification date, security review, and required CI approval. + +#### Scenario: Public evidence does not silently satisfy private authorization +- **WHEN** public Apertis documentation is present but maintainer routing/resale authorization is not recorded +- **THEN** the implementation may remain locally verified, but the PR remains explicitly not merge-ready diff --git a/openspec/changes/apertis-reference-directory/tasks.md b/openspec/changes/apertis-reference-directory/tasks.md new file mode 100644 index 00000000000..e82ffcb892a --- /dev/null +++ b/openspec/changes/apertis-reference-directory/tasks.md @@ -0,0 +1,25 @@ +## 1. Spec and evidence lock + +- [x] 1.1 Update the proposal/design/specs from reference-only to canonical provider behavior. +- [x] 1.2 Validate the change strictly and record the public evidence links plus unresolved private authorization gate. + +## 2. Canonical provider surfaces + +- [x] 2.1 Register Apertis in the canonical registry with fixed host, key auth, live discovery, and custom-destination preservation. +- [x] 2.2 Derive CLI/key-login/dashboard/GUI provider surfaces and update registry parity expectations. +- [x] 2.3 Keep Bun GUI lint and explicit npm-based React Doctor validation green. + +## 3. Regression proof + +- [x] 3.1 Add a fixture-backed authenticated `/models` validation and live-discovery test. +- [x] 3.2 Add canonical chat routing and same-named custom-provider preservation tests. + +## 4. User-facing documentation + +- [x] 4.1 Update the English and translated provider catalogs, endpoint table, counts, and Apertis discovery notes. +- [x] 4.2 Keep public evidence distinct from maintainer-only routing/resale authorization. + +## 5. Verification and handoff + +- [x] 5.1 Run focused tests, typecheck, full test suite, GUI lint/build, privacy scan, and diff checks. +- [x] 5.2 Inspect latest-base diff and prepare the PR-ready evidence/status handoff without pushing, messaging, merging, or deploying. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 00000000000..392946c67c0 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 91d3ad07ddf..9fddff9aed2 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1111,6 +1111,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ }, // llama-3.3-70b was deprecated by Cerebras on 2026-02-16. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "gpt-oss-120b" }, + // Public contract verified 2026-08-04 against https://docs.apertis.ai/api/, + // https://docs.apertis.ai/api/utilities/models/, https://apertis.ai/terms, and the Apertis + // product/legal pages. Private routing/resale authorization remains a maintainer review gate. + { + id: "apertis", + label: "Apertis", + baseUrl: "https://api.apertis.ai/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://apertis.ai/setting?tab=keys", + liveModels: true, + preserveCustomDestination: true, + note: "OpenAI-compatible multi-provider API; live model access is scoped to the API key's plan.", + }, { id: "deepinfra", label: "DeepInfra", diff --git a/tests/apertis-provider.test.ts b/tests/apertis-provider.test.ts new file mode 100644 index 00000000000..253f869773b --- /dev/null +++ b/tests/apertis-provider.test.ts @@ -0,0 +1,221 @@ +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 { routeModel } from "../src/router"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; +import { formatProviderDisplayName, isCatalogProviderId } from "../gui/src/provider-icons"; +import type { TFn } from "../gui/src/i18n/shared"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; + +const FIXTURE = readFileSync(join(import.meta.dir, "fixtures/apertis-models.json"), "utf8"); +const BASE_URL = "https://api.apertis.ai/v1"; +const API_KEY = "apertis-test-key"; +const identityT: TFn = key => key; +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache("apertis"); +}); + +function registryEntry(): ProviderRegistryEntry { + const entry = PROVIDER_REGISTRY.find(row => row.id === "apertis"); + if (!entry) throw new Error("missing apertis registry entry"); + return entry; +} + +function providerConfig(overrides: Partial = {}): OcxConfig { + return withStubbedProviderFetch({ + port: 10100, + defaultProvider: "apertis", + providers: { + apertis: { + adapter: "openai-chat", + baseUrl: BASE_URL, + authMode: "key", + apiKey: API_KEY, + liveModels: true, + // Discovery is fixture-only; this avoids platform-specific public-DNS classification. + allowPrivateNetwork: true, + ...overrides, + }, + }, + }); +} + +describe("Apertis provider", () => { + test("registers a fixed OpenAI transport with live discovery", () => { + expect(registryEntry()).toMatchObject({ + id: "apertis", + label: "Apertis", + adapter: "openai-chat", + baseUrl: BASE_URL, + authKind: "key", + dashboardUrl: "https://apertis.ai/setting?tab=keys", + liveModels: true, + preserveCustomDestination: true, + }); + expect(registryEntry()).not.toHaveProperty("models"); + expect(registryEntry()).not.toHaveProperty("defaultModel"); + expect(registryEntry()).not.toHaveProperty("modelDiscovery"); + }); + + test("derives CLI and dashboard presets without persisting registry trust policy", () => { + const entry = registryEntry(); + expect(buildInitProviders()).toEqual(deriveInitProviders()); + expect(KEY_LOGIN_PROVIDERS.apertis).toMatchObject({ + adapter: "openai-chat", + baseUrl: BASE_URL, + dashboardUrl: entry.dashboardUrl, + liveModels: true, + }); + expect(buildInitProviders().find(row => row.id === "apertis")).toMatchObject({ + kind: "key", + adapter: "openai-chat", + baseUrl: BASE_URL, + }); + expect(deriveProviderPresets().find(row => row.id === "apertis")).toMatchObject({ + auth: "key", + dashboardUrl: entry.dashboardUrl, + }); + + const seed = providerConfigSeed(entry); + expect(seed).toMatchObject({ + adapter: "openai-chat", + baseUrl: BASE_URL, + authMode: "key", + liveModels: true, + }); + expect(seed).not.toHaveProperty("models"); + expect(seed).not.toHaveProperty("defaultModel"); + expect(seed).not.toHaveProperty("modelDiscovery"); + expect(seed).not.toHaveProperty("preserveCustomDestination"); + expect(KEY_LOGIN_PROVIDERS.apertis).not.toHaveProperty("modelDiscovery"); + expect(KEY_LOGIN_PROVIDERS.apertis).not.toHaveProperty("preserveCustomDestination"); + expect(formatProviderDisplayName("apertis", identityT)).toBe("Apertis"); + expect(isCatalogProviderId("apertis")).toBe(true); + }); + + test("lists and validates models through the documented Bearer-authenticated endpoint", async () => { + expect(buildModelsRequest(providerConfig().providers.apertis!, API_KEY, "apertis")).toEqual({ + url: `${BASE_URL}/models`, + headers: { Authorization: `Bearer ${API_KEY}` }, + }); + + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe(`${BASE_URL}/models`); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${API_KEY}`); + expect(init?.redirect).toBe("error"); + return new Response(FIXTURE, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + expect(await validateApiKey("apertis", KEY_LOGIN_PROVIDERS.apertis!, API_KEY)).toBe(true); + }); + + test("rejects unauthorized keys without claiming validation", async () => { + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe(`${BASE_URL}/models`); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${API_KEY}`); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + + expect(await validateApiKey("apertis", KEY_LOGIN_PROVIDERS.apertis!, API_KEY)).toBe(false); + }); + + test("rejects forbidden keys without claiming validation", async () => { + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe(`${BASE_URL}/models`); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${API_KEY}`); + return new Response("forbidden", { status: 403 }); + }) as typeof fetch; + + expect(await validateApiKey("apertis", KEY_LOGIN_PROVIDERS.apertis!, API_KEY)).toBe(false); + }); + + test("keeps transient endpoint failures unknown", async () => { + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe(`${BASE_URL}/models`); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${API_KEY}`); + return new Response("temporarily unavailable", { status: 503 }); + }) as typeof fetch; + + expect(await validateApiKey("apertis", KEY_LOGIN_PROVIDERS.apertis!, API_KEY)).toBe("unknown"); + + globalThis.fetch = (async () => { + throw new TypeError("network unavailable"); + }) as typeof fetch; + expect(await validateApiKey("apertis", KEY_LOGIN_PROVIDERS.apertis!, API_KEY)).toBe("unknown"); + }); + + test("discovers OpenAI-shaped model ids", async () => { + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe(`${BASE_URL}/models`); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${API_KEY}`); + expect(init?.redirect).toBe("manual"); + return new Response(FIXTURE, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const models = await gatherRoutedModels(providerConfig()); + expect(models.filter(row => row.provider === "apertis").map(row => row.id)).toEqual([ + "claude-sonnet-4.5", + "gpt-4.1", + ]); + }); + + test("routes chat completions to the fixed provider host", () => { + const route = routeModel(providerConfig(), "apertis/gpt-4.1"); + const request = createOpenAIChatAdapter(route.provider).buildRequest({ + modelId: route.modelId, + context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] }, + stream: true, + options: {}, + }); + const body = JSON.parse(String(request.body)) as Record; + + expect(request.url).toBe(`${BASE_URL}/chat/completions`); + expect(request.headers.Authorization).toBe(`Bearer ${API_KEY}`); + expect(body.model).toBe("gpt-4.1"); + }); + + test("does not retarget same-named custom providers", () => { + const customConfig = providerConfig({ baseUrl: "https://custom.example/v1" }); + const route = routeModel(customConfig, "apertis/custom-model"); + expect(route.provider).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://custom.example/v1", + authMode: "key", + }); + expect(buildModelsRequest(customConfig.providers.apertis!, "custom-key", "apertis")).toEqual({ + url: "https://custom.example/v1/models", + headers: { Authorization: "Bearer custom-key" }, + }); + + const customAdapter = routeModel(providerConfig({ + adapter: "anthropic", + baseUrl: "https://custom.example/anthropic", + }), "apertis/custom-model"); + expect(customAdapter.provider).toMatchObject({ + adapter: "anthropic", + baseUrl: "https://custom.example/anthropic", + authMode: "key", + }); + }); +}); diff --git a/tests/fixtures/apertis-models.json b/tests/fixtures/apertis-models.json new file mode 100644 index 00000000000..ca0fc60261e --- /dev/null +++ b/tests/fixtures/apertis-models.json @@ -0,0 +1,21 @@ +{ + "object": "list", + "data": [ + { + "id": "gpt-4.1", + "object": "model", + "created": 1626777600, + "owned_by": "OpenAI", + "root": "gpt-4.1", + "parent": null + }, + { + "id": "claude-sonnet-4.5", + "object": "model", + "created": 1626777600, + "owned_by": "Anthropic", + "root": "claude-sonnet-4.5", + "parent": null + } + ] +} diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 11093a5cf6d..705da48a0c3 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", "apertis", "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",