@hegemonart/get-design-done 1.33.0 → 1.33.6
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 +49 -0
- package/README.md +4 -0
- package/SKILL.md +1 -0
- package/agents/design-authority-watcher.md +4 -0
- package/connections/connections.md +2 -0
- package/connections/openrouter.md +86 -0
- package/hooks/budget-enforcer.ts +103 -0
- package/package.json +5 -2
- package/reference/gdd-runtime-audit.md +111 -0
- package/reference/gdd-threat-model.md +399 -0
- package/reference/openrouter-tier-mapping.md +98 -0
- package/reference/prices.openrouter.md +26 -0
- package/reference/registry.json +28 -0
- package/scripts/lib/authority-watcher/index.cjs +147 -0
- package/scripts/lib/budget-enforcer.cjs +16 -0
- package/scripts/lib/openrouter/catalog-fetcher.cjs +326 -0
- package/scripts/lib/peer-cli/acp-client.cjs +9 -1
- package/scripts/lib/peer-cli/asp-client.cjs +10 -1
- package/scripts/lib/peer-cli/sanitize-env.cjs +198 -0
- package/scripts/lib/redact.cjs +20 -1
- package/scripts/lib/tier-resolver-openrouter.cjs +343 -0
- package/scripts/lib/transports/ws.cjs +67 -3
- package/sdk/event-stream/types.ts +24 -2
- package/sdk/mcp/gdd-state/schemas/add_blocker.schema.json +2 -0
- package/sdk/mcp/gdd-state/schemas/add_decision.schema.json +1 -0
- package/sdk/mcp/gdd-state/schemas/add_must_have.schema.json +1 -0
- package/sdk/mcp/gdd-state/schemas/checkpoint.schema.json +1 -0
- package/sdk/mcp/gdd-state/schemas/frontmatter_update.schema.json +1 -1
- package/sdk/mcp/gdd-state/schemas/get.schema.json +2 -1
- package/sdk/mcp/gdd-state/schemas/probe_connections.schema.json +2 -0
- package/sdk/mcp/gdd-state/schemas/resolve_blocker.schema.json +1 -0
- package/sdk/mcp/gdd-state/server.js +137 -48
- package/sdk/mcp/gdd-state/tools/add_blocker.ts +2 -0
- package/sdk/mcp/gdd-state/tools/add_decision.ts +2 -0
- package/sdk/mcp/gdd-state/tools/add_must_have.ts +2 -0
- package/sdk/mcp/gdd-state/tools/checkpoint.ts +2 -0
- package/sdk/mcp/gdd-state/tools/frontmatter_update.ts +2 -0
- package/sdk/mcp/gdd-state/tools/get.ts +2 -0
- package/sdk/mcp/gdd-state/tools/probe_connections.ts +2 -0
- package/sdk/mcp/gdd-state/tools/resolve_blocker.ts +2 -0
- package/sdk/mcp/gdd-state/tools/set_status.ts +2 -0
- package/sdk/mcp/gdd-state/tools/shared.ts +117 -7
- package/sdk/mcp/gdd-state/tools/transition_stage.ts +2 -0
- package/sdk/mcp/gdd-state/tools/update_progress.ts +2 -0
- package/skills/openrouter-status/SKILL.md +86 -0
|
@@ -170,6 +170,147 @@ function buildKfmCandidate(article, options) {
|
|
|
170
170
|
};
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
// -------------------------------------------------------------------
|
|
174
|
+
// Plan 33.6-03 (SC#8) — OpenRouter catalog drift
|
|
175
|
+
// -------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Index a catalog `models[]` by id, keeping only well-formed rows
|
|
179
|
+
* (objects with a non-empty string `id`). Last write wins on dup ids.
|
|
180
|
+
*
|
|
181
|
+
* @param {unknown} models
|
|
182
|
+
* @returns {Map<string, object>}
|
|
183
|
+
*/
|
|
184
|
+
function indexById(models) {
|
|
185
|
+
const map = new Map();
|
|
186
|
+
if (!Array.isArray(models)) return map;
|
|
187
|
+
for (const m of models) {
|
|
188
|
+
if (m && typeof m === 'object' && typeof m.id === 'string' && m.id.length > 0) {
|
|
189
|
+
map.set(m.id, m);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return map;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Best-effort "is this model flagged deprecated?" signal. Tolerant of the
|
|
197
|
+
* several shapes an upstream catalog might use: a boolean `deprecated`,
|
|
198
|
+
* or a `status`/`lifecycle`/`availability` string containing "deprecat".
|
|
199
|
+
*
|
|
200
|
+
* @param {object} model
|
|
201
|
+
* @returns {boolean}
|
|
202
|
+
*/
|
|
203
|
+
function isDeprecatedFlag(model) {
|
|
204
|
+
if (!model || typeof model !== 'object') return false;
|
|
205
|
+
if (model.deprecated === true) return true;
|
|
206
|
+
for (const key of ['status', 'lifecycle', 'availability', 'state']) {
|
|
207
|
+
const v = model[key];
|
|
208
|
+
if (typeof v === 'string' && /deprecat/i.test(v)) return true;
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Stable string-compare of a single pricing field. A pricing field is the
|
|
215
|
+
* catalog's `pricing.prompt` / `pricing.completion` (strings like "0.000003").
|
|
216
|
+
* Compares as strings (the catalog stores them as strings) after a defensive
|
|
217
|
+
* String() coercion; missing → ''.
|
|
218
|
+
*/
|
|
219
|
+
function priceStr(model, field) {
|
|
220
|
+
if (!model || typeof model !== 'object' || !model.pricing || typeof model.pricing !== 'object') {
|
|
221
|
+
return '';
|
|
222
|
+
}
|
|
223
|
+
const v = model.pricing[field];
|
|
224
|
+
return v === undefined || v === null ? '' : String(v);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function pricingChanged(prevModel, currModel) {
|
|
228
|
+
return (
|
|
229
|
+
priceStr(prevModel, 'prompt') !== priceStr(currModel, 'prompt') ||
|
|
230
|
+
priceStr(prevModel, 'completion') !== priceStr(currModel, 'completion')
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Diff a prior vs current OpenRouter catalog (`models[]` arrays) and classify
|
|
236
|
+
* each delta. Plan 33.6-03 / SC#8.
|
|
237
|
+
*
|
|
238
|
+
* Classification per id:
|
|
239
|
+
* - `new-model` — id in curr, not in prev.
|
|
240
|
+
* - `withdrawn` — id in prev, not in curr.
|
|
241
|
+
* - `deprecated` — id in BOTH, and the curr row carries a deprecated/status
|
|
242
|
+
* flag (best-effort `isDeprecatedFlag`). Takes precedence
|
|
243
|
+
* over a coincident pricing change.
|
|
244
|
+
* - `pricing-change` — id in BOTH, not deprecated, with a changed
|
|
245
|
+
* pricing.prompt or pricing.completion.
|
|
246
|
+
* - (unchanged ids produce NO entry.)
|
|
247
|
+
*
|
|
248
|
+
* Surfacing (the actionable "a model you pinned is going away" signal):
|
|
249
|
+
* `surfaced === true` ONLY when `change ∈ {deprecated, withdrawn}` AND the id
|
|
250
|
+
* is in `options.overrides` (the configured `openrouter_tier_overrides`
|
|
251
|
+
* values). `new-model` / `pricing-change` are classified but `surfaced:false`
|
|
252
|
+
* (noise control per CONTEXT). A deprecated/withdrawn id NOT in overrides is
|
|
253
|
+
* also `surfaced:false`.
|
|
254
|
+
*
|
|
255
|
+
* Pure, zero deps, NEVER throws — garbage inputs degrade to `[]`.
|
|
256
|
+
*
|
|
257
|
+
* @param {Array<object>} prevModels prior catalog `models[]`
|
|
258
|
+
* @param {Array<object>} currModels current catalog `models[]`
|
|
259
|
+
* @param {object} [options]
|
|
260
|
+
* @param {Array<string>} [options.overrides] configured openrouter_tier_overrides id values
|
|
261
|
+
* @returns {Array<{id:string, change:('new-model'|'pricing-change'|'deprecated'|'withdrawn'), surfaced:boolean}>}
|
|
262
|
+
*/
|
|
263
|
+
function diffOpenRouterCatalog(prevModels, currModels, options) {
|
|
264
|
+
try {
|
|
265
|
+
const prev = indexById(prevModels);
|
|
266
|
+
const curr = indexById(currModels);
|
|
267
|
+
|
|
268
|
+
const opts = options && typeof options === 'object' ? options : {};
|
|
269
|
+
const overrideList = Array.isArray(opts.overrides) ? opts.overrides : [];
|
|
270
|
+
const overrides = new Set(overrideList.filter(x => typeof x === 'string' && x.length > 0));
|
|
271
|
+
|
|
272
|
+
/** @type {Array<{id:string, change:string, surfaced:boolean}>} */
|
|
273
|
+
const out = [];
|
|
274
|
+
|
|
275
|
+
// Deterministic id order: union of all ids, sorted ascending. Keeps the
|
|
276
|
+
// output stable for a fixed input pair (no Date, no randomness).
|
|
277
|
+
const allIds = new Set([...prev.keys(), ...curr.keys()]);
|
|
278
|
+
const sortedIds = [...allIds].sort();
|
|
279
|
+
|
|
280
|
+
for (const id of sortedIds) {
|
|
281
|
+
const inPrev = prev.has(id);
|
|
282
|
+
const inCurr = curr.has(id);
|
|
283
|
+
let change = null;
|
|
284
|
+
|
|
285
|
+
if (inCurr && !inPrev) {
|
|
286
|
+
change = 'new-model';
|
|
287
|
+
} else if (inPrev && !inCurr) {
|
|
288
|
+
change = 'withdrawn';
|
|
289
|
+
} else {
|
|
290
|
+
// id in both.
|
|
291
|
+
const currModel = curr.get(id);
|
|
292
|
+
if (isDeprecatedFlag(currModel)) {
|
|
293
|
+
change = 'deprecated';
|
|
294
|
+
} else if (pricingChanged(prev.get(id), currModel)) {
|
|
295
|
+
change = 'pricing-change';
|
|
296
|
+
} else {
|
|
297
|
+
continue; // unchanged → no delta
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const actionable = change === 'deprecated' || change === 'withdrawn';
|
|
302
|
+
const surfaced = actionable && overrides.has(id);
|
|
303
|
+
out.push({ id, change, surfaced });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return out;
|
|
307
|
+
} catch {
|
|
308
|
+
// Absolute backstop — diffOpenRouterCatalog NEVER throws (parity with the
|
|
309
|
+
// never-throws discipline of the 33.6 adapter/fetcher).
|
|
310
|
+
return [];
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
173
314
|
/**
|
|
174
315
|
* Classify a list of fetched articles into events. Emits one kfm-candidate
|
|
175
316
|
* per whitelist-matched article. Other articles produce no events here
|
|
@@ -192,10 +333,16 @@ module.exports = {
|
|
|
192
333
|
classifyArticles,
|
|
193
334
|
matchesKfmWhitelist,
|
|
194
335
|
buildKfmCandidate,
|
|
336
|
+
// Plan 33.6-03 (SC#8) — OpenRouter catalog weekly-diff classifier. Added as a
|
|
337
|
+
// sibling pure function; the existing API above is unchanged.
|
|
338
|
+
diffOpenRouterCatalog,
|
|
195
339
|
// Exposed for tests / advanced consumers.
|
|
196
340
|
KFM_WHITELIST_PATTERNS,
|
|
197
341
|
MAX_RAW_EXCERPT,
|
|
198
342
|
_deriveSymptom: deriveSymptom,
|
|
199
343
|
_derivePatternHint: derivePatternHint,
|
|
200
344
|
_truncateExcerpt: truncateExcerpt,
|
|
345
|
+
_indexById: indexById,
|
|
346
|
+
_isDeprecatedFlag: isDeprecatedFlag,
|
|
347
|
+
_pricingChanged: pricingChanged,
|
|
201
348
|
};
|
|
@@ -407,6 +407,15 @@ function computeCost(args, opts) {
|
|
|
407
407
|
* Phase 27. The peer-CLI ID when `runtime_role === "peer"` (e.g.
|
|
408
408
|
* `"gemini"`, `"codex"`). Omitted from output when absent or when
|
|
409
409
|
* `runtime_role === "host"`.
|
|
410
|
+
* @param {string} [args.provider]
|
|
411
|
+
* Phase 33.6 / Plan 33.6-03 (SC#6). The resolution provider — set to
|
|
412
|
+
* `"openrouter"` by the caller when the model was resolved via the
|
|
413
|
+
* OpenRouter tier-resolver adapter
|
|
414
|
+
* (`scripts/lib/tier-resolver-openrouter.cjs`). Additive/back-compat:
|
|
415
|
+
* when absent (the native-resolution default + every pre-33.6 caller)
|
|
416
|
+
* the key is OMITTED — exactly how `peer_id` is omitted for host rows —
|
|
417
|
+
* so the legacy on-disk cost-row shape stays byte-stable. Only a
|
|
418
|
+
* non-empty string is threaded; anything else is dropped.
|
|
410
419
|
* @returns {object}
|
|
411
420
|
*/
|
|
412
421
|
function buildCostEventPayload(args) {
|
|
@@ -433,6 +442,13 @@ function buildCostEventPayload(args) {
|
|
|
433
442
|
: null;
|
|
434
443
|
out.peer_id = pid;
|
|
435
444
|
}
|
|
445
|
+
// Phase 33.6 SC#6 — additive provider tag. Threaded ONLY when the caller
|
|
446
|
+
// passes a non-empty string (e.g. "openrouter" when the OpenRouter adapter
|
|
447
|
+
// resolved the model). Omitted otherwise, mirroring peer_id, so the legacy
|
|
448
|
+
// cost-row shape stays stable for native-resolution + pre-33.6 callers.
|
|
449
|
+
if (args && typeof args.provider === 'string' && args.provider.length > 0) {
|
|
450
|
+
out.provider = args.provider;
|
|
451
|
+
}
|
|
436
452
|
return out;
|
|
437
453
|
}
|
|
438
454
|
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// scripts/lib/openrouter/catalog-fetcher.cjs — Plan 33.6-01 (Wave A.1)
|
|
3
|
+
//
|
|
4
|
+
// The plugin's FIRST plugin-side outbound REST client. Fetches the OpenRouter
|
|
5
|
+
// model catalog (https://openrouter.ai/api/v1/models), maps it into the CONTEXT
|
|
6
|
+
// cache shape, and writes it ATOMICALLY to .design/cache/openrouter-models.json
|
|
7
|
+
// with a 24h TTL skip-if-fresh. The fetch is gated behind an INJECTABLE
|
|
8
|
+
// `fetchImpl` (default global `fetch`) so the entire default test suite is
|
|
9
|
+
// hermetic (D-07) and there is NO new runtime dependency — no axios/node-fetch/
|
|
10
|
+
// undici (D-10). The fetch( egress is allowlisted via scripts/lib/openrouter/**
|
|
11
|
+
// in scripts/security/outbound-allowlist.json (D-06), with a matching egress
|
|
12
|
+
// entry in reference/gdd-threat-model.md.
|
|
13
|
+
//
|
|
14
|
+
// Decisions honored:
|
|
15
|
+
// D-02 Catalog TTL = 24h default (overridable via ttlHours; the caller wires
|
|
16
|
+
// .design/config.json#openrouter_catalog_ttl_hours — the fetcher just
|
|
17
|
+
// takes ttlHours).
|
|
18
|
+
// D-06 fetch( is allowlisted via scripts/lib/openrouter/**; threat-model has
|
|
19
|
+
// the OpenRouter-egress entry.
|
|
20
|
+
// D-07 fetchImpl is injectable (default global fetch); no live network in tests.
|
|
21
|
+
// D-08 Graceful degrade — fetchCatalog NEVER throws. No key / fetch-fail /
|
|
22
|
+
// parse-fail → cached-if-any-else-null. Tier resolution falls back to the
|
|
23
|
+
// native provider.
|
|
24
|
+
// D-10 No new dependency — global fetch + sdk/primitives (jittered-backoff,
|
|
25
|
+
// error-classifier) + scripts/lib/rate-guard.cjs only.
|
|
26
|
+
//
|
|
27
|
+
// The OPENROUTER_API_KEY is read from process.env, sent ONLY as an Authorization:
|
|
28
|
+
// Bearer header, and is NEVER persisted to the cache nor written to any log seam.
|
|
29
|
+
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
|
|
33
|
+
const { delayMs, sleep } = require('../../../sdk/primitives/jittered-backoff.cjs');
|
|
34
|
+
const { classify, FailoverReason } = require('../../../sdk/primitives/error-classifier.cjs');
|
|
35
|
+
const rateGuard = require('../rate-guard.cjs');
|
|
36
|
+
|
|
37
|
+
// Repo root is three levels up from scripts/lib/openrouter/.
|
|
38
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
39
|
+
const DEFAULT_CACHE_PATH = path.join(REPO_ROOT, '.design', 'cache', 'openrouter-models.json');
|
|
40
|
+
|
|
41
|
+
const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
42
|
+
const MODELS_PATH = '/models';
|
|
43
|
+
const MAX_ATTEMPTS = 3; // 1 initial + 2 retries — bounded, never infinite (D-08).
|
|
44
|
+
const PROVIDER = 'openrouter'; // rate-guard provider key.
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read + parse the catalog cache at `cachePath`. Returns the `models[]` array, or
|
|
48
|
+
* null when the file is missing, corrupt, or shape-invalid. NEVER throws.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} [opts]
|
|
51
|
+
* @param {string} [opts.cachePath] defaults to <repo>/.design/cache/openrouter-models.json
|
|
52
|
+
* @returns {Array<object>|null}
|
|
53
|
+
*/
|
|
54
|
+
function readCatalog(opts) {
|
|
55
|
+
const cachePath = (opts && typeof opts.cachePath === 'string' && opts.cachePath) || DEFAULT_CACHE_PATH;
|
|
56
|
+
try {
|
|
57
|
+
if (!fs.existsSync(cachePath)) return null;
|
|
58
|
+
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
59
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.models)) return null;
|
|
60
|
+
return parsed.models;
|
|
61
|
+
} catch {
|
|
62
|
+
// Corrupt JSON / read error → treat as no cache.
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read the full parsed cache object (not just models) for TTL inspection.
|
|
69
|
+
* Returns the parsed object or null. NEVER throws.
|
|
70
|
+
*/
|
|
71
|
+
function readCacheObject(cachePath) {
|
|
72
|
+
try {
|
|
73
|
+
if (!fs.existsSync(cachePath)) return null;
|
|
74
|
+
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
75
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.models)) return null;
|
|
76
|
+
return parsed;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Is the cache object fresh relative to `nowMs` under `ttlHours`?
|
|
84
|
+
* A missing/unparseable fetched_at is treated as stale (forces a re-fetch).
|
|
85
|
+
*/
|
|
86
|
+
function isFresh(cacheObj, ttlHours, nowMs) {
|
|
87
|
+
if (!cacheObj || typeof cacheObj.fetched_at !== 'string') return false;
|
|
88
|
+
const fetchedMs = Date.parse(cacheObj.fetched_at);
|
|
89
|
+
if (!Number.isFinite(fetchedMs)) return false;
|
|
90
|
+
const ageMs = nowMs - fetchedMs;
|
|
91
|
+
return ageMs >= 0 && ageMs < ttlHours * 3600_000;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Map an OpenRouter /models response into the CONTEXT cache shape. Defensive:
|
|
96
|
+
* tolerates missing fields, keeps ONLY id/name/context_length/pricing.{prompt,
|
|
97
|
+
* completion}, drops everything else. The /models response is untrusted input —
|
|
98
|
+
* it is mapped, never eval'd.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} body the parsed { data: [...] } response
|
|
101
|
+
* @param {string} fetchedAtIso ISO timestamp to stamp
|
|
102
|
+
* @param {number} ttlHours
|
|
103
|
+
* @param {string} sourceUrl
|
|
104
|
+
* @returns {{fetched_at:string, ttl_hours:number, source:string, models:Array<object>}}
|
|
105
|
+
*/
|
|
106
|
+
function mapResponse(body, fetchedAtIso, ttlHours, sourceUrl) {
|
|
107
|
+
const data = body && Array.isArray(body.data) ? body.data : [];
|
|
108
|
+
const models = [];
|
|
109
|
+
for (const entry of data) {
|
|
110
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
111
|
+
if (typeof entry.id !== 'string' || entry.id.length === 0) continue;
|
|
112
|
+
const pricing = entry.pricing && typeof entry.pricing === 'object' ? entry.pricing : {};
|
|
113
|
+
models.push({
|
|
114
|
+
id: entry.id,
|
|
115
|
+
name: typeof entry.name === 'string' ? entry.name : entry.id,
|
|
116
|
+
context_length: Number.isFinite(entry.context_length) ? entry.context_length : null,
|
|
117
|
+
pricing: {
|
|
118
|
+
prompt: pricing.prompt !== undefined && pricing.prompt !== null ? String(pricing.prompt) : null,
|
|
119
|
+
completion:
|
|
120
|
+
pricing.completion !== undefined && pricing.completion !== null
|
|
121
|
+
? String(pricing.completion)
|
|
122
|
+
: null,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
fetched_at: fetchedAtIso,
|
|
128
|
+
ttl_hours: ttlHours,
|
|
129
|
+
source: sourceUrl,
|
|
130
|
+
models,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Atomically write `obj` (JSON) to `cachePath`: write a per-pid temp file in the
|
|
136
|
+
* same directory, then rename over the target. mkdir -p the dir first. The
|
|
137
|
+
* rename is atomic on POSIX and NTFS. NEVER throws — write failure degrades.
|
|
138
|
+
*
|
|
139
|
+
* @returns {boolean} true on success, false on any failure.
|
|
140
|
+
*/
|
|
141
|
+
function atomicWrite(cachePath, obj) {
|
|
142
|
+
try {
|
|
143
|
+
const dir = path.dirname(cachePath);
|
|
144
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
145
|
+
const tmp = `${cachePath}.${process.pid}.${Date.now()}.tmp`;
|
|
146
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
|
147
|
+
try {
|
|
148
|
+
fs.renameSync(tmp, cachePath);
|
|
149
|
+
} catch (renameErr) {
|
|
150
|
+
// Best-effort cleanup of the temp file so we never leave litter behind.
|
|
151
|
+
try {
|
|
152
|
+
fs.unlinkSync(tmp);
|
|
153
|
+
} catch {
|
|
154
|
+
/* ignore */
|
|
155
|
+
}
|
|
156
|
+
throw renameErr;
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Perform the live fetch with bounded jittered-backoff retry on retryable
|
|
166
|
+
* classes (NETWORK_TRANSIENT / RATE_LIMITED), feeding any rate-limit headers to
|
|
167
|
+
* rate-guard. Non-retryable classes (AUTH_ERROR / VALIDATION / ...) stop
|
|
168
|
+
* immediately. Returns the parsed response body on success, or null on any
|
|
169
|
+
* exhausted/non-retryable failure. NEVER throws.
|
|
170
|
+
*
|
|
171
|
+
* @returns {Promise<object|null>}
|
|
172
|
+
*/
|
|
173
|
+
async function fetchWithRetry({ fetchImpl, url, apiKey, backoffOpts }) {
|
|
174
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
175
|
+
// Respect any prior rate-limit window before issuing the request.
|
|
176
|
+
try {
|
|
177
|
+
await rateGuard.blockUntilReady(PROVIDER);
|
|
178
|
+
} catch {
|
|
179
|
+
/* rate-guard is best-effort — never let it break the fetch */
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let res;
|
|
183
|
+
let thrown = null;
|
|
184
|
+
try {
|
|
185
|
+
res = await fetchImpl(url, {
|
|
186
|
+
method: 'GET',
|
|
187
|
+
headers: {
|
|
188
|
+
Authorization: `Bearer ${apiKey}`,
|
|
189
|
+
Accept: 'application/json',
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
} catch (networkErr) {
|
|
193
|
+
thrown = networkErr;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// A thrown/rejected fetch → classify the raw error.
|
|
197
|
+
if (thrown) {
|
|
198
|
+
const { reason } = classify(thrown);
|
|
199
|
+
if (reason === FailoverReason.NETWORK_TRANSIENT || reason === FailoverReason.RATE_LIMITED) {
|
|
200
|
+
if (attempt < MAX_ATTEMPTS - 1) {
|
|
201
|
+
await sleep(attempt, backoffOpts);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Non-retryable, or retries exhausted.
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Feed response headers to rate-guard (best-effort) so a 429/limit window is
|
|
210
|
+
// recorded for the next call.
|
|
211
|
+
try {
|
|
212
|
+
if (res && res.headers) await rateGuard.ingestHeaders(PROVIDER, res.headers);
|
|
213
|
+
} catch {
|
|
214
|
+
/* best-effort */
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (res && res.ok) {
|
|
218
|
+
try {
|
|
219
|
+
return await res.json();
|
|
220
|
+
} catch {
|
|
221
|
+
// A 200 with an unparseable body is a transient-ish anomaly; retry if budget remains.
|
|
222
|
+
if (attempt < MAX_ATTEMPTS - 1) {
|
|
223
|
+
await sleep(attempt, backoffOpts);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Non-OK HTTP — classify by status.
|
|
231
|
+
const status = res && Number.isFinite(res.status) ? res.status : 0;
|
|
232
|
+
const { reason } = classify({ status });
|
|
233
|
+
if (reason === FailoverReason.NETWORK_TRANSIENT || reason === FailoverReason.RATE_LIMITED) {
|
|
234
|
+
if (attempt < MAX_ATTEMPTS - 1) {
|
|
235
|
+
await sleep(attempt, backoffOpts);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
// AUTH_ERROR / VALIDATION / NETWORK_PERMANENT / etc. — do NOT retry.
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fetch (or load-from-cache) the OpenRouter model catalog.
|
|
248
|
+
*
|
|
249
|
+
* Order of operations:
|
|
250
|
+
* 1. readCatalog → if a cache is present AND fresh (within ttlHours of now)
|
|
251
|
+
* → return cache.models WITHOUT calling fetchImpl (TTL skip).
|
|
252
|
+
* 2. else if no apiKey → return cache.models if a cache is present (stale ok),
|
|
253
|
+
* else null. (Graceful — never fetches without a key.)
|
|
254
|
+
* 3. else fetch <baseUrl>/models via fetchImpl (Authorization: Bearer <apiKey>),
|
|
255
|
+
* retrying transient/rate-limited failures on a jittered-backoff curve with
|
|
256
|
+
* rate-guard awareness; non-retryable classes stop.
|
|
257
|
+
* 4. on success → map to the CONTEXT cache shape → atomic write → return models.
|
|
258
|
+
* 5. on exhausted/failed fetch → return cache.models if present else null.
|
|
259
|
+
*
|
|
260
|
+
* NEVER throws (D-08). The whole body is wrapped; any escaped error degrades to
|
|
261
|
+
* cached-if-any-else-null.
|
|
262
|
+
*
|
|
263
|
+
* @param {object} [opts]
|
|
264
|
+
* @param {function} [opts.fetchImpl] injectable fetch (default global fetch — D-07/D-10)
|
|
265
|
+
* @param {function} [opts.now] () => Date, for deterministic TTL (default () => new Date())
|
|
266
|
+
* @param {string} [opts.cachePath] default <repo>/.design/cache/openrouter-models.json
|
|
267
|
+
* @param {number} [opts.ttlHours] default 24 (D-02)
|
|
268
|
+
* @param {string} [opts.apiKey] default process.env.OPENROUTER_API_KEY
|
|
269
|
+
* @param {string} [opts.baseUrl] default process.env.OPENROUTER_BASE_URL || the OpenRouter base
|
|
270
|
+
* @param {object} [opts.backoffOpts] passed to jittered-backoff (tests pass near-zero)
|
|
271
|
+
* @returns {Promise<Array<object>|null>}
|
|
272
|
+
*/
|
|
273
|
+
async function fetchCatalog(opts) {
|
|
274
|
+
const o = opts || {};
|
|
275
|
+
const fetchImpl = typeof o.fetchImpl === 'function' ? o.fetchImpl : globalThis.fetch;
|
|
276
|
+
const nowFn = typeof o.now === 'function' ? o.now : () => new Date();
|
|
277
|
+
const cachePath =
|
|
278
|
+
typeof o.cachePath === 'string' && o.cachePath.length > 0 ? o.cachePath : DEFAULT_CACHE_PATH;
|
|
279
|
+
const ttlHours = Number.isFinite(o.ttlHours) ? o.ttlHours : 24;
|
|
280
|
+
const apiKey = 'apiKey' in o ? o.apiKey : process.env.OPENROUTER_API_KEY;
|
|
281
|
+
const baseUrl =
|
|
282
|
+
(typeof o.baseUrl === 'string' && o.baseUrl) || process.env.OPENROUTER_BASE_URL || DEFAULT_BASE_URL;
|
|
283
|
+
const backoffOpts = o.backoffOpts;
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const nowMs = nowFn().getTime();
|
|
287
|
+
const cacheObj = readCacheObject(cachePath);
|
|
288
|
+
|
|
289
|
+
// 1. TTL skip — fresh cache short-circuits the fetch entirely.
|
|
290
|
+
if (cacheObj && isFresh(cacheObj, ttlHours, nowMs)) {
|
|
291
|
+
return cacheObj.models;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 2. No key → never fetch; degrade to cached-if-any-else-null.
|
|
295
|
+
if (!apiKey || typeof apiKey !== 'string' || apiKey.length === 0) {
|
|
296
|
+
return cacheObj ? cacheObj.models : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 3. Fetch with bounded retry.
|
|
300
|
+
const sourceUrl = `${baseUrl}${MODELS_PATH}`;
|
|
301
|
+
const body = await fetchWithRetry({ fetchImpl, url: sourceUrl, apiKey, backoffOpts });
|
|
302
|
+
|
|
303
|
+
// 5. Exhausted / failed → degrade to cached-if-any-else-null.
|
|
304
|
+
if (body === null) {
|
|
305
|
+
return cacheObj ? cacheObj.models : null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 4. Success → map + atomic write.
|
|
309
|
+
const fetchedAtIso = nowFn().toISOString();
|
|
310
|
+
// source stays the canonical OpenRouter models URL even when a custom baseUrl
|
|
311
|
+
// is used, so the cache's `source` is the public contract value.
|
|
312
|
+
const mapped = mapResponse(body, fetchedAtIso, ttlHours, `${DEFAULT_BASE_URL}${MODELS_PATH}`);
|
|
313
|
+
atomicWrite(cachePath, mapped); // best-effort; a write failure still returns the models
|
|
314
|
+
return mapped.models;
|
|
315
|
+
} catch {
|
|
316
|
+
// Absolute backstop — fetchCatalog NEVER throws (D-08).
|
|
317
|
+
const fallback = readCatalog({ cachePath });
|
|
318
|
+
return fallback;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
module.exports = { fetchCatalog, readCatalog, _internal: { mapResponse, isFresh, atomicWrite } };
|
|
323
|
+
// `delayMs` is part of the resilience-primitive contract (jittered-backoff) and
|
|
324
|
+
// is exercised indirectly via `sleep`; reference it so linters/readers see the
|
|
325
|
+
// full retry-curve seam is wired.
|
|
326
|
+
void delayMs;
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
|
|
51
51
|
const { spawn } = require('child_process');
|
|
52
52
|
const { EventEmitter } = require('events');
|
|
53
|
+
const { sanitizeEnv, readPeerCliAllowlist } = require('./sanitize-env.cjs');
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
56
|
* Hard cap on the size of a single un-terminated line read from the
|
|
@@ -99,7 +100,14 @@ function createAcpClient(opts) {
|
|
|
99
100
|
const command = opts.command;
|
|
100
101
|
const args = Array.isArray(opts.args) ? opts.args : [];
|
|
101
102
|
const cwd = typeof opts.cwd === 'string' ? opts.cwd : process.cwd();
|
|
102
|
-
|
|
103
|
+
// Plan 33.5-04 (D-03): when the caller does not supply an explicit env, the
|
|
104
|
+
// child inherits a SANITIZED env (OS-essential baseline + the configured
|
|
105
|
+
// peer_cli.env_allowlist) instead of the raw full process.env — so GDD's
|
|
106
|
+
// ANTHROPIC_API_KEY/GH_TOKEN/GDD_* never leak to spawned peers. An explicit
|
|
107
|
+
// opts.env still wins (callers/tests can pass a full env).
|
|
108
|
+
const env = opts.env && typeof opts.env === 'object'
|
|
109
|
+
? opts.env
|
|
110
|
+
: sanitizeEnv(process.env, { allowlist: readPeerCliAllowlist() });
|
|
103
111
|
|
|
104
112
|
const events = new EventEmitter();
|
|
105
113
|
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
'use strict';
|
|
81
81
|
|
|
82
82
|
const { spawn } = require('node:child_process');
|
|
83
|
+
const { sanitizeEnv, readPeerCliAllowlist } = require('./sanitize-env.cjs');
|
|
83
84
|
|
|
84
85
|
/** Per-line cap before we treat the stream as malformed. */
|
|
85
86
|
const MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
@@ -119,7 +120,15 @@ function createAspClient(opts) {
|
|
|
119
120
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
121
|
};
|
|
121
122
|
if (typeof opts.cwd === 'string' && opts.cwd.length > 0) spawnOptions.cwd = opts.cwd;
|
|
122
|
-
if (opts.env && typeof opts.env === 'object')
|
|
123
|
+
if (opts.env && typeof opts.env === 'object') {
|
|
124
|
+
spawnOptions.env = opts.env;
|
|
125
|
+
} else {
|
|
126
|
+
// Plan 33.5-04 (D-03): without an explicit env, the child ALWAYS gets a
|
|
127
|
+
// defined, SANITIZED env (OS-essential baseline + peer_cli.env_allowlist)
|
|
128
|
+
// rather than Node defaulting to the raw process.env — closing the
|
|
129
|
+
// ANTHROPIC_API_KEY/GH_TOKEN/GDD_* leak. Explicit opts.env still wins.
|
|
130
|
+
spawnOptions.env = sanitizeEnv(process.env, { allowlist: readPeerCliAllowlist() });
|
|
131
|
+
}
|
|
123
132
|
|
|
124
133
|
// Test-injection seam: callers (or unit tests) can supply a pre-built
|
|
125
134
|
// ChildProcess so we don't actually fork a binary in tests. The mock
|