import { describe, expect, it, vi } from "vitest"; import { discoverKilocodeModels, KILOCODE_MODELS_URL } from "./provider-models.js"; function makeGatewayModel(overrides: Record = {}) { return { id: "anthropic/claude-sonnet-4", name: "Anthropic: Claude Sonnet 4", created: 1700000000, description: "A model", context_length: 200000, architecture: { input_modalities: ["text", "image"], output_modalities: ["text"], tokenizer: "Claude", }, top_provider: { is_moderated: false, max_completion_tokens: 8192, }, pricing: { prompt: "0.000003", completion: "0.000015", input_cache_read: "0.0000003", input_cache_write: "0.00000375", }, supported_parameters: ["max_tokens", "temperature", "tools", "reasoning"], ...overrides, }; } function makeAutoModel(overrides: Record = {}) { return makeGatewayModel({ id: "kilo/auto", name: "Kilo: Auto", context_length: 1000000, architecture: { input_modalities: ["text", "image"], output_modalities: ["text"], tokenizer: "Other", }, top_provider: { is_moderated: false, max_completion_tokens: 128000, }, pricing: { prompt: "0.000005", completion: "0.000025", }, supported_parameters: ["max_tokens", "temperature", "tools", "reasoning", "include_reasoning"], ...overrides, }); } async function withFetchPathTest( mockFetch: ReturnType, runAssertions: () => Promise, ) { const origNodeEnv = process.env.NODE_ENV; const origVitest = process.env.VITEST; delete process.env.NODE_ENV; delete process.env.VITEST; vi.stubGlobal("fetch", mockFetch); try { await runAssertions(); } finally { if (origNodeEnv === undefined) { delete process.env.NODE_ENV; } else { process.env.NODE_ENV = origNodeEnv; } if (origVitest === undefined) { delete process.env.VITEST; } else { process.env.VITEST = origVitest; } vi.unstubAllGlobals(); } } describe("discoverKilocodeModels", () => { it("returns static catalog in test environment", async () => { const models = await discoverKilocodeModels(); expect(models.length).toBeGreaterThan(0); expect(models.some((m) => m.id === "kilo/auto")).toBe(true); }); it("static catalog has correct defaults for kilo/auto", async () => { const models = await discoverKilocodeModels(); const auto = models.find((m) => m.id === "kilo/auto"); expect(auto).toBeDefined(); expect(auto?.name).toBe("Kilo Auto"); expect(auto?.reasoning).toBe(true); expect(auto?.input).toEqual(["text", "image"]); expect(auto?.contextWindow).toBe(1000000); expect(auto?.maxTokens).toBe(128000); expect(auto?.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); }); }); describe("discoverKilocodeModels (fetch path)", () => { it("parses gateway models with correct pricing conversion", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ data: [makeAutoModel(), makeGatewayModel()], }), }); await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); expect(mockFetch).toHaveBeenCalledWith( KILOCODE_MODELS_URL, expect.objectContaining({ headers: { Accept: "application/json" }, }), ); expect(models.length).toBe(2); const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4"); expect(sonnet).toBeDefined(); expect(sonnet?.cost.input).toBeCloseTo(3.0); expect(sonnet?.cost.output).toBeCloseTo(15.0); expect(sonnet?.cost.cacheRead).toBeCloseTo(0.3); expect(sonnet?.cost.cacheWrite).toBeCloseTo(3.75); expect(sonnet?.input).toEqual(["text", "image"]); expect(sonnet?.reasoning).toBe(true); expect(sonnet?.contextWindow).toBe(200000); expect(sonnet?.maxTokens).toBe(8192); }); }); it("falls back to static catalog on network error", async () => { const mockFetch = vi.fn().mockRejectedValue(new Error("network error")); await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); expect(models.length).toBeGreaterThan(0); expect(models.some((m) => m.id === "kilo/auto")).toBe(true); }); }); it("falls back to static catalog on HTTP error", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 500, }); await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); expect(models.length).toBeGreaterThan(0); expect(models.some((m) => m.id === "kilo/auto")).toBe(true); }); }); it("ensures kilo/auto is present even when API doesn't return it", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ data: [makeGatewayModel()], }), }); await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); expect(models.some((m) => m.id === "kilo/auto")).toBe(true); expect(models.some((m) => m.id === "anthropic/claude-sonnet-4")).toBe(true); }); }); it("detects text-only models without image modality", async () => { const textOnlyModel = makeGatewayModel({ id: "some/text-model", architecture: { input_modalities: ["text"], output_modalities: ["text"], }, supported_parameters: ["max_tokens", "temperature"], }); const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ data: [textOnlyModel] }), }); await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); const textModel = models.find((m) => m.id === "some/text-model"); expect(textModel?.input).toEqual(["text"]); expect(textModel?.reasoning).toBe(false); }); }); it("keeps a later valid duplicate when an earlier entry is malformed", async () => { const malformedAutoModel = makeAutoModel({ name: "Broken Kilo Auto", pricing: undefined, }); const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ data: [malformedAutoModel, makeAutoModel(), makeGatewayModel()], }), }); await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); const auto = models.find((m) => m.id === "kilo/auto"); expect(auto).toBeDefined(); expect(auto?.name).toBe("Kilo: Auto"); expect(auto?.cost.input).toBeCloseTo(5.0); expect(models.some((m) => m.id === "anthropic/claude-sonnet-4")).toBe(true); }); }); });