Files
moltbot/src/commands/models.list.test.ts
2026-02-23 05:20:14 +01:00

329 lines
10 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
let modelsListCommand: typeof import("./models/list.list-command.js").modelsListCommand;
let loadModelRegistry: typeof import("./models/list.registry.js").loadModelRegistry;
let toModelRow: typeof import("./models/list.registry.js").toModelRow;
const loadConfig = vi.fn();
const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined);
const ensurePiAuthJsonFromAuthProfiles = vi
.fn()
.mockResolvedValue({ wrote: false, authPath: "/tmp/openclaw-agent/auth.json" });
const resolveOpenClawAgentDir = vi.fn().mockReturnValue("/tmp/openclaw-agent");
const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} });
const listProfilesForProvider = vi.fn().mockReturnValue([]);
const resolveAuthProfileDisplayLabel = vi.fn(({ profileId }: { profileId: string }) => profileId);
const resolveAuthStorePathForDisplay = vi
.fn()
.mockReturnValue("/tmp/openclaw-agent/auth-profiles.json");
const resolveProfileUnusableUntilForDisplay = vi.fn().mockReturnValue(null);
const resolveEnvApiKey = vi.fn().mockReturnValue(undefined);
const resolveAwsSdkEnvVarName = vi.fn().mockReturnValue(undefined);
const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined);
const modelRegistryState = {
models: [] as Array<Record<string, unknown>>,
available: [] as Array<Record<string, unknown>>,
getAllError: undefined as unknown,
getAvailableError: undefined as unknown,
};
let previousExitCode: typeof process.exitCode;
vi.mock("../config/config.js", () => ({
CONFIG_PATH: "/tmp/openclaw.json",
STATE_DIR: "/tmp/openclaw-state",
loadConfig,
}));
vi.mock("../agents/models-config.js", () => ({
ensureOpenClawModelsJson,
}));
vi.mock("../agents/pi-auth-json.js", () => ({
ensurePiAuthJsonFromAuthProfiles,
}));
vi.mock("../agents/agent-paths.js", () => ({
resolveOpenClawAgentDir,
}));
vi.mock("../agents/auth-profiles.js", () => ({
ensureAuthProfileStore,
listProfilesForProvider,
resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay,
resolveProfileUnusableUntilForDisplay,
}));
vi.mock("../agents/model-auth.js", () => ({
resolveEnvApiKey,
resolveAwsSdkEnvVarName,
getCustomProviderApiKey,
}));
vi.mock("../agents/pi-model-discovery.js", () => {
class MockModelRegistry {
find(provider: string, id: string) {
return (
modelRegistryState.models.find((model) => model.provider === provider && model.id === id) ??
null
);
}
getAll() {
if (modelRegistryState.getAllError !== undefined) {
throw modelRegistryState.getAllError;
}
return modelRegistryState.models;
}
getAvailable() {
if (modelRegistryState.getAvailableError !== undefined) {
throw modelRegistryState.getAvailableError;
}
return modelRegistryState.available;
}
}
return {
discoverAuthStorage: () => ({}) as unknown,
discoverModels: () => new MockModelRegistry() as unknown,
};
});
vi.mock("../agents/pi-embedded-runner/model.js", () => ({
resolveModel: () => {
throw new Error("resolveModel should not be called from models.list tests");
},
}));
function makeRuntime() {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
}
function expectModelRegistryUnavailable(
runtime: ReturnType<typeof makeRuntime>,
expectedDetail: string,
) {
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("Model registry unavailable:");
expect(runtime.error.mock.calls[0]?.[0]).toContain(expectedDetail);
expect(runtime.log).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
}
beforeEach(() => {
previousExitCode = process.exitCode;
process.exitCode = undefined;
modelRegistryState.getAllError = undefined;
modelRegistryState.getAvailableError = undefined;
listProfilesForProvider.mockReturnValue([]);
ensurePiAuthJsonFromAuthProfiles.mockClear();
});
afterEach(() => {
process.exitCode = previousExitCode;
});
describe("models list/status", () => {
const ZAI_MODEL = {
provider: "zai",
id: "glm-4.7",
name: "GLM-4.7",
input: ["text"],
baseUrl: "https://api.z.ai/v1",
contextWindow: 128000,
};
const OPENAI_MODEL = {
provider: "openai",
id: "gpt-4.1-mini",
name: "GPT-4.1 mini",
input: ["text"],
baseUrl: "https://api.openai.com/v1",
contextWindow: 128000,
};
const GOOGLE_ANTIGRAVITY_TEMPLATE_BASE = {
provider: "google-antigravity",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
};
function setDefaultModel(model: string) {
loadConfig.mockReturnValue({
agents: { defaults: { model } },
});
}
function configureModelAsConfigured(model: string) {
loadConfig.mockReturnValue({
agents: {
defaults: {
model,
models: {
[model]: {},
},
},
},
});
}
function configureGoogleAntigravityModel(modelId: string) {
configureModelAsConfigured(`google-antigravity/${modelId}`);
}
function makeGoogleAntigravityTemplate(id: string, name: string) {
return {
...GOOGLE_ANTIGRAVITY_TEMPLATE_BASE,
id,
name,
};
}
function enableGoogleAntigravityAuthProfile() {
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
}
function parseJsonLog(runtime: ReturnType<typeof makeRuntime>) {
expect(runtime.log).toHaveBeenCalledTimes(1);
return JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
}
async function expectZaiProviderFilter(provider: string) {
setDefaultZaiRegistry();
const runtime = makeRuntime();
await modelsListCommand({ all: true, provider, json: true }, runtime);
const payload = parseJsonLog(runtime);
expect(payload.count).toBe(1);
expect(payload.models[0]?.key).toBe("zai/glm-4.7");
}
function setDefaultZaiRegistry(params: { available?: boolean } = {}) {
const available = params.available ?? true;
setDefaultModel("z.ai/glm-4.7");
modelRegistryState.models = [ZAI_MODEL, OPENAI_MODEL];
modelRegistryState.available = available ? [ZAI_MODEL, OPENAI_MODEL] : [];
}
beforeAll(async () => {
({ modelsListCommand } = await import("./models/list.list-command.js"));
({ loadModelRegistry, toModelRow } = await import("./models/list.registry.js"));
});
it("models list syncs auth-profiles into auth.json before availability checks", async () => {
setDefaultZaiRegistry();
const runtime = makeRuntime();
await modelsListCommand({ all: true, json: true }, runtime);
expect(ensurePiAuthJsonFromAuthProfiles).toHaveBeenCalledWith("/tmp/openclaw-agent");
});
it("models list outputs canonical zai key for configured z.ai model", async () => {
setDefaultZaiRegistry();
const runtime = makeRuntime();
await modelsListCommand({ json: true }, runtime);
const payload = parseJsonLog(runtime);
expect(payload.models[0]?.key).toBe("zai/glm-4.7");
});
it("models list plain outputs canonical zai key", async () => {
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
modelRegistryState.models = [ZAI_MODEL];
modelRegistryState.available = [ZAI_MODEL];
await modelsListCommand({ plain: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
expect(runtime.log.mock.calls[0]?.[0]).toBe("zai/glm-4.7");
});
it.each(["z.ai", "Z.AI", "z-ai"] as const)(
"models list provider filter normalizes %s alias",
async (provider) => {
await expectZaiProviderFilter(provider);
},
);
it("models list marks auth as unavailable when ZAI key is missing", async () => {
setDefaultZaiRegistry({ available: false });
const runtime = makeRuntime();
await modelsListCommand({ all: true, json: true }, runtime);
const payload = parseJsonLog(runtime);
expect(payload.models[0]?.available).toBe(false);
});
it("models list does not treat availability-unavailable code as discovery fallback", async () => {
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
modelRegistryState.getAllError = Object.assign(new Error("model discovery failed"), {
code: "MODEL_AVAILABILITY_UNAVAILABLE",
});
const runtime = makeRuntime();
await modelsListCommand({ json: true }, runtime);
expectModelRegistryUnavailable(runtime, "model discovery failed");
expect(runtime.error.mock.calls[0]?.[0]).not.toContain("configured models may appear missing");
});
it("models list fails fast when registry model discovery is unavailable", async () => {
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
enableGoogleAntigravityAuthProfile();
modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), {
code: "MODEL_DISCOVERY_UNAVAILABLE",
});
const runtime = makeRuntime();
modelRegistryState.models = [];
modelRegistryState.available = [];
await modelsListCommand({ json: true }, runtime);
expectModelRegistryUnavailable(runtime, "model discovery unavailable");
});
it("loadModelRegistry throws when model discovery is unavailable", async () => {
modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), {
code: "MODEL_DISCOVERY_UNAVAILABLE",
});
modelRegistryState.available = [
makeGoogleAntigravityTemplate("claude-opus-4-5-thinking", "Claude Opus 4.5 Thinking"),
];
await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable");
});
it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => {
const row = toModelRow({
model: makeGoogleAntigravityTemplate(
"claude-opus-4-6-thinking",
"Claude Opus 4.6 Thinking",
) as never,
key: "google-antigravity/claude-opus-4-6-thinking",
tags: [],
availableKeys: undefined,
});
expect(row.missing).toBe(false);
expect(row.available).toBe(false);
});
});