test: move provider tests into owning extensions

This commit is contained in:
Peter Steinberger
2026-04-06 16:45:59 +01:00
parent c109a7623b
commit d60149c655
9 changed files with 52 additions and 71 deletions

View File

@@ -0,0 +1,57 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveImplicitProvidersForTest } from "../../src/agents/models-config.e2e-harness.js";
import { captureEnv } from "../../src/test-utils/env.js";
describe("Kilo Gateway implicit provider", () => {
it("should include kilocode when KILOCODE_API_KEY is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KILOCODE_API_KEY"]);
process.env.KILOCODE_API_KEY = "test-key";
try {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.kilocode).toBeDefined();
expect(providers?.kilocode?.models?.length).toBeGreaterThan(0);
} finally {
envSnapshot.restore();
}
});
it("should not include kilocode when no API key is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KILOCODE_API_KEY"]);
delete process.env.KILOCODE_API_KEY;
try {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.kilocode).toBeUndefined();
} finally {
envSnapshot.restore();
}
});
it("should preserve an explicit kilocode provider override", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KILOCODE_API_KEY"]);
process.env.KILOCODE_API_KEY = "test-key";
try {
const providers = await resolveImplicitProvidersForTest({
agentDir,
explicitProviders: {
kilocode: {
baseUrl: "https://proxy.example.com/v1",
api: "openai-completions",
models: [],
},
},
});
expect(providers?.kilocode?.baseUrl).toBe("https://proxy.example.com/v1");
} finally {
envSnapshot.restore();
}
});
});

View File

@@ -0,0 +1,216 @@
import { describe, expect, it, vi } from "vitest";
import { discoverKilocodeModels, KILOCODE_MODELS_URL } from "./provider-models.js";
function makeGatewayModel(overrides: Record<string, unknown> = {}) {
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<string, unknown> = {}) {
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<typeof vi.fn>,
runAssertions: () => Promise<void>,
) {
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);
});
});
});