Skip to content

Commit a01fafb

Browse files
committed
feat(providers): add Apertis preset
1 parent e1fc0f2 commit a01fafb

10 files changed

Lines changed: 224 additions & 6 deletions

File tree

docs-site/src/content/docs/guides/providers.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses
190190

191191
## 3. API-key catalog
192192

193-
opencodex ships 65 built-in presets: 54 key-based, seven OAuth, three local, and the default
193+
opencodex ships 66 built-in presets: 55 key-based, seven OAuth, three local, and the default
194194
ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard,
195195
validates the key, and stores it. Notable entries:
196196

@@ -209,6 +209,7 @@ validates the key, and stores it. Notable entries:
209209
| Cerebras | `https://api.cerebras.ai/v1` |
210210
| DeepInfra | `https://api.deepinfra.com/v1/openai` |
211211
| Hyperbolic | `https://api.hyperbolic.xyz/v1` |
212+
| Apertis | `https://api.apertis.ai/v1` |
212213
| Together | `https://api.together.xyz/v1` |
213214
| Fireworks | `https://api.fireworks.ai/inference/v1` |
214215
| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` |

docs-site/src/content/docs/ja/guides/providers.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht
119119

120120
## 3. API キーカタログ
121121

122-
opencodex には組み込みプリセットが 65 個含まれています。キー方式 54、OAuth 7、ローカル 3、
122+
opencodex には組み込みプリセットが 66 個含まれています。キー方式 55、OAuth 7、ローカル 3、
123123
デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、
124124
入力したキーを検証した後保存します。主な項目は以下のとおりです:
125125

@@ -138,6 +138,7 @@ opencodex には組み込みプリセットが 65 個含まれています。キ
138138
| Cerebras | `https://api.cerebras.ai/v1` |
139139
| DeepInfra | `https://api.deepinfra.com/v1/openai` |
140140
| Hyperbolic | `https://api.hyperbolic.xyz/v1` |
141+
| Apertis | `https://api.apertis.ai/v1` |
141142
| Together | `https://api.together.xyz/v1` |
142143
| Fireworks | `https://api.fireworks.ai/inference/v1` |
143144
| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` |

docs-site/src/content/docs/ko/guides/providers.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http
119119

120120
## 3. API 키 카탈로그
121121

122-
opencodex에는 빌트인 프리셋이 65개 들어 있습니다. 키 방식 54개, OAuth 7개, 로컬 3개,
122+
opencodex에는 빌트인 프리셋이 66개 들어 있습니다. 키 방식 55개, OAuth 7개, 로컬 3개,
123123
기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고,
124124
입력한 키를 검증한 뒤 저장합니다. 주요 항목은 다음과 같습니다:
125125

@@ -138,6 +138,7 @@ opencodex에는 빌트인 프리셋이 65개 들어 있습니다. 키 방식 54
138138
| Cerebras | `https://api.cerebras.ai/v1` |
139139
| DeepInfra | `https://api.deepinfra.com/v1/openai` |
140140
| Hyperbolic | `https://api.hyperbolic.xyz/v1` |
141+
| Apertis | `https://api.apertis.ai/v1` |
141142
| Together | `https://api.together.xyz/v1` |
142143
| Fireworks | `https://api.fireworks.ai/inference/v1` |
143144
| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` |

docs-site/src/content/docs/ru/guides/providers.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ OAuth-провайдеры, чьи учётные данные содержат
126126

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

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

@@ -145,6 +145,7 @@ opencodex поставляется с 65 встроенными пресетам
145145
| Cerebras | `https://api.cerebras.ai/v1` |
146146
| DeepInfra | `https://api.deepinfra.com/v1/openai` |
147147
| Hyperbolic | `https://api.hyperbolic.xyz/v1` |
148+
| Apertis | `https://api.apertis.ai/v1` |
148149
| Together | `https://api.together.xyz/v1` |
149150
| Fireworks | `https://api.fireworks.ai/inference/v1` |
150151
| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` |

docs-site/src/content/docs/zh-cn/guides/providers.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ Kiro 登录需要 Kiro CLI:Unix 使用 `curl -fsSL https://cli.kiro.dev/instal
111111

112112
## 3. API 密钥目录
113113

114-
opencodex 内置 65 个预设:54 个密钥预设、7 个 OAuth 预设、3 个本地预设,以及默认的
114+
opencodex 内置 66 个预设:55 个密钥预设、7 个 OAuth 预设、3 个本地预设,以及默认的
115115
ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。
116116
主要条目包括:
117117

@@ -130,6 +130,7 @@ ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提
130130
| Cerebras | `https://api.cerebras.ai/v1` |
131131
| DeepInfra | `https://api.deepinfra.com/v1/openai` |
132132
| Hyperbolic | `https://api.hyperbolic.xyz/v1` |
133+
| Apertis | `https://api.apertis.ai/v1` |
133134
| Together | `https://api.together.xyz/v1` |
134135
| Fireworks | `https://api.fireworks.ai/inference/v1` |
135136
| Moonshot (Kimi API) · Kimi (coding) | `https://api.moonshot.ai/v1` · `https://api.kimi.com/coding/v1` |

gui/src/provider-icons.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
6767
xiaomi: "Xiaomi",
6868
cursor: "Cursor",
6969
deepseek: "DeepSeek",
70+
apertis: "Apertis",
7071
github: "GitHub",
7172
"github-copilot": "GitHub Copilot",
7273
"gitlab-duo": "GitLab Duo",

src/providers/registry.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,17 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
983983
},
984984
note: "Serverless text and vision-language chat models only; Hyperbolic's separate image, audio, and GPU endpoints are out of scope.",
985985
},
986+
{
987+
id: "apertis",
988+
label: "Apertis",
989+
baseUrl: "https://api.apertis.ai/v1",
990+
adapter: "openai-chat",
991+
authKind: "key",
992+
dashboardUrl: "https://apertis.ai/setting?tab=keys",
993+
liveModels: true,
994+
preserveCustomDestination: true,
995+
note: "Paid multi-provider API; the live model catalog is scoped to the API key's plan and access.",
996+
},
986997
// FREEZE 2026-07-10: exact serverless ids remain auth-gated/unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
987998
{ id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys" },
988999
{ id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" },

tests/apertis-provider.test.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { readFileSync } from "node:fs";
3+
import { join } from "node:path";
4+
import { createOpenAIChatAdapter } from "../src/adapters/openai-chat";
5+
import { gatherRoutedModels } from "../src/codex/catalog";
6+
import { clearModelCache } from "../src/codex/model-cache";
7+
import { buildInitProviders } from "../src/cli/init";
8+
import { buildModelsRequest } from "../src/oauth";
9+
import { KEY_LOGIN_PROVIDERS, validateApiKey } from "../src/oauth/key-providers";
10+
import {
11+
deriveInitProviders,
12+
deriveProviderPresets,
13+
providerConfigSeed,
14+
} from "../src/providers/derive";
15+
import { PROVIDER_REGISTRY, type ProviderRegistryEntry } from "../src/providers/registry";
16+
import { routeModel } from "../src/router";
17+
import type { OcxConfig, OcxProviderConfig } from "../src/types";
18+
import { formatProviderDisplayName, isCatalogProviderId } from "../gui/src/provider-icons";
19+
import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch";
20+
21+
const FIXTURE = readFileSync(join(import.meta.dir, "fixtures/apertis-models.json"), "utf8");
22+
const BASE_URL = "https://api.apertis.ai/v1";
23+
const API_KEY = "apertis-test-key";
24+
const originalFetch = globalThis.fetch;
25+
26+
afterEach(() => {
27+
globalThis.fetch = originalFetch;
28+
clearModelCache("apertis");
29+
});
30+
31+
function registryEntry(): ProviderRegistryEntry {
32+
const entry = PROVIDER_REGISTRY.find(row => row.id === "apertis");
33+
if (!entry) throw new Error("missing apertis registry entry");
34+
return entry;
35+
}
36+
37+
function providerConfig(overrides: Partial<OcxProviderConfig> = {}): OcxConfig {
38+
return {
39+
port: 10100,
40+
defaultProvider: "apertis",
41+
providers: {
42+
apertis: {
43+
adapter: "openai-chat",
44+
baseUrl: BASE_URL,
45+
authMode: "key",
46+
apiKey: API_KEY,
47+
liveModels: true,
48+
// Discovery is fixture-only; this avoids platform-specific public-DNS classification.
49+
allowPrivateNetwork: true,
50+
...overrides,
51+
},
52+
},
53+
};
54+
}
55+
56+
describe("Apertis provider", () => {
57+
test("registers a fixed OpenAI transport with live discovery", () => {
58+
expect(registryEntry()).toMatchObject({
59+
id: "apertis",
60+
label: "Apertis",
61+
adapter: "openai-chat",
62+
baseUrl: BASE_URL,
63+
authKind: "key",
64+
dashboardUrl: "https://apertis.ai/setting?tab=keys",
65+
liveModels: true,
66+
preserveCustomDestination: true,
67+
});
68+
expect(registryEntry()).not.toHaveProperty("modelDiscovery");
69+
});
70+
71+
test("derives CLI and dashboard presets without persisting registry trust policy", () => {
72+
const entry = registryEntry();
73+
expect(buildInitProviders()).toEqual(deriveInitProviders());
74+
expect(KEY_LOGIN_PROVIDERS.apertis).toMatchObject({
75+
adapter: "openai-chat",
76+
baseUrl: BASE_URL,
77+
dashboardUrl: entry.dashboardUrl,
78+
liveModels: true,
79+
});
80+
expect(buildInitProviders().find(row => row.id === "apertis")).toMatchObject({
81+
kind: "key",
82+
adapter: "openai-chat",
83+
baseUrl: BASE_URL,
84+
});
85+
expect(deriveProviderPresets().find(row => row.id === "apertis")).toMatchObject({
86+
auth: "key",
87+
dashboardUrl: entry.dashboardUrl,
88+
});
89+
90+
const seed = providerConfigSeed(entry);
91+
expect(seed).toMatchObject({
92+
adapter: "openai-chat",
93+
baseUrl: BASE_URL,
94+
authMode: "key",
95+
liveModels: true,
96+
});
97+
expect(seed).not.toHaveProperty("modelDiscovery");
98+
expect(seed).not.toHaveProperty("preserveCustomDestination");
99+
expect(KEY_LOGIN_PROVIDERS.apertis).not.toHaveProperty("modelDiscovery");
100+
expect(KEY_LOGIN_PROVIDERS.apertis).not.toHaveProperty("preserveCustomDestination");
101+
expect(formatProviderDisplayName("apertis")).toBe("Apertis");
102+
expect(isCatalogProviderId("apertis")).toBe(true);
103+
});
104+
105+
test("lists and validates models through the documented Bearer-authenticated endpoint", async () => {
106+
expect(buildModelsRequest(providerConfig().providers.apertis!, API_KEY, "apertis")).toEqual({
107+
url: `${BASE_URL}/models`,
108+
headers: { Authorization: `Bearer ${API_KEY}` },
109+
});
110+
111+
globalThis.fetch = (async (input, init) => {
112+
expect(String(input)).toBe(`${BASE_URL}/models`);
113+
expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${API_KEY}`);
114+
expect(init?.redirect).toBe("error");
115+
return new Response(FIXTURE, {
116+
status: 200,
117+
headers: { "content-type": "application/json" },
118+
});
119+
}) as typeof fetch;
120+
121+
expect(await validateApiKey("apertis", KEY_LOGIN_PROVIDERS.apertis!, API_KEY)).toBe(true);
122+
});
123+
124+
test("discovers OpenAI-shaped model ids", async () => {
125+
globalThis.fetch = (async (input, init) => {
126+
expect(String(input)).toBe(`${BASE_URL}/models`);
127+
expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${API_KEY}`);
128+
expect(init?.redirect).toBe("manual");
129+
return new Response(FIXTURE, {
130+
status: 200,
131+
headers: { "content-type": "application/json" },
132+
});
133+
}) as typeof fetch;
134+
135+
const models = await gatherRoutedModels(withStubbedProviderFetch(providerConfig()));
136+
expect(models.filter(row => row.provider === "apertis").map(row => row.id)).toEqual([
137+
"claude-sonnet-4.5",
138+
"gpt-4.1",
139+
]);
140+
});
141+
142+
test("routes chat completions to the fixed provider host", () => {
143+
const route = routeModel(providerConfig(), "apertis/gpt-4.1");
144+
const request = createOpenAIChatAdapter(route.provider).buildRequest({
145+
modelId: route.modelId,
146+
context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] },
147+
stream: true,
148+
options: {},
149+
});
150+
const body = JSON.parse(String(request.body)) as Record<string, unknown>;
151+
152+
expect(request.url).toBe(`${BASE_URL}/chat/completions`);
153+
expect(request.headers.Authorization).toBe(`Bearer ${API_KEY}`);
154+
expect(body.model).toBe("gpt-4.1");
155+
});
156+
157+
test("does not retarget same-named custom providers", () => {
158+
const customConfig = providerConfig({ baseUrl: "https://custom.example/v1" });
159+
const route = routeModel(customConfig, "apertis/custom-model");
160+
expect(route.provider).toMatchObject({
161+
adapter: "openai-chat",
162+
baseUrl: "https://custom.example/v1",
163+
authMode: "key",
164+
});
165+
expect(buildModelsRequest(customConfig.providers.apertis!, "custom-key", "apertis")).toEqual({
166+
url: "https://custom.example/v1/models",
167+
headers: { Authorization: "Bearer custom-key" },
168+
});
169+
170+
const customAdapter = routeModel(providerConfig({
171+
adapter: "anthropic",
172+
baseUrl: "https://custom.example/anthropic",
173+
}), "apertis/custom-model");
174+
expect(customAdapter.provider).toMatchObject({
175+
adapter: "anthropic",
176+
baseUrl: "https://custom.example/anthropic",
177+
authMode: "key",
178+
});
179+
});
180+
});

tests/fixtures/apertis-models.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"object": "list",
3+
"data": [
4+
{
5+
"id": "gpt-4.1",
6+
"object": "model",
7+
"created": 1626777600,
8+
"owned_by": "OpenAI",
9+
"root": "gpt-4.1",
10+
"parent": null
11+
},
12+
{
13+
"id": "claude-sonnet-4.5",
14+
"object": "model",
15+
"created": 1626777600,
16+
"owned_by": "Anthropic",
17+
"root": "claude-sonnet-4.5",
18+
"parent": null
19+
}
20+
]
21+
}

tests/provider-registry-parity.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function nativeTemplate(): Record<string, unknown> {
3131

3232
const EXPECTED_KEY_PROVIDER_IDS = [
3333
"anthropic-apikey", "openai-apikey", "umans", "opencode-go", "neuralwatt", "openrouter", "orcarouter", "bizrouter", "groq", "google", "google-vertex", "azure-openai",
34-
"deepseek", "cerebras", "deepinfra", "hyperbolic", "together", "fireworks", "firepass", "moonshot",
34+
"deepseek", "cerebras", "deepinfra", "hyperbolic", "apertis", "together", "fireworks", "firepass", "moonshot",
3535
"huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan",
3636
"volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral",
3737
"minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway",

0 commit comments

Comments
 (0)