1
0
Files
DiAL/src/server/checker/runner/llm/observation.ts
lanyuanxiaoyao 375dd3492b feat: 结构化 observation 替代 statusDetail,API 层动态构造 detail
- CheckResult: statusDetail -> observation (持久化) + detail (API 动态派生)
- 存储: status_detail 列 -> observation TEXT (JSON)
- CheckerDefinition: 新增 buildDetail(observation) 方法
- 各 checker 返回结构化 observation,API 层通过 registry 调用 buildDetail
- HTTP: bodyPreview 在 status/header 失败时也提前采集
- UDP: observation 包含 durationMs,未响应归为 error failure
- CMD: 超时/输出超限时保留已收集 observation
- TCP: connectTimeMs 仅含连接建立耗时,不含 banner 等待
- 新增 buildDetail 单测和 mapCheckResult 覆盖测试
- 同步 openspec 主规范,归档 checker-observation 变更
2026-05-19 22:49:00 +08:00

175 lines
4.7 KiB
TypeScript

import type { APICallError } from "ai";
import type {
LlmCheckObservation,
LlmHttpMetadata,
LlmMode,
LlmPersistedHttpMetadata,
LlmPersistedObservation,
LlmProvider,
LlmStreamObservation,
LlmUsageObservation,
} from "./types";
import { LLM_HEADERS_MAX, LLM_OUTPUT_PREVIEW_MAX } 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,
};
}
export function toPersistedObservation(obs: LlmCheckObservation): LlmPersistedObservation {
const outputText = obs.outputText;
const outputPreview =
outputText !== null
? outputText.length <= LLM_OUTPUT_PREVIEW_MAX
? outputText
: outputText.slice(0, LLM_OUTPUT_PREVIEW_MAX)
: null;
const outputLength = outputText !== null ? outputText.length : null;
const http: LlmPersistedHttpMetadata | null = obs.http
? {
headers: truncateHeaders(obs.http.headers, LLM_HEADERS_MAX),
status: obs.http.status,
statusText: obs.http.statusText,
}
: null;
return {
finishReason: obs.finishReason,
http,
mode: obs.mode,
model: obs.model,
outputLength,
outputPreview,
provider: obs.provider,
rawFinishReason: obs.rawFinishReason,
stream: obs.stream,
usage: obs.usage,
warnings: obs.warnings,
};
}
function truncateHeaders(headers: Record<string, string>, maxCount: number): Record<string, string> {
const entries = Object.entries(headers);
if (entries.length <= maxCount) return headers;
return Object.fromEntries(entries.slice(0, maxCount));
}