openclaw/src/commands/models/shared.test.ts
Julian Gardner 8755080620 fix: validate model names in models set/fallbacks commands (#4179)
Add configuration-time validation for model names using the model catalog.
Invalid models now show fuzzy suggestions. Add --force flag to skip validation.

- Add validateModelInCatalog() and validateImageModel() helpers
- Update models set, set-image, fallbacks add, image-fallbacks add
- Graceful degradation when catalog unavailable
- Add tests for validation functions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:50:17 +02:00

155 lines
5.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockLoadModelCatalog = vi.fn();
const mockFindModelInCatalog = vi.fn();
const mockModelSupportsVision = vi.fn();
vi.mock("../../agents/model-catalog.js", () => ({
loadModelCatalog: () => mockLoadModelCatalog(),
findModelInCatalog: (catalog: unknown[], provider: string, modelId: string) =>
mockFindModelInCatalog(catalog, provider, modelId),
modelSupportsVision: (entry: unknown) => mockModelSupportsVision(entry),
}));
describe("validateModelInCatalog", () => {
beforeEach(() => {
vi.resetModules();
mockLoadModelCatalog.mockReset();
mockFindModelInCatalog.mockReset();
mockModelSupportsVision.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns valid: true when model is found in catalog", async () => {
const mockEntry = { id: "claude-opus-4-5", name: "Claude Opus 4.5", provider: "anthropic" };
mockLoadModelCatalog.mockResolvedValue([mockEntry]);
mockFindModelInCatalog.mockReturnValue(mockEntry);
const { validateModelInCatalog } = await import("./shared.js");
const result = await validateModelInCatalog("anthropic", "claude-opus-4-5");
expect(result.valid).toBe(true);
expect(result.entry).toEqual(mockEntry);
expect(result.suggestions).toBeUndefined();
});
it("returns valid: false with suggestions when model not found", async () => {
const catalog = [
{ id: "claude-opus-4-5", name: "Claude Opus 4.5", provider: "anthropic" },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic" },
];
mockLoadModelCatalog.mockResolvedValue(catalog);
mockFindModelInCatalog.mockReturnValue(undefined);
const { validateModelInCatalog } = await import("./shared.js");
const result = await validateModelInCatalog("anthropic", "claude-sonnet-4");
expect(result.valid).toBe(false);
expect(result.suggestions).toBeDefined();
expect(result.suggestions?.length).toBeGreaterThan(0);
// claude-sonnet-4-5 should be suggested as it's similar
expect(result.suggestions).toContain("anthropic/claude-sonnet-4-5");
});
it("returns valid: true when catalog is empty (graceful degradation)", async () => {
mockLoadModelCatalog.mockResolvedValue([]);
const { validateModelInCatalog } = await import("./shared.js");
const result = await validateModelInCatalog("anthropic", "claude-opus-4-5");
expect(result.valid).toBe(true);
expect(result.entry).toBeUndefined();
});
it("returns valid: true when catalog fails to load (graceful degradation)", async () => {
mockLoadModelCatalog.mockRejectedValue(new Error("Failed to load catalog"));
const { validateModelInCatalog } = await import("./shared.js");
const result = await validateModelInCatalog("anthropic", "claude-opus-4-5");
expect(result.valid).toBe(true);
expect(result.entry).toBeUndefined();
});
it("fuzzy matches similar model names within same provider", async () => {
const catalog = [
{ id: "gpt-4o", name: "GPT-4o", provider: "openai" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini", provider: "openai" },
{ id: "gpt-4-turbo", name: "GPT-4 Turbo", provider: "openai" },
];
mockLoadModelCatalog.mockResolvedValue(catalog);
mockFindModelInCatalog.mockReturnValue(undefined);
const { validateModelInCatalog } = await import("./shared.js");
const result = await validateModelInCatalog("openai", "gpt-4");
expect(result.valid).toBe(false);
expect(result.suggestions).toBeDefined();
// gpt-4o and gpt-4-turbo should be suggested
expect(result.suggestions?.some((s) => s.includes("gpt-4"))).toBe(true);
});
});
describe("validateImageModel", () => {
beforeEach(() => {
vi.resetModules();
mockLoadModelCatalog.mockReset();
mockFindModelInCatalog.mockReset();
mockModelSupportsVision.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns supportsVision: true when model supports image input", async () => {
const mockEntry = {
id: "gpt-4o",
name: "GPT-4o",
provider: "openai",
input: ["text", "image"],
};
mockLoadModelCatalog.mockResolvedValue([mockEntry]);
mockFindModelInCatalog.mockReturnValue(mockEntry);
mockModelSupportsVision.mockReturnValue(true);
const { validateImageModel } = await import("./shared.js");
const result = await validateImageModel("openai", "gpt-4o");
expect(result.valid).toBe(true);
expect(result.supportsVision).toBe(true);
});
it("returns supportsVision: false when model does not support image input", async () => {
const mockEntry = {
id: "gpt-3.5-turbo",
name: "GPT-3.5 Turbo",
provider: "openai",
input: ["text"],
};
mockLoadModelCatalog.mockResolvedValue([mockEntry]);
mockFindModelInCatalog.mockReturnValue(mockEntry);
mockModelSupportsVision.mockReturnValue(false);
const { validateImageModel } = await import("./shared.js");
const result = await validateImageModel("openai", "gpt-3.5-turbo");
expect(result.valid).toBe(true);
expect(result.supportsVision).toBe(false);
});
it("returns valid: false when model not found", async () => {
mockLoadModelCatalog.mockResolvedValue([{ id: "gpt-4o", name: "GPT-4o", provider: "openai" }]);
mockFindModelInCatalog.mockReturnValue(undefined);
const { validateImageModel } = await import("./shared.js");
const result = await validateImageModel("openai", "nonexistent-model");
expect(result.valid).toBe(false);
expect(result.supportsVision).toBeUndefined();
});
});