@zhin.js/gateway 1.2.0 → 1.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.md +1 -1
- package/lib/config/configuration.d.ts +32 -3
- package/lib/config/configuration.js +120 -25
- package/lib/config/environment-files.d.ts +42 -0
- package/lib/config/environment-files.js +347 -7
- package/lib/config/literal-secrets.d.ts +3 -0
- package/lib/config/literal-secrets.js +4 -1
- package/lib/config/patch-path-messages.d.ts +15 -0
- package/lib/config/patch-path-messages.js +71 -0
- package/lib/config/secret-storage.d.ts +42 -0
- package/lib/config/secret-storage.js +140 -0
- package/lib/config/validation-messages.d.ts +15 -0
- package/lib/config/validation-messages.js +66 -0
- package/lib/config.d.ts +1 -0
- package/lib/config.js +1 -0
- package/lib/database/console-role.js +2 -1
- package/lib/http/console-rpc.d.ts +3 -1
- package/lib/http/console-rpc.js +31 -4
- package/lib/http/gateway-console.d.ts +5 -0
- package/lib/http/gateway-console.js +31 -5
- package/lib/http/gateway-http-server.d.ts +2 -0
- package/lib/http/gateway-http-server.js +42 -1
- package/lib/http/local-principal.d.ts +1 -1
- package/lib/http/local-principal.js +3 -2
- package/lib/http/plugin-switch-rpc.js +7 -6
- package/lib/http/start-gateway.d.ts +1 -1
- package/lib/http/start-gateway.js +2 -0
- package/lib/iam/service.js +12 -0
- package/lib/iam/setup-service.d.ts +3 -1
- package/lib/iam/setup-service.js +2 -1
- package/lib/onboarding/ignored-builds.d.ts +18 -0
- package/lib/onboarding/ignored-builds.js +63 -0
- package/lib/onboarding/index.d.ts +1 -0
- package/lib/onboarding/index.js +1 -0
- package/lib/packages/errors.js +1 -0
- package/lib/packages/rpc.d.ts +6 -1
- package/lib/packages/rpc.js +28 -2
- package/lib/packages/service.d.ts +3 -1
- package/lib/packages/service.js +74 -9
- package/lib/plugin-management/plugin-candidates.d.ts +9 -0
- package/lib/plugin-management/plugin-candidates.js +55 -0
- package/lib/plugin-management/use-cases.js +8 -8
- package/lib/plugin-management/workflow.d.ts +13 -2
- package/lib/plugin-management/workflow.js +46 -13
- package/lib/plugin-management.d.ts +1 -0
- package/lib/plugin-management.js +1 -0
- package/lib/project/identity.d.ts +2 -0
- package/lib/project/identity.js +21 -0
- package/lib/project.d.ts +1 -0
- package/lib/project.js +1 -0
- package/lib/supervisor/instance-supervisor.d.ts +4 -1
- package/lib/supervisor/instance-supervisor.js +18 -1
- package/lib/terminal/modules.d.ts +12 -0
- package/lib/terminal/modules.js +38 -2
- package/lib/usage/aggregate.d.ts +64 -3
- package/lib/usage/aggregate.js +199 -26
- package/lib/usage/prompt-cache.d.ts +49 -0
- package/lib/usage/prompt-cache.js +113 -0
- package/lib/usage.d.ts +1 -0
- package/lib/usage.js +1 -0
- package/package.json +8 -8
|
@@ -17,7 +17,10 @@ export interface InstanceStatus {
|
|
|
17
17
|
readonly pid?: number;
|
|
18
18
|
/** Epoch ms of the current child's `ready`. */
|
|
19
19
|
readonly startedAt?: number;
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Committed Runtime generation of the current instance: reported in `ready`, then advanced by
|
|
22
|
+
* every route table the instance publishes after a hot reload commits (#1250).
|
|
23
|
+
*/
|
|
21
24
|
readonly generation?: number;
|
|
22
25
|
/** Protocol version reported by the instance in `ready`. */
|
|
23
26
|
readonly protocolVersion?: number;
|
|
@@ -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.#
|
|
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;
|
package/lib/terminal/modules.js
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/lib/usage/aggregate.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
80
|
-
export declare function
|
|
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>;
|
package/lib/usage/aggregate.js
CHANGED
|
@@ -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
|
-
/**
|
|
84
|
-
export function
|
|
85
|
-
const
|
|
86
|
-
return
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
160
|
-
const
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|