niceeval 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.zh.md +11 -0
  2. package/docs/README.md +2 -1
  3. package/docs/adapters/collection.md +1 -1
  4. package/docs/adapters/contract.md +6 -5
  5. package/docs/adapters/reference/claude-code-otel-telemetry.md +1 -1
  6. package/docs/assertions.md +0 -1
  7. package/docs/capabilities-by-construction.md +2 -2
  8. package/docs/cli.md +2 -2
  9. package/docs/concepts.md +2 -2
  10. package/docs/e2e-ci.md +135 -98
  11. package/docs/eval-authoring.md +3 -3
  12. package/docs/experiments.md +7 -4
  13. package/docs/observability.md +9 -5
  14. package/docs/origin-integration.md +18 -18
  15. package/docs/references.md +1 -1
  16. package/docs/reports.md +551 -0
  17. package/docs/results-format.md +3 -3
  18. package/docs/results-lib.md +191 -0
  19. package/docs/runner.md +1 -1
  20. package/docs/sandbox.md +1 -1
  21. package/docs/source-map.md +22 -2
  22. package/docs/tier-sync.md +74 -58
  23. package/docs/view.md +39 -5
  24. package/package.json +29 -3
  25. package/src/agents/ai-sdk.ts +3 -0
  26. package/src/agents/claude-code.ts +18 -1
  27. package/src/agents/codex.ts +3 -2
  28. package/src/agents/index.ts +16 -0
  29. package/src/agents/openai-compat.test.ts +56 -0
  30. package/src/agents/openai-compat.ts +151 -0
  31. package/src/agents/types.ts +3 -1
  32. package/src/cli.ts +3 -2
  33. package/src/context/context.test.ts +78 -1
  34. package/src/context/context.ts +19 -11
  35. package/src/context/session.ts +2 -0
  36. package/src/context/types.ts +11 -5
  37. package/src/i18n/en.ts +3 -4
  38. package/src/i18n/zh-CN.ts +2 -3
  39. package/src/o11y/cost.test.ts +40 -0
  40. package/src/o11y/cost.ts +27 -5
  41. package/src/o11y/derive.ts +2 -2
  42. package/src/o11y/otlp/mappers/claude-code.test.ts +31 -0
  43. package/src/o11y/otlp/mappers/claude-code.ts +24 -0
  44. package/src/o11y/otlp/mappers/index.ts +1 -0
  45. package/src/o11y/otlp/parse.test.ts +127 -0
  46. package/src/o11y/prices.json +176 -119
  47. package/src/o11y/types.ts +2 -1
  48. package/src/report/aggregate.ts +215 -0
  49. package/src/report/compute.ts +405 -0
  50. package/src/report/format.ts +47 -0
  51. package/src/report/index.ts +40 -0
  52. package/src/report/metrics.ts +96 -0
  53. package/src/report/react/CaseList.tsx +70 -0
  54. package/src/report/react/DeltaTable.tsx +79 -0
  55. package/src/report/react/MetricMatrix.tsx +75 -0
  56. package/src/report/react/MetricScatter.tsx +217 -0
  57. package/src/report/react/MetricTable.tsx +71 -0
  58. package/src/report/react/RunOverview.tsx +87 -0
  59. package/src/report/react/Scoreboard.tsx +87 -0
  60. package/src/report/react/cell.tsx +49 -0
  61. package/src/report/react/colors.ts +42 -0
  62. package/src/report/react/data.ts +17 -0
  63. package/src/report/react/fixtures.ts +236 -0
  64. package/src/report/react/format.ts +34 -0
  65. package/src/report/react/index.tsx +34 -0
  66. package/src/report/react/render.test.tsx +303 -0
  67. package/src/report/react/styles.css +302 -0
  68. package/src/report/report.test.ts +667 -0
  69. package/src/report/types.ts +210 -0
  70. package/src/results/copy.ts +200 -0
  71. package/src/results/format.ts +75 -0
  72. package/src/results/index.ts +27 -0
  73. package/src/results/open.ts +207 -0
  74. package/src/results/results.test.ts +359 -0
  75. package/src/results/select.ts +101 -0
  76. package/src/results/types.ts +98 -0
  77. package/src/runner/attempt.ts +3 -1
  78. package/src/runner/report.test.ts +111 -0
  79. package/src/runner/report.ts +52 -1
  80. package/src/runner/reporters/artifacts.ts +1 -1
  81. package/src/runner/reporters/braintrust.test.ts +106 -0
  82. package/src/runner/reporters/braintrust.ts +197 -0
  83. package/src/runner/reporters/index.ts +3 -1
  84. package/src/runner/run.ts +30 -19
  85. package/src/runner/types.ts +17 -0
  86. package/src/sandbox/types.ts +3 -3
  87. package/src/view/app/App.tsx +82 -17
  88. package/src/view/app/components/CostScoreChart.tsx +127 -0
  89. package/src/view/app/i18n.ts +10 -1
  90. package/src/view/app/lib/attempt-route.test.ts +89 -0
  91. package/src/view/app/lib/attempt-route.ts +52 -0
  92. package/src/view/app/lib/chart.ts +72 -0
  93. package/src/view/app/lib/outcome.ts +1 -1
  94. package/src/view/app/types.ts +4 -5
  95. package/src/view/client-dist/app.css +1 -1
  96. package/src/view/client-dist/app.js +18 -18
  97. package/src/view/index.ts +12 -18
  98. package/src/view/loader.ts +14 -7
  99. package/src/view/shared/types.ts +14 -1
  100. package/src/view/styles.css +43 -0
  101. package/src/view/template.html +0 -1
@@ -38,7 +38,7 @@ import type {
38
38
  ValueAssertion,
39
39
  } from "../types.ts";
40
40
 
41
- /** t.file(path) 返回它,延迟到 finalize 再读沙箱文件;t.check 识别并解析它。 */
41
+ /** t.sandbox.file(path) 返回它,延迟到 finalize 再读沙箱文件;t.check 识别并解析它。 */
42
42
  export class FileRef {
43
43
  constructor(public readonly path: string) {}
44
44
  }
@@ -60,6 +60,7 @@ export interface ContextDeps {
60
60
  agent: Agent;
61
61
  sandbox: Sandbox;
62
62
  model?: string;
63
+ reasoningEffort?: string;
63
64
  flags: Record<string, unknown>;
64
65
  signal: AbortSignal;
65
66
  log(msg: string): void;
@@ -74,7 +75,7 @@ export interface ContextDeps {
74
75
 
75
76
  /**
76
77
  * 沙箱能力守卫:非沙箱型 agent(kind !== "sandbox")调文件系统类断言就报清晰错误。
77
- * 这是唯一仍需要构造证据之外强制检查的能力——`t.file`/`t.fileChanged()` 等直接读沙箱
78
+ * 这是唯一仍需要构造证据之外强制检查的能力——`t.sandbox.file`/`t.sandbox.fileChanged()` 等直接读沙箱
78
79
  * 文件系统,没有沙箱就没有东西可读,不报错会静默返回空结果。其余能力(多轮对话、
79
80
  * 工具断言……)都不再问卷式声明,由「做没做到」的构造证据决定,见
80
81
  * docs-site/zh/concepts/adapter.mdx「能力从哪来」一节。
@@ -90,6 +91,7 @@ export function createEvalContext(deps: ContextDeps): { context: TestContext; st
90
91
  agent: deps.agent,
91
92
  sandbox: deps.sandbox,
92
93
  model: deps.model,
94
+ reasoningEffort: deps.reasoningEffort,
93
95
  flags: deps.flags,
94
96
  signal: deps.signal,
95
97
  log: deps.log,
@@ -126,6 +128,14 @@ export function createEvalContext(deps: ContextDeps): { context: TestContext; st
126
128
  Object.entries(state.late.diff.generatedFiles).some(([p, c]) => re.test(p) || re.test(c)),
127
129
  };
128
130
 
131
+ const sandboxAssertions = {
132
+ file: (path: string) => new FileRef(path) as unknown as string,
133
+ fileChanged: (path: string) => collector.record(Scoped.fileChanged(path)),
134
+ fileDeleted: (path: string) => collector.record(Scoped.fileDeleted(path)),
135
+ notInDiff: (re: RegExp) => collector.record(Scoped.notInDiff(re)),
136
+ noFailedShellCommands: () => collector.record(Scoped.noFailedShellCommands()),
137
+ };
138
+
129
139
  const sandboxHandle: SandboxHandle = {
130
140
  get workdir() {
131
141
  return deps.sandbox.workdir;
@@ -147,6 +157,7 @@ export function createEvalContext(deps: ContextDeps): { context: TestContext; st
147
157
  deps.sandbox.uploadDirectory(resolveLocalPath(deps.evalBaseDir, localDir), targetDir, opts),
148
158
  downloadFile: (path) => deps.sandbox.downloadFile(path),
149
159
  uploadFile: (path, content) => deps.sandbox.uploadFile(path, content),
160
+ ...sandboxAssertions,
150
161
  };
151
162
 
152
163
  function recordScoped(
@@ -258,9 +269,6 @@ export function createEvalContext(deps: ContextDeps): { context: TestContext; st
258
269
  // 提示可信度,都不需要在这里拦。
259
270
  const guards: Record<string, unknown> = {};
260
271
  if (deps.agent.kind !== "sandbox") {
261
- for (const m of ["file", "fileChanged", "fileDeleted", "notInDiff", "noFailedShellCommands"]) {
262
- guards[m] = capabilityGuard(deps.agent.name, "sandbox", m);
263
- }
264
272
  Object.defineProperty(guards, "sandbox", {
265
273
  get: capabilityGuard(deps.agent.name, "sandbox", "sandbox"),
266
274
  enumerable: true,
@@ -275,6 +283,7 @@ export function createEvalContext(deps: ContextDeps): { context: TestContext; st
275
283
  newSession: () => makeSessionHandle(manager.newSession()),
276
284
  signal: deps.signal,
277
285
  model: deps.model,
286
+ reasoningEffort: deps.reasoningEffort,
278
287
  flags: deps.flags,
279
288
  log: deps.log,
280
289
  skip: (reason: string) => {
@@ -316,11 +325,6 @@ export function createEvalContext(deps: ContextDeps): { context: TestContext; st
316
325
  },
317
326
 
318
327
  sandbox: sandboxHandle,
319
- file: (path: string) => new FileRef(path) as unknown as string,
320
- fileChanged: (path: string) => collector.record(Scoped.fileChanged(path)),
321
- fileDeleted: (path: string) => collector.record(Scoped.fileDeleted(path)),
322
- notInDiff: (re: RegExp) => collector.record(Scoped.notInDiff(re)),
323
- noFailedShellCommands: () => collector.record(Scoped.noFailedShellCommands()),
324
328
  };
325
329
  const context = Object.defineProperties(
326
330
  {},
@@ -407,16 +411,21 @@ function makeTurnHandle(turn: Turn, collector: AssertionCollector, deps: Context
407
411
  }),
408
412
  messageIncludes: (token) => scoped(Scoped.messageIncludes(token)),
409
413
  succeeded: () => scoped(Scoped.succeeded()),
414
+ parked: () => scoped(Scoped.parked()),
410
415
  calledTool: (name, match) => scoped(Scoped.calledTool(name, match)),
411
416
  notCalledTool: (name, match) => scoped(Scoped.notCalledTool(name, match)),
412
417
  toolOrder: (names) => scoped(Scoped.toolOrder(names)),
413
418
  usedNoTools: () => scoped(Scoped.usedNoTools()),
414
419
  maxToolCalls: (max) => scoped(Scoped.maxToolCalls(max)),
420
+ loadedSkill: (skill) => scoped(Scoped.loadedSkill(skill)),
421
+ noFailedActions: () => scoped(Scoped.noFailedActions()),
415
422
  event: (type, opts) => scoped(Scoped.eventOfType(type, opts)),
416
423
  notEvent: (type) => scoped(Scoped.notEventOfType(type)),
417
424
  calledSubagent: (name, match) => scoped(Scoped.calledSubagent(name, match)),
418
425
  eventOrder: (types) => scoped(Scoped.eventOrder(types)),
419
426
  eventsSatisfy: (predicate, label) => scoped(Scoped.eventsSatisfy(predicate, label)),
427
+ maxTokens: (max) => scoped(Scoped.maxTokens(max)),
428
+ maxCost: (usd) => scoped(Scoped.maxCost(usd)),
420
429
  judge: buildJudge({
421
430
  record: (spec) => collector.record(spec),
422
431
  judge: deps.judge,
@@ -533,4 +542,3 @@ function partialObjectMatches(actual: unknown, expected: Record<string, unknown>
533
542
  }
534
543
  return true;
535
544
  }
536
-
@@ -87,6 +87,7 @@ export interface SessionDeps {
87
87
  agent: Agent;
88
88
  sandbox: Sandbox;
89
89
  model?: string;
90
+ reasoningEffort?: string;
90
91
  flags: Record<string, unknown>;
91
92
  signal: AbortSignal;
92
93
  log(msg: string): void;
@@ -136,6 +137,7 @@ export class SessionManager {
136
137
  const ctx: AgentContext = {
137
138
  signal: this.deps.signal,
138
139
  model: this.deps.model,
140
+ reasoningEffort: this.deps.reasoningEffort,
139
141
  flags: this.deps.flags,
140
142
  sandbox: this.deps.sandbox,
141
143
  session,
@@ -26,16 +26,21 @@ export interface TurnHandle {
26
26
  /** 断言本轮助手回复包含 token(仅限本轮事件流,不跨轮)。 */
27
27
  messageIncludes(token: string | RegExp): AssertionHandle;
28
28
  succeeded(): AssertionHandle;
29
+ parked(): AssertionHandle;
29
30
  calledTool(name: string, match?: ToolMatch): AssertionHandle;
30
31
  notCalledTool(name: string, match?: ToolMatch): AssertionHandle;
31
32
  toolOrder(names: string[]): AssertionHandle;
32
33
  usedNoTools(): AssertionHandle;
33
34
  maxToolCalls(max: number): AssertionHandle;
35
+ loadedSkill(skill: string): AssertionHandle;
36
+ noFailedActions(): AssertionHandle;
34
37
  event(type: StreamEvent["type"], opts?: { count?: number }): AssertionHandle;
35
38
  notEvent(type: StreamEvent["type"]): AssertionHandle;
36
39
  calledSubagent(name: string, match?: SubagentMatch): AssertionHandle;
37
40
  eventOrder(types: StreamEvent["type"][]): AssertionHandle;
38
41
  eventsSatisfy(predicate: (events: readonly StreamEvent[]) => boolean, label?: string): AssertionHandle;
42
+ maxTokens(max: number): AssertionHandle;
43
+ maxCost(usd: number): AssertionHandle;
39
44
  readonly judge: JudgeNamespace;
40
45
  }
41
46
 
@@ -105,6 +110,11 @@ export interface SandboxHandle {
105
110
  uploadFile(path: string, content: Buffer): Promise<void>;
106
111
  readonly sandboxId: string;
107
112
  readonly diff: DiffView;
113
+ file(path: string): string;
114
+ fileChanged(path: string): AssertionHandle;
115
+ fileDeleted(path: string): AssertionHandle;
116
+ notInDiff(re: RegExp): AssertionHandle;
117
+ noFailedShellCommands(): AssertionHandle;
108
118
  }
109
119
 
110
120
  export interface SessionHandle {
@@ -157,6 +167,7 @@ export interface TestContext {
157
167
  // 运行上下文
158
168
  readonly signal: AbortSignal;
159
169
  readonly model?: string;
170
+ readonly reasoningEffort?: string;
160
171
  readonly flags: Readonly<Record<string, unknown>>;
161
172
  log(msg: string): void;
162
173
  skip(reason: string): never;
@@ -189,11 +200,6 @@ export interface TestContext {
189
200
 
190
201
  // 工作区 / 沙箱
191
202
  readonly sandbox: SandboxHandle;
192
- file(path: string): string;
193
- fileChanged(path: string): AssertionHandle;
194
- fileDeleted(path: string): AssertionHandle;
195
- notInDiff(re: RegExp): AssertionHandle;
196
- noFailedShellCommands(): AssertionHandle;
197
203
 
198
204
  // 效率 / 成本
199
205
  readonly usage: Usage;
package/src/i18n/en.ts CHANGED
@@ -35,9 +35,9 @@ export const en = {
35
35
  "Usage:\n" +
36
36
  " niceeval exp [group|experiment] [eval-id-prefix…] run experiments\n" +
37
37
  " niceeval list list discovered evals\n" +
38
- " niceeval view [summary.json|dir] [--out x.html|dir] [--port n] [--no-open]\n" +
39
- " --out *.html exports a single-file report; --out <dir> also bundles\n" +
40
- " artifacts so the directory can be served by any static host\n" +
38
+ " niceeval view [summary.json|dir] [--out dir] [--port n] [--no-open]\n" +
39
+ " --out <dir> exports a static site: index.html plus the viewer\n" +
40
+ " artifacts, ready for any static host\n" +
41
41
  " niceeval clean delete .niceeval/ artifacts\n" +
42
42
  " niceeval init scaffold config + evals/\n\n" +
43
43
  "Flags:\n" +
@@ -66,7 +66,6 @@ export const en = {
66
66
  "cli.run.experimentRequiredKnown": "Discovered experiments: {{experiments}}\n",
67
67
  "cli.sandboxFlagRemoved": "`--sandbox` is not a CLI flag. Set `sandbox` in the experiment (or `niceeval.config.ts` as a project-wide fallback) to dockerSandbox() / vercelSandbox() / e2bSandbox() (import from \"niceeval/sandbox\").\n",
68
68
  "cli.unimplemented": "Command \"{{command}}\" is not implemented yet (MVP).\n",
69
- "cli.view.exported": "Exported eval report page: {{out}}\n",
70
69
  "cli.view.exportedDir": "Exported static report site: {{out}} (serve the whole directory with any static host; opening index.html via file:// cannot fetch artifacts)\n",
71
70
  "cli.view.incompatible": "{{dir}}: written by niceeval {{producer}} (schemaVersion {{schemaVersion}}); this CLI reads schemaVersion {{supported}}.\nRun `{{command}}` to view it.\n",
72
71
  "cli.view.url": "niceeval view: {{url}}\n",
package/src/i18n/zh-CN.ts CHANGED
@@ -33,8 +33,8 @@ export const zhCN = {
33
33
  "用法:\n" +
34
34
  " niceeval exp [组|实验] [eval-id 前缀…] 跑实验\n" +
35
35
  " niceeval list 列出发现到的 eval\n" +
36
- " niceeval view [summary.json|目录] [--out x.html|目录] [--port n] [--no-open]\n" +
37
- " --out *.html 导出单文件报告;给目录则连工件一起导出,可直接静态托管\n" +
36
+ " niceeval view [summary.json|目录] [--out 目录] [--port n] [--no-open]\n" +
37
+ " --out <目录> 静态导出:index.html 连同查看器工件,可直接静态托管\n" +
38
38
  " niceeval clean 删除 .niceeval/ 历史工件\n" +
39
39
  " niceeval init 脚手架 config + evals/\n\n" +
40
40
  "标志:\n" +
@@ -63,7 +63,6 @@ export const zhCN = {
63
63
  "cli.run.experimentRequiredKnown": "已发现实验:{{experiments}}\n",
64
64
  "cli.sandboxFlagRemoved": "`--sandbox` 不是 CLI flag。请在 experiment(或 niceeval.config.ts 做全项目兜底)里把 sandbox 设成 dockerSandbox() / vercelSandbox() / e2bSandbox()(从 \"niceeval/sandbox\" 导入)。\n",
65
65
  "cli.unimplemented": "命令 \"{{command}}\" 暂未实现(MVP)。\n",
66
- "cli.view.exported": "已导出实验查看页:{{out}}\n",
67
66
  "cli.view.exportedDir": "已导出静态查看站:{{out}}(整个目录可直接静态托管;本地打开 {{out}}/index.html 需经 http 服务,file:// 下工件 fetch 不可用)\n",
68
67
  "cli.view.incompatible": "{{dir}}: 由 niceeval {{producer}} 写入(schemaVersion {{schemaVersion}}),当前 CLI 只读 schemaVersion {{supported}}。\n运行 `{{command}}` 查看这份报告。\n",
69
68
  "cli.view.url": "niceeval view: {{url}}\n",
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { estimateCost } from "./cost.ts";
4
+
5
+ describe("estimateCost pricing overrides", () => {
6
+ it("精确 model key 覆盖内置价格表", () => {
7
+ const usd = estimateCost(
8
+ "anthropic/claude-opus-4-8",
9
+ { inputTokens: 1_000_000, outputTokens: 0 },
10
+ { "anthropic/claude-opus-4-8": { inputPerMTok: 5, outputPerMTok: 25 } },
11
+ );
12
+ expect(usd).toBe(5);
13
+ });
14
+
15
+ it("provider/* 通配覆盖同 provider 下所有 model", () => {
16
+ const usd = estimateCost(
17
+ "my-selfhosted/llama-70b",
18
+ { inputTokens: 1_000_000, outputTokens: 1_000_000 },
19
+ { "my-selfhosted/*": { inputPerMTok: 0, outputPerMTok: 0 } },
20
+ );
21
+ expect(usd).toBeUndefined(); // 自托管免费:usd 恒 0,estimateCost 只在 > 0 时返回数值
22
+ });
23
+
24
+ it("没有覆盖时落回内置快照(查不到就是 undefined,不瞎猜)", () => {
25
+ const usd = estimateCost("totally-unknown-model-xyz", { inputTokens: 1000, outputTokens: 1000 });
26
+ expect(usd).toBeUndefined();
27
+ });
28
+
29
+ it("精确 key 优先于通配", () => {
30
+ const usd = estimateCost(
31
+ "my-selfhosted/llama-70b",
32
+ { inputTokens: 1_000_000, outputTokens: 0 },
33
+ {
34
+ "my-selfhosted/*": { inputPerMTok: 0, outputPerMTok: 0 },
35
+ "my-selfhosted/llama-70b": { inputPerMTok: 2, outputPerMTok: 2 },
36
+ },
37
+ );
38
+ expect(usd).toBe(2);
39
+ });
40
+ });
package/src/o11y/cost.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  // 价格表兜底估算:agent 没带回网关实测成本时,用 token 用量 × vendored 单价估一个。
2
2
  // 数据来自 src/o11y/prices.json(models.dev,见 scripts/sync-prices.ts);per-1M USD。
3
+ // 用户可在 `defineConfig({ pricing })` 里覆盖 / 补充(见 Observability · 用量与成本),
4
+ // 精确 model key 和 `provider/*` 通配都查用户表在先,查不到才落回内置快照。
3
5
  //
4
6
  // 与 types.ts 的约定一致:usage.costUSD(实测)优先,这里只在缺实测时兜底,
5
7
  // 查不到价就返回 undefined —— 显示 "—" 而不是骗人的 $0。
6
8
 
7
9
  import { readFileSync } from "node:fs";
8
10
  import { fileURLToPath } from "node:url";
9
- import type { Usage } from "../types.ts";
11
+ import type { PriceOverride, Usage } from "../types.ts";
10
12
 
11
13
  interface Price {
12
14
  in: number;
@@ -24,11 +26,27 @@ const PRICES: Record<string, Price> = (() => {
24
26
  }
25
27
  })();
26
28
 
29
+ function toPrice(o: PriceOverride): Price {
30
+ return { in: o.inputPerMTok, out: o.outputPerMTok, cacheRead: o.cacheReadPerMTok, cacheWrite: o.cacheWritePerMTok };
31
+ }
32
+
33
+ /**
34
+ * 用户覆盖表:精确 model key 优先,再退而查 `provider/*` 通配
35
+ *(`anthropic/*` 命中 `anthropic/claude-…`,批量覆盖自托管 / 网关折扣场景)。
36
+ */
37
+ function lookupOverride(model: string, overrides: Record<string, PriceOverride> | undefined): Price | undefined {
38
+ if (!overrides) return undefined;
39
+ if (overrides[model]) return toPrice(overrides[model]);
40
+ const provider = model.includes("/") ? model.slice(0, model.indexOf("/")) : undefined;
41
+ if (provider && overrides[`${provider}/*`]) return toPrice(overrides[`${provider}/*`]);
42
+ return undefined;
43
+ }
44
+
27
45
  /**
28
46
  * 把五花八门的 model 标识归一到价格表的 key:精确命中优先,再退而去掉 provider 前缀
29
47
  * (`anthropic/claude-…` → `claude-…`)和末尾日期版本(`…-4-5-20251001` → `…-4-5`)。
30
48
  */
31
- function lookup(model: string): Price | undefined {
49
+ function lookupBuiltin(model: string): Price | undefined {
32
50
  if (PRICES[model]) return PRICES[model];
33
51
  const bare = model.includes("/") ? model.slice(model.lastIndexOf("/") + 1) : model;
34
52
  if (PRICES[bare]) return PRICES[bare];
@@ -39,11 +57,15 @@ function lookup(model: string): Price | undefined {
39
57
 
40
58
  /**
41
59
  * 按 token 桶 × 单价估算一次运行的美元成本。cache 桶缺专门单价时退回 input 价
42
- * (cache token 本质也是 input)。无 model / 查不到价 / 零用量 → undefined。
60
+ * (cache token 本质也是 input)。无 model / 查不到价(用户表 + 内置快照都没有)→ undefined。
43
61
  */
44
- export function estimateCost(model: string | undefined, usage: Usage): number | undefined {
62
+ export function estimateCost(
63
+ model: string | undefined,
64
+ usage: Usage,
65
+ overrides?: Record<string, PriceOverride>,
66
+ ): number | undefined {
45
67
  if (!model) return undefined;
46
- const p = lookup(model);
68
+ const p = lookupOverride(model, overrides) ?? lookupBuiltin(model);
47
69
  if (!p) return undefined;
48
70
  const bucket = (tokens: number | undefined, price: number | undefined, fallback: number): number =>
49
71
  tokens ? tokens * (price ?? fallback) : 0;
@@ -207,7 +207,7 @@ export function buildO11ySummary(
207
207
  usage: Usage,
208
208
  durationMs: number,
209
209
  ): O11ySummary {
210
- const toolCalls: Record<string, number> = {};
210
+ const toolCalls: Partial<Record<ToolName, number>> = {};
211
211
  let totalToolCalls = 0;
212
212
  const filesRead = new Set<string>();
213
213
  const filesModified = new Set<string>();
@@ -243,7 +243,7 @@ export function buildO11ySummary(
243
243
  break;
244
244
 
245
245
  case "action.called": {
246
- const canonical: ToolName | string = ev.tool ?? "unknown";
246
+ const canonical: ToolName = ev.tool ?? "unknown";
247
247
  toolCalls[canonical] = (toolCalls[canonical] ?? 0) + 1;
248
248
  totalToolCalls += 1;
249
249
 
@@ -0,0 +1,31 @@
1
+ // mapClaudeCodeSpans:span 形状取自真实运行(claude CLI 2.1.202 beta 遥测,E2B 实测)。
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import type { TraceSpan } from "../../../types.ts";
5
+ import { mapClaudeCodeSpans } from "./claude-code.ts";
6
+
7
+ function span(name: string, attributes?: TraceSpan["attributes"]): TraceSpan {
8
+ return { traceId: "t", spanId: name, name, startMs: 0, endMs: 1, attributes };
9
+ }
10
+
11
+ describe("mapClaudeCodeSpans", () => {
12
+ it("interaction 归 turn,自带标准 op 的 span 交给 heuristic 定 kind", () => {
13
+ const mapped = mapClaudeCodeSpans([
14
+ span("claude_code.interaction", { "session.id": "s1" }),
15
+ span("claude_code.llm_request", { "gen_ai.operation.name": "chat", model: "m" }),
16
+ span("claude_code.tool", { "gen_ai.operation.name": "execute_tool", tool_name: "Bash" }),
17
+ ]);
18
+ expect(mapped.map((s) => s.kind)).toEqual(["turn", "model", "tool"]);
19
+ });
20
+
21
+ it("tool_use_id 复制成 call_id 供 I/O join;已有 call_id 的不覆盖", () => {
22
+ const mapped = mapClaudeCodeSpans([
23
+ span("claude_code.tool", { "gen_ai.operation.name": "execute_tool", tool_use_id: "toolu_1" }),
24
+ span("claude_code.tool", { "gen_ai.operation.name": "execute_tool", tool_use_id: "toolu_2", call_id: "keep" }),
25
+ span("claude_code.hook", {}),
26
+ ]);
27
+ expect(mapped[0].attributes?.call_id).toBe("toolu_1");
28
+ expect(mapped[1].attributes?.call_id).toBe("keep");
29
+ expect(mapped[2].attributes?.call_id).toBeUndefined();
30
+ });
31
+ });
@@ -0,0 +1,24 @@
1
+ // claude-code 的 span mapper:原生 OTLP span(beta 遥测)→ canonical GenAI semconv。
2
+ //
3
+ // claude CLI 的 span 自带标准 gen_ai.operation.name(llm_request → chat、tool →
4
+ // execute_tool),通用 heuristic 直接信任 —— 这里只编码 heuristic 做不到的两件事:
5
+ // 1. claude_code.interaction(每个 user prompt 的根 span)归 "turn":它没有对应的
6
+ // GenAI 操作,不标注会在大 trace 过滤时被当噪声丢掉,瀑布图失去回合层;
7
+ // 2. tool span 的 tool_use_id 复制成 call_id —— transcript 事件流的 callId 就是
8
+ // tool_use id,enrichTraceWithIO 按 call_id 才 join 得上工具入参/出参。
9
+
10
+ import type { JsonValue, TraceSpan } from "../../../types.ts";
11
+ import { heuristicTag, tagSpan } from "../canonical.ts";
12
+
13
+ export function mapClaudeCodeSpans(spans: TraceSpan[]): TraceSpan[] {
14
+ return spans.map((s) => {
15
+ const tagged =
16
+ s.name === "claude_code.interaction" ? tagSpan(s, { kind: "turn" }) : tagSpan(s, heuristicTag(s));
17
+ const tuid = tagged.attributes?.["tool_use_id"];
18
+ if (typeof tuid === "string" && tagged.attributes?.["call_id"] === undefined) {
19
+ const attributes: Record<string, JsonValue> = { ...tagged.attributes, call_id: tuid };
20
+ return { ...tagged, attributes };
21
+ }
22
+ return tagged;
23
+ });
24
+ }
@@ -10,4 +10,5 @@
10
10
 
11
11
  export { mapCodexSpans } from "./codex.ts";
12
12
  export { mapBubSpans } from "./bub.ts";
13
+ export { mapClaudeCodeSpans } from "./claude-code.ts";
13
14
  export { mapGenericSpans } from "../canonical.ts";
@@ -0,0 +1,127 @@
1
+ // parseOtlpTraces 两种线编码的回归测试。protobuf 路径此前零覆盖——bub(Python OTLP
2
+ // 出口只有 protobuf)的 trace 全靠它,解析静默失败的表现就是"span 收到了但 trace 是空的"。
3
+ // 这里不引 opentelemetry 依赖,用一个最小 protobuf writer 按 opentelemetry-proto 字段号手工编码。
4
+
5
+ import { describe, expect, it } from "vitest";
6
+ import { parseOtlpTraces } from "./parse.ts";
7
+
8
+ // ── 最小 protobuf writer(varint / length-delimited / fixed64)──
9
+
10
+ function varint(n: number | bigint): Buffer {
11
+ let v = BigInt(n);
12
+ const out: number[] = [];
13
+ for (;;) {
14
+ const b = Number(v & 0x7fn);
15
+ v >>= 7n;
16
+ if (v === 0n) {
17
+ out.push(b);
18
+ break;
19
+ }
20
+ out.push(b | 0x80);
21
+ }
22
+ return Buffer.from(out);
23
+ }
24
+
25
+ function lenDelim(field: number, payload: Buffer): Buffer {
26
+ return Buffer.concat([varint((field << 3) | 2), varint(payload.length), payload]);
27
+ }
28
+
29
+ function fixed64(field: number, n: bigint): Buffer {
30
+ const b = Buffer.alloc(8);
31
+ b.writeBigUInt64LE(n);
32
+ return Buffer.concat([varint((field << 3) | 1), b]);
33
+ }
34
+
35
+ function varintField(field: number, n: number): Buffer {
36
+ return Buffer.concat([varint(field << 3), varint(n)]);
37
+ }
38
+
39
+ function stringAttr(key: string, value: string): Buffer {
40
+ // KeyValue { 1:key 2:AnyValue{ 1:string_value } }
41
+ return Buffer.concat([
42
+ lenDelim(1, Buffer.from(key)),
43
+ lenDelim(2, lenDelim(1, Buffer.from(value))),
44
+ ]);
45
+ }
46
+
47
+ describe("parseOtlpTraces / protobuf", () => {
48
+ it("按 opentelemetry-proto 字段号解出 span 的瀑布图字段", () => {
49
+ const traceId = Buffer.from("0102030405060708090a0b0c0d0e0f10", "hex");
50
+ const spanId = Buffer.from("1112131415161718", "hex");
51
+ const parentId = Buffer.from("2122232425262728", "hex");
52
+ // Span { 1:trace_id 2:span_id 4:parent 5:name 7:start 8:end 9:attrs 15:Status{3:code} }
53
+ const span = Buffer.concat([
54
+ lenDelim(1, traceId),
55
+ lenDelim(2, spanId),
56
+ lenDelim(4, parentId),
57
+ lenDelim(5, Buffer.from("bub.agent.step")),
58
+ fixed64(7, 1_700_000_000_000_000_000n),
59
+ fixed64(8, 1_700_000_001_500_000_000n),
60
+ lenDelim(9, stringAttr("gen_ai.operation.name", "chat")),
61
+ lenDelim(15, varintField(3, 2)), // STATUS_CODE_ERROR
62
+ ]);
63
+ // ExportTraceServiceRequest { 1:ResourceSpans { 2:ScopeSpans { 2:Span } } }
64
+ const body = lenDelim(1, lenDelim(2, lenDelim(2, span)));
65
+
66
+ const spans = parseOtlpTraces(body, "application/x-protobuf");
67
+ expect(spans).toHaveLength(1);
68
+ expect(spans[0]).toEqual({
69
+ traceId: "0102030405060708090a0b0c0d0e0f10",
70
+ spanId: "1112131415161718",
71
+ parentSpanId: "2122232425262728",
72
+ name: "bub.agent.step",
73
+ startMs: 1_700_000_000_000,
74
+ endMs: 1_700_000_001_500,
75
+ status: "error",
76
+ attributes: { "gen_ai.operation.name": "chat" },
77
+ });
78
+ });
79
+
80
+ it("未知字段跳过不影响解析,坏 payload 回空数组而不是抛错", () => {
81
+ // 在 span 前后夹私有字段(field 200, varint)——解析应无视它们。
82
+ const span = Buffer.concat([
83
+ varintField(200, 7),
84
+ lenDelim(5, Buffer.from("only-name")),
85
+ varintField(201, 9),
86
+ ]);
87
+ const body = lenDelim(1, lenDelim(2, lenDelim(2, span)));
88
+ const spans = parseOtlpTraces(body, "application/x-protobuf");
89
+ expect(spans).toHaveLength(1);
90
+ expect(spans[0].name).toBe("only-name");
91
+
92
+ expect(parseOtlpTraces(Buffer.from("not protobuf at all"), "application/x-protobuf")).toEqual([]);
93
+ });
94
+ });
95
+
96
+ describe("parseOtlpTraces / json", () => {
97
+ it("codex 风格 OTLP/JSON:hex id + 十进制纳秒字符串", () => {
98
+ const body = Buffer.from(
99
+ JSON.stringify({
100
+ resourceSpans: [
101
+ {
102
+ scopeSpans: [
103
+ {
104
+ spans: [
105
+ {
106
+ traceId: "0102030405060708090a0b0c0d0e0f10",
107
+ spanId: "1112131415161718",
108
+ name: "codex.turn",
109
+ startTimeUnixNano: "1700000000000000000",
110
+ endTimeUnixNano: "1700000001500000000",
111
+ status: { code: 1 },
112
+ attributes: [{ key: "k", value: { stringValue: "v" } }],
113
+ },
114
+ ],
115
+ },
116
+ ],
117
+ },
118
+ ],
119
+ }),
120
+ );
121
+ const spans = parseOtlpTraces(body, "application/json");
122
+ expect(spans).toHaveLength(1);
123
+ expect(spans[0].name).toBe("codex.turn");
124
+ expect(spans[0].startMs).toBe(1_700_000_000_000);
125
+ expect(spans[0].attributes).toEqual({ k: "v" });
126
+ });
127
+ });