From 81eeb9fab7b75d672927eaf6378326eb9130b7ff Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Mon, 6 Jul 2026 19:35:05 +0300 Subject: [PATCH] feat: support glm --- README.md | 4 +- README.ru.md | 4 +- src/models.ts | 25 +++++--- test/index.test.ts | 8 +-- test/models.test.ts | 150 ++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 169 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index d1adbe6..ff30f2c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.ru.md b/README.ru.md index 5cf8735..1e3f306 100644 --- a/README.ru.md +++ b/README.ru.md @@ -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` включается автоматически там, где это применимо. ## Ограничения diff --git a/src/models.ts b/src/models.ts index 947a95f..da691d5 100644 --- a/src/models.ts +++ b/src/models.ts @@ -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"; @@ -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([ - ["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([ + ["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 = { @@ -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; @@ -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, @@ -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 } : {}, }; } diff --git a/test/index.test.ts b/test/index.test.ts index f3f3f49..6e57118 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/test/models.test.ts b/test/models.test.ts index d30a20e..d1e8875 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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", () => {