import { describe, expect, test } from "bun:test"; import type { ResolvedLlmTarget } from "../../../../../src/server/checker/runner/llm/types"; import type { ResolveContext } from "../../../../../src/server/checker/runner/types"; import type { RawTargetConfig } from "../../../../../src/server/checker/types"; import { checkerRegistry } from "../../../../../src/server/checker/runner"; import { validateLlmConfig } from "../../../../../src/server/checker/runner/llm/validate"; interface SerializedConfig { headers: Record; ignoreSSL: boolean; key: string; mode: string; model: string; options: Record; prompt: string; provider: string; providerOptions: Record; url: string; } function asLlm(resolved: ReturnType["resolve"]>): ResolvedLlmTarget { return resolved as ResolvedLlmTarget; } function makeRawTarget(overrides?: Partial): RawTargetConfig { return { id: "test-llm", llm: { model: "gpt-4o-mini", prompt: "Say OK", provider: "openai", url: "https://api.openai.com/v1", }, type: "llm", ...overrides, }; } function makeResolveContext(overrides?: Partial): ResolveContext { return { configDir: "/tmp", defaultIntervalMs: 30000, defaults: {}, defaultTimeoutMs: 10000, ...overrides, }; } function parseSerializedConfig(json: string): SerializedConfig { return JSON.parse(json) as SerializedConfig; } describe("LlmChecker schema", () => { const checker = checkerRegistry.tryGet("llm"); test("llm checker 注册到 registry", () => { expect(checker).toBeDefined(); expect(checker?.type).toBe("llm"); expect(checker?.configKey).toBe("llm"); }); test("schemas 包含 config、defaults、expect", () => { expect(checker).toBeDefined(); expect(Object.keys(checker!.schemas).sort()).toEqual(["config", "defaults", "expect"].sort()); }); }); describe("LlmChecker validate", () => { test("合法 LLM target 无校验问题", () => { const issues = validateLlmConfig({ defaults: {}, targets: [makeRawTarget()], }); expect(issues).toHaveLength(0); }); test("provider 非法报错", () => { const issues = validateLlmConfig({ defaults: {}, targets: [ makeRawTarget({ llm: { model: "m", prompt: "p", provider: "gemini", url: "https://x" }, }), ], }); expect(issues.length).toBeGreaterThan(0); expect(issues.some((i) => i.code === "invalid-type" && i.path.includes("provider"))).toBe(true); }); test("url 非法报错", () => { const issues = validateLlmConfig({ defaults: {}, targets: [ makeRawTarget({ llm: { model: "m", prompt: "p", provider: "openai", url: "ftp://bad" }, }), ], }); expect(issues.length).toBeGreaterThan(0); expect(issues.some((i) => i.code === "invalid-url")).toBe(true); }); test("model 为空报错", () => { const issues = validateLlmConfig({ defaults: {}, targets: [ makeRawTarget({ llm: { model: "", prompt: "p", provider: "openai", url: "https://x" }, }), ], }); expect(issues.length).toBeGreaterThan(0); expect(issues.some((i) => i.path.includes("model"))).toBe(true); }); test("prompt 为空报错", () => { const issues = validateLlmConfig({ defaults: {}, targets: [ makeRawTarget({ llm: { model: "m", prompt: "", provider: "openai", url: "https://x" }, }), ], }); expect(issues.length).toBeGreaterThan(0); expect(issues.some((i) => i.path.includes("prompt"))).toBe(true); }); test("mode 非法报错", () => { const issues = validateLlmConfig({ defaults: {}, targets: [ makeRawTarget({ llm: { mode: "batch", model: "m", prompt: "p", provider: "openai", url: "https://x" }, }), ], }); expect(issues.length).toBeGreaterThan(0); expect(issues.some((i) => i.path.includes("mode"))).toBe(true); }); test("openai provider 不允许 authToken", () => { const issues = validateLlmConfig({ defaults: {}, targets: [ makeRawTarget({ llm: { authToken: "tok", model: "m", prompt: "p", provider: "openai", url: "https://x" }, }), ], }); expect(issues.some((i) => i.code === "invalid-auth")).toBe(true); }); test("anthropic 同时配置 key 和 authToken 报错", () => { const issues = validateLlmConfig({ defaults: {}, targets: [ makeRawTarget({ llm: { authToken: "tok", key: "k", model: "m", prompt: "p", provider: "anthropic", url: "https://x" }, }), ], }); expect(issues.some((i) => i.code === "auth-conflict")).toBe(true); }); test("ignoreSSL 非布尔值报错", () => { const issues = validateLlmConfig({ defaults: {}, targets: [ makeRawTarget({ llm: { ignoreSSL: "yes", model: "m", prompt: "p", provider: "openai", url: "https://x" }, }), ], }); expect(issues.some((i) => i.path.includes("ignoreSSL"))).toBe(true); }); test("options.maxOutputTokens 非正整数报错", () => { const issues = validateLlmConfig({ defaults: {}, targets: [ makeRawTarget({ llm: { model: "m", options: { maxOutputTokens: -1 }, prompt: "p", provider: "openai", url: "https://x" }, }), ], }); expect(issues.some((i) => i.path.includes("maxOutputTokens"))).toBe(true); }); test("options.stopSequences 非字符串数组报错", () => { const issues = validateLlmConfig({ defaults: {}, targets: [ makeRawTarget({ llm: { model: "m", options: { stopSequences: [123] }, prompt: "p", provider: "openai", url: "https://x" }, }), ], }); expect(issues.some((i) => i.path.includes("stopSequences"))).toBe(true); }); test("expect.output 缺少规则类型报错", () => { const issues = validateLlmConfig({ defaults: {}, targets: [makeRawTarget({ expect: { output: [{}] } })], }); expect(issues.some((i) => i.code === "empty-matcher")).toBe(true); }); test("expect.output 直接 matcher 混入 extractor 报错", () => { const issues = validateLlmConfig({ defaults: {}, targets: [makeRawTarget({ expect: { output: [{ contains: "y", json: { path: "$.status" } }] } })], }); expect(issues.some((i) => i.code === "invalid-content-expectation")).toBe(true); }); test("expect.output regex ReDoS 报错", () => { const issues = validateLlmConfig({ defaults: {}, targets: [makeRawTarget({ expect: { output: [{ regex: "(a+)+" }] } })], }); expect(issues.some((i) => i.code === "unsafe-regex")).toBe(true); }); test("expect.stream 在 mode:http 下报错", () => { const issues = validateLlmConfig({ defaults: {}, targets: [ makeRawTarget({ expect: { stream: { completed: true } }, llm: { mode: "http", model: "m", prompt: "p", provider: "openai", url: "https://x" }, }), ], }); expect(issues.some((i) => i.message.includes("stream mode"))).toBe(true); }); test("expect.stream 在 mode:stream 下合法", () => { const issues = validateLlmConfig({ defaults: {}, targets: [ makeRawTarget({ expect: { stream: { completed: true } }, llm: { mode: "stream", model: "m", prompt: "p", provider: "openai", url: "https://x" }, }), ], }); expect(issues).toHaveLength(0); }); test("defaults.llm 合法配置", () => { const issues = validateLlmConfig({ defaults: { llm: { headers: { "X-Custom": "val" }, ignoreSSL: false, mode: "http", options: { maxOutputTokens: 32 } }, }, targets: [makeRawTarget()], }); expect(issues).toHaveLength(0); }); test("defaults.llm mode 非法报错", () => { const issues = validateLlmConfig({ defaults: { llm: { mode: "batch" } }, targets: [makeRawTarget()], }); expect(issues.some((i) => i.path.includes("defaults.llm.mode"))).toBe(true); }); }); describe("LlmChecker resolve", () => { const checker = checkerRegistry.tryGet("llm")!; test("最简 target 填充默认值", () => { const resolved = asLlm(checker.resolve(makeRawTarget(), makeResolveContext())); expect(resolved.type).toBe("llm"); expect(resolved.llm.mode).toBe("http"); expect(resolved.llm.key).toBe(""); expect(resolved.llm.ignoreSSL).toBe(false); expect(resolved.llm.options.maxOutputTokens).toBe(16); expect(resolved.llm.options.temperature).toBe(0); expect(resolved.group).toBe("default"); expect(resolved.intervalMs).toBe(30000); expect(resolved.timeoutMs).toBe(10000); expect(resolved.rawExpect).toBeUndefined(); expect(resolved.expect).toEqual({ status: [200] }); }); test("stream mode 未配置 expect.stream 时不物化 completed", () => { const raw = makeRawTarget({ expect: { output: [{ contains: "OK" }] }, llm: { mode: "stream", model: "gpt-4o-mini", prompt: "Say OK", provider: "openai", url: "https://api.openai.com/v1", }, }); const resolved = asLlm(checker.resolve(raw, makeResolveContext())); expect(resolved.rawExpect).toEqual({ output: [{ contains: "OK" }] }); expect(resolved.expect?.stream).toBeUndefined(); }); test("配置 expect.stream 但省略 completed 时默认 true", () => { const raw = makeRawTarget({ expect: { stream: { firstTokenMs: 100 } }, llm: { mode: "stream", model: "gpt-4o-mini", prompt: "Say OK", provider: "openai", url: "https://api.openai.com/v1", }, }); const resolved = asLlm(checker.resolve(raw, makeResolveContext())); expect(resolved.rawExpect).toEqual({ stream: { firstTokenMs: 100 } }); expect(resolved.expect?.stream).toEqual({ completed: true, firstTokenMs: { equals: 100 } }); }); test("defaults.llm 与 target.llm 浅合并", () => { const raw = makeRawTarget({ llm: { headers: { Authorization: "Bearer test" }, model: "gpt-4o-mini", prompt: "Say OK", provider: "openai", url: "https://api.openai.com/v1", }, }); const ctx = makeResolveContext({ defaults: { llm: { headers: { "X-Custom": "default" }, ignoreSSL: true, mode: "stream", options: { maxOutputTokens: 64, temperature: 0.5 }, }, }, }); const resolved = asLlm(checker.resolve(raw, ctx)); expect(resolved.llm.mode).toBe("stream"); expect(resolved.llm.ignoreSSL).toBe(true); expect(resolved.llm.headers).toEqual({ Authorization: "Bearer test", "X-Custom": "default" }); expect(resolved.llm.options.maxOutputTokens).toBe(64); expect(resolved.llm.options.temperature).toBe(0.5); }); test("target 字段覆盖 defaults", () => { const raw = makeRawTarget({ llm: { ignoreSSL: false, mode: "http", model: "gpt-4o-mini", options: { maxOutputTokens: 8 }, prompt: "Say OK", provider: "openai", url: "https://api.openai.com/v1", }, }); const ctx = makeResolveContext({ defaults: { llm: { ignoreSSL: true, mode: "stream", options: { maxOutputTokens: 64 }, }, }, }); const resolved = asLlm(checker.resolve(raw, ctx)); expect(resolved.llm.mode).toBe("http"); expect(resolved.llm.ignoreSSL).toBe(false); expect(resolved.llm.options.maxOutputTokens).toBe(8); }); test("serialize 返回正确格式", () => { const resolved = asLlm(checker.resolve(makeRawTarget(), makeResolveContext())); const serialized = checker.serialize(resolved); expect(serialized.target).toBe("openai:gpt-4o-mini @ https://api.openai.com/v1"); const config = parseSerializedConfig(serialized.config); expect(config.provider).toBe("openai"); expect(config.key).toBe(""); expect(config.model).toBe("gpt-4o-mini"); }); test("serialize 隐藏 key", () => { const raw = makeRawTarget({ llm: { key: "sk-secret-key", model: "m", prompt: "p", provider: "openai", url: "https://x" }, }); const resolved = asLlm(checker.resolve(raw, makeResolveContext())); const serialized = checker.serialize(resolved); const config = parseSerializedConfig(serialized.config); expect(config.key).toBe("***"); }); test("providerOptions 浅合并", () => { const raw = makeRawTarget({ llm: { model: "m", prompt: "p", provider: "openai", providerOptions: { openai: { store: true } }, url: "https://x", }, }); const ctx = makeResolveContext({ defaults: { llm: { providerOptions: { openai: { user: "default-user" } }, }, }, }); const resolved = asLlm(checker.resolve(raw, ctx)); expect(resolved.llm.providerOptions).toEqual({ openai: { store: true } }); }); });