feat: improve model health and usage summary

This commit is contained in:
lsy committed 2026-03-14 18:06:08 +08:00
1 parent 4115891bab
commit 9005bce339
27 files changed
+356 -18

No files matched your search

+2 -2
View File
@@ -7,5 +7,5 @@ apps/ui/dist/
apps/worker/.wrangler/
tests/
helloagents/plan
helloagents/archive
.helloagents/plan
.helloagents/archive
@@ -20,6 +20,15 @@
- **[worker/models]**: 引入通道模型能力表并基于“模型广场测试结果+可配置TTL(默认2小时)”进行模型发布与路由匹配
- 类型: 微调(无方案包)
- 文件: apps/worker/migrations/0004_add_channel_model_capabilities.sql, apps/worker/src/services/channel-model-capabilities.ts, apps/worker/src/services/channel-testing.ts, apps/worker/src/services/settings.ts, apps/worker/src/routes/settings.ts, apps/worker/src/routes/models.ts, apps/worker/src/routes/newapiUsers.ts, apps/worker/src/routes/proxy.ts, tests/worker/channel-model-capabilities.test.ts, tests/worker/newapi.test.ts, helloagents/modules/settings.md, helloagents/modules/models.md, helloagents/modules/proxy.md
- **[worker/models]**: 运行时失败冷却与成功刷新能力表,TTL 过期回退模型列表
- 方案: [202603141718_model-health](archive/2026-03/202603141718_model-health/)
- 文件: apps/worker/src/routes/proxy.ts, apps/worker/src/services/channel-model-capabilities.ts, apps/worker/src/routes/models.ts, apps/worker/src/routes/newapiUsers.ts, apps/worker/src/services/settings.ts, apps/worker/src/routes/settings.ts, tests/worker/settings.test.ts, tests/worker/channel-model-capabilities.test.ts, .helloagents/modules/proxy.md, .helloagents/modules/models.md, .helloagents/modules/settings.md
- **[proxy]**: 失败判定简化为非 2xx/超时即失败
- 方案: [202603141749_usage-summary](archive/2026-03/202603141749_usage-summary/)
- 文件: apps/worker/src/routes/proxy.ts
- **[admin-ui/usage]**: 使用日志新增概述卡片(总量/成功/失败/错误率/平均延迟)
- 方案: [202603141749_usage-summary](archive/2026-03/202603141749_usage-summary/)
- 文件: apps/ui/src/core/utils.ts, apps/ui/src/features/UsageView.tsx, tests/ui/usage.test.ts, .helloagents/modules/usage.md, .helloagents/modules/admin-ui.md
- **[worker/proxy]**: 移除临时调试日志(请求入口/出口与上游响应汇总)
- 类型: 微调(无方案包)
- 文件: apps/worker/src/index.ts, apps/worker/src/routes/proxy.ts
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -25,6 +25,7 @@
- done-hub 站点仅使用 base_url,不再维护多地址输入
- 站点连通测试提示令牌成功/失败统计
- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
- 使用日志顶部展示概述(总量/成功/失败/错误率/平均延迟)
- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀
- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`)
- 系统设置增加定时签到开关与时间(中国时间)
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -11,7 +11,7 @@
## 行为规范
- 仅聚合启用状态的渠道
- 使用“模型广场测试结果”作为可用模型来源
- 模型结果带 TTL(默认 2 小时,来自 settings),过期后不再返回
- 模型结果带 TTL(默认 2 小时,来自 settings),过期后回退到渠道声明模型列表(避免模型广场清空)
- 模型归一化支持字符串数组与对象数组(对象取 `id` 字段)
## 依赖关系
@@ -27,6 +27,8 @@
- 允许 `metadata_json.header_override` 与 `metadata_json.query_override` 注入额外上游请求头/查询参数
- Anthropic 默认注入 `anthropic-version=2023-06-01`,可通过 `header_override` 覆盖
- 路由时优先使用模型能力表(TTL 内测试成功的模型),无能力记录时回退渠道声明模型列表
- 上游失败/超时会记录到能力表并进入冷却期,冷却期内跳过对应渠道/模型
- 上游成功响应会刷新能力表,清空错误并延长可用窗口
## 依赖关系
- `channels` / `tokens` / `usage_logs` 表
@@ -5,6 +5,7 @@
- 为使用日志与面板提供统一配置读取
- 维护定时签到执行时间
- 维护模型能力 TTL(模型广场与路由过滤窗口)
- 维护模型失败冷却时间(运行时失败的渠道/模型冷却窗口)
## 接口定义
- `GET /api/settings` 读取设置
@@ -17,6 +18,7 @@
- 更新签到时间后会触发 `CheckinScheduler` 重新调度
- 若更新后当前时间已超过新签到时间,当日可再次触发自动签到
- 模型能力 TTL 以小时计(默认 2 小时),用于控制“模型广场测试结果”的有效期
- 模型失败冷却以分钟计(默认 10 分钟),用于跳过最近失败的渠道/模型
## 依赖关系
- `settings` 表
File renamed without changes.
File renamed without changes.
@@ -14,6 +14,7 @@
- 查询结果附带渠道与令牌名称
- usage_logs 记录输入/输出 tokens、首 token 延迟、流式标记与推理强度
- 推理强度来自请求体 `reasoning` / `reasoning_effort` 字段
- 前端使用日志列表计算概述(总量/成功/失败/错误率/平均延迟)
## 依赖关系
- `usage_logs` 表
+40
View File
@@ -1,3 +1,5 @@
import type { UsageLog } from "./types";
/**
* Formats a datetime string for display.
*
@@ -76,3 +78,41 @@ export const buildPageItems = (current: number, total: number): PageItem[] => {
return true;
});
};
export type UsageSummary = {
total: number;
success: number;
failed: number;
errorRate: number;
avgLatencyMs: number | null;
totalTokens: number;
};
export const summarizeUsageLogs = (logs: UsageLog[]): UsageSummary => {
const total = logs.length;
const success = logs.filter((log) => log.status === "ok").length;
const failed = total - success;
let latencySum = 0;
let latencyCount = 0;
let totalTokens = 0;
for (const log of logs) {
const latency = log.latency_ms;
if (latency !== null && latency !== undefined && !Number.isNaN(latency)) {
latencySum += latency;
latencyCount += 1;
}
if (log.total_tokens !== null && log.total_tokens !== undefined) {
totalTokens += log.total_tokens;
} else {
totalTokens += (log.prompt_tokens ?? 0) + (log.completion_tokens ?? 0);
}
}
return {
total,
success,
failed,
errorRate: total > 0 ? (failed / total) * 100 : 0,
avgLatencyMs: latencyCount > 0 ? latencySum / latencyCount : null,
totalTokens,
};
};
+27 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "hono/jsx/dom";
import type { UsageLog } from "../core/types";
import { buildPageItems, formatDateTime } from "../core/utils";
import { buildPageItems, formatDateTime, summarizeUsageLogs } from "../core/utils";
type UsageViewProps = {
usage: UsageLog[];
@@ -42,6 +42,7 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
const [pageSize, setPageSize] = useState(50);
const [page, setPage] = useState(1);
const total = usage.length;
const summary = useMemo(() => summarizeUsageLogs(usage), [usage]);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(total / pageSize)),
[total, pageSize],
@@ -85,6 +86,31 @@ export const UsageView = ({ usage, onRefresh }: UsageViewProps) => {
</button>
</div>
</div>
<div class="mt-4 rounded-xl border border-stone-200 bg-stone-50 px-4 py-3">
<div class="flex flex-wrap items-center gap-3 text-xs text-stone-600">
<span class="font-semibold text-stone-700">
概述(当前展示 {summary.total} 条)
</span>
<span class="rounded-full bg-emerald-100 px-3 py-1 font-semibold text-emerald-700">
成功 {summary.success}
</span>
<span class="rounded-full bg-rose-100 px-3 py-1 font-semibold text-rose-700">
失败 {summary.failed}
</span>
<span class="rounded-full bg-stone-200 px-3 py-1 font-semibold text-stone-700">
错误率 {summary.total === 0 ? "-" : `${summary.errorRate.toFixed(1)}%`}
</span>
<span class="rounded-full bg-stone-200 px-3 py-1 font-semibold text-stone-700">
平均延迟{" "}
{summary.avgLatencyMs === null
? "-"
: `${summary.avgLatencyMs.toFixed(0)} ms`}
</span>
<span class="rounded-full bg-stone-200 px-3 py-1 font-semibold text-stone-700">
总 Tokens {summary.totalTokens}
</span>
</div>
</div>
<div class="mt-4 overflow-hidden rounded-xl border border-stone-200">
<div class="h-[420px] overflow-auto sm:h-[520px]">
<table class="min-w-[960px] w-full border-collapse text-xs sm:text-sm">
+7 -3
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { listVerifiedModelEntries } from "../services/channel-model-capabilities";
import { listModelEntriesWithFallback } from "../services/channel-model-capabilities";
import { listActiveChannels } from "../services/channel-repo";
import { getModelCapabilityTtlHours } from "../services/settings";
@@ -13,9 +13,13 @@ models.get("/", async (c) => {
const channels = await listActiveChannels(c.env.DB);
const ttlHours = await getModelCapabilityTtlHours(c.env.DB);
const ttlSeconds = Math.max(1, Math.floor(ttlHours)) * 60 * 60;
const entries = await listVerifiedModelEntries(
const entries = await listModelEntriesWithFallback(
c.env.DB,
channels.map((channel) => ({ id: channel.id, name: channel.name })),
channels.map((channel) => ({
id: channel.id,
name: channel.name,
models_json: channel.models_json,
})),
ttlSeconds,
);
+7 -3
View File
@@ -2,7 +2,7 @@ import { Hono } from "hono";
import type { AppEnv } from "../env";
import { newApiAuth } from "../middleware/newApiAuth";
import {
listVerifiedModelsByChannel,
listModelsByChannelWithFallback,
} from "../services/channel-model-capabilities";
import { listActiveChannels } from "../services/channel-repo";
import { getModelCapabilityTtlHours } from "../services/settings";
@@ -15,9 +15,13 @@ users.get("/models", async (c) => {
const channels = await listActiveChannels(c.env.DB);
const ttlHours = await getModelCapabilityTtlHours(c.env.DB);
const ttlSeconds = Math.max(1, Math.floor(ttlHours)) * 60 * 60;
const map = await listVerifiedModelsByChannel(
const map = await listModelsByChannelWithFallback(
c.env.DB,
channels.map((channel) => channel.id),
channels.map((channel) => ({
id: channel.id,
name: channel.name,
models_json: channel.models_json,
})),
ttlSeconds,
);
const modelSet = new Set<string>();
+98 -8
View File
@@ -17,8 +17,16 @@ import {
createWeightedOrder,
extractModels,
} from "../services/channels";
import { listVerifiedModelsByChannel } from "../services/channel-model-capabilities";
import { getModelCapabilityTtlHours } from "../services/settings";
import {
listCoolingDownChannelsForModel,
listVerifiedModelsByChannel,
recordChannelModelError,
upsertChannelModelCapabilities,
} from "../services/channel-model-capabilities";
import {
getModelCapabilityTtlHours,
getModelFailureCooldownMinutes,
} from "../services/settings";
import {
applyGeminiModelToPath,
buildUpstreamChatRequest,
@@ -55,6 +63,17 @@ type ExecutionContextLike = {
waitUntil: (promise: Promise<unknown>) => void;
};
function scheduleDbWrite(
c: { executionCtx?: ExecutionContextLike },
task: Promise<void>,
): void {
if (c.executionCtx?.waitUntil) {
c.executionCtx.waitUntil(task);
} else {
task.catch(() => undefined);
}
}
function channelSupportsModel(
channel: ChannelRecord,
model: string | null,
@@ -64,19 +83,24 @@ function channelSupportsModel(
return true;
}
const verified = verifiedModelsByChannel.get(channel.id);
const declaredModels = extractModels(channel).map((entry) => entry.id);
const metadata = parseChannelMetadata(channel.metadata_json);
const mapped = resolveMappedModel(metadata.model_mapping, model);
const declaredAllows =
declaredModels.length === 0
? true
: (mapped ? declaredModels.includes(mapped) : false) ||
declaredModels.includes(model);
if (!declaredAllows) {
return false;
}
if (verified && verified.size > 0) {
if (mapped && verified.has(mapped)) {
return true;
}
return verified.has(model);
}
const declaredModels = extractModels(channel).map((entry) => entry.id);
if (mapped && declaredModels.includes(mapped)) {
return true;
}
return declaredModels.includes(model);
return true;
}
export function selectCandidateChannels(
@@ -318,11 +342,40 @@ proxy.all("/*", tokenAuth, async (c) => {
allowedChannels.map((channel) => channel.id),
ttlSeconds,
);
const candidates = selectCandidateChannels(
let candidates = selectCandidateChannels(
allowedChannels,
downstreamModel,
verifiedModelsByChannel,
);
const cooldownMinutes = await getModelFailureCooldownMinutes(c.env.DB);
const cooldownSeconds = Math.max(0, Math.floor(cooldownMinutes)) * 60;
if (downstreamModel && cooldownSeconds > 0 && candidates.length > 0) {
const coolingChannels = await listCoolingDownChannelsForModel(
c.env.DB,
candidates.map((channel) => channel.id),
downstreamModel,
cooldownSeconds,
);
if (coolingChannels.size > 0) {
candidates = candidates.filter(
(channel) => !coolingChannels.has(channel.id),
);
if (candidates.length === 0) {
console.warn("[proxy:model_cooldown]", {
path: c.req.path,
model: downstreamModel,
cooldown_minutes: cooldownMinutes,
blocked_channels: coolingChannels.size,
});
return jsonError(
c,
503,
"upstream_cooldown",
"upstream_cooldown",
);
}
}
}
if (candidates.length === 0 && allowedChannels.length > 0) {
console.warn("[proxy:no_compatible_channels]", {
@@ -345,6 +398,7 @@ proxy.all("/*", tokenAuth, async (c) => {
1000,
Number(c.env.PROXY_UPSTREAM_TIMEOUT_MS ?? "30000"),
);
const nowSeconds = Math.floor(Date.now() / 1000);
let lastResponse: Response | null = null;
let lastChannel: ChannelRecord | null = null;
let lastRequestPath = targetPath;
@@ -363,6 +417,7 @@ proxy.all("/*", tokenAuth, async (c) => {
verifiedModelsByChannel,
);
const upstreamModel = resolvedModel.model;
const recordModel = upstreamModel ?? downstreamModel;
if (
upstreamProvider === "gemini" &&
!upstreamModel &&
@@ -594,8 +649,31 @@ proxy.all("/*", tokenAuth, async (c) => {
selectedChannel = channel;
selectedUpstreamProvider = upstreamProvider;
selectedUpstreamModel = upstreamModel;
if (recordModel) {
scheduleDbWrite(
c,
upsertChannelModelCapabilities(
c.env.DB,
channel.id,
[recordModel],
nowSeconds,
),
);
}
break;
}
if (recordModel) {
scheduleDbWrite(
c,
recordChannelModelError(
c.env.DB,
channel.id,
recordModel,
String(response.status),
nowSeconds,
),
);
}
} catch (error) {
const isTimeout =
error instanceof Error &&
@@ -610,6 +688,18 @@ proxy.all("/*", tokenAuth, async (c) => {
reason: isTimeout ? "timeout" : "exception",
error: error instanceof Error ? error.message : String(error),
});
if (recordModel) {
scheduleDbWrite(
c,
recordChannelModelError(
c.env.DB,
channel.id,
recordModel,
isTimeout ? "timeout" : "exception",
nowSeconds,
),
);
}
lastResponse = null;
}
}
+20
View File
@@ -9,12 +9,14 @@ import {
getRetentionDays,
getSessionTtlHours,
getModelCapabilityTtlHours,
getModelFailureCooldownMinutes,
isAdminPasswordSet,
setAdminPasswordHash,
setCheckinScheduleTime,
setRetentionDays,
setSessionTtlHours,
setModelCapabilityTtlHours,
setModelFailureCooldownMinutes,
} from "../services/settings";
import { sha256Hex } from "../utils/crypto";
import { jsonError } from "../utils/http";
@@ -30,12 +32,16 @@ settings.get("/", async (c) => {
const adminPasswordSet = await isAdminPasswordSet(c.env.DB);
const checkinScheduleTime = await getCheckinScheduleTime(c.env.DB);
const modelCapabilityTtlHours = await getModelCapabilityTtlHours(c.env.DB);
const modelFailureCooldownMinutes = await getModelFailureCooldownMinutes(
c.env.DB,
);
return c.json({
log_retention_days: retention,
session_ttl_hours: sessionTtlHours,
admin_password_set: adminPasswordSet,
checkin_schedule_time: checkinScheduleTime,
model_capability_ttl_hours: modelCapabilityTtlHours,
model_failure_cooldown_minutes: modelFailureCooldownMinutes,
});
});
@@ -94,6 +100,20 @@ settings.put("/", async (c) => {
touched = true;
}
if (body.model_failure_cooldown_minutes !== undefined) {
const minutes = Number(body.model_failure_cooldown_minutes);
if (Number.isNaN(minutes) || minutes < 1) {
return jsonError(
c,
400,
"invalid_model_failure_cooldown_minutes",
"invalid_model_failure_cooldown_minutes",
);
}
await setModelFailureCooldownMinutes(c.env.DB, minutes);
touched = true;
}
if (typeof body.admin_password === "string" && body.admin_password.trim()) {
const hash = await sha256Hex(body.admin_password.trim());
await setAdminPasswordHash(c.env.DB, hash);
@@ -1,5 +1,6 @@
import type { D1Database } from "@cloudflare/workers-types";
import { nowIso } from "../utils/time";
import { extractModelIds } from "./channel-models";
import type { ModelEntry } from "./channel-models";
export const DEFAULT_MODEL_CAPABILITY_TTL_SEC = 2 * 60 * 60;
@@ -67,6 +68,95 @@ export async function listVerifiedModelEntries(
return entries;
}
export async function listModelsByChannelWithFallback(
db: D1Database,
channels: Array<{ id: string; name: string; models_json?: string | null }>,
ttlSeconds: number = DEFAULT_MODEL_CAPABILITY_TTL_SEC,
): Promise<Map<string, Set<string>>> {
const ids = channels.map((channel) => channel.id);
const verified = await listVerifiedModelsByChannel(db, ids, ttlSeconds);
const map = new Map<string, Set<string>>();
for (const channel of channels) {
const verifiedModels = verified.get(channel.id);
if (verifiedModels && verifiedModels.size > 0) {
map.set(channel.id, new Set(verifiedModels));
continue;
}
const declaredModels = extractModelIds(channel);
if (declaredModels.length > 0) {
map.set(channel.id, new Set(declaredModels));
}
}
return map;
}
export async function listModelEntriesWithFallback(
db: D1Database,
channels: Array<{ id: string; name: string; models_json?: string | null }>,
ttlSeconds: number = DEFAULT_MODEL_CAPABILITY_TTL_SEC,
): Promise<ModelEntry[]> {
const map = await listModelsByChannelWithFallback(db, channels, ttlSeconds);
const entries: ModelEntry[] = [];
for (const channel of channels) {
const models = map.get(channel.id);
if (!models) {
continue;
}
for (const id of models) {
entries.push({ id, label: id, channelId: channel.id, channelName: channel.name });
}
}
return entries;
}
export async function listCoolingDownChannelsForModel(
db: D1Database,
channelIds: string[],
model: string | null,
cooldownSeconds: number,
): Promise<Set<string>> {
if (!model || channelIds.length === 0 || cooldownSeconds <= 0) {
return new Set();
}
const now = Math.floor(Date.now() / 1000);
const cutoff = now - cooldownSeconds;
const placeholders = channelIds.map(() => "?").join(", ");
const rows = await db
.prepare(
`SELECT channel_id, last_err_at, last_ok_at FROM channel_model_capabilities WHERE model = ? AND channel_id IN (${placeholders}) AND last_err_at IS NOT NULL AND last_err_at >= ?`,
)
.bind(model, ...channelIds, cutoff)
.all<{ channel_id: string; last_err_at: number | null; last_ok_at: number | null }>();
const blocked = new Set<string>();
for (const row of rows.results ?? []) {
const lastErr = Number(row.last_err_at ?? 0);
const lastOk = Number(row.last_ok_at ?? 0);
if (lastErr && lastErr >= lastOk) {
blocked.add(row.channel_id);
}
}
return blocked;
}
export async function recordChannelModelError(
db: D1Database,
channelId: string,
model: string | null,
errorCode: string,
nowSeconds: number = Math.floor(Date.now() / 1000),
): Promise<void> {
if (!model) {
return;
}
const timestamp = nowIso();
await db
.prepare(
"INSERT INTO channel_model_capabilities (channel_id, model, last_ok_at, last_err_at, last_err_code, created_at, updated_at) VALUES (?, ?, 0, ?, ?, ?, ?) ON CONFLICT(channel_id, model) DO UPDATE SET last_err_at = excluded.last_err_at, last_err_code = excluded.last_err_code, updated_at = excluded.updated_at",
)
.bind(channelId, model, nowSeconds, errorCode, timestamp, timestamp)
.run();
}
export async function upsertChannelModelCapabilities(
db: D1Database,
channelId: string,
+17
View File
@@ -5,11 +5,13 @@ const DEFAULT_LOG_RETENTION_DAYS = 30;
const DEFAULT_SESSION_TTL_HOURS = 12;
const DEFAULT_CHECKIN_SCHEDULE_TIME = "00:10";
const DEFAULT_MODEL_CAPABILITY_TTL_HOURS = 2;
const DEFAULT_MODEL_FAILURE_COOLDOWN_MINUTES = 10;
const RETENTION_KEY = "log_retention_days";
const SESSION_TTL_KEY = "session_ttl_hours";
const ADMIN_PASSWORD_HASH_KEY = "admin_password_hash";
const CHECKIN_SCHEDULE_TIME_KEY = "checkin_schedule_time";
const MODEL_CAPABILITY_TTL_KEY = "model_capability_ttl_hours";
const MODEL_FAILURE_COOLDOWN_KEY = "model_failure_cooldown_minutes";
async function readSetting(
db: D1Database,
@@ -143,6 +145,21 @@ export async function setModelCapabilityTtlHours(
await upsertSetting(db, MODEL_CAPABILITY_TTL_KEY, value);
}
export async function getModelFailureCooldownMinutes(
db: D1Database,
): Promise<number> {
const value = await readSetting(db, MODEL_FAILURE_COOLDOWN_KEY);
return parsePositiveNumber(value, DEFAULT_MODEL_FAILURE_COOLDOWN_MINUTES);
}
export async function setModelFailureCooldownMinutes(
db: D1Database,
minutes: number,
): Promise<void> {
const value = Math.max(1, Math.floor(minutes)).toString();
await upsertSetting(db, MODEL_FAILURE_COOLDOWN_KEY, value);
}
/**
* Loads generic settings as a key/value map.
*/
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { summarizeUsageLogs } from "../../apps/ui/src/core/utils";
const buildLog = (partial: Partial<{ status: string; total_tokens: number | null; prompt_tokens: number | null; completion_tokens: number | null; latency_ms: number | null }>) => ({
id: "1",
model: "gpt-4",
channel_id: "c1",
token_id: "t1",
total_tokens: partial.total_tokens ?? null,
prompt_tokens: partial.prompt_tokens ?? null,
completion_tokens: partial.completion_tokens ?? null,
latency_ms: partial.latency_ms ?? null,
status: partial.status ?? "ok",
created_at: "2026-03-14T00:00:00Z",
});
describe("usage summary", () => {
it("summarizes success, failure, tokens, and latency", () => {
const logs = [
buildLog({ status: "ok", total_tokens: 20, latency_ms: 1000 }),
buildLog({ status: "error", total_tokens: 10, latency_ms: 3000 }),
buildLog({ status: "ok", total_tokens: null, prompt_tokens: 5, completion_tokens: 5, latency_ms: null }),
];
const summary = summarizeUsageLogs(logs as any);
expect(summary.total).toBe(3);
expect(summary.success).toBe(2);
expect(summary.failed).toBe(1);
expect(summary.totalTokens).toBe(40);
expect(Math.round(summary.errorRate)).toBe(33);
expect(summary.avgLatencyMs).toBe(2000);
});
});