From a01fafb0d0b93996d67e4b92c2ddb3573a70990f Mon Sep 17 00:00:00 2001 From: theQuert Date: Fri, 31 Jul 2026 17:34:41 +0800 Subject: [PATCH] feat(providers): add Apertis preset --- .../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 +- gui/src/provider-icons.ts | 1 + src/providers/registry.ts | 11 ++ tests/apertis-provider.test.ts | 180 ++++++++++++++++++ tests/fixtures/apertis-models.json | 21 ++ tests/provider-registry-parity.test.ts | 2 +- 10 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 tests/apertis-provider.test.ts create mode 100644 tests/fixtures/apertis-models.json diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 2c3da6070..f7554242d 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -190,7 +190,7 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses ## 3. API-key catalog -opencodex ships 65 built-in presets: 54 key-based, seven OAuth, three local, and the default +opencodex ships 66 built-in presets: 55 key-based, seven OAuth, three local, and the default ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard, validates the key, and stores it. Notable entries: @@ -209,6 +209,7 @@ validates the key, and stores it. Notable entries: | Cerebras | `https://api.cerebras.ai/v1` | | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | +| Apertis | `https://api.apertis.ai/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` | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index b47146a0c..855b8071b 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -119,7 +119,7 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht ## 3. API キーカタログ -opencodex には組み込みプリセットが 65 個含まれています。キー方式 54、OAuth 7、ローカル 3、 +opencodex には組み込みプリセットが 66 個含まれています。キー方式 55、OAuth 7、ローカル 3、 デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 入力したキーを検証した後保存します。主な項目は以下のとおりです: @@ -138,6 +138,7 @@ opencodex には組み込みプリセットが 65 個含まれています。キ | Cerebras | `https://api.cerebras.ai/v1` | | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | +| Apertis | `https://api.apertis.ai/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` | diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 968014ce2..340a39cca 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -119,7 +119,7 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 -opencodex에는 빌트인 프리셋이 65개 들어 있습니다. 키 방식 54개, OAuth 7개, 로컬 3개, +opencodex에는 빌트인 프리셋이 66개 들어 있습니다. 키 방식 55개, OAuth 7개, 로컬 3개, 기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, 입력한 키를 검증한 뒤 저장합니다. 주요 항목은 다음과 같습니다: @@ -138,6 +138,7 @@ opencodex에는 빌트인 프리셋이 65개 들어 있습니다. 키 방식 54 | Cerebras | `https://api.cerebras.ai/v1` | | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | +| Apertis | `https://api.apertis.ai/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` | diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 8bf6be3bc..a5a779ddd 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -126,7 +126,7 @@ OAuth-провайдеры, чьи учётные данные содержат ## 3. Каталог API-ключей -opencodex поставляется с 65 встроенными пресетами: 54 на основе ключей, семь OAuth, три локальных и +opencodex поставляется с 66 встроенными пресетами: 55 на основе ключей, семь OAuth, три локальных и пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу выдачи ключей провайдера, проверяет ключ и сохраняет его. Наиболее заметные записи: @@ -145,6 +145,7 @@ opencodex поставляется с 65 встроенными пресетам | Cerebras | `https://api.cerebras.ai/v1` | | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | +| Apertis | `https://api.apertis.ai/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` | diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index c86149aa5..d2e824eba 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -111,7 +111,7 @@ Kiro 登录需要 Kiro CLI:Unix 使用 `curl -fsSL https://cli.kiro.dev/instal ## 3. API 密钥目录 -opencodex 内置 65 个预设:54 个密钥预设、7 个 OAuth 预设、3 个本地预设,以及默认的 +opencodex 内置 66 个预设:55 个密钥预设、7 个 OAuth 预设、3 个本地预设,以及默认的 ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。 主要条目包括: @@ -130,6 +130,7 @@ ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提 | Cerebras | `https://api.cerebras.ai/v1` | | DeepInfra | `https://api.deepinfra.com/v1/openai` | | Hyperbolic | `https://api.hyperbolic.xyz/v1` | +| Apertis | `https://api.apertis.ai/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` | diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index 96de07735..d3946d59c 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -67,6 +67,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/src/providers/registry.ts b/src/providers/registry.ts index 48ad865eb..31e493842 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -983,6 +983,17 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ }, note: "Serverless text and vision-language chat models only; Hyperbolic's separate image, audio, and GPU endpoints are out of scope.", }, + { + 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: "Paid multi-provider API; the live model catalog is scoped to the API key's plan and access.", + }, // 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/apertis-provider.test.ts b/tests/apertis-provider.test.ts new file mode 100644 index 000000000..0ce163453 --- /dev/null +++ b/tests/apertis-provider.test.ts @@ -0,0 +1,180 @@ +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 { 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 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 { + 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("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("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")).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("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(withStubbedProviderFetch(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 000000000..ca0fc6026 --- /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 3ed777716..167748e04 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", "orcarouter", "bizrouter", "groq", "google", "google-vertex", "azure-openai", - "deepseek", "cerebras", "deepinfra", "hyperbolic", "together", "fireworks", "firepass", "moonshot", + "deepseek", "cerebras", "deepinfra", "hyperbolic", "apertis", "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",