diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index c51a24de2..95a1efb60 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1004,6 +1004,7 @@ in your environment and reference the model by provider/model. Notes: - `z.ai/*` and `z-ai/*` are accepted aliases and normalize to `zai/*`. - If `ZAI_API_KEY` is missing, requests to `zai/*` will fail with an auth error at runtime. +- Example error: `No API key found for provider "zai".` - Z.AI’s general API endpoint is `https://api.z.ai/api/paas/v4`. The GLM Coding Plan uses the dedicated Coding endpoint `https://api.z.ai/api/coding/paas/v4`. The built-in `zai` provider uses the Coding endpoint. If you need the general diff --git a/src/agents/auth-profiles.test.ts b/src/agents/auth-profiles.test.ts index f7fecba89..dbddb27cc 100644 --- a/src/agents/auth-profiles.test.ts +++ b/src/agents/auth-profiles.test.ts @@ -89,6 +89,67 @@ describe("resolveAuthProfileOrder", () => { expect(order).toEqual(["anthropic:work", "anthropic:default"]); }); + it("normalizes z.ai aliases in auth.order", () => { + const order = resolveAuthProfileOrder({ + cfg: { + auth: { + order: { "z.ai": ["zai:work", "zai:default"] }, + profiles: { + "zai:default": { provider: "zai", mode: "api_key" }, + "zai:work": { provider: "zai", mode: "api_key" }, + }, + }, + }, + store: { + version: 1, + profiles: { + "zai:default": { + type: "api_key", + provider: "zai", + key: "sk-default", + }, + "zai:work": { + type: "api_key", + provider: "zai", + key: "sk-work", + }, + }, + }, + provider: "zai", + }); + expect(order).toEqual(["zai:work", "zai:default"]); + }); + + it("normalizes z.ai aliases in auth.profiles", () => { + const order = resolveAuthProfileOrder({ + cfg: { + auth: { + profiles: { + "zai:default": { provider: "z.ai", mode: "api_key" }, + "zai:work": { provider: "Z.AI", mode: "api_key" }, + }, + }, + }, + store: { + version: 1, + profiles: { + "zai:default": { + type: "api_key", + provider: "zai", + key: "sk-default", + }, + "zai:work": { + type: "api_key", + provider: "zai", + key: "sk-work", + }, + }, + }, + provider: "zai", + }); + expect(order).toEqual(["zai:default", "zai:work"]); + }); + it("prioritizes oauth profiles when order missing", () => { const mixedStore: AuthProfileStore = { version: 1, diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 460faef50..b28f7f6ba 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -12,6 +12,7 @@ import type { ClawdbotConfig } from "../config/config.js"; import { resolveOAuthPath } from "../config/paths.js"; import { resolveUserPath } from "../utils.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; +import { normalizeProviderId } from "./model-selection.js"; const AUTH_STORE_VERSION = 1; const AUTH_PROFILE_FILENAME = "auth-profiles.json"; @@ -378,8 +379,9 @@ export function listProfilesForProvider( store: AuthProfileStore, provider: string, ): string[] { + const providerKey = normalizeProviderId(provider); return Object.entries(store.profiles) - .filter(([, cred]) => cred.provider === provider) + .filter(([, cred]) => normalizeProviderId(cred.provider) === providerKey) .map(([id]) => id); } @@ -539,17 +541,26 @@ export function resolveAuthProfileOrder(params: { preferredProfile?: string; }): string[] { const { cfg, store, provider, preferredProfile } = params; - const configuredOrder = cfg?.auth?.order?.[provider]; + const providerKey = normalizeProviderId(provider); + const configuredOrder = + cfg?.auth?.order?.[providerKey] ?? + cfg?.auth?.order?.[provider] ?? + (providerKey === "zai" + ? (cfg?.auth?.order?.["z.ai"] ?? cfg?.auth?.order?.["z-ai"]) + : undefined); const explicitProfiles = cfg?.auth?.profiles ? Object.entries(cfg.auth.profiles) - .filter(([, profile]) => profile.provider === provider) + .filter( + ([, profile]) => + normalizeProviderId(profile.provider) === providerKey, + ) .map(([profileId]) => profileId) : []; const baseOrder = configuredOrder ?? (explicitProfiles.length > 0 ? explicitProfiles - : listProfilesForProvider(store, provider)); + : listProfilesForProvider(store, providerKey)); if (baseOrder.length === 0) return []; const filtered = baseOrder.filter((profileId) => { diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 49900389f..d6f959b66 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -163,4 +163,40 @@ describe("getApiKeyForModel", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it("throws when ZAI API key is missing", async () => { + const previousZai = process.env.ZAI_API_KEY; + const previousLegacy = process.env.Z_AI_API_KEY; + + try { + delete process.env.ZAI_API_KEY; + delete process.env.Z_AI_API_KEY; + + vi.resetModules(); + const { resolveApiKeyForProvider } = await import("./model-auth.js"); + + let error: unknown = null; + try { + await resolveApiKeyForProvider({ + provider: "zai", + store: { version: 1, profiles: {} }, + }); + } catch (err) { + error = err; + } + + expect(String(error)).toContain('No API key found for provider "zai".'); + } finally { + if (previousZai === undefined) { + delete process.env.ZAI_API_KEY; + } else { + process.env.ZAI_API_KEY = previousZai; + } + if (previousLegacy === undefined) { + delete process.env.Z_AI_API_KEY; + } else { + process.env.Z_AI_API_KEY = previousLegacy; + } + } + }); }); diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 7e87c2916..8131da54f 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; -import { resolveConfiguredModelRef } from "./model-selection.js"; +import { + normalizeProviderId, + resolveConfiguredModelRef, +} from "./model-selection.js"; describe("resolveConfiguredModelRef", () => { it("parses provider/model from agent.model.primary", () => { @@ -129,3 +132,15 @@ describe("resolveConfiguredModelRef", () => { expect(resolved).toEqual({ provider: "zai", model: "glm-4.7" }); }); }); + +describe("normalizeProviderId", () => { + it("normalizes z.ai aliases to canonical zai", () => { + expect(normalizeProviderId("z.ai")).toBe("zai"); + expect(normalizeProviderId("z-ai")).toBe("zai"); + }); + + it("normalizes provider casing", () => { + expect(normalizeProviderId("OpenAI")).toBe("openai"); + expect(normalizeProviderId("Z.AI")).toBe("zai"); + }); +}); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index ac25155df..12a06a44b 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -21,7 +21,7 @@ export function modelKey(provider: string, model: string) { return `${provider}/${model}`; } -function normalizeProvider(provider: string): string { +export function normalizeProviderId(provider: string): string { const normalized = provider.trim().toLowerCase(); if (normalized === "z.ai" || normalized === "z-ai") return "zai"; return normalized; @@ -35,10 +35,10 @@ export function parseModelRef( if (!trimmed) return null; const slash = trimmed.indexOf("/"); if (slash === -1) { - return { provider: normalizeProvider(defaultProvider), model: trimmed }; + return { provider: normalizeProviderId(defaultProvider), model: trimmed }; } const providerRaw = trimmed.slice(0, slash).trim(); - const provider = normalizeProvider(providerRaw); + const provider = normalizeProviderId(providerRaw); const model = trimmed.slice(slash + 1).trim(); if (!provider || !model) return null; return { provider, model }; diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index 15181df7a..e00a305bc 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -156,4 +156,104 @@ describe("models list/status", () => { expect(payload.count).toBe(1); expect(payload.models[0]?.key).toBe("zai/glm-4.7"); }); + + it("models list provider filter normalizes Z.AI alias casing", async () => { + loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } }); + const runtime = makeRuntime(); + + const models = [ + { + provider: "zai", + id: "glm-4.7", + name: "GLM-4.7", + input: ["text"], + baseUrl: "https://api.z.ai/v1", + contextWindow: 128000, + }, + { + provider: "openai", + id: "gpt-4.1-mini", + name: "GPT-4.1 mini", + input: ["text"], + baseUrl: "https://api.openai.com/v1", + contextWindow: 128000, + }, + ]; + + discoverModels.mockReturnValue({ + getAll: () => models, + getAvailable: () => models, + }); + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ all: true, provider: "Z.AI", json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.count).toBe(1); + expect(payload.models[0]?.key).toBe("zai/glm-4.7"); + }); + + it("models list provider filter normalizes z-ai alias", async () => { + loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } }); + const runtime = makeRuntime(); + + const models = [ + { + provider: "zai", + id: "glm-4.7", + name: "GLM-4.7", + input: ["text"], + baseUrl: "https://api.z.ai/v1", + contextWindow: 128000, + }, + { + provider: "openai", + id: "gpt-4.1-mini", + name: "GPT-4.1 mini", + input: ["text"], + baseUrl: "https://api.openai.com/v1", + contextWindow: 128000, + }, + ]; + + discoverModels.mockReturnValue({ + getAll: () => models, + getAvailable: () => models, + }); + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ all: true, provider: "z-ai", json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.count).toBe(1); + expect(payload.models[0]?.key).toBe("zai/glm-4.7"); + }); + + it("models list marks auth as unavailable when ZAI key is missing", async () => { + loadConfig.mockReturnValue({ agent: { model: "z.ai/glm-4.7" } }); + const runtime = makeRuntime(); + + const model = { + provider: "zai", + id: "glm-4.7", + name: "GLM-4.7", + input: ["text"], + baseUrl: "https://api.z.ai/v1", + contextWindow: 128000, + }; + + discoverModels.mockReturnValue({ + getAll: () => [model], + getAvailable: () => [], + }); + + const { modelsListCommand } = await import("./models/list.js"); + await modelsListCommand({ all: true, json: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); + expect(payload.models[0]?.available).toBe(false); + }); });