1
0

feat: 新增 LLM checker 支持大模型服务应用层拨测

基于 AI SDK v6 实现 openai/openai-responses/anthropic 三类 provider 的 http/stream 模式调用
支持 output/finishReason/usage/stream 等完整 expect 断言链路
新增 9 个源文件和 5 个测试文件共 78 个测试
更新 README/DEVELOPMENT/probes.example.yaml 和 probe-config.schema.json
This commit is contained in:
2026-05-19 00:06:53 +08:00
parent 52262a31f6
commit 349896bd02
24 changed files with 3511 additions and 8 deletions

View File

@@ -2,6 +2,7 @@ import { CommandChecker } from "./cmd";
import { DbChecker } from "./db";
import { HttpChecker } from "./http";
import { IcmpChecker } from "./icmp";
import { LlmChecker } from "./llm";
import { CheckerRegistry } from "./registry";
import { TcpChecker } from "./tcp";
import { UdpChecker } from "./udp";
@@ -13,6 +14,7 @@ const checkers = [
new TcpChecker(),
new IcmpChecker(),
new UdpChecker(),
new LlmChecker(),
];
export function createDefaultCheckerRegistry(): CheckerRegistry {

View File

@@ -0,0 +1,311 @@
import type { JSONObject } from "@ai-sdk/provider";
import { APICallError, generateText, streamText } from "ai";
import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { LlmCheckObservation, LlmExpectConfig, LlmTargetConfig, ResolvedLlmTarget } from "./types";
import { checkDuration } from "../../expect/duration";
import { errorFailure } from "../../expect/failure";
import { runExpects } from "./expect";
import {
buildObservationFromApiCallError,
buildObservationFromGenerateText,
buildObservationFromStreamText,
} from "./observation";
import { createProviderModel } from "./provider";
import { llmCheckerSchemas } from "./schema";
import { validateLlmConfig } from "./validate";
export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
readonly configKey = "llm";
readonly schemas = llmCheckerSchemas;
readonly type = "llm";
async execute(t: ResolvedLlmTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const expect = t.expect;
const start = performance.now();
try {
const { http: httpMeta, model } = createProviderModel(t.llm);
if (t.llm.mode === "stream") {
return await this.executeStream(t, model, httpMeta, expect, ctx, timestamp, start);
}
return await this.executeHttp(t, model, httpMeta, expect, ctx, timestamp, start);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
if (error instanceof APICallError) {
const observation = buildObservationFromApiCallError(error, t.llm.provider, t.llm.model, t.llm.mode);
if (observation.http === null) {
return {
durationMs,
failure: errorFailure("request", "request", error.message),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
const expectResult = runExpects(observation, expect);
const failure = expectResult.failure ?? durationResult.failure;
return {
durationMs,
failure,
matched: failure === null,
statusDetail: buildStatusDetail(observation),
targetId: t.id,
timestamp,
};
}
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
return {
durationMs,
failure: errorFailure(
"request",
"request",
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedLlmTarget {
const t = target as RawTargetConfig & { llm: LlmTargetConfig; type: "llm" };
const llmDefaults = context.defaults["llm"] as
| undefined
| {
headers?: Record<string, string>;
ignoreSSL?: boolean;
mode?: string;
options?: Record<string, unknown>;
providerOptions?: Record<string, Record<string, unknown>>;
};
const resolvedConfig = {
authToken: t.llm.authToken,
headers: { ...(llmDefaults?.headers ?? {}), ...(t.llm.headers ?? {}) },
ignoreSSL: t.llm.ignoreSSL ?? llmDefaults?.ignoreSSL ?? false,
key: t.llm.key ?? "",
mode: (t.llm.mode ?? llmDefaults?.mode ?? "http") as "http" | "stream",
model: t.llm.model,
options: {
frequencyPenalty:
t.llm.options?.frequencyPenalty ?? (llmDefaults?.options?.["frequencyPenalty"] as number | undefined),
maxOutputTokens:
t.llm.options?.maxOutputTokens ?? (llmDefaults?.options?.["maxOutputTokens"] as number | undefined) ?? 16,
presencePenalty:
t.llm.options?.presencePenalty ?? (llmDefaults?.options?.["presencePenalty"] as number | undefined),
seed: t.llm.options?.seed ?? (llmDefaults?.options?.["seed"] as number | undefined),
stopSequences:
t.llm.options?.stopSequences ?? (llmDefaults?.options?.["stopSequences"] as string[] | undefined),
temperature: t.llm.options?.temperature ?? (llmDefaults?.options?.["temperature"] as number | undefined) ?? 0,
topK: t.llm.options?.topK ?? (llmDefaults?.options?.["topK"] as number | undefined),
topP: t.llm.options?.topP ?? (llmDefaults?.options?.["topP"] as number | undefined),
},
prompt: t.llm.prompt,
provider: t.llm.provider,
providerOptions: {
...((llmDefaults?.providerOptions ?? {}) as Record<string, JSONObject>),
...(t.llm.providerOptions ?? {}),
},
url: t.llm.url,
};
return {
description: (target.description as null | string) ?? null,
expect: target.expect as LlmExpectConfig | undefined,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
llm: resolvedConfig,
name: (target.name as null | string) ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "llm",
} satisfies ResolvedLlmTarget;
}
serialize(t: ResolvedLlmTarget): { config: string; target: string } {
return {
config: JSON.stringify({
headers: t.llm.headers,
ignoreSSL: t.llm.ignoreSSL,
key: t.llm.key ? "***" : "",
mode: t.llm.mode,
model: t.llm.model,
options: t.llm.options,
prompt: t.llm.prompt,
provider: t.llm.provider,
providerOptions: t.llm.providerOptions,
url: t.llm.url,
}),
target: `${t.llm.provider}:${t.llm.model} @ ${t.llm.url}`,
};
}
validate(input: CheckerValidationInput) {
return validateLlmConfig(input);
}
private async executeHttp(
t: ResolvedLlmTarget,
model: ReturnType<typeof createProviderModel>["model"],
httpMeta: null | { headers: Record<string, string>; status: number; statusText: string },
expect: LlmExpectConfig | undefined,
ctx: CheckerContext,
timestamp: string,
start: number,
): Promise<CheckResult> {
const result = await generateText({
abortSignal: ctx.signal,
maxRetries: 0,
model,
prompt: t.llm.prompt,
providerOptions: t.llm.providerOptions,
...buildSdkOptions(t.llm),
});
const respHeaders = result.response?.headers;
const http = httpMeta ?? {
headers: respHeaders ? Object.fromEntries(Object.entries(respHeaders)) : {},
status: 200,
statusText: "",
};
const observation = buildObservationFromGenerateText(
t.llm.provider,
t.llm.model,
t.llm.mode,
{
finishReason: result.finishReason,
rawFinishReason: result.rawFinishReason,
text: result.text,
usage: {
inputTokens: result.usage.inputTokens ?? 0,
outputTokens: result.usage.outputTokens ?? 0,
totalTokens: result.usage.totalTokens,
},
warnings: result.warnings?.map((w) =>
w.type === "unsupported"
? `unsupported: ${w.feature}`
: ((w as Record<string, string>)["message"] ?? JSON.stringify(w)),
),
},
http,
);
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
const expectResult = runExpects(observation, expect);
const failure = expectResult.failure ?? durationResult.failure;
return {
durationMs,
failure,
matched: failure === null,
statusDetail: buildStatusDetail(observation),
targetId: t.id,
timestamp,
};
}
private async executeStream(
t: ResolvedLlmTarget,
model: ReturnType<typeof createProviderModel>["model"],
httpMeta: null | { headers: Record<string, string>; status: number; statusText: string },
expect: LlmExpectConfig | undefined,
ctx: CheckerContext,
timestamp: string,
start: number,
): Promise<CheckResult> {
const stream = streamText({
abortSignal: ctx.signal,
maxRetries: 0,
model,
prompt: t.llm.prompt,
providerOptions: t.llm.providerOptions,
...buildSdkOptions(t.llm),
});
const observation = await buildObservationFromStreamText(
t.llm.provider,
t.llm.model,
t.llm.mode,
stream.fullStream,
httpMeta,
start,
);
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
const expectResult = runExpects(observation, expect);
const failure = expectResult.failure ?? durationResult.failure;
return {
durationMs,
failure,
matched: failure === null,
statusDetail: buildStatusDetail(observation),
targetId: t.id,
timestamp,
};
}
}
function buildSdkOptions(config: ResolvedLlmTarget["llm"]): Record<string, unknown> {
const options: Record<string, unknown> = {};
const opts = config.options;
if (opts.maxOutputTokens !== undefined) options["maxOutputTokens"] = opts.maxOutputTokens;
if (opts.temperature !== undefined) options["temperature"] = opts.temperature;
if (opts.topP !== undefined) options["topP"] = opts.topP;
if (opts.topK !== undefined) options["topK"] = opts.topK;
if (opts.presencePenalty !== undefined) options["presencePenalty"] = opts.presencePenalty;
if (opts.frequencyPenalty !== undefined) options["frequencyPenalty"] = opts.frequencyPenalty;
if (opts.stopSequences !== undefined) options["stopSequences"] = opts.stopSequences;
if (opts.seed !== undefined) options["seed"] = opts.seed;
return options;
}
function buildStatusDetail(observation: LlmCheckObservation): string {
const parts: string[] = [`LLM ${observation.provider} ${observation.mode}`];
if (observation.http) {
parts.push(String(observation.http.status));
}
if (observation.finishReason) {
parts.push(`finish=${observation.finishReason}`);
}
if (observation.rawFinishReason) {
parts.push(`raw=${observation.rawFinishReason}`);
}
if (observation.stream?.firstTokenMs != null) {
parts.push(`firstToken=${observation.stream.firstTokenMs}ms`);
}
if (observation.outputText !== null) {
parts.push(`output=${observation.outputText.length} chars`);
}
if (observation.usage) {
parts.push(`usage=${observation.usage.inputTokens}/${observation.usage.outputTokens} tokens`);
}
return parts.join(", ");
}

View File

@@ -0,0 +1,168 @@
import type { ExpectResult } from "../../expect/types";
import type { LlmCheckObservation, LlmExpectConfig } from "./types";
import { checkDuration } from "../../expect/duration";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "../../expect/operator";
import { checkHeaders, checkStatus } from "../http/expect";
import { checkOutputRules } from "./output";
export function checkStreamExpect(observation: LlmCheckObservation, expect: LlmExpectConfig): ExpectResult {
if (!observation.stream || !expect.stream) return { failure: null, matched: true };
const expectedCompleted = expect.stream.completed ?? true;
if (observation.stream.completed !== expectedCompleted) {
return {
failure: mismatchFailure(
"stream",
"stream.completed",
expectedCompleted,
observation.stream.completed,
"stream.completed mismatch",
),
matched: false,
};
}
if (expect.stream.firstTokenMs && observation.stream.firstTokenMs !== null) {
if (!applyOperator(observation.stream.firstTokenMs, expect.stream.firstTokenMs)) {
return {
failure: mismatchFailure(
"stream",
"stream.firstTokenMs",
expect.stream.firstTokenMs,
observation.stream.firstTokenMs,
"stream.firstTokenMs mismatch",
),
matched: false,
};
}
} else if (expect.stream.firstTokenMs && observation.stream.firstTokenMs === null) {
return {
failure: mismatchFailure(
"stream",
"stream.firstTokenMs",
expect.stream.firstTokenMs,
null,
"stream.firstTokenMs missing",
),
matched: false,
};
}
return { failure: null, matched: true };
}
export function runExpects(observation: LlmCheckObservation, expect: LlmExpectConfig | undefined): ExpectResult {
if (!expect) {
const defaultStatus = checkStatus(observation.http?.status ?? 0, [200]);
if (!defaultStatus.matched) return defaultStatus;
return { failure: null, matched: true };
}
const http = observation.http;
const statusResult = checkStatus(http?.status ?? 0, expect.status ?? [200]);
if (!statusResult.matched) return statusResult;
if (http && expect.headers) {
const headersResult = checkHeaders(http.headers, expect.headers);
if (!headersResult.matched) return headersResult;
}
if (observation.stream !== null) {
const streamResult = checkStreamExpect(observation, expect);
if (!streamResult.matched) return streamResult;
}
const outputResult = checkOutputRules(observation.outputText, expect.output);
if (!outputResult.matched) return outputResult;
if (expect.finishReason !== undefined) {
if (observation.finishReason !== expect.finishReason) {
return {
failure: mismatchFailure(
"finishReason",
"finishReason",
expect.finishReason,
observation.finishReason,
"finishReason mismatch",
),
matched: false,
};
}
}
if (expect.rawFinishReason !== undefined) {
if (observation.rawFinishReason !== expect.rawFinishReason) {
return {
failure: mismatchFailure(
"rawFinishReason",
"rawFinishReason",
expect.rawFinishReason,
observation.rawFinishReason,
"rawFinishReason mismatch",
),
matched: false,
};
}
}
if (expect.usage && observation.usage) {
const usageResult = checkUsageExpect(observation.usage, expect.usage);
if (!usageResult.matched) return usageResult;
}
return { failure: null, matched: true };
}
function checkUsageExpect(
usage: { inputTokens: number; outputTokens: number; totalTokens: number },
expectUsage: { inputTokens?: unknown; outputTokens?: unknown; totalTokens?: unknown },
): ExpectResult {
if (expectUsage.inputTokens !== undefined) {
if (!applyOperator(usage.inputTokens, expectUsage.inputTokens as Parameters<typeof applyOperator>[1])) {
return {
failure: mismatchFailure(
"usage",
"usage.inputTokens",
expectUsage.inputTokens,
usage.inputTokens,
"usage.inputTokens mismatch",
),
matched: false,
};
}
}
if (expectUsage.outputTokens !== undefined) {
if (!applyOperator(usage.outputTokens, expectUsage.outputTokens as Parameters<typeof applyOperator>[1])) {
return {
failure: mismatchFailure(
"usage",
"usage.outputTokens",
expectUsage.outputTokens,
usage.outputTokens,
"usage.outputTokens mismatch",
),
matched: false,
};
}
}
if (expectUsage.totalTokens !== undefined) {
if (!applyOperator(usage.totalTokens, expectUsage.totalTokens as Parameters<typeof applyOperator>[1])) {
return {
failure: mismatchFailure(
"usage",
"usage.totalTokens",
expectUsage.totalTokens,
usage.totalTokens,
"usage.totalTokens mismatch",
),
matched: false,
};
}
}
return { failure: null, matched: true };
}
export { checkDuration };

View File

@@ -0,0 +1 @@
export { LlmChecker } from "./execute";

View File

@@ -0,0 +1,131 @@
import type { APICallError } from "ai";
import type {
LlmCheckObservation,
LlmHttpMetadata,
LlmMode,
LlmProvider,
LlmStreamObservation,
LlmUsageObservation,
} from "./types";
export function buildObservationFromApiCallError(
error: APICallError,
provider: LlmProvider,
model: string,
mode: LlmMode,
): LlmCheckObservation {
const http: LlmHttpMetadata | null =
error.statusCode !== undefined
? {
headers: error.responseHeaders ?? {},
status: error.statusCode,
statusText: "",
}
: null;
return {
finishReason: null,
http,
mode,
model,
outputText: null,
provider,
rawFinishReason: null,
stream: null,
usage: null,
warnings: [],
};
}
export function buildObservationFromGenerateText(
provider: LlmProvider,
model: string,
mode: LlmMode,
result: {
finishReason: string;
rawFinishReason: null | string | undefined;
text: string;
usage: { inputTokens: number; outputTokens: number; totalTokens?: number | undefined };
warnings?: string[];
},
http: LlmHttpMetadata | null,
): LlmCheckObservation {
return {
finishReason: result.finishReason,
http,
mode,
model,
outputText: result.text,
provider,
rawFinishReason: result.rawFinishReason ?? null,
stream: null,
usage: {
inputTokens: result.usage.inputTokens ?? 0,
outputTokens: result.usage.outputTokens ?? 0,
totalTokens: result.usage.totalTokens ?? (result.usage.inputTokens ?? 0) + (result.usage.outputTokens ?? 0),
},
warnings: result.warnings ?? [],
};
}
export async function buildObservationFromStreamText(
provider: LlmProvider,
model: string,
mode: LlmMode,
fullStream: AsyncIterable<unknown>,
http: LlmHttpMetadata | null,
startMs: number,
): Promise<LlmCheckObservation> {
let outputText = "";
let firstTokenMs: null | number = null;
let completed = false;
let finishReason: null | string = null;
let rawFinishReason: null | string = null;
let usage: LlmUsageObservation | null = null;
const warnings: string[] = [];
for await (const part of fullStream) {
const p = part as Record<string, unknown>;
const type = p["type"] as string;
if (type === "text-delta") {
const delta = p["textDelta"] as string;
if (delta !== "") {
firstTokenMs ??= Math.round(performance.now() - startMs);
outputText += delta;
}
} else if (type === "finish") {
completed = true;
finishReason = (p["finishReason"] as string) ?? null;
rawFinishReason = (p["rawFinishReason"] as string | undefined) ?? null;
const totalUsage = p["totalUsage"] as
| undefined
| { inputTokens: number; outputTokens: number; totalTokens: number };
const partUsage = p["usage"] as undefined | { inputTokens: number; outputTokens: number };
usage = {
inputTokens: totalUsage?.inputTokens ?? partUsage?.inputTokens ?? 0,
outputTokens: totalUsage?.outputTokens ?? partUsage?.outputTokens ?? 0,
totalTokens: totalUsage?.totalTokens ?? (partUsage?.inputTokens ?? 0) + (partUsage?.outputTokens ?? 0),
};
} else if (type === "error") {
const err = p["error"] as Error | undefined;
warnings.push(err?.message ?? "stream error");
}
}
return {
finishReason,
http,
mode,
model,
outputText: outputText || null,
provider,
rawFinishReason,
stream: { completed, firstTokenMs } satisfies LlmStreamObservation,
usage,
warnings,
};
}

View File

@@ -0,0 +1,83 @@
import type { ExpectResult } from "../../expect/types";
import type { OutputRule } from "./types";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator, evaluateJsonPath } from "../../expect/operator";
export function checkOutputRules(outputText: null | string, rules: OutputRule[] | undefined): ExpectResult {
if (!rules || rules.length === 0) return { failure: null, matched: true };
for (const rule of rules) {
const result = checkSingleOutputRule(outputText, rule);
if (!result.matched) return result;
}
return { failure: null, matched: true };
}
function checkSingleOutputRule(outputText: null | string, rule: OutputRule): ExpectResult {
if ("equals" in rule) {
if (outputText === null || outputText !== rule.equals) {
return {
failure: mismatchFailure("output", "output", rule.equals, outputText, "output equals mismatch"),
matched: false,
};
}
return { failure: null, matched: true };
}
if ("contains" in rule) {
if (!outputText?.includes(rule.contains)) {
return {
failure: mismatchFailure(
"output",
"output",
`contains: ${rule.contains}`,
outputText,
"output contains mismatch",
),
matched: false,
};
}
return { failure: null, matched: true };
}
if ("regex" in rule) {
if (outputText === null || !new RegExp(rule.regex).test(outputText)) {
return {
failure: mismatchFailure("output", "output", `match: ${rule.regex}`, outputText, "output regex mismatch"),
matched: false,
};
}
return { failure: null, matched: true };
}
if ("json" in rule) {
if (outputText === null) {
return {
failure: mismatchFailure("output", "output", "valid JSON", null, "output is null, cannot parse JSON"),
matched: false,
};
}
let parsed: unknown;
try {
parsed = JSON.parse(outputText);
} catch {
return {
failure: mismatchFailure("output", "output", "valid JSON", outputText, "output is not valid JSON"),
matched: false,
};
}
const value = evaluateJsonPath(parsed, rule.json.path);
if (!applyOperator(value, rule.json)) {
return {
failure: mismatchFailure("output", "output", rule.json, value, "output json mismatch"),
matched: false,
};
}
return { failure: null, matched: true };
}
return { failure: null, matched: true };
}

View File

@@ -0,0 +1,62 @@
import type { LanguageModel } from "ai";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createOpenAI } from "@ai-sdk/openai";
import type { LlmHttpMetadata, ResolvedLlmConfig } from "./types";
export interface ProviderResult {
http: LlmHttpMetadata | null;
model: LanguageModel;
}
export function createProviderModel(config: ResolvedLlmConfig): ProviderResult {
let httpMeta: LlmHttpMetadata | null = null;
const observingFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const fetchInit: Record<string, unknown> = { ...init };
if (config.ignoreSSL) {
fetchInit["tls"] = { rejectUnauthorized: false };
}
const response = await fetch(input, fetchInit);
httpMeta = {
headers: Object.fromEntries(response.headers),
status: response.status,
statusText: response.statusText,
};
return response;
};
const sharedOptions = {
apiKey: config.key,
baseURL: config.url,
fetch: observingFetch as typeof fetch,
headers: config.headers,
};
let model: LanguageModel;
switch (config.provider) {
case "anthropic": {
const provider = createAnthropic({
...sharedOptions,
...(config.authToken ? { headers: { ...config.headers, Authorization: `Bearer ${config.authToken}` } } : {}),
});
model = provider.messages(config.model);
break;
}
case "openai": {
const provider = createOpenAI(sharedOptions);
model = provider.chat(config.model);
break;
}
case "openai-responses": {
const provider = createOpenAI(sharedOptions);
model = provider.responses(config.model);
break;
}
}
return { http: httpMeta, model };
}

View File

@@ -0,0 +1,115 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import {
createHeaderExpectSchema,
createPureOperatorSchema,
statusCodePatternSchema,
stringMapSchema,
} from "../../schema/fragments";
function createLlmOptionsSchema() {
return Type.Object(
{
frequencyPenalty: Type.Optional(Type.Number()),
maxOutputTokens: Type.Optional(Type.Integer({ minimum: 1 })),
presencePenalty: Type.Optional(Type.Number()),
seed: Type.Optional(Type.Number()),
stopSequences: Type.Optional(Type.Array(Type.String())),
temperature: Type.Optional(Type.Number()),
topK: Type.Optional(Type.Number()),
topP: Type.Optional(Type.Number()),
},
{ additionalProperties: false },
);
}
function createLlmOutputRulesSchema() {
return Type.Array(
Type.Object(
{
contains: Type.Optional(Type.String()),
equals: Type.Optional(Type.String()),
json: Type.Optional(
Type.Object({ path: Type.String(), ...operatorProperties() }, { additionalProperties: false }),
),
regex: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
);
}
function operatorProperties() {
return {
contains: Type.Optional(Type.String()),
empty: Type.Optional(Type.Boolean()),
equals: Type.Optional(Type.Number()),
exists: Type.Optional(Type.Boolean()),
gt: Type.Optional(Type.Number()),
gte: Type.Optional(Type.Number()),
lt: Type.Optional(Type.Number()),
lte: Type.Optional(Type.Number()),
match: Type.Optional(Type.String()),
};
}
export const llmCheckerSchemas: CheckerSchemas = {
config: Type.Object(
{
authToken: Type.Optional(Type.String()),
headers: Type.Optional(stringMapSchema),
ignoreSSL: Type.Optional(Type.Boolean()),
key: Type.Optional(Type.String()),
mode: Type.Optional(Type.Union([Type.Literal("http"), Type.Literal("stream")])),
model: Type.String({ minLength: 1 }),
options: Type.Optional(createLlmOptionsSchema()),
prompt: Type.String({ minLength: 1 }),
provider: Type.Union([Type.Literal("openai"), Type.Literal("openai-responses"), Type.Literal("anthropic")]),
providerOptions: Type.Optional(Type.Record(Type.String(), Type.Object({}, { additionalProperties: true }))),
url: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
),
defaults: Type.Object(
{
headers: Type.Optional(stringMapSchema),
ignoreSSL: Type.Optional(Type.Boolean()),
mode: Type.Optional(Type.Union([Type.Literal("http"), Type.Literal("stream")])),
options: Type.Optional(createLlmOptionsSchema()),
providerOptions: Type.Optional(Type.Record(Type.String(), Type.Object({}, { additionalProperties: true }))),
},
{ additionalProperties: false },
),
expect: Type.Object(
{
finishReason: Type.Optional(Type.String()),
headers: Type.Optional(createHeaderExpectSchema()),
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
output: Type.Optional(createLlmOutputRulesSchema()),
rawFinishReason: Type.Optional(Type.String()),
status: Type.Optional(Type.Array(statusCodePatternSchema)),
stream: Type.Optional(
Type.Object(
{
completed: Type.Optional(Type.Boolean()),
firstTokenMs: Type.Optional(createPureOperatorSchema()),
},
{ additionalProperties: false },
),
),
usage: Type.Optional(
Type.Object(
{
inputTokens: Type.Optional(createPureOperatorSchema()),
outputTokens: Type.Optional(createPureOperatorSchema()),
totalTokens: Type.Optional(createPureOperatorSchema()),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
),
};

View File

@@ -0,0 +1,121 @@
import type { JSONObject } from "@ai-sdk/provider";
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
export interface LlmCheckObservation {
finishReason: null | string;
http: LlmHttpMetadata | null;
mode: LlmMode;
model: string;
outputText: null | string;
provider: LlmProvider;
rawFinishReason: null | string;
stream: LlmStreamObservation | null;
usage: LlmUsageObservation | null;
warnings: string[];
}
export interface LlmDefaultsConfig {
headers?: Record<string, string>;
ignoreSSL?: boolean;
mode?: LlmMode;
options?: LlmOptions;
providerOptions?: Record<string, JSONObject>;
}
export interface LlmExpectConfig {
finishReason?: string;
headers?: Record<string, ExpectOperator | string>;
maxDurationMs?: number;
output?: OutputRule[];
rawFinishReason?: string;
status?: Array<number | string>;
stream?: LlmStreamExpect;
usage?: LlmUsageExpect;
}
export interface LlmHttpMetadata {
headers: Record<string, string>;
status: number;
statusText: string;
}
export type LlmMode = "http" | "stream";
export interface LlmOptions {
frequencyPenalty?: number;
maxOutputTokens?: number;
presencePenalty?: number;
seed?: number;
stopSequences?: string[];
temperature?: number;
topK?: number;
topP?: number;
}
export type LlmProvider = "anthropic" | "openai" | "openai-responses";
export interface LlmStreamExpect {
completed?: boolean;
firstTokenMs?: ExpectOperator;
}
export interface LlmStreamObservation {
completed: boolean;
firstTokenMs: null | number;
}
export interface LlmTargetConfig {
authToken?: string;
headers?: Record<string, string>;
ignoreSSL?: boolean;
key?: string;
mode?: LlmMode;
model: string;
options?: LlmOptions;
prompt: string;
provider: LlmProvider;
providerOptions?: Record<string, JSONObject>;
url: string;
}
export interface LlmUsageExpect {
inputTokens?: ExpectOperator;
outputTokens?: ExpectOperator;
totalTokens?: ExpectOperator;
}
export interface LlmUsageObservation {
inputTokens: number;
outputTokens: number;
totalTokens: number;
}
export interface OutputJsonRule extends ExpectOperator {
path: string;
}
export type OutputRule = { contains: string } | { equals: string } | { json: OutputJsonRule } | { regex: string };
export interface ResolvedLlmConfig {
authToken?: string;
headers: Record<string, string>;
ignoreSSL: boolean;
key: string;
mode: LlmMode;
model: string;
options: LlmOptions;
prompt: string;
provider: LlmProvider;
providerOptions: Record<string, JSONObject>;
url: string;
}
export interface ResolvedLlmTarget extends ResolvedTargetBase {
expect?: LlmExpectConfig;
group: string;
intervalMs: number;
llm: ResolvedLlmConfig;
name: null | string;
timeoutMs: number;
type: "llm";
}

View File

@@ -0,0 +1,397 @@
import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { isUnsafeRegex } from "../../expect/redos";
import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
import { issue, joinPath } from "../../schema/issues";
const ALLOWED_PROVIDERS = new Set(["anthropic", "openai", "openai-responses"]);
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const ALLOWED_MODES = new Set(["http", "stream"]);
const OUTPUT_RULE_KEYS = ["contains", "equals", "json", "regex"] as const;
export function validateLlmConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults =
isPlainRecord(input.defaults) && isPlainRecord(input.defaults["llm"]) ? input.defaults["llm"] : undefined;
if (defaults) {
issues.push(...validateLlmDefaults(defaults, "defaults.llm"));
}
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainRecord(target)) continue;
if (target["type"] !== "llm") continue;
issues.push(...validateLlmTarget(target, `targets[${i}]`));
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function validateLlmDefaults(defaults: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
if (defaults["mode"] !== undefined && !ALLOWED_MODES.has(defaults["mode"] as string)) {
issues.push(issue("invalid-type", joinPath(path, "mode"), "必须为 http 或 stream"));
}
if (defaults["ignoreSSL"] !== undefined && !isBoolean(defaults["ignoreSSL"])) {
issues.push(issue("invalid-type", joinPath(path, "ignoreSSL"), "必须为布尔值"));
}
if (defaults["headers"] !== undefined) {
issues.push(...validateStringMap(defaults["headers"], joinPath(path, "headers")));
}
if (defaults["options"] !== undefined) {
issues.push(...validateLlmOptions(defaults["options"], joinPath(path, "options")));
}
if (defaults["providerOptions"] !== undefined) {
issues.push(...validateProviderOptions(defaults["providerOptions"], joinPath(path, "providerOptions")));
}
return issues;
}
function validateLlmExpect(
target: Record<string, unknown>,
path: string,
mode: string | undefined,
targetName?: string,
): ConfigValidationIssue[] {
const expect = target["expect"];
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (isArray(expect["status"])) {
issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName));
}
if (isPlainRecord(expect["headers"])) {
for (const [key, value] of Object.entries(expect["headers"])) {
if (isString(value)) continue;
issues.push(...validateOperatorObject(value, joinPath(joinPath(expectPath, "headers"), key), targetName));
}
}
if (expect["output"] !== undefined) {
issues.push(...validateOutputRules(expect["output"], joinPath(expectPath, "output"), targetName));
}
if (expect["finishReason"] !== undefined && !isString(expect["finishReason"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "finishReason"), "必须为字符串", targetName));
}
if (expect["rawFinishReason"] !== undefined && !isString(expect["rawFinishReason"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "rawFinishReason"), "必须为字符串", targetName));
}
if (expect["usage"] !== undefined) {
issues.push(...validateUsageExpect(expect["usage"], joinPath(expectPath, "usage"), targetName));
}
if (expect["stream"] !== undefined) {
if (mode === "http") {
issues.push(
issue("invalid-type", joinPath(expectPath, "stream"), "expect.stream 仅支持 stream mode", targetName),
);
} else {
issues.push(...validateStreamExpect(expect["stream"], joinPath(expectPath, "stream"), targetName));
}
}
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
}
return issues;
}
function validateLlmOptions(options: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(options)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (options["maxOutputTokens"] !== undefined) {
if (
!isNumber(options["maxOutputTokens"]) ||
!Number.isInteger(options["maxOutputTokens"]) ||
options["maxOutputTokens"] < 1
) {
issues.push(issue("invalid-type", joinPath(path, "maxOutputTokens"), "必须为正整数", targetName));
}
}
for (const key of ["temperature", "topP", "topK", "presencePenalty", "frequencyPenalty", "seed"]) {
if (options[key] !== undefined && (!isNumber(options[key]) || !Number.isFinite(options[key]))) {
issues.push(issue("invalid-type", joinPath(path, key), "必须为有限数字", targetName));
}
}
if (options["stopSequences"] !== undefined) {
if (!isArray(options["stopSequences"])) {
issues.push(issue("invalid-type", joinPath(path, "stopSequences"), "必须为字符串数组", targetName));
} else {
for (let i = 0; i < options["stopSequences"].length; i++) {
if (!isString(options["stopSequences"][i])) {
issues.push(issue("invalid-type", `${joinPath(path, "stopSequences")}[${i}]`, "必须为字符串", targetName));
}
}
}
}
return issues;
}
function validateLlmTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const llm = target["llm"];
if (!isPlainRecord(llm)) {
issues.push(issue("required", joinPath(path, "llm"), "缺少 llm 配置", targetName));
issues.push(...validateLlmExpect(target, path, undefined, targetName));
return issues;
}
if (!isString(llm["provider"]) || !ALLOWED_PROVIDERS.has(llm["provider"])) {
issues.push(
issue(
"invalid-type",
joinPath(joinPath(path, "llm"), "provider"),
"必须为 openai、openai-responses 或 anthropic",
targetName,
),
);
}
if (!isString(llm["url"]) || llm["url"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "llm"), "url"), "缺少 llm.url 字段", targetName));
} else {
try {
const url = new URL(llm["url"]);
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
issues.push(
issue(
"invalid-url",
joinPath(joinPath(path, "llm"), "url"),
"格式不合法,必须以 http:// 或 https:// 开头",
targetName,
),
);
}
} catch {
issues.push(issue("invalid-url", joinPath(joinPath(path, "llm"), "url"), "格式不合法", targetName));
}
}
if (!isString(llm["model"]) || llm["model"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "llm"), "model"), "必须为非空字符串", targetName));
}
if (!isString(llm["prompt"]) || llm["prompt"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "llm"), "prompt"), "必须为非空字符串", targetName));
}
if (llm["mode"] !== undefined && !ALLOWED_MODES.has(llm["mode"] as string)) {
issues.push(issue("invalid-type", joinPath(joinPath(path, "llm"), "mode"), "必须为 http 或 stream", targetName));
}
if (llm["headers"] !== undefined) {
issues.push(...validateStringMap(llm["headers"], joinPath(joinPath(path, "llm"), "headers"), targetName));
}
if (llm["ignoreSSL"] !== undefined && !isBoolean(llm["ignoreSSL"])) {
issues.push(issue("invalid-type", joinPath(joinPath(path, "llm"), "ignoreSSL"), "必须为布尔值", targetName));
}
const provider = llm["provider"] as string | undefined;
if (llm["authToken"] !== undefined) {
if (provider !== "anthropic") {
issues.push(
issue(
"invalid-auth",
joinPath(joinPath(path, "llm"), "authToken"),
"authToken 仅支持 anthropic provider",
targetName,
),
);
}
}
if (
provider === "anthropic" &&
isString(llm["key"]) &&
llm["key"].trim() !== "" &&
isString(llm["authToken"]) &&
llm["authToken"].trim() !== ""
) {
issues.push(
issue("auth-conflict", joinPath(joinPath(path, "llm"), "key"), "key 与 authToken 不能同时配置", targetName),
);
}
if (llm["options"] !== undefined) {
issues.push(...validateLlmOptions(llm["options"], joinPath(joinPath(path, "llm"), "options"), targetName));
}
if (llm["providerOptions"] !== undefined) {
issues.push(
...validateProviderOptions(
llm["providerOptions"],
joinPath(joinPath(path, "llm"), "providerOptions"),
targetName,
),
);
}
const mode = (llm["mode"] as string | undefined) ?? "http";
issues.push(...validateLlmExpect(target, path, mode, targetName));
return issues;
}
function validateOutputJsonRule(value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(value)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (!isString(value["path"]) || !value["path"].startsWith("$.") || value["path"].length <= 2) {
issues.push(issue("invalid-jsonpath", joinPath(path, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName));
}
const operatorKeys = new Set(["path"]);
const operators: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
if (operatorKeys.has(key)) continue;
operators[key] = val;
}
issues.push(...validateOperatorObject(operators, path, targetName, { requireAtLeastOne: false }));
return issues;
}
function validateOutputRegex(value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isString(value)) return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(value);
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
return isUnsafeRegex(value) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
}
function validateOutputRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
return rules.flatMap((rule, index) => validateSingleOutputRule(rule, `${path}[${index}]`, targetName));
}
function validateProviderOptions(value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainObject(value)) return [issue("invalid-type", path, "必须为 JSON object", targetName)];
return [];
}
function validateSingleOutputRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const found = OUTPUT_RULE_KEYS.filter((type) => type in rule);
if (found.length === 0) return [issue("missing-body-rule", path, "缺少支持的规则类型", targetName)];
if (found.length > 1) return [issue("multiple-body-rules", path, "只能配置一种规则类型", targetName)];
const ruleType = found[0]!;
const issues: ConfigValidationIssue[] = [];
for (const key of Object.keys(rule)) {
if (key !== ruleType) issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
}
if (issues.length > 0) return issues;
switch (ruleType) {
case "contains":
return isString(rule["contains"])
? []
: [issue("invalid-type", joinPath(path, "contains"), "必须为字符串", targetName)];
case "equals":
return isString(rule["equals"])
? []
: [issue("invalid-type", joinPath(path, "equals"), "必须为字符串", targetName)];
case "json":
return validateOutputJsonRule(rule["json"], joinPath(path, "json"), targetName);
case "regex":
return validateOutputRegex(rule["regex"], joinPath(path, "regex"), targetName);
}
}
function validateStatusValues(values: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < values.length; i++) {
const value = values[i];
const itemPath = `${path}[${i}]`;
if (isNumber(value)) {
if (!Number.isInteger(value) || value < 100 || value > 599) {
issues.push(issue("invalid-status", itemPath, "status 数字必须为 100-599 之间的整数", targetName));
}
continue;
}
if (isString(value)) {
if (!/^[1-5]xx$/.test(value)) {
issues.push(issue("invalid-status", itemPath, "status 模式必须为 1xx 到 5xx", targetName));
}
continue;
}
issues.push(issue("invalid-status", itemPath, "status 必须为整数或 1xx 到 5xx 模式", targetName));
}
return issues;
}
function validateStreamExpect(stream: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(stream)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (stream["completed"] !== undefined && !isBoolean(stream["completed"])) {
issues.push(issue("invalid-type", joinPath(path, "completed"), "必须为布尔值", targetName));
}
if (stream["firstTokenMs"] !== undefined) {
issues.push(...validateOperatorObject(stream["firstTokenMs"], joinPath(path, "firstTokenMs"), targetName));
}
return issues;
}
function validateStringMap(value: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainObject(value)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
if (!isString(val)) {
issues.push(issue("invalid-type", joinPath(path, key), "必须为字符串", targetName));
}
}
return issues;
}
function validateUsageExpect(usage: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(usage)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (usage["inputTokens"] !== undefined) {
issues.push(...validateOperatorObject(usage["inputTokens"], joinPath(path, "inputTokens"), targetName));
}
if (usage["outputTokens"] !== undefined) {
issues.push(...validateOperatorObject(usage["outputTokens"], joinPath(path, "outputTokens"), targetName));
}
if (usage["totalTokens"] !== undefined) {
issues.push(...validateOperatorObject(usage["totalTokens"], joinPath(path, "totalTokens"), targetName));
}
return issues;
}