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.
- package/README.zh.md +11 -0
- package/docs/README.md +2 -1
- package/docs/adapters/collection.md +1 -1
- package/docs/adapters/contract.md +6 -5
- package/docs/adapters/reference/claude-code-otel-telemetry.md +1 -1
- package/docs/assertions.md +0 -1
- package/docs/capabilities-by-construction.md +2 -2
- package/docs/cli.md +2 -2
- package/docs/concepts.md +2 -2
- package/docs/e2e-ci.md +135 -98
- package/docs/eval-authoring.md +3 -3
- package/docs/experiments.md +7 -4
- package/docs/observability.md +9 -5
- package/docs/origin-integration.md +18 -18
- package/docs/references.md +1 -1
- package/docs/reports.md +551 -0
- package/docs/results-format.md +3 -3
- package/docs/results-lib.md +191 -0
- package/docs/runner.md +1 -1
- package/docs/sandbox.md +1 -1
- package/docs/source-map.md +22 -2
- package/docs/tier-sync.md +74 -58
- package/docs/view.md +39 -5
- package/package.json +29 -3
- package/src/agents/ai-sdk.ts +3 -0
- package/src/agents/claude-code.ts +18 -1
- package/src/agents/codex.ts +3 -2
- package/src/agents/index.ts +16 -0
- package/src/agents/openai-compat.test.ts +56 -0
- package/src/agents/openai-compat.ts +151 -0
- package/src/agents/types.ts +3 -1
- package/src/cli.ts +3 -2
- package/src/context/context.test.ts +78 -1
- package/src/context/context.ts +19 -11
- package/src/context/session.ts +2 -0
- package/src/context/types.ts +11 -5
- package/src/i18n/en.ts +3 -4
- package/src/i18n/zh-CN.ts +2 -3
- package/src/o11y/cost.test.ts +40 -0
- package/src/o11y/cost.ts +27 -5
- package/src/o11y/derive.ts +2 -2
- package/src/o11y/otlp/mappers/claude-code.test.ts +31 -0
- package/src/o11y/otlp/mappers/claude-code.ts +24 -0
- package/src/o11y/otlp/mappers/index.ts +1 -0
- package/src/o11y/otlp/parse.test.ts +127 -0
- package/src/o11y/prices.json +176 -119
- package/src/o11y/types.ts +2 -1
- package/src/report/aggregate.ts +215 -0
- package/src/report/compute.ts +405 -0
- package/src/report/format.ts +47 -0
- package/src/report/index.ts +40 -0
- package/src/report/metrics.ts +96 -0
- package/src/report/react/CaseList.tsx +70 -0
- package/src/report/react/DeltaTable.tsx +79 -0
- package/src/report/react/MetricMatrix.tsx +75 -0
- package/src/report/react/MetricScatter.tsx +217 -0
- package/src/report/react/MetricTable.tsx +71 -0
- package/src/report/react/RunOverview.tsx +87 -0
- package/src/report/react/Scoreboard.tsx +87 -0
- package/src/report/react/cell.tsx +49 -0
- package/src/report/react/colors.ts +42 -0
- package/src/report/react/data.ts +17 -0
- package/src/report/react/fixtures.ts +236 -0
- package/src/report/react/format.ts +34 -0
- package/src/report/react/index.tsx +34 -0
- package/src/report/react/render.test.tsx +303 -0
- package/src/report/react/styles.css +302 -0
- package/src/report/report.test.ts +667 -0
- package/src/report/types.ts +210 -0
- package/src/results/copy.ts +200 -0
- package/src/results/format.ts +75 -0
- package/src/results/index.ts +27 -0
- package/src/results/open.ts +207 -0
- package/src/results/results.test.ts +359 -0
- package/src/results/select.ts +101 -0
- package/src/results/types.ts +98 -0
- package/src/runner/attempt.ts +3 -1
- package/src/runner/report.test.ts +111 -0
- package/src/runner/report.ts +52 -1
- package/src/runner/reporters/artifacts.ts +1 -1
- package/src/runner/reporters/braintrust.test.ts +106 -0
- package/src/runner/reporters/braintrust.ts +197 -0
- package/src/runner/reporters/index.ts +3 -1
- package/src/runner/run.ts +30 -19
- package/src/runner/types.ts +17 -0
- package/src/sandbox/types.ts +3 -3
- package/src/view/app/App.tsx +82 -17
- package/src/view/app/components/CostScoreChart.tsx +127 -0
- package/src/view/app/i18n.ts +10 -1
- package/src/view/app/lib/attempt-route.test.ts +89 -0
- package/src/view/app/lib/attempt-route.ts +52 -0
- package/src/view/app/lib/chart.ts +72 -0
- package/src/view/app/lib/outcome.ts +1 -1
- package/src/view/app/types.ts +4 -5
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +18 -18
- package/src/view/index.ts +12 -18
- package/src/view/loader.ts +14 -7
- package/src/view/shared/types.ts +14 -1
- package/src/view/styles.css +43 -0
- package/src/view/template.html +0 -1
package/src/context/context.ts
CHANGED
|
@@ -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
|
-
|
package/src/context/session.ts
CHANGED
|
@@ -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,
|
package/src/context/types.ts
CHANGED
|
@@ -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
|
|
39
|
-
" --out
|
|
40
|
-
" artifacts
|
|
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
|
|
37
|
-
" --out
|
|
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
|
|
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 / 查不到价
|
|
60
|
+
* (cache token 本质也是 input)。无 model / 查不到价(用户表 + 内置快照都没有)→ undefined。
|
|
43
61
|
*/
|
|
44
|
-
export function estimateCost(
|
|
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 =
|
|
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;
|
package/src/o11y/derive.ts
CHANGED
|
@@ -207,7 +207,7 @@ export function buildO11ySummary(
|
|
|
207
207
|
usage: Usage,
|
|
208
208
|
durationMs: number,
|
|
209
209
|
): O11ySummary {
|
|
210
|
-
const toolCalls: Record<
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
+
});
|