@zhin.js/gateway 1.1.2 → 1.3.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 (60) hide show
  1. package/README.md +5 -5
  2. package/lib/config/configuration.d.ts +16 -0
  3. package/lib/config/configuration.js +32 -7
  4. package/lib/config/environment-files.d.ts +42 -0
  5. package/lib/config/environment-files.js +347 -7
  6. package/lib/config/validation-messages.d.ts +13 -0
  7. package/lib/config/validation-messages.js +59 -0
  8. package/lib/database/config.js +31 -0
  9. package/lib/database/console-role.d.ts +35 -0
  10. package/lib/database/console-role.js +108 -0
  11. package/lib/database/gateway-store.d.ts +43 -0
  12. package/lib/database/gateway-store.js +90 -0
  13. package/lib/database/index.d.ts +2 -0
  14. package/lib/database/index.js +2 -0
  15. package/lib/database/project-database.d.ts +45 -0
  16. package/lib/database/project-database.js +212 -6
  17. package/lib/http/console-rpc.d.ts +3 -1
  18. package/lib/http/console-rpc.js +31 -4
  19. package/lib/http/gateway-console.d.ts +15 -8
  20. package/lib/http/gateway-console.js +134 -50
  21. package/lib/http/gateway-http-server.js +39 -1
  22. package/lib/http/plugin-switch-rpc.js +11 -6
  23. package/lib/http/start-gateway.js +9 -0
  24. package/lib/iam/index.d.ts +4 -3
  25. package/lib/iam/index.js +4 -3
  26. package/lib/iam/install.d.ts +17 -11
  27. package/lib/iam/install.js +39 -18
  28. package/lib/iam/legacy-import.d.ts +151 -0
  29. package/lib/iam/legacy-import.js +600 -0
  30. package/lib/iam/local-admin.d.ts +2 -0
  31. package/lib/iam/local-admin.js +6 -1
  32. package/lib/iam/routes.js +10 -2
  33. package/lib/iam/rpc.js +1 -1
  34. package/lib/iam/service.d.ts +31 -0
  35. package/lib/iam/service.js +49 -4
  36. package/lib/iam/setup-service.d.ts +2 -0
  37. package/lib/iam/setup-service.js +11 -4
  38. package/lib/iam/tables.d.ts +6 -6
  39. package/lib/iam/tables.js +6 -8
  40. package/lib/local/protocol.d.ts +6 -0
  41. package/lib/local/protocol.js +6 -0
  42. package/lib/packages/errors.js +1 -0
  43. package/lib/packages/rpc.d.ts +6 -1
  44. package/lib/packages/rpc.js +28 -2
  45. package/lib/packages/service.d.ts +3 -1
  46. package/lib/packages/service.js +22 -4
  47. package/lib/plugin-management/use-cases.js +8 -8
  48. package/lib/plugin-management/workflow.d.ts +8 -2
  49. package/lib/plugin-management/workflow.js +28 -10
  50. package/lib/supervisor/instance-supervisor.d.ts +4 -1
  51. package/lib/supervisor/instance-supervisor.js +18 -1
  52. package/lib/terminal/modules.d.ts +12 -0
  53. package/lib/terminal/modules.js +38 -2
  54. package/lib/usage/aggregate.d.ts +64 -3
  55. package/lib/usage/aggregate.js +199 -26
  56. package/lib/usage/prompt-cache.d.ts +49 -0
  57. package/lib/usage/prompt-cache.js +113 -0
  58. package/lib/usage.d.ts +1 -0
  59. package/lib/usage.js +1 -0
  60. package/package.json +7 -7
@@ -55,6 +55,8 @@ export class InstanceSupervisor extends EventEmitter {
55
55
  #backoffTimer;
56
56
  #restartTimes = [];
57
57
  #ready;
58
+ /** Latest committed generation of the current child (`ready`, then each route table). */
59
+ #generation;
58
60
  #startedAt;
59
61
  #lastExit;
60
62
  #lastFailure;
@@ -76,7 +78,7 @@ export class InstanceSupervisor extends EventEmitter {
76
78
  state: this.#state,
77
79
  ...(child?.process.pid !== undefined ? { pid: child.process.pid } : {}),
78
80
  ...(this.#startedAt !== undefined && this.#state === 'running' ? { startedAt: this.#startedAt } : {}),
79
- ...(this.#ready?.generation !== undefined ? { generation: this.#ready.generation } : {}),
81
+ ...(this.#generation !== undefined ? { generation: this.#generation } : {}),
80
82
  ...(this.#ready ? { protocolVersion: this.#ready.version } : {}),
81
83
  lastExitCode: this.#lastExit?.code ?? null,
82
84
  lastExitSignal: this.#lastExit?.signal ?? null,
@@ -153,6 +155,7 @@ export class InstanceSupervisor extends EventEmitter {
153
155
  const { spawn: spec } = this.#options;
154
156
  const output = this.#options.output ?? 'pipe';
155
157
  this.#ready = undefined;
158
+ this.#generation = undefined;
156
159
  this.#startedAt = undefined;
157
160
  this.#setState('starting');
158
161
  let processHandle;
@@ -250,12 +253,19 @@ export class InstanceSupervisor extends EventEmitter {
250
253
  }
251
254
  child.ready = true;
252
255
  this.#ready = message.payload;
256
+ this.#noteGeneration(message.payload.generation);
253
257
  this.#startedAt = Date.now();
254
258
  this.#setState('running');
255
259
  this.emit('ready', message.payload);
256
260
  child.settleStart?.();
257
261
  return;
258
262
  }
263
+ if (message.type === 'routes') {
264
+ // The instance republishes its route table on every generation commit (hot reload,
265
+ // configuration apply); the table carries that generation (#1250).
266
+ this.#noteGeneration(message.payload.table.generation);
267
+ return;
268
+ }
259
269
  if (message.type !== 'restart-request')
260
270
  return;
261
271
  this.emit('restart-request', message.payload);
@@ -273,6 +283,13 @@ export class InstanceSupervisor extends EventEmitter {
273
283
  this.#setState('stopping');
274
284
  void this.#terminate(child, `restart:${message.payload.reason}`);
275
285
  }
286
+ /** Generations only move forward; a late table of an older generation never rolls status back. */
287
+ #noteGeneration(generation) {
288
+ if (generation === undefined)
289
+ return;
290
+ if (this.#generation === undefined || generation > this.#generation)
291
+ this.#generation = generation;
292
+ }
276
293
  #onExit(child, code, signal) {
277
294
  if (this.#child !== child)
278
295
  return;
@@ -85,3 +85,15 @@ export declare function createModuleCache(loaders?: TerminalModuleLoaders): {
85
85
  pty(): Promise<ModuleLoadResult<PtyModule>>;
86
86
  ssh(): Promise<ModuleLoadResult<SshModule>>;
87
87
  };
88
+ /**
89
+ * Whether `error` says the package itself cannot be resolved (not installed), as opposed to the
90
+ * package loading but failing inside (e.g. node-pty's `pty.node` missing because pnpm skipped its
91
+ * build script, or an addon built for another Node.js ABI).
92
+ */
93
+ export declare function isPackageNotInstalledError(name: string, error: unknown): boolean;
94
+ /**
95
+ * Operator hint for an optional terminal module that failed to load (#1250). Distinguishes "not
96
+ * installed" from "installed but its native addon was not built" — the usual cause with pnpm, which
97
+ * skips dependency build scripts unless `allowBuilds` lets them run. Matches `zhin doctor`'s fix text.
98
+ */
99
+ export declare function moduleLoadFailureHint(name: string, error: unknown): string;
@@ -38,7 +38,43 @@ async function settle(load, name) {
38
38
  try {
39
39
  return { ok: true, module: await load() };
40
40
  }
41
- catch {
42
- return { ok: false, reason: `${name} 不可用(可选依赖未安装或原生模块构建失败):在项目中执行 pnpm add ${name} 后重启` };
41
+ catch (error) {
42
+ return { ok: false, reason: moduleLoadFailureHint(name, error) };
43
43
  }
44
44
  }
45
+ /** Modules whose package ships a native addon pnpm builds only when allowed. */
46
+ const NATIVE_MODULES = new Set(['node-pty']);
47
+ /**
48
+ * Whether `error` says the package itself cannot be resolved (not installed), as opposed to the
49
+ * package loading but failing inside (e.g. node-pty's `pty.node` missing because pnpm skipped its
50
+ * build script, or an addon built for another Node.js ABI).
51
+ */
52
+ export function isPackageNotInstalledError(name, error) {
53
+ if (!error || typeof error !== 'object')
54
+ return false;
55
+ const code = error.code;
56
+ if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND')
57
+ return false;
58
+ const message = String(error.message ?? '');
59
+ // `Cannot find package 'node-pty' imported from …` (ESM) / `Cannot find module 'node-pty'` (CJS).
60
+ // A missing file inside the package (`../build/Release/pty.node`) names a path, not the package.
61
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
62
+ return new RegExp(`Cannot find (?:package|module) '${escaped}(?:/[^']*)?'`).test(message)
63
+ && !/\.node'/.test(message);
64
+ }
65
+ /**
66
+ * Operator hint for an optional terminal module that failed to load (#1250). Distinguishes "not
67
+ * installed" from "installed but its native addon was not built" — the usual cause with pnpm, which
68
+ * skips dependency build scripts unless `allowBuilds` lets them run. Matches `zhin doctor`'s fix text.
69
+ */
70
+ export function moduleLoadFailureHint(name, error) {
71
+ if (isPackageNotInstalledError(name, error)) {
72
+ return `${name} 未安装(可选依赖):在项目中执行 pnpm add ${name} 后重启`;
73
+ }
74
+ if (NATIVE_MODULES.has(name)) {
75
+ return `${name} 已安装,但原生模块未构建或与当前 Node.js 不匹配(pnpm 默认跳过依赖的构建脚本):`
76
+ + `在 pnpm-workspace.yaml 的 allowBuilds 中放行 ${name}(或运行 zhin migrate),然后执行 pnpm rebuild ${name} 并重启`
77
+ + '(需要 Python 与 C/C++ 编译工具链)';
78
+ }
79
+ return `${name} 已安装但加载失败:执行 pnpm install 修复依赖后重启`;
80
+ }
@@ -1,3 +1,4 @@
1
+ import { type PromptCacheHint } from './prompt-cache.js';
1
2
  export declare const DAY_MS = 86400000;
2
3
  export declare const HOUR_MS = 3600000;
3
4
  export declare const FIXED_SEGMENTS: ReadonlySet<string>;
@@ -28,6 +29,20 @@ export interface UsageStatsQuery {
28
29
  readonly until: number;
29
30
  readonly bucket: UsageBucket;
30
31
  readonly session?: string;
32
+ /** IANA zone for bucket boundaries; defaults to {@link defaultUsageTimeZone}. */
33
+ readonly timeZone?: string;
34
+ }
35
+ /**
36
+ * Resolves a recorded provider id (`services:<id>`, `accounts:<id>`, `profiles:<id>` or a
37
+ * legacy alias) to the service it belongs to. Traces record either the service or the account
38
+ * depending on the call path, so without this one model splits into two rows.
39
+ */
40
+ export type UsageProviderResolver = (provider: string) => Readonly<{
41
+ key: string;
42
+ label: string;
43
+ }> | undefined;
44
+ export interface UsageStatsOptions {
45
+ readonly resolveProvider?: UsageProviderResolver;
31
46
  }
32
47
  export interface UsageStatsTokens {
33
48
  readonly calls: number;
@@ -51,37 +66,83 @@ export interface UsageStats {
51
66
  readonly p50: number;
52
67
  readonly p90: number;
53
68
  };
69
+ /** `inputTokens` is the provider-reported prompt tokens of every model call (all iterations, sub-agents included). */
70
+ readonly inputBasis: 'reported-all-calls';
54
71
  };
55
72
  readonly byModel: ReadonlyArray<UsageStatsTokens & {
73
+ /** Merge key: the resolved service id, or the recorded provider when it cannot be resolved. */
56
74
  readonly provider: string;
57
75
  readonly model: string;
76
+ /** `服务名 · 模型`, or just the model when the recorded provider is an opaque id. */
77
+ readonly displayName: string;
78
+ readonly providerLabel?: string;
79
+ /** Recorded provider ids merged into this row (e.g. `services:…` and `accounts:…`). */
80
+ readonly providerIds: readonly string[];
58
81
  }>;
59
82
  readonly bySession: ReadonlyArray<UsageStatsTokens & {
60
83
  readonly sessionKey: string;
61
84
  readonly platform?: string;
62
85
  readonly scope?: string;
63
86
  readonly sceneId?: string;
87
+ /** Readable conversation label from the trace origin (`平台 · 群 名称`), when recorded. */
88
+ readonly displayName?: string;
64
89
  readonly turns: number;
65
90
  }>;
91
+ /** `byModel` keys use `${provider}/${model}` of the merged rows; `modelLabels` maps them to display names. */
66
92
  readonly series: ReadonlyArray<UsageStatsTokens & {
67
93
  readonly bucketStart: number;
68
94
  readonly byModel: Readonly<Record<string, number>>;
69
95
  }>;
96
+ readonly modelLabels: Readonly<Record<string, string>>;
97
+ /**
98
+ * Estimated prompt composition of each turn's FIRST model request (`context_budget`), summed over
99
+ * turns that recorded it. Not comparable to `totals.inputTokens` (actual, every call); see `inputBreakdown`.
100
+ */
70
101
  readonly segments: {
71
102
  readonly history: number;
72
103
  readonly fixed: number;
73
104
  readonly user: number;
74
105
  };
106
+ readonly inputBreakdown: {
107
+ readonly basis: 'first-request-estimate';
108
+ /** Turns that carried a context budget. */
109
+ readonly turns: number;
110
+ readonly history: number;
111
+ readonly fixed: number;
112
+ readonly user: number;
113
+ /** Segments not counted as history / fixed / user (e.g. active skills). */
114
+ readonly other: number;
115
+ /** history + fixed + user + other. */
116
+ readonly estimatedTotal: number;
117
+ /** Provider-reported prompt tokens of the same first requests, for comparison with `estimatedTotal`. */
118
+ readonly firstRequestInputTokens: number;
119
+ };
75
120
  readonly filesRead: number;
76
121
  readonly malformedLines: number;
122
+ /**
123
+ * Services that likely do not support or pass through prompt caching (#1250): a long run of
124
+ * calls without cache reads while adjacent requests shared their prefix. Empty when none.
125
+ */
126
+ readonly promptCacheHints: readonly PromptCacheHint[];
77
127
  }
78
128
  export declare const USAGE_STATS_SESSION_LIMIT = 50;
79
- /** Start of the local hour/day containing `ms`. */
80
- export declare function bucketStartOf(ms: number, bucket: UsageBucket): number;
129
+ /** `TZ` wins; otherwise the zone this process runs in (same rule as the instance Schedule Host). */
130
+ export declare function defaultUsageTimeZone(environment?: Readonly<Record<string, string | undefined>>): string;
131
+ export declare function isUsageTimeZone(value: unknown): value is string;
132
+ /** Start of the hour/day containing `ms` in `timeZone` (default: the process zone). */
133
+ export declare function bucketStartOf(ms: number, bucket: UsageBucket, timeZone?: string): number;
134
+ /** `YYYY-MM-DD` of `ms` in `timeZone` (default: the process zone). */
135
+ export declare function dayKeyOf(ms: number, timeZone?: string): string;
136
+ /** Ids minted by the provider graph (`services:<uuid>` …) carry no meaning for a reader. */
137
+ export declare function isOpaqueProviderId(provider: string): boolean;
138
+ /** `服务名 · 模型`; an unresolved opaque id shows the model alone instead of a UUID. */
139
+ export declare function usageModelDisplayName(provider: string, model: string, label?: string): string;
140
+ /** Readable label for a trace's conversation, from its recorded IM origin; `undefined` otherwise. */
141
+ export declare function sessionDisplayNameOf(trace: any): string | undefined;
81
142
  /** `platform:endpoint:scope:sceneId`; other shapes keep whatever parts they have. */
82
143
  export declare function parseSessionKey(key: string): {
83
144
  platform?: string;
84
145
  scope?: string;
85
146
  sceneId?: string;
86
147
  };
87
- export declare function buildUsageStats(dir: string, query: UsageStatsQuery): Promise<UsageStats>;
148
+ export declare function buildUsageStats(dir: string, query: UsageStatsQuery, options?: UsageStatsOptions): Promise<UsageStats>;
@@ -9,6 +9,7 @@ import { createReadStream } from 'node:fs';
9
9
  import { readdir } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
  import { createInterface } from 'node:readline';
12
+ import { PromptCacheProbe } from './prompt-cache.js';
12
13
  export const DAY_MS = 86_400_000;
13
14
  export const HOUR_MS = 3_600_000;
14
15
  export const FIXED_SEGMENTS = new Set(['system', 'tool_schemas', 'output_schema', 'tool_index', 'skill_catalog', 'agent_catalog', 'memory', 'bootstrap']);
@@ -80,21 +81,126 @@ export async function scanTraces(dir, options, visit) {
80
81
  return { files: names.length, filesRead, lines, malformed };
81
82
  }
82
83
  export const USAGE_STATS_SESSION_LIMIT = 50;
83
- /** Start of the local hour/day containing `ms`. */
84
- export function bucketStartOf(ms, bucket) {
85
- const d = new Date(ms);
86
- return bucket === 'day'
87
- ? new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
88
- : new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime();
89
- }
90
- function nextBucket(start, bucket) {
91
- const d = new Date(start);
92
- return bucket === 'day'
93
- ? new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime()
94
- : start + HOUR_MS;
84
+ /** `TZ` wins; otherwise the zone this process runs in (same rule as the instance Schedule Host). */
85
+ export function defaultUsageTimeZone(environment = process.env) {
86
+ const candidate = environment.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
87
+ return isUsageTimeZone(candidate) ? candidate : 'UTC';
88
+ }
89
+ export function isUsageTimeZone(value) {
90
+ if (typeof value !== 'string' || !value || value.length > 64)
91
+ return false;
92
+ try {
93
+ new Intl.DateTimeFormat('en-US', { timeZone: value });
94
+ return true;
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
100
+ const zoneFormatters = new Map();
101
+ /** Wall-clock fields of `ms` in `timeZone`. */
102
+ function wallClock(ms, timeZone) {
103
+ let format = zoneFormatters.get(timeZone);
104
+ if (!format) {
105
+ format = new Intl.DateTimeFormat('en-US', { timeZone, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' });
106
+ zoneFormatters.set(timeZone, format);
107
+ }
108
+ const parts = {};
109
+ for (const part of format.formatToParts(new Date(ms)))
110
+ if (part.type !== 'literal')
111
+ parts[part.type] = Number(part.value);
112
+ return { year: parts.year, month: parts.month, day: parts.day, hour: parts.hour % 24, minute: parts.minute, second: parts.second };
113
+ }
114
+ /** Wall clock of `ms` in `timeZone`, encoded as if it were UTC. */
115
+ function wallMs(ms, timeZone) {
116
+ const w = wallClock(ms, timeZone);
117
+ return Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second) + (((ms % 1000) + 1000) % 1000);
118
+ }
119
+ /** Start of the hour/day containing `ms` in `timeZone` (default: the process zone). */
120
+ export function bucketStartOf(ms, bucket, timeZone) {
121
+ if (!timeZone) {
122
+ const d = new Date(ms);
123
+ return bucket === 'day'
124
+ ? new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
125
+ : new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime();
126
+ }
127
+ const wall = wallMs(ms, timeZone);
128
+ const w = wallClock(ms, timeZone);
129
+ const target = bucket === 'day' ? Date.UTC(w.year, w.month - 1, w.day) : Date.UTC(w.year, w.month - 1, w.day, w.hour);
130
+ let start = ms - (wall - target);
131
+ // A daylight-saving change between `target` and `ms` shifts the estimate; correct it once.
132
+ for (let i = 0; i < 2; i++) {
133
+ const drift = wallMs(start, timeZone) - target;
134
+ if (drift === 0)
135
+ break;
136
+ start -= drift;
137
+ }
138
+ return start;
139
+ }
140
+ function nextBucket(start, bucket, timeZone) {
141
+ if (!timeZone) {
142
+ const d = new Date(start);
143
+ return bucket === 'day'
144
+ ? new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime()
145
+ : start + HOUR_MS;
146
+ }
147
+ // 26 h always lands inside the next local day (days are 23–25 h long).
148
+ return bucket === 'day' ? bucketStartOf(start + 26 * HOUR_MS, 'day', timeZone) : bucketStartOf(start + HOUR_MS + 1, 'hour', timeZone);
149
+ }
150
+ /** `YYYY-MM-DD` of `ms` in `timeZone` (default: the process zone). */
151
+ export function dayKeyOf(ms, timeZone) {
152
+ if (!timeZone) {
153
+ const d = new Date(ms);
154
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
155
+ }
156
+ const w = wallClock(ms, timeZone);
157
+ return `${w.year}-${String(w.month).padStart(2, '0')}-${String(w.day).padStart(2, '0')}`;
158
+ }
159
+ /** Ids minted by the provider graph (`services:<uuid>` …) carry no meaning for a reader. */
160
+ export function isOpaqueProviderId(provider) {
161
+ return /^(services|accounts|profiles):/u.test(provider);
162
+ }
163
+ /** `服务名 · 模型`; an unresolved opaque id shows the model alone instead of a UUID. */
164
+ export function usageModelDisplayName(provider, model, label) {
165
+ if (label)
166
+ return `${label} · ${model}`;
167
+ if (!provider || provider === 'unknown' || isOpaqueProviderId(provider))
168
+ return model;
169
+ return `${provider} · ${model}`;
170
+ }
171
+ const SCOPE_LABELS = { private: '私聊', direct: '私聊', group: '群', channel: '频道', guild: '频道' };
172
+ /** Readable label for a trace's conversation, from its recorded IM origin; `undefined` otherwise. */
173
+ export function sessionDisplayNameOf(trace) {
174
+ const origin = trace?.origin;
175
+ if (!origin || typeof origin !== 'object')
176
+ return undefined;
177
+ if (origin.kind === 'im') {
178
+ const platform = typeof origin.platform === 'string' ? origin.platform : '';
179
+ const scope = typeof origin.scope === 'string' ? origin.scope : typeof origin.conversation?.kind === 'string' ? origin.conversation.kind : '';
180
+ const conversationName = typeof origin.conversation?.name === 'string' && origin.conversation.name.trim() ? origin.conversation.name.trim() : undefined;
181
+ const principalName = typeof trace?.principal?.display_name === 'string' && trace.principal.display_name.trim() ? trace.principal.display_name.trim() : undefined;
182
+ const sceneId = typeof origin.sceneId === 'string' ? origin.sceneId : typeof origin.conversation?.id === 'string' ? origin.conversation.id : '';
183
+ const name = conversationName ?? ((scope === 'private' || scope === 'direct') ? principalName : undefined) ?? sceneId;
184
+ const parts = [platform, `${SCOPE_LABELS[scope] ?? scope}${name ? ` ${name}` : ''}`.trim()].filter(Boolean);
185
+ return parts.length ? parts.join(' · ') : undefined;
186
+ }
187
+ if (origin.kind === 'http')
188
+ return 'HTTP';
189
+ if (origin.kind === 'schedule')
190
+ return `定时任务 ${String(origin.jobId ?? '')}`.trim();
191
+ if (origin.kind === 'a2a')
192
+ return 'A2A';
193
+ if (origin.kind === 'mcp')
194
+ return 'MCP';
195
+ if (origin.kind === 'internal')
196
+ return `内部 ${String(origin.source ?? '')}`.trim();
197
+ return undefined;
95
198
  }
96
199
  /** `platform:endpoint:scope:sceneId`; other shapes keep whatever parts they have. */
97
200
  export function parseSessionKey(key) {
201
+ // Hashed identities (`personal-session:v1:<sha>`, `scene-task:v1:<sha>`) carry no address.
202
+ if (/^[a-z-]+:v\d+:[0-9a-f]{16,}$/u.test(key))
203
+ return {};
98
204
  const parts = key.split(':');
99
205
  if (parts.length >= 4)
100
206
  return { platform: parts[0], scope: parts[2], sceneId: parts.slice(3).join(':') };
@@ -113,40 +219,80 @@ function tokens(t) {
113
219
  cacheShare: t.input > 0 ? Math.min(1, t.cache / t.input) : 0,
114
220
  };
115
221
  }
222
+ /** Sub-agent nesting depth the aggregate follows; deeper (malformed) trees are cut off. */
223
+ const MAX_SUBAGENT_DEPTH = 16;
116
224
  function nearestRank(sorted, p) {
117
225
  if (sorted.length === 0)
118
226
  return 0;
119
227
  return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))];
120
228
  }
121
- export async function buildUsageStats(dir, query) {
229
+ export async function buildUsageStats(dir, query, options = {}) {
122
230
  const { since, until, bucket } = query;
231
+ const timeZone = query.timeZone && isUsageTimeZone(query.timeZone) ? query.timeZone : defaultUsageTimeZone();
123
232
  const total = tally();
124
233
  let turns = 0;
125
234
  const callShares = [];
126
235
  const segments = { history: 0, fixed: 0, user: 0 };
236
+ const breakdown = { turns: 0, other: 0, firstRequestInputTokens: 0 };
127
237
  const models = new Map();
238
+ const resolved = new Map();
128
239
  const sessions = new Map();
129
240
  const buckets = new Map();
130
- for (let t = bucketStartOf(since, bucket); t < until; t = nextBucket(t, bucket)) {
241
+ for (let t = bucketStartOf(since, bucket, timeZone); t < until; t = nextBucket(t, bucket, timeZone)) {
131
242
  buckets.set(t, { ...tally(), byModel: {} });
132
243
  }
244
+ const resolve = (provider) => {
245
+ if (!options.resolveProvider)
246
+ return undefined;
247
+ if (!resolved.has(provider)) {
248
+ let value;
249
+ try {
250
+ value = options.resolveProvider(provider);
251
+ }
252
+ catch {
253
+ value = undefined;
254
+ }
255
+ resolved.set(provider, value);
256
+ }
257
+ return resolved.get(provider);
258
+ };
259
+ const cacheProbe = new PromptCacheProbe({ resolveProvider: resolve });
133
260
  const scan = await scanTraces(dir, { since, until, session: query.session }, (trace, startedAt) => {
134
261
  turns++;
262
+ cacheProbe.visit(trace);
135
263
  const session = sessions.get(trace.session_key) ?? { ...tally(), turns: 0 };
136
264
  session.turns++;
265
+ session.displayName ??= sessionDisplayNameOf(trace);
137
266
  sessions.set(trace.session_key, session);
138
- const calls = Array.isArray(trace.model_calls) ? trace.model_calls : [];
139
- for (const raw of calls) {
267
+ // Every model call of the turn: the root's, then each sub-agent's (nested, up to MAX_SUBAGENT_DEPTH). A
268
+ // sub-agent trace's own `model_calls` are not repeated in its parent's, so nothing counts twice;
269
+ // its calls count toward the root turn's session. `firstPrompt` is the root's first request.
270
+ const calls = [];
271
+ const collect = (node, depth) => {
272
+ if (!node || typeof node !== 'object' || depth > MAX_SUBAGENT_DEPTH)
273
+ return;
274
+ if (Array.isArray(node.model_calls))
275
+ calls.push(...node.model_calls);
276
+ if (Array.isArray(node.subagents))
277
+ for (const child of node.subagents)
278
+ collect(child, depth + 1);
279
+ };
280
+ collect(trace, 0);
281
+ const rootCalls = Array.isArray(trace.model_calls) ? trace.model_calls.length : 0;
282
+ let firstPrompt;
283
+ for (const [index, raw] of calls.entries()) {
140
284
  const call = raw;
141
285
  const prompt = num(call?.prompt_tokens);
142
286
  if (prompt === undefined)
143
287
  continue;
288
+ if (index < rootCalls)
289
+ firstPrompt ??= prompt;
144
290
  const output = num(call?.completion_tokens) ?? 0;
145
291
  const cache = num(call?.cache_read_tokens) ?? 0;
146
292
  const cacheWrite = num(call?.cache_write_tokens) ?? 0;
147
293
  if (prompt > 0)
148
294
  callShares.push(Math.min(1, cache / prompt));
149
- const provider = typeof call?.provider === 'string' && call.provider ? call.provider : 'unknown';
295
+ const recorded = typeof call?.provider === 'string' && call.provider ? call.provider : 'unknown';
150
296
  const model = typeof call?.model === 'string' && call.model ? call.model : 'unknown';
151
297
  const at = num(call?.started_at) ?? num(call?.at) ?? startedAt;
152
298
  for (const t of [total, session]) {
@@ -156,15 +302,19 @@ export async function buildUsageStats(dir, query) {
156
302
  t.cache += cache;
157
303
  t.cacheWrite += cacheWrite;
158
304
  }
159
- const modelKey = `${provider}/${model}`;
160
- const m = models.get(modelKey) ?? { ...tally(), provider, model };
305
+ // One row per (service, model): `services:` and `accounts:` ids of the same service merge.
306
+ const service = resolve(recorded);
307
+ const groupKey = service ? `${service.key}\n${model}` : isOpaqueProviderId(recorded) ? `\n${model}` : `${recorded}\n${model}`;
308
+ const m = models.get(groupKey) ?? { ...tally(), provider: service?.key ?? recorded, model, ...(service ? { label: service.label } : {}), providerIds: new Set() };
161
309
  m.calls++;
162
310
  m.input += prompt;
163
311
  m.output += output;
164
312
  m.cache += cache;
165
313
  m.cacheWrite += cacheWrite;
166
- models.set(modelKey, m);
167
- const slot = buckets.get(bucketStartOf(at, bucket)) ?? buckets.get(bucketStartOf(startedAt, bucket));
314
+ m.providerIds.add(recorded);
315
+ models.set(groupKey, m);
316
+ const modelKey = `${m.provider}/${m.model}`;
317
+ const slot = buckets.get(bucketStartOf(at, bucket, timeZone)) ?? buckets.get(bucketStartOf(startedAt, bucket, timeZone));
168
318
  if (slot) {
169
319
  slot.calls++;
170
320
  slot.input += prompt;
@@ -176,6 +326,8 @@ export async function buildUsageStats(dir, query) {
176
326
  }
177
327
  const budget = trace.context_budget?.segments;
178
328
  if (budget && typeof budget === 'object') {
329
+ breakdown.turns++;
330
+ breakdown.firstRequestInputTokens += firstPrompt ?? 0;
179
331
  for (const [key, value] of Object.entries(budget)) {
180
332
  const n = num(value) ?? 0;
181
333
  if (key === 'history')
@@ -184,29 +336,50 @@ export async function buildUsageStats(dir, query) {
184
336
  segments.user += n;
185
337
  else if (FIXED_SEGMENTS.has(key))
186
338
  segments.fixed += n;
339
+ else
340
+ breakdown.other += n;
187
341
  }
188
342
  }
189
343
  });
190
344
  callShares.sort((a, b) => a - b);
345
+ const byModel = [...models.values()]
346
+ .map(m => ({
347
+ provider: m.provider,
348
+ model: m.model,
349
+ displayName: usageModelDisplayName(m.provider, m.model, m.label),
350
+ ...(m.label ? { providerLabel: m.label } : {}),
351
+ providerIds: [...m.providerIds].sort(),
352
+ ...tokens(m),
353
+ }))
354
+ .sort((a, b) => b.inputTokens - a.inputTokens);
191
355
  return {
192
- range: { since, until, bucket, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone },
356
+ range: { since, until, bucket, timeZone },
193
357
  totals: {
194
358
  turns,
195
359
  ...tokens(total),
196
360
  cacheShareByCall: { p50: nearestRank(callShares, 50), p90: nearestRank(callShares, 90) },
361
+ inputBasis: 'reported-all-calls',
197
362
  },
198
- byModel: [...models.values()]
199
- .map(m => ({ provider: m.provider, model: m.model, ...tokens(m) }))
200
- .sort((a, b) => b.inputTokens - a.inputTokens),
363
+ byModel,
201
364
  bySession: [...sessions.entries()]
202
- .map(([sessionKey, s]) => ({ sessionKey, ...parseSessionKey(sessionKey), turns: s.turns, ...tokens(s) }))
365
+ .map(([sessionKey, s]) => ({ sessionKey, ...parseSessionKey(sessionKey), ...(s.displayName ? { displayName: s.displayName } : {}), turns: s.turns, ...tokens(s) }))
203
366
  .sort((a, b) => b.inputTokens - a.inputTokens)
204
367
  .slice(0, USAGE_STATS_SESSION_LIMIT),
205
368
  series: [...buckets.entries()]
206
369
  .sort(([a], [b]) => a - b)
207
370
  .map(([bucketStart, b]) => ({ bucketStart, ...tokens(b), byModel: b.byModel })),
371
+ modelLabels: Object.fromEntries(byModel.map(m => [`${m.provider}/${m.model}`, m.displayName])),
208
372
  segments,
373
+ inputBreakdown: {
374
+ basis: 'first-request-estimate',
375
+ turns: breakdown.turns,
376
+ ...segments,
377
+ other: breakdown.other,
378
+ estimatedTotal: segments.history + segments.fixed + segments.user + breakdown.other,
379
+ firstRequestInputTokens: breakdown.firstRequestInputTokens,
380
+ },
209
381
  filesRead: scan.filesRead,
210
382
  malformedLines: scan.malformed,
383
+ promptCacheHints: cacheProbe.hints(),
211
384
  };
212
385
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * #1250 usage-cache-unsupported-hint: spots a provider that never reports prompt-cache reads
3
+ * although its adjacent requests share the cacheable prefix (system prompt + tool definitions,
4
+ * recorded per model call as `prefix_hash`). Read-only over the trace archive records.
5
+ */
6
+ import type { UsageProviderResolver } from './aggregate.js';
7
+ /** Defaults: at least 20 consecutive zero-cache calls across at least 3 sessions. */
8
+ export declare const PROMPT_CACHE_HINT_MIN_CALLS = 20;
9
+ export declare const PROMPT_CACHE_HINT_MIN_SESSIONS = 3;
10
+ /** Share of adjacent same-session requests with an identical prefix needed as evidence. */
11
+ export declare const PROMPT_CACHE_HINT_MIN_SAME_PREFIX = 0.5;
12
+ export interface PromptCacheHint {
13
+ /** Merge key: resolved service id, else the recorded provider id. */
14
+ readonly provider: string;
15
+ /** Service display name when resolvable. */
16
+ readonly label?: string;
17
+ /** Recorded provider ids merged into this row. */
18
+ readonly providerIds: readonly string[];
19
+ /** Most recent run of consecutive calls without a cache read. */
20
+ readonly zeroCacheCalls: number;
21
+ /** Sessions those calls came from. */
22
+ readonly sessions: number;
23
+ /** Calls in that run that reported `cacheRead = 0` (the rest did not report it at all). */
24
+ readonly reportedZeroCalls: number;
25
+ /** Adjacent same-session request pairs in that run whose prefix hashes were compared. */
26
+ readonly comparedPairs: number;
27
+ /** Of those, pairs with an identical prefix. */
28
+ readonly samePrefixPairs: number;
29
+ /** samePrefixPairs / comparedPairs. */
30
+ readonly samePrefixRatio: number;
31
+ /** Readable hint, e.g. for CLI output. */
32
+ readonly message: string;
33
+ }
34
+ export interface PromptCacheProbeOptions {
35
+ readonly resolveProvider?: UsageProviderResolver;
36
+ readonly minCalls?: number;
37
+ readonly minSessions?: number;
38
+ readonly minSamePrefixRatio?: number;
39
+ }
40
+ /**
41
+ * Feed trace records in chronological order ({@link visit}), then read {@link hints}.
42
+ * Sub-agent turns are included with their own session keys.
43
+ */
44
+ export declare class PromptCacheProbe {
45
+ #private;
46
+ constructor(options?: PromptCacheProbeOptions);
47
+ visit(trace: any): void;
48
+ hints(): PromptCacheHint[];
49
+ }