@hegemonart/get-design-done 1.25.0 → 1.27.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +96 -0
- package/README.md +12 -6
- package/SKILL.md +3 -0
- package/agents/README.md +89 -0
- package/agents/design-reflector.md +43 -0
- package/agents/gdd-intel-updater.md +34 -1
- package/hooks/budget-enforcer.ts +143 -4
- package/package.json +1 -1
- package/reference/model-prices.md +40 -19
- package/reference/peer-cli-capabilities.md +151 -0
- package/reference/peer-protocols.md +266 -0
- package/reference/prices/antigravity.md +21 -0
- package/reference/prices/augment.md +21 -0
- package/reference/prices/claude.md +42 -0
- package/reference/prices/cline.md +23 -0
- package/reference/prices/codebuddy.md +21 -0
- package/reference/prices/codex.md +25 -0
- package/reference/prices/copilot.md +21 -0
- package/reference/prices/cursor.md +21 -0
- package/reference/prices/gemini.md +25 -0
- package/reference/prices/kilo.md +21 -0
- package/reference/prices/opencode.md +23 -0
- package/reference/prices/qwen.md +25 -0
- package/reference/prices/trae.md +23 -0
- package/reference/prices/windsurf.md +21 -0
- package/reference/registry.json +121 -1
- package/reference/runtime-models.md +446 -0
- package/reference/schemas/runtime-models.schema.json +123 -0
- package/scripts/install.cjs +8 -0
- package/scripts/lib/bandit-router.cjs +214 -7
- package/scripts/lib/budget-enforcer.cjs +514 -0
- package/scripts/lib/cost-arbitrage.cjs +294 -0
- package/scripts/lib/event-stream/index.ts +14 -1
- package/scripts/lib/event-stream/types.ts +125 -1
- package/scripts/lib/install/installer.cjs +188 -11
- package/scripts/lib/install/parse-runtime-models.cjs +267 -0
- package/scripts/lib/install/runtimes.cjs +101 -0
- package/scripts/lib/peer-cli/acp-client.cjs +375 -0
- package/scripts/lib/peer-cli/adapters/codex.cjs +101 -0
- package/scripts/lib/peer-cli/adapters/copilot.cjs +79 -0
- package/scripts/lib/peer-cli/adapters/cursor.cjs +78 -0
- package/scripts/lib/peer-cli/adapters/gemini.cjs +81 -0
- package/scripts/lib/peer-cli/adapters/qwen.cjs +72 -0
- package/scripts/lib/peer-cli/asp-client.cjs +587 -0
- package/scripts/lib/peer-cli/broker-lifecycle.cjs +406 -0
- package/scripts/lib/peer-cli/registry.cjs +434 -0
- package/scripts/lib/peer-cli/spawn-cmd.cjs +149 -0
- package/scripts/lib/runtime-detect.cjs +96 -0
- package/scripts/lib/session-runner/index.ts +215 -0
- package/scripts/lib/session-runner/types.ts +60 -0
- package/scripts/lib/tier-resolver.cjs +311 -0
- package/scripts/validate-frontmatter.ts +297 -2
- package/skills/peer-cli-add/SKILL.md +170 -0
- package/skills/peer-cli-customize/SKILL.md +110 -0
- package/skills/peers/SKILL.md +101 -0
- package/skills/router/SKILL.md +51 -2
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// scripts/lib/budget-enforcer.cjs
|
|
2
|
+
//
|
|
3
|
+
// Plan 26-05 — shared cost-computation backend for budget-enforcer.
|
|
4
|
+
//
|
|
5
|
+
// Pure module that takes a `(model_id, runtime_id, token_counts)` triple and
|
|
6
|
+
// returns a USD cost figure by reading the per-runtime price table at
|
|
7
|
+
// `reference/prices/<runtime>.md`. Used by both:
|
|
8
|
+
//
|
|
9
|
+
// 1. `hooks/budget-enforcer.ts` — the Claude Code PreToolUse hook, when
|
|
10
|
+
// the router decision payload carries a `resolved_models[agent]`
|
|
11
|
+
// entry. The hook calls `computeCost({ model_id, runtime, tokens_in,
|
|
12
|
+
// tokens_out, cache_hit })` and writes the resulting figure into the
|
|
13
|
+
// OPT-09 telemetry row.
|
|
14
|
+
// 2. Non-CC code-level mirrors of the budget-enforcer surface — runtime
|
|
15
|
+
// adapters that wrap the same Phase 22 events.jsonl primitives. Same
|
|
16
|
+
// backend means cost numbers are computed identically across runtime
|
|
17
|
+
// hosts; downstream cost-aggregator (Phase 22) can roll up by
|
|
18
|
+
// `runtime` tag with apples-to-apples figures.
|
|
19
|
+
//
|
|
20
|
+
// `.cjs` extension matches Phase 22 primitives. The .ts hook reaches it
|
|
21
|
+
// via `createRequire` — same scheme as `rate-guard.cjs` and
|
|
22
|
+
// `iteration-budget.cjs`.
|
|
23
|
+
//
|
|
24
|
+
// Per-runtime price tables (D-08):
|
|
25
|
+
// - `reference/prices/claude.md` — Anthropic models
|
|
26
|
+
// - `reference/prices/codex.md` — OpenAI Codex (gpt-5 family)
|
|
27
|
+
// - `reference/prices/gemini.md` — Google Gemini 2.5 family
|
|
28
|
+
// - `reference/prices/qwen.md` — Alibaba Qwen 3 family
|
|
29
|
+
// - `reference/prices/<other>.md` — stub-only at v1.26.0; researcher
|
|
30
|
+
// fills with provenance citation in a later cycle.
|
|
31
|
+
//
|
|
32
|
+
// Each price table has the same canonical row shape:
|
|
33
|
+
// | Model | Tier | input_per_1m | output_per_1m | cached_input_per_1m |
|
|
34
|
+
//
|
|
35
|
+
// The parser extracts the markdown table by header signature, so future
|
|
36
|
+
// per-runtime authors can add columns at the right edge without breaking
|
|
37
|
+
// the consumer (forward-compatible).
|
|
38
|
+
//
|
|
39
|
+
// Fallback chain (mirrors tier-resolver D-04 spirit):
|
|
40
|
+
// 1. Runtime price table has the model row → use it.
|
|
41
|
+
// 2. Runtime price table missing OR model row missing → fall back to
|
|
42
|
+
// claude.md and emit `cost_lookup_fallback` event (caller emits;
|
|
43
|
+
// this module surfaces the fact via the return shape).
|
|
44
|
+
// 3. claude.md also missing the model → return null cost + reason.
|
|
45
|
+
//
|
|
46
|
+
// Pure module — no top-level side effects beyond the lazy cache. Never
|
|
47
|
+
// throws on missing/malformed price tables (returns null cost +
|
|
48
|
+
// diagnostic reason); throws ONLY on programmer errors (bad arg shapes).
|
|
49
|
+
|
|
50
|
+
'use strict';
|
|
51
|
+
|
|
52
|
+
const fs = require('node:fs');
|
|
53
|
+
const path = require('node:path');
|
|
54
|
+
|
|
55
|
+
const REPO_ROOT_GUESS = path.resolve(__dirname, '..', '..');
|
|
56
|
+
const DEFAULT_RUNTIME_ID = 'claude';
|
|
57
|
+
const VALID_TIERS = Object.freeze(['opus', 'sonnet', 'haiku']);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parsed price-row shape returned by `parsePriceTable`.
|
|
61
|
+
* Numeric fields are USD per 1M tokens.
|
|
62
|
+
*
|
|
63
|
+
* @typedef {{
|
|
64
|
+
* model: string,
|
|
65
|
+
* tier: string,
|
|
66
|
+
* input_per_1m: number,
|
|
67
|
+
* output_per_1m: number,
|
|
68
|
+
* cached_input_per_1m: number,
|
|
69
|
+
* }} PriceRow
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* In-memory cache of parsed price tables, keyed by runtime ID. `null`
|
|
74
|
+
* means we tried and the file was missing/unparseable (so we don't
|
|
75
|
+
* re-read the same broken file every spawn).
|
|
76
|
+
*/
|
|
77
|
+
const _cache = new Map();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reset the parsed-prices cache. Tests use this after writing fixture
|
|
81
|
+
* price tables to a temp directory; production callers rarely need it.
|
|
82
|
+
*/
|
|
83
|
+
function reset() {
|
|
84
|
+
_cache.clear();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Compute the absolute path to `reference/prices/<runtime>.md`. Honors
|
|
89
|
+
* the `cwd` option for test isolation — defaults to the repo root
|
|
90
|
+
* derived from this module's filesystem location.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} runtime
|
|
93
|
+
* @param {{cwd?: string}} [opts]
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
function priceTablePath(runtime, opts) {
|
|
97
|
+
const root = (opts && typeof opts.cwd === 'string' && opts.cwd.length > 0)
|
|
98
|
+
? opts.cwd
|
|
99
|
+
: REPO_ROOT_GUESS;
|
|
100
|
+
return path.join(root, 'reference', 'prices', `${runtime}.md`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse a markdown price table by locating the canonical header row and
|
|
105
|
+
* scanning subsequent `|`-delimited rows until the table ends. The
|
|
106
|
+
* header signature is the four required columns in order:
|
|
107
|
+
*
|
|
108
|
+
* `| Model | Tier | input_per_1m | output_per_1m | cached_input_per_1m |`
|
|
109
|
+
*
|
|
110
|
+
* Extra columns at the right are ignored (forward-compatible). Rows
|
|
111
|
+
* missing required columns or carrying placeholder TODO markers
|
|
112
|
+
* (e.g. `<TODO>` in a numeric cell) are skipped silently — the caller
|
|
113
|
+
* sees a null lookup for that model and falls back per the chain.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} markdown
|
|
116
|
+
* @returns {PriceRow[]}
|
|
117
|
+
*/
|
|
118
|
+
function parsePriceTable(markdown) {
|
|
119
|
+
if (typeof markdown !== 'string' || markdown.length === 0) return [];
|
|
120
|
+
|
|
121
|
+
const lines = markdown.split(/\r?\n/);
|
|
122
|
+
const rows = [];
|
|
123
|
+
|
|
124
|
+
// Locate header row by scanning for the canonical column signature.
|
|
125
|
+
// We tolerate whitespace + casing variations on the header cells.
|
|
126
|
+
const headerNeedles = ['model', 'tier', 'input_per_1m', 'output_per_1m', 'cached_input_per_1m'];
|
|
127
|
+
let headerIdx = -1;
|
|
128
|
+
for (let i = 0; i < lines.length; i++) {
|
|
129
|
+
const line = lines[i];
|
|
130
|
+
if (!/^\s*\|/.test(line)) continue;
|
|
131
|
+
const cells = line.split('|').map((s) => s.trim().toLowerCase());
|
|
132
|
+
let matched = 0;
|
|
133
|
+
for (const needle of headerNeedles) {
|
|
134
|
+
if (cells.includes(needle)) matched++;
|
|
135
|
+
}
|
|
136
|
+
if (matched === headerNeedles.length) {
|
|
137
|
+
headerIdx = i;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (headerIdx < 0) return [];
|
|
142
|
+
|
|
143
|
+
// Build the cell-index map from the header so column reorderings are
|
|
144
|
+
// tolerated (a column-shuffle reorg is a docs PR not a logic break).
|
|
145
|
+
const headerCells = lines[headerIdx].split('|').map((s) => s.trim().toLowerCase());
|
|
146
|
+
const colIdx = {};
|
|
147
|
+
for (const needle of headerNeedles) {
|
|
148
|
+
colIdx[needle] = headerCells.indexOf(needle);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Skip the separator row (e.g. `|---|---|...|`).
|
|
152
|
+
let i = headerIdx + 1;
|
|
153
|
+
if (i < lines.length && /^\s*\|[\s|:-]+\|\s*$/.test(lines[i])) i++;
|
|
154
|
+
|
|
155
|
+
for (; i < lines.length; i++) {
|
|
156
|
+
const line = lines[i];
|
|
157
|
+
if (!/^\s*\|/.test(line)) break; // table ended
|
|
158
|
+
const cells = line.split('|').map((s) => s.trim());
|
|
159
|
+
// Pull required cells by header index.
|
|
160
|
+
const model = cells[colIdx.model];
|
|
161
|
+
const tier = cells[colIdx.tier];
|
|
162
|
+
const input = Number(cells[colIdx.input_per_1m]);
|
|
163
|
+
const output = Number(cells[colIdx.output_per_1m]);
|
|
164
|
+
const cached = Number(cells[colIdx.cached_input_per_1m]);
|
|
165
|
+
if (typeof model !== 'string' || model.length === 0) continue;
|
|
166
|
+
// Skip TODO/placeholder rows where prices haven't been confirmed.
|
|
167
|
+
if (
|
|
168
|
+
!Number.isFinite(input) ||
|
|
169
|
+
!Number.isFinite(output) ||
|
|
170
|
+
!Number.isFinite(cached)
|
|
171
|
+
) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
rows.push({
|
|
175
|
+
model,
|
|
176
|
+
tier: typeof tier === 'string' ? tier : 'unknown',
|
|
177
|
+
input_per_1m: input,
|
|
178
|
+
output_per_1m: output,
|
|
179
|
+
cached_input_per_1m: cached,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return rows;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Load + cache the parsed price table for a runtime. Returns the empty
|
|
187
|
+
* array (cached) if the file is missing or unparseable, so subsequent
|
|
188
|
+
* calls don't re-read.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} runtime
|
|
191
|
+
* @param {{cwd?: string}} [opts]
|
|
192
|
+
* @returns {PriceRow[]}
|
|
193
|
+
*/
|
|
194
|
+
function loadPriceTable(runtime, opts) {
|
|
195
|
+
const key = `${runtime}:${(opts && opts.cwd) || ''}`;
|
|
196
|
+
if (_cache.has(key)) return _cache.get(key);
|
|
197
|
+
const fp = priceTablePath(runtime, opts);
|
|
198
|
+
let rows = [];
|
|
199
|
+
try {
|
|
200
|
+
if (fs.existsSync(fp)) {
|
|
201
|
+
const md = fs.readFileSync(fp, 'utf8');
|
|
202
|
+
rows = parsePriceTable(md);
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
rows = [];
|
|
206
|
+
}
|
|
207
|
+
_cache.set(key, rows);
|
|
208
|
+
return rows;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Find a price row by model ID OR by tier name. Model-ID match takes
|
|
213
|
+
* precedence (exact concrete-model lookup is the resolved_models path);
|
|
214
|
+
* tier match is the back-compat fallback for callers that only know
|
|
215
|
+
* the tier (legacy model_tier_overrides path).
|
|
216
|
+
*
|
|
217
|
+
* @param {PriceRow[]} rows
|
|
218
|
+
* @param {{model_id?: string|null, tier?: string|null}} q
|
|
219
|
+
* @returns {PriceRow | null}
|
|
220
|
+
*/
|
|
221
|
+
function findPriceRow(rows, q) {
|
|
222
|
+
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
223
|
+
const wantModel = q && typeof q.model_id === 'string' && q.model_id.length > 0
|
|
224
|
+
? q.model_id
|
|
225
|
+
: null;
|
|
226
|
+
const wantTier = q && typeof q.tier === 'string' && q.tier.length > 0
|
|
227
|
+
? q.tier
|
|
228
|
+
: null;
|
|
229
|
+
if (wantModel !== null) {
|
|
230
|
+
for (const r of rows) {
|
|
231
|
+
if (r.model === wantModel) return r;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (wantTier !== null) {
|
|
235
|
+
for (const r of rows) {
|
|
236
|
+
if (r.tier === wantTier) return r;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Apply the OPT-09 estimator formula to a price row + token counts.
|
|
244
|
+
* Cache-hit input rows charge `cached_input_per_1m` instead of
|
|
245
|
+
* `input_per_1m` (consistent with skills/router/SKILL.md D-08).
|
|
246
|
+
*
|
|
247
|
+
* @param {PriceRow} row
|
|
248
|
+
* @param {{tokens_in: number, tokens_out: number, cache_hit?: boolean}} t
|
|
249
|
+
* @returns {number} cost in USD
|
|
250
|
+
*/
|
|
251
|
+
function applyFormula(row, t) {
|
|
252
|
+
const tokensIn = Math.max(0, Number(t.tokens_in || 0));
|
|
253
|
+
const tokensOut = Math.max(0, Number(t.tokens_out || 0));
|
|
254
|
+
const inputRate = t.cache_hit === true ? row.cached_input_per_1m : row.input_per_1m;
|
|
255
|
+
return (
|
|
256
|
+
(tokensIn / 1_000_000) * inputRate +
|
|
257
|
+
(tokensOut / 1_000_000) * row.output_per_1m
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Compute the USD cost for a spawn, given a concrete model identifier
|
|
263
|
+
* (the resolved_models path) OR a tier name (the legacy fallback path)
|
|
264
|
+
* plus token counts.
|
|
265
|
+
*
|
|
266
|
+
* Lookup order:
|
|
267
|
+
* 1. The runtime's price table by model_id → use.
|
|
268
|
+
* 2. The runtime's price table by tier → use.
|
|
269
|
+
* 3. The claude price table by model_id → use (fallback).
|
|
270
|
+
* 4. The claude price table by tier → use (fallback).
|
|
271
|
+
* 5. Nothing matched → return { cost_usd: null, reason }.
|
|
272
|
+
*
|
|
273
|
+
* Never throws for missing files / missing rows. Returns `cost_usd: null`
|
|
274
|
+
* + a diagnostic `reason` string the caller can emit on the event stream
|
|
275
|
+
* (`cost_lookup_failed` event) or write into telemetry.
|
|
276
|
+
*
|
|
277
|
+
* @param {object} args
|
|
278
|
+
* @param {string|null} [args.model_id]
|
|
279
|
+
* Concrete model name (e.g. 'gpt-5', 'claude-sonnet-4-7'). Preferred.
|
|
280
|
+
* @param {string|null} [args.tier]
|
|
281
|
+
* Tier name ('opus'|'sonnet'|'haiku'). Used when model_id is absent.
|
|
282
|
+
* @param {string} args.runtime
|
|
283
|
+
* Runtime ID ('claude', 'codex', 'gemini', …). Required.
|
|
284
|
+
* @param {number} args.tokens_in
|
|
285
|
+
* @param {number} args.tokens_out
|
|
286
|
+
* @param {boolean} [args.cache_hit]
|
|
287
|
+
* @param {object} [opts]
|
|
288
|
+
* @param {string} [opts.cwd]
|
|
289
|
+
* Override repo root (tests).
|
|
290
|
+
* @returns {{
|
|
291
|
+
* cost_usd: number|null,
|
|
292
|
+
* model: string|null,
|
|
293
|
+
* tier: string|null,
|
|
294
|
+
* runtime_used: string|null,
|
|
295
|
+
* fallback: boolean,
|
|
296
|
+
* reason: string|null,
|
|
297
|
+
* }}
|
|
298
|
+
*/
|
|
299
|
+
function computeCost(args, opts) {
|
|
300
|
+
if (!args || typeof args !== 'object') {
|
|
301
|
+
return {
|
|
302
|
+
cost_usd: null,
|
|
303
|
+
model: null,
|
|
304
|
+
tier: null,
|
|
305
|
+
runtime_used: null,
|
|
306
|
+
fallback: false,
|
|
307
|
+
reason: 'invalid_args',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const runtime = typeof args.runtime === 'string' && args.runtime.length > 0
|
|
311
|
+
? args.runtime
|
|
312
|
+
: null;
|
|
313
|
+
if (runtime === null) {
|
|
314
|
+
return {
|
|
315
|
+
cost_usd: null,
|
|
316
|
+
model: null,
|
|
317
|
+
tier: typeof args.tier === 'string' ? args.tier : null,
|
|
318
|
+
runtime_used: null,
|
|
319
|
+
fallback: false,
|
|
320
|
+
reason: 'missing_runtime',
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const tokens = {
|
|
325
|
+
tokens_in: Number(args.tokens_in || 0),
|
|
326
|
+
tokens_out: Number(args.tokens_out || 0),
|
|
327
|
+
cache_hit: args.cache_hit === true,
|
|
328
|
+
};
|
|
329
|
+
const q = {
|
|
330
|
+
model_id: typeof args.model_id === 'string' && args.model_id.length > 0
|
|
331
|
+
? args.model_id
|
|
332
|
+
: null,
|
|
333
|
+
tier: typeof args.tier === 'string' && args.tier.length > 0
|
|
334
|
+
? args.tier
|
|
335
|
+
: null,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Branch 1+2: runtime price table.
|
|
339
|
+
const rows = loadPriceTable(runtime, opts);
|
|
340
|
+
const direct = findPriceRow(rows, q);
|
|
341
|
+
if (direct !== null) {
|
|
342
|
+
return {
|
|
343
|
+
cost_usd: applyFormula(direct, tokens),
|
|
344
|
+
model: direct.model,
|
|
345
|
+
tier: direct.tier,
|
|
346
|
+
runtime_used: runtime,
|
|
347
|
+
fallback: false,
|
|
348
|
+
reason: null,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Branch 3+4: claude fallback (only if not already querying claude).
|
|
353
|
+
if (runtime !== DEFAULT_RUNTIME_ID) {
|
|
354
|
+
const fallbackRows = loadPriceTable(DEFAULT_RUNTIME_ID, opts);
|
|
355
|
+
const fb = findPriceRow(fallbackRows, q);
|
|
356
|
+
if (fb !== null) {
|
|
357
|
+
return {
|
|
358
|
+
cost_usd: applyFormula(fb, tokens),
|
|
359
|
+
model: fb.model,
|
|
360
|
+
tier: fb.tier,
|
|
361
|
+
runtime_used: DEFAULT_RUNTIME_ID,
|
|
362
|
+
fallback: true,
|
|
363
|
+
reason: rows.length === 0 ? 'runtime_table_missing' : 'model_not_in_runtime_table',
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Branch 5: nothing matched.
|
|
369
|
+
return {
|
|
370
|
+
cost_usd: null,
|
|
371
|
+
model: null,
|
|
372
|
+
tier: q.tier,
|
|
373
|
+
runtime_used: null,
|
|
374
|
+
fallback: false,
|
|
375
|
+
reason: rows.length === 0 ? 'runtime_table_missing' : 'model_not_found',
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Convenience: build a `cost_recorded` event payload (D-08 shape) from
|
|
381
|
+
* a computeCost result + the spawn metadata. Returned object is the
|
|
382
|
+
* `payload` field for `appendEvent({ type: 'cost_recorded', payload })`.
|
|
383
|
+
* The .ts hook owns the actual `appendEvent()` call (it has the typed
|
|
384
|
+
* event-stream import); .cjs callers (non-CC mirrors) compose this with
|
|
385
|
+
* the JSONL line shape used in tier-resolver.cjs's `emitEvent()`.
|
|
386
|
+
*
|
|
387
|
+
* Phase 27 / Plan 27-08 (D-09) extension — additive: cost rows now
|
|
388
|
+
* optionally carry `runtime_role` ("host" | "peer", default "host" when
|
|
389
|
+
* absent for back-compat) and `peer_id` (set only when
|
|
390
|
+
* `runtime_role === "peer"`). Pre-Phase-27 callers that don't pass these
|
|
391
|
+
* fields get the legacy shape with `runtime_role: "host"` stamped — so
|
|
392
|
+
* the cost-aggregator + reflector cross-runtime arbitrage
|
|
393
|
+
* (`scripts/lib/cost-arbitrage.cjs`) never sees an absent role tag and
|
|
394
|
+
* mixed-role cycle history rolls up correctly without crashing.
|
|
395
|
+
*
|
|
396
|
+
* @param {object} args
|
|
397
|
+
* @param {string} args.runtime
|
|
398
|
+
* @param {string} args.agent
|
|
399
|
+
* @param {string|null} args.model_id
|
|
400
|
+
* @param {string|null} args.tier
|
|
401
|
+
* @param {number} args.tokens_in
|
|
402
|
+
* @param {number} args.tokens_out
|
|
403
|
+
* @param {number|null} args.cost_usd
|
|
404
|
+
* @param {('host'|'peer')} [args.runtime_role]
|
|
405
|
+
* Phase 27. Defaults to `"host"` when absent.
|
|
406
|
+
* @param {string|null} [args.peer_id]
|
|
407
|
+
* Phase 27. The peer-CLI ID when `runtime_role === "peer"` (e.g.
|
|
408
|
+
* `"gemini"`, `"codex"`). Omitted from output when absent or when
|
|
409
|
+
* `runtime_role === "host"`.
|
|
410
|
+
* @returns {object}
|
|
411
|
+
*/
|
|
412
|
+
function buildCostEventPayload(args) {
|
|
413
|
+
const role = args && args.runtime_role === 'peer' ? 'peer' : 'host';
|
|
414
|
+
/** @type {Record<string, unknown>} */
|
|
415
|
+
const out = {
|
|
416
|
+
runtime: args.runtime,
|
|
417
|
+
agent: args.agent,
|
|
418
|
+
model_id: args.model_id,
|
|
419
|
+
tier: args.tier,
|
|
420
|
+
tokens_in: Number(args.tokens_in || 0),
|
|
421
|
+
tokens_out: Number(args.tokens_out || 0),
|
|
422
|
+
cost_usd: typeof args.cost_usd === 'number' && Number.isFinite(args.cost_usd)
|
|
423
|
+
? args.cost_usd
|
|
424
|
+
: null,
|
|
425
|
+
runtime_role: role,
|
|
426
|
+
};
|
|
427
|
+
// peer_id is only meaningful when role === "peer"; we omit it for
|
|
428
|
+
// host rows to keep the legacy on-disk shape stable for cost-aggregator
|
|
429
|
+
// tests that snapshot the line content.
|
|
430
|
+
if (role === 'peer') {
|
|
431
|
+
const pid = args && typeof args.peer_id === 'string' && args.peer_id.length > 0
|
|
432
|
+
? args.peer_id
|
|
433
|
+
: null;
|
|
434
|
+
out.peer_id = pid;
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Phase 27 / Plan 27-08 helper — derive `(runtime_role, peer_id)` from a
|
|
441
|
+
* router decision shape, mirroring `modelFromResolved`'s contract:
|
|
442
|
+
*
|
|
443
|
+
* - When the router decision carries `runtime_role: "peer"` AND
|
|
444
|
+
* `peer_id: "<non-empty-string>"`, return `{ role: 'peer', peer_id }`.
|
|
445
|
+
* - When the decision is absent, malformed, or not flagged as peer-mode,
|
|
446
|
+
* return `{ role: 'host', peer_id: null }`.
|
|
447
|
+
*
|
|
448
|
+
* Plan 27-06's session-runner sets `runtime_role` + `peer_id` on its
|
|
449
|
+
* router-decision payload before invoking the budget-enforcer hook; this
|
|
450
|
+
* helper is the pure backend lookup that the hook (and any non-CC
|
|
451
|
+
* cost-recorder mirror) calls to thread those values into the cost row.
|
|
452
|
+
*
|
|
453
|
+
* Defensive on every shape — never throws on null / wrong type. Returning
|
|
454
|
+
* `host` on every error path keeps the legacy back-compat default
|
|
455
|
+
* everywhere.
|
|
456
|
+
*
|
|
457
|
+
* @param {unknown} routerDecision
|
|
458
|
+
* @returns {{ role: 'host' | 'peer', peer_id: string | null }}
|
|
459
|
+
*/
|
|
460
|
+
function roleFromRouterDecision(routerDecision) {
|
|
461
|
+
if (!routerDecision || typeof routerDecision !== 'object') {
|
|
462
|
+
return { role: 'host', peer_id: null };
|
|
463
|
+
}
|
|
464
|
+
const role = routerDecision.runtime_role;
|
|
465
|
+
if (role !== 'peer') return { role: 'host', peer_id: null };
|
|
466
|
+
const pid = routerDecision.peer_id;
|
|
467
|
+
if (typeof pid !== 'string' || pid.length === 0) {
|
|
468
|
+
// peer-flagged but no peer_id is malformed — degrade to host to
|
|
469
|
+
// avoid emitting a half-tagged cost row.
|
|
470
|
+
return { role: 'host', peer_id: null };
|
|
471
|
+
}
|
|
472
|
+
return { role: 'peer', peer_id: pid };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Resolve a concrete model_id from the router's `resolved_models` map
|
|
477
|
+
* for a given agent. Returns null when:
|
|
478
|
+
* - resolved_models is absent / not an object;
|
|
479
|
+
* - the agent key is missing;
|
|
480
|
+
* - the value is not a non-empty string.
|
|
481
|
+
*
|
|
482
|
+
* Hosts on the resolved_models consumer path (D-07): if this returns
|
|
483
|
+
* non-null, the cost lookup goes through the per-runtime price table by
|
|
484
|
+
* model_id; otherwise the caller falls back to the legacy
|
|
485
|
+
* model_tier_overrides path.
|
|
486
|
+
*
|
|
487
|
+
* @param {unknown} resolvedModels
|
|
488
|
+
* @param {string} agent
|
|
489
|
+
* @returns {string|null}
|
|
490
|
+
*/
|
|
491
|
+
function modelFromResolved(resolvedModels, agent) {
|
|
492
|
+
if (!resolvedModels || typeof resolvedModels !== 'object') return null;
|
|
493
|
+
const v = resolvedModels[agent];
|
|
494
|
+
if (typeof v === 'string' && v.length > 0) return v;
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
module.exports = {
|
|
499
|
+
computeCost,
|
|
500
|
+
buildCostEventPayload,
|
|
501
|
+
modelFromResolved,
|
|
502
|
+
// Plan 27-08 (D-09): runtime-role + peer-id derivation from router
|
|
503
|
+
// decision. Used by the .ts hook and any non-CC cost-recorder mirror to
|
|
504
|
+
// thread peer-delegation tags into cost.jsonl rows.
|
|
505
|
+
roleFromRouterDecision,
|
|
506
|
+
parsePriceTable,
|
|
507
|
+
loadPriceTable,
|
|
508
|
+
priceTablePath,
|
|
509
|
+
reset,
|
|
510
|
+
VALID_TIERS,
|
|
511
|
+
DEFAULT_RUNTIME_ID,
|
|
512
|
+
// surfaced for tests only — stable API = computeCost + modelFromResolved
|
|
513
|
+
_internal: { findPriceRow, applyFormula },
|
|
514
|
+
};
|