@sema-agent/server 3.5.1 → 3.5.3

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/dist/budget.js CHANGED
@@ -131,6 +131,31 @@ export function createTracer(metrics, costQuota, modelUsage, fleetUsage, fleetLe
131
131
  if (principal)
132
132
  costQuota?.add(principal, e.costMicroUsd);
133
133
  }
134
+ // ┌─ #59 裁定([2052]① cli 报案 → 黑板 [2073] 立案;本块是该工单的落点,勿删) ─────────────────┐
135
+ // 病灶报案:下面三处 `e.costMicroUsd ?? 0`(本行 + fleetUsage + fleetLease)把「成本未知」记成
136
+ // 「免费」,与 `pricingConfigured=false` 的部署组合 ⇒ 全部账目假 0。
137
+ //
138
+ // 亲读上游后的**事实纠偏**:core 在 brain.call 帧上**恒发数字、从不缺席**——
139
+ // runtask.ts `usageCostMicroUsd(...)` 返回 `number`(内部 `computeCostMicroUsd` 恒返回 number),
140
+ // 帧上 `costMicroUsd: turnCostMicroUsd` 是**无条件**键;无价目表时 `modelCostToPricing(undefined)`
141
+ // 产出全零单价 ⇒ 每次调用如实算出 **0**。
142
+ // 所以「0 = 免费」与「0 = 没配价目表」的歧义**产生在 core**,server 收到的信息里根本不含这个区分:
143
+ // 把这三处改成透传缺席**治不了根**(缺席永不到达),只会在 wire/SDK 面开一个今天零 producer 的可缺键。
144
+ // `?? 0` 因此定性为**类型面守卫**(core 契约把该键声明为 optional),不是语义折零点。
145
+ //
146
+ // 三问:
147
+ // · 谁需要 —— 消费端要区分真零/未知(cli 在 CC 对照里正是靠「诚实缺席不编造」赢的那一点)。
148
+ // · 谁受伤 —— (a) 聚合面遇缺席:usage-analytics 与 aggregateModelUsage 此前把 DB JSON 当全必填做裸
149
+ // 算术,一行缺键就 NaN/字符串毒掉整窗(#59 同批已修,红先复现在
150
+ // test/usage-analytics.test.ts + test/model-usage.test.ts);(b) 历史行:已按 0 记账的行无法回溯
151
+ // 区分,只能靠部署位读回。
152
+ // · 补偿 —— 第一级 = `capabilities.pricingConfigured`(main.ts:801,已在):false ⇒ 消费端把任何 0
153
+ // 渲染成「未知」而非「免费」;SDK `ModelUsageDelta.costMicroUsd` 的文档逐字同源。第二级(行级缺席)
154
+ // 需要 core 先在源头消歧 —— **工单挂 core**:无价目表时别为该调用编 0(缺席该键,或带一位
155
+ // priced=false),到货后本处三行同批改透传,聚合面已经接得住。
156
+ // 现状语义(本批测试钉死,勿静默改):core 发 0 ⇒ server **如实透传 0**,消歧走部署位;
157
+ // 缺席(今日无 producer)⇒ 记 0,不抛不 NaN。
158
+ // └──────────────────────────────────────────────────────────────────────────────────────────────┘
134
159
  // E8 (shell-host): per-task × per-model usage for the `TaskStats.modelUsage` echo. `record` is a NO-OP unless
135
160
  // e.taskId is a REGISTERED top-level run (the leak fence — sub-task/throwaway taskIds are never registered, so
136
161
  // they neither leak nor enter the echo; their spend is in TaskStats.nested). Drained at turn boundaries into the
@@ -41,7 +41,13 @@ export declare function attachModelUsage<T extends TaskResult>(result: T, ctx: {
41
41
  /** E8 (shell-host contract): SUM the per-turn `model_usage` delta events in a run's durable event log into the
42
42
  * cumulative per-model total for `TaskStats.modelUsage`. The tracer drains deltas at each turn boundary
43
43
  * (append-only) → this is resume-safe: it sums across ALL process legs of a suspended/resumed run. Returns
44
- * undefined when the run logged no model usage (→ the shell soft-degrades to aggregate cost only). */
44
+ * undefined when the run logged no model usage (→ the shell soft-degrades to aggregate cost only).
45
+ *
46
+ * #59([2052]①):事件体是 **DB 里的 JSON**(旧版本 / 别的 producer 都可能写),此前的 `?? 0` 只接住
47
+ * null/undefined——NaN 与字符串会原样进 `+`,一个坏键就把该模型的整 run 总量变成 NaN 或字符串拼接,
48
+ * 再顺着 `TaskStats.modelUsage` 回声流进 usage-analytics 的整窗聚合。守卫改「非有限数 = 缺席、按 0 计」,
49
+ * 与 usage-analytics 读侧同口径。注意本折叠对「costMicroUsd 缺席」与「costMicroUsd=0」的输出相同(都是 0):
50
+ * 行级 0-vs-未知的消歧不在这里,也不在 server —— 裁定见 budget.ts 的「#59 裁定」块。 */
45
51
  export declare function aggregateModelUsage(events: RunEvent[]): Record<string, ModelUsageDelta> | undefined;
46
52
  export type TraceBlock = {
47
53
  type: "thinking";
@@ -39,8 +39,15 @@ export async function attachModelUsage(result, ctx) {
39
39
  /** E8 (shell-host contract): SUM the per-turn `model_usage` delta events in a run's durable event log into the
40
40
  * cumulative per-model total for `TaskStats.modelUsage`. The tracer drains deltas at each turn boundary
41
41
  * (append-only) → this is resume-safe: it sums across ALL process legs of a suspended/resumed run. Returns
42
- * undefined when the run logged no model usage (→ the shell soft-degrades to aggregate cost only). */
42
+ * undefined when the run logged no model usage (→ the shell soft-degrades to aggregate cost only).
43
+ *
44
+ * #59([2052]①):事件体是 **DB 里的 JSON**(旧版本 / 别的 producer 都可能写),此前的 `?? 0` 只接住
45
+ * null/undefined——NaN 与字符串会原样进 `+`,一个坏键就把该模型的整 run 总量变成 NaN 或字符串拼接,
46
+ * 再顺着 `TaskStats.modelUsage` 回声流进 usage-analytics 的整窗聚合。守卫改「非有限数 = 缺席、按 0 计」,
47
+ * 与 usage-analytics 读侧同口径。注意本折叠对「costMicroUsd 缺席」与「costMicroUsd=0」的输出相同(都是 0):
48
+ * 行级 0-vs-未知的消歧不在这里,也不在 server —— 裁定见 budget.ts 的「#59 裁定」块。 */
43
49
  export function aggregateModelUsage(events) {
50
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
44
51
  const total = {};
45
52
  let any = false;
46
53
  for (const ev of events) {
@@ -52,11 +59,11 @@ export function aggregateModelUsage(events) {
52
59
  for (const [model, d] of Object.entries(usage)) {
53
60
  any = true;
54
61
  const cur = total[model] ?? { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, costMicroUsd: 0 };
55
- cur.inputTokens += d.inputTokens ?? 0;
56
- cur.outputTokens += d.outputTokens ?? 0;
57
- cur.cacheReadTokens += d.cacheReadTokens ?? 0;
58
- cur.cacheWriteTokens += d.cacheWriteTokens ?? 0;
59
- cur.costMicroUsd += d.costMicroUsd ?? 0;
62
+ cur.inputTokens += num(d.inputTokens);
63
+ cur.outputTokens += num(d.outputTokens);
64
+ cur.cacheReadTokens += num(d.cacheReadTokens);
65
+ cur.cacheWriteTokens += num(d.cacheWriteTokens);
66
+ cur.costMicroUsd += num(d.costMicroUsd);
60
67
  total[model] = cur;
61
68
  }
62
69
  }
@@ -12,7 +12,9 @@
12
12
  * - tokensOut = Σ outputTokens;
13
13
  * - costUsd = Σ costMicroUsd / 1e6(authoritative);
14
14
  * - 旧行/无 modelUsage echo 的行(1.x 早期或非终态)fallback `stats.tokens` 整数计入 tokensOut 并置
15
- * `estimated:true` 于该聚合(诚实标注,契约要求"token 数标注估算与否");
15
+ * `estimated:true` 于该聚合(诚实标注,契约要求"token 数标注估算与否");#59 起 `estimated` 还覆盖
16
+ * 第二种口径不完整:**有 echo 行但缺数值键**(该键按 0 计入 ⇒ 总量偏低)——两臂同义:别把总数当精确值;
17
+ * - 行内数值键一律走 {@link numOf} 守卫:数据源是 DB 里的 JSON,裸算术遇缺席/非数会 NaN 毒掉整窗(#59);
16
18
  * - 扫窗有行数上限(store 侧 LIMIT)——触顶时结果携带 `truncated:true`(诚实截断,绝不静默)。
17
19
  */
18
20
  /** store 侧 usageScan 的行投影(所有后端同形状;createdAtMs=epoch ms)。 */
@@ -23,21 +25,33 @@ export interface UsageRow {
23
25
  /** result.stats 摘要;result NULL/不可解析 ⇒ undefined(不计 token/cost,仍计 tasks)。 */
24
26
  stats?: {
25
27
  tokens?: number;
26
- modelUsage?: Record<string, {
27
- inputTokens: number;
28
- outputTokens: number;
29
- cacheReadTokens: number;
30
- cacheWriteTokens: number;
31
- costMicroUsd: number;
32
- }>;
28
+ modelUsage?: Record<string, UsageModelEntry>;
33
29
  };
34
30
  }
31
+ /**
32
+ * 一行 per-model echo。**五键逐个可选**——本类型描述的不是本进程铸的对象,而是 **DB 里 result JSON 的一段**:
33
+ * 旧版本写的行、别的 producer 写的行、以及 wire 契约本身(SDK `ModelUsageDelta` 五键全可选 + `[k]:unknown`
34
+ * 开放)都可能少键。#59([2052]①):此处原先声明成「全必填」,`projectUsageStats` 又对任意 JSON 做裸 cast,
35
+ * 于是 fold 侧的裸算术遇缺席就产出 NaN,**一行毒一整窗**(NaN 沿 Σ 传染,JSON.stringify(NaN)==="null",
36
+ * 消费端读到的是「没有数」而不是「少了一行」)。声明改诚实 + fold 侧 {@link numOf} 数值守卫。
37
+ */
38
+ export interface UsageModelEntry {
39
+ inputTokens?: number;
40
+ outputTokens?: number;
41
+ cacheReadTokens?: number;
42
+ cacheWriteTokens?: number;
43
+ /** 整数 micro-USD。**缺席 = 未知**,不是 0(SDK `ModelUsageDelta` 同款单轨契约)。注意在场的 `0` 仍有
44
+ * 歧义(引擎无价目表时恒发 0),那层消歧在部署面 `capabilities.pricingConfigured`——裁定见 budget.ts。 */
45
+ costMicroUsd?: number;
46
+ }
35
47
  export interface UsageTotals {
36
48
  tasks: number;
37
49
  tokensIn: number;
38
50
  tokensOut: number;
39
51
  costUsd: number;
40
- /** 有行走了 stats.tokens fallback(无 per-model echo)——token 口径部分为估算。 */
52
+ /** 本聚合的口径**不完整**——两种来源:①有行走了 stats.tokens fallback(无 per-model echo,token 数为估算);
53
+ * ②有 per-model echo 行**缺数值键**(该键按 0 计入,总量偏低;成本键缺席时尤其:少算 ≠ 免费)。
54
+ * 两者都只表达「别把这个总数当精确值」,消费端一视同仁。 */
41
55
  estimated: boolean;
42
56
  }
43
57
  /** GET /v1/usage/summary — 窗内总量。 */
@@ -12,19 +12,37 @@
12
12
  * - tokensOut = Σ outputTokens;
13
13
  * - costUsd = Σ costMicroUsd / 1e6(authoritative);
14
14
  * - 旧行/无 modelUsage echo 的行(1.x 早期或非终态)fallback `stats.tokens` 整数计入 tokensOut 并置
15
- * `estimated:true` 于该聚合(诚实标注,契约要求"token 数标注估算与否");
15
+ * `estimated:true` 于该聚合(诚实标注,契约要求"token 数标注估算与否");#59 起 `estimated` 还覆盖
16
+ * 第二种口径不完整:**有 echo 行但缺数值键**(该键按 0 计入 ⇒ 总量偏低)——两臂同义:别把总数当精确值;
17
+ * - 行内数值键一律走 {@link numOf} 守卫:数据源是 DB 里的 JSON,裸算术遇缺席/非数会 NaN 毒掉整窗(#59);
16
18
  * - 扫窗有行数上限(store 侧 LIMIT)——触顶时结果携带 `truncated:true`(诚实截断,绝不静默)。
17
19
  */
20
+ /** JSON 来源的数值键:非 number / 非有限 ⇒ undefined(缺席),**绝不返回 NaN**,也绝不让字符串滑进 `+`。 */
21
+ function numOf(d, key) {
22
+ const v = d[key];
23
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
24
+ }
18
25
  const emptyTotals = () => ({ tasks: 0, tokensIn: 0, tokensOut: 0, costUsd: 0, estimated: false });
26
+ /** 折入一行 per-model echo(summary/series 的 foldRow 与 breakdown 的 model 维共用——两处写两遍必漂移)。
27
+ * 缺席/非数键按 0 计入并置 `estimated`(诚实标注偏低,绝不静默,也绝不 NaN 毒窗)。 */
28
+ function foldModelEntry(t, d) {
29
+ const input = numOf(d, "inputTokens");
30
+ const output = numOf(d, "outputTokens");
31
+ const cacheRead = numOf(d, "cacheReadTokens");
32
+ const cacheWrite = numOf(d, "cacheWriteTokens");
33
+ const cost = numOf(d, "costMicroUsd");
34
+ t.tokensIn += (input ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0);
35
+ t.tokensOut += output ?? 0;
36
+ t.costUsd += (cost ?? 0) / 1e6;
37
+ if (input === undefined || output === undefined || cacheRead === undefined || cacheWrite === undefined || cost === undefined)
38
+ t.estimated = true;
39
+ }
19
40
  function foldRow(t, row) {
20
41
  t.tasks += 1;
21
42
  const mu = row.stats?.modelUsage;
22
43
  if (mu && Object.keys(mu).length > 0) {
23
- for (const d of Object.values(mu)) {
24
- t.tokensIn += d.inputTokens + d.cacheReadTokens + d.cacheWriteTokens;
25
- t.tokensOut += d.outputTokens;
26
- t.costUsd += d.costMicroUsd / 1e6;
27
- }
44
+ for (const d of Object.values(mu))
45
+ foldModelEntry(t, d);
28
46
  }
29
47
  else if (row.stats?.tokens) {
30
48
  t.tokensOut += row.stats.tokens; // 旧行 fallback:总数无 in/out 拆分,归 out 侧并标 estimated
@@ -75,9 +93,7 @@ export function usageBreakdown(rows, dimension) {
75
93
  for (const [model, d] of Object.entries(mu)) {
76
94
  const t = bucket(model);
77
95
  t.tasks += 1; // 每 (run, model) 计一次 —— 一个 run 用两个模型在两桶各计 1(桶内 tasks=touched-run 数)
78
- t.tokensIn += d.inputTokens + d.cacheReadTokens + d.cacheWriteTokens;
79
- t.tokensOut += d.outputTokens;
80
- t.costUsd += d.costMicroUsd / 1e6;
96
+ foldModelEntry(t, d); // #59:与 summary/series 同一守卫(缺席键不毒桶,只标 estimated;桶间不传染)
81
97
  }
82
98
  }
83
99
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "3.5.1",
3
+ "version": "3.5.3",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -67,7 +67,7 @@
67
67
  "sharp": "^0.35.3"
68
68
  },
69
69
  "devDependencies": {
70
- "@sema-agent/sdk": "^2.1.1",
70
+ "@sema-agent/sdk": "^2.1.2",
71
71
  "@types/libsodium-wrappers": "^0.7.14",
72
72
  "@types/node": "22.10.2",
73
73
  "@types/pg": "^8.20.0",