fix: normalize z.ai provider ids in auth profiles

This commit is contained in:
mneves75
2026-01-06 11:49:37 -03:00
committed by Peter Steinberger
parent 13c1ce1f05
commit 3550dc294d
7 changed files with 232 additions and 8 deletions

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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;
}
}
});
});

View File

@@ -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");
});
});

View File

@@ -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 };

View File

@@ -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);
});
});