Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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` |
Expand Down
3 changes: 2 additions & 1 deletion docs-site/src/content/docs/ja/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** ピッカーはキー発行ページを開き、
入力したキーを検証した後保存します。主な項目は以下のとおりです:

Expand All @@ -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` |
Expand Down
3 changes: 2 additions & 1 deletion docs-site/src/content/docs/ko/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** 선택기는 키 발급 페이지를 열고,
입력한 키를 검증한 뒤 저장합니다. 주요 항목은 다음과 같습니다:

Expand All @@ -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` |
Expand Down
3 changes: 2 additions & 1 deletion docs-site/src/content/docs/ru/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ OAuth-провайдеры, чьи учётные данные содержат

## 3. Каталог API-ключей

opencodex поставляется с 65 встроенными пресетами: 54 на основе ключей, семь OAuth, три локальных и
opencodex поставляется с 66 встроенными пресетами: 55 на основе ключей, семь OAuth, три локальных и
пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу
выдачи ключей провайдера, проверяет ключ и сохраняет его. Наиболее заметные записи:

Expand All @@ -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` |
Expand Down
3 changes: 2 additions & 1 deletion docs-site/src/content/docs/zh-cn/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** 选择器会打开密钥提供商的控制台,验证并保存密钥。
主要条目包括:

Expand All @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions gui/src/provider-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
xiaomi: "Xiaomi",
cursor: "Cursor",
deepseek: "DeepSeek",
apertis: "Apertis",
github: "GitHub",
"github-copilot": "GitHub Copilot",
"gitlab-duo": "GitLab Duo",
Expand Down
11 changes: 11 additions & 0 deletions src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
180 changes: 180 additions & 0 deletions tests/apertis-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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<OcxProviderConfig> = {}): 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<string, unknown>;

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",
});
});
});
21 changes: 21 additions & 0 deletions tests/fixtures/apertis-models.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
2 changes: 1 addition & 1 deletion tests/provider-registry-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function nativeTemplate(): Record<string, unknown> {

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",
Expand Down
Loading