Skip to content
Open
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ During authentication, you will need a [CoreInfra AI Hub](https://hub.coreinfra.
## Features

- **Up-to-date model list** - the catalog is loaded dynamically from the Hub API on every startup and always reflects its current state. Model capabilities (context limits, reasoning, tool use, etc.) are resolved from the models.dev catalog.
- **OpenAI and Anthropic models** - both model families are supported, including GPT-5.x and Claude 4.x.
- **Reasoning support** - `interleaved thinking` mode is enabled automatically for Anthropic models.
- **All available models** - every model exposed by the Hub is supported.
- **Reasoning support** - `interleaved thinking` mode is enabled automatically where applicable.

## Limitations

Expand Down
4 changes: 2 additions & 2 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ opencode providers login --provider coreinfra
## Возможности

- **Актуальный список моделей** — каталог динамически загружается из API Hub при каждом запуске и всегда отражает его текущее состояние. Возможности моделей (контекст, reasoning, tool use и т.д.) определяются из каталога models.dev.
- **Модели OpenAI и Anthropic** — поддерживаются обе линейки, включая GPT-5.x и Claude 4.x.
- **Поддержка reasoning** — для моделей Anthropic автоматически включается режим `interleaved thinking`.
- **Все доступные модели** — поддерживается любая модель, предоставленная Hub.
- **Поддержка reasoning** — режим `interleaved thinking` включается автоматически там, где это применимо.

## Ограничения

Expand Down
25 changes: 16 additions & 9 deletions src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ const CACHE_PATH = join(
const MODELS_DEV_URL = "https://models.dev/api.json";
const DEFAULT_HUB_BASE = "https://hub.coreinfra.ai";
const HUB_URL = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/hub/api/prices`;
const OPENAI_BASE = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/codex/api/v1`;
const ANTHROPIC_BASE = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/claude/api/v1`;
const OPENAI_BASE = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/openai/api/v1`;
const ANTHROPIC_BASE = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/anthropic/api/v1`;
const ANTHROPIC_BETA_HEADER =
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14";

Expand All @@ -34,11 +34,15 @@ const DEFAULT_COST = {
type Protocol = "openai" | "anthropic";

// Providers we explicitly know how to route. Models from any provider not
// listed here are skipped. Adding a provider is a one-line change here.
const PROVIDERS = new Map<string, { protocol: Protocol }>([
["openai", { protocol: "openai" }],
["anthropic", { protocol: "anthropic" }],
["deepseek", { protocol: "anthropic" }],
// listed here are skipped. Adding a provider is a one-line change here. The
// `npm` package selects the wire API: `@ai-sdk/openai` targets OpenAI's
// Responses API (`/responses`), while `@ai-sdk/openai-compatible` targets the
// Chat Completions API (`/chat/completions`).
const PROVIDERS = new Map<string, { protocol: Protocol; npm: string }>([
["openai", { protocol: "openai", npm: "@ai-sdk/openai" }],
["anthropic", { protocol: "anthropic", npm: "@ai-sdk/anthropic" }],
["deepseek", { protocol: "anthropic", npm: "@ai-sdk/anthropic" }],
["zai", { protocol: "openai", npm: "@ai-sdk/openai-compatible" }],
]);

type ModelDevEntry = {
Expand All @@ -49,6 +53,7 @@ type ModelDevEntry = {
reasoning?: boolean;
temperature?: boolean;
tool_call?: boolean;
interleaved?: boolean | { field: string };
modalities?: { input?: string[]; output?: string[] };
cost?: {
input?: number;
Expand Down Expand Up @@ -193,7 +198,7 @@ export function buildConfigModels(
name: entry?.name ?? hubModel.display_name,
provider: {
api: anthropic ? ANTHROPIC_BASE : OPENAI_BASE,
npm: anthropic ? "@ai-sdk/anthropic" : "@ai-sdk/openai",
npm: known.npm,
},
attachment: entry?.attachment ?? DEFAULT_CAPS.attachment,
reasoning: entry?.reasoning ?? DEFAULT_CAPS.reasoning,
Expand All @@ -214,7 +219,9 @@ export function buildConfigModels(
context: entry?.limit?.context ?? DEFAULT_LIMIT.context,
output: entry?.limit?.output ?? DEFAULT_LIMIT.output,
},
interleaved: anthropic ? { field: "reasoning_content" } : true,
interleaved:
entry?.interleaved ??
(anthropic ? { field: "reasoning_content" } : true),
headers: anthropic ? { "anthropic-beta": ANTHROPIC_BETA_HEADER } : {},
};
}
Expand Down
8 changes: 4 additions & 4 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ describe("config hook", () => {
id: "gpt-5.4-nano",
name: "GPT-5.4 Nano",
provider: {
api: "https://hub.coreinfra.ai/codex/api/v1",
api: "https://hub.coreinfra.ai/openai/api/v1",
npm: "@ai-sdk/openai",
},
attachment: true,
Expand All @@ -196,7 +196,7 @@ describe("config hook", () => {
id: "claude-sonnet-4-20250514",
name: "Claude Sonnet 4",
provider: {
api: "https://hub.coreinfra.ai/claude/api/v1",
api: "https://hub.coreinfra.ai/anthropic/api/v1",
npm: "@ai-sdk/anthropic",
},
attachment: true,
Expand All @@ -217,7 +217,7 @@ describe("config hook", () => {
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
provider: {
api: "https://hub.coreinfra.ai/claude/api/v1",
api: "https://hub.coreinfra.ai/anthropic/api/v1",
npm: "@ai-sdk/anthropic",
},
attachment: false,
Expand All @@ -238,7 +238,7 @@ describe("config hook", () => {
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
provider: {
api: "https://hub.coreinfra.ai/claude/api/v1",
api: "https://hub.coreinfra.ai/anthropic/api/v1",
npm: "@ai-sdk/anthropic",
},
attachment: false,
Expand Down
150 changes: 145 additions & 5 deletions test/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ describe("buildConfigModels", () => {
id: "gpt-5.4-nano",
name: "GPT-5.4 Nano",
provider: {
api: "https://hub.coreinfra.ai/codex/api/v1",
api: "https://hub.coreinfra.ai/openai/api/v1",
npm: "@ai-sdk/openai",
},
attachment: true,
Expand All @@ -149,7 +149,7 @@ describe("buildConfigModels", () => {
id: "claude-sonnet-4-20250514",
name: "Claude Sonnet 4",
provider: {
api: "https://hub.coreinfra.ai/claude/api/v1",
api: "https://hub.coreinfra.ai/anthropic/api/v1",
npm: "@ai-sdk/anthropic",
},
attachment: true,
Expand All @@ -174,7 +174,7 @@ describe("buildConfigModels", () => {
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
provider: {
api: "https://hub.coreinfra.ai/claude/api/v1",
api: "https://hub.coreinfra.ai/anthropic/api/v1",
npm: "@ai-sdk/anthropic",
},
attachment: false,
Expand All @@ -199,7 +199,7 @@ describe("buildConfigModels", () => {
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
provider: {
api: "https://hub.coreinfra.ai/claude/api/v1",
api: "https://hub.coreinfra.ai/anthropic/api/v1",
npm: "@ai-sdk/anthropic",
},
attachment: false,
Expand Down Expand Up @@ -240,7 +240,7 @@ describe("buildConfigModels", () => {
id: "unknown-model",
name: "Unknown Model",
provider: {
api: "https://hub.coreinfra.ai/codex/api/v1",
api: "https://hub.coreinfra.ai/openai/api/v1",
npm: "@ai-sdk/openai",
},
attachment: true,
Expand Down Expand Up @@ -385,6 +385,146 @@ describe("buildConfigModels", () => {
expect(models).toEqual({});
expect(warnings).toEqual([]);
});

it("routes zai (GLM) through the openai chat-completions path", () => {
const modelsDevData = {
zai: {
models: {
"glm-5.2": {
id: "glm-5.2",
name: "GLM-5.2",
limit: { context: 128000, output: 16000 },
attachment: false,
reasoning: true,
temperature: true,
tool_call: true,
interleaved: { field: "reasoning_content" },
modalities: { input: ["text"], output: ["text"] },
cost: { input: 0.5, output: 2, cache_read: 0.05, cache_write: 0 },
},
"glm-4.7": {
id: "glm-4.7",
name: "GLM-4.7",
limit: { context: 128000, output: 16000 },
attachment: false,
reasoning: true,
temperature: true,
tool_call: true,
interleaved: { field: "reasoning_content" },
modalities: { input: ["text"], output: ["text"] },
cost: { input: 0.2, output: 0.8, cache_read: 0.02, cache_write: 0 },
},
},
},
};

const hubData = {
providers: {
zai: {
models: {
"glm-5.2": { display_name: "GLM-5.2" },
"glm-4.7": { display_name: "GLM-4.7" },
},
},
},
};

const { models, warnings } = buildConfigModels(modelsDevData, hubData);

expect(warnings).toEqual([]);
expect(Object.keys(models)).toHaveLength(2);

expect(models["glm-5.2"]).toEqual({
id: "glm-5.2",
name: "GLM-5.2",
provider: {
api: "https://hub.coreinfra.ai/openai/api/v1",
// zai uses the openai-compatible SDK -> Chat Completions (/chat/completions),
// NOT the openai SDK which would use the Responses API (/responses).
npm: "@ai-sdk/openai-compatible",
},
attachment: false,
reasoning: true,
temperature: true,
tool_call: true,
modalities: { input: ["text"], output: ["text"] },
cost: { input: 0.5, output: 2, cache_read: 0.05, cache_write: 0 },
limit: { context: 128000, output: 16000 },
interleaved: { field: "reasoning_content" },
headers: {},
});
});

it("uses Responses API for openai but Chat Completions for zai", () => {
const modelsDevData = {
openai: { models: { "gpt-x": { name: "GPT X" } } },
zai: { models: { "glm-x": { name: "GLM X" } } },
};
const hubData = {
providers: {
openai: { models: { "gpt-x": { display_name: "GPT X" } } },
zai: { models: { "glm-x": { display_name: "GLM X" } } },
},
};
const { models } = buildConfigModels(modelsDevData, hubData);

// openai -> @ai-sdk/openai (Responses API, /responses)
expect(models["gpt-x"].provider.npm).toBe("@ai-sdk/openai");
// zai -> @ai-sdk/openai-compatible (Chat Completions, /chat/completions)
expect(models["glm-x"].provider.npm).toBe("@ai-sdk/openai-compatible");
// both share the same hub base URL (the hub routes by model name)
expect(models["gpt-x"].provider.api).toBe(models["glm-x"].provider.api);
});

it("respects models.dev interleaved and falls back per protocol", () => {
// openai-protocol zai model: explicit interleaved wins over protocol default
const explicit = buildConfigModels(
{
zai: {
models: {
"glm-5.2": {
name: "GLM-5.2",
interleaved: { field: "reasoning_content" },
},
},
},
},
{
providers: {
zai: { models: { "glm-5.2": { display_name: "GLM-5.2" } } },
},
},
);
expect(explicit.models["glm-5.2"].interleaved).toEqual({
field: "reasoning_content",
});

// openai-protocol zai model without interleaved -> default true
const openaiFallback = buildConfigModels(
{ zai: { models: { "glm-4.5": { name: "GLM-4.5" } } } },
{
providers: {
zai: { models: { "glm-4.5": { display_name: "GLM-4.5" } } },
},
},
);
expect(openaiFallback.models["glm-4.5"].interleaved).toBe(true);

// anthropic-protocol model without interleaved -> default reasoning_content field
const anthropicFallback = buildConfigModels(
{ anthropic: { models: { "claude-x": { name: "Claude X" } } } },
{
providers: {
anthropic: {
models: { "claude-x": { display_name: "Claude X" } },
},
},
},
);
expect(anthropicFallback.models["claude-x"].interleaved).toEqual({
field: "reasoning_content",
});
});
});

describe("fetchModelsDevData", () => {
Expand Down