@hegemonart/get-design-done 1.33.5 → 1.34.1
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 +6 -3
- package/.claude-plugin/plugin.json +5 -2
- package/CHANGELOG.md +48 -0
- package/README.md +14 -0
- package/SKILL.md +1 -0
- package/agents/compose-executor.md +142 -0
- package/agents/design-authority-watcher.md +4 -0
- package/agents/design-context-builder.md +35 -1
- package/agents/design-verifier.md +14 -18
- package/agents/flutter-executor.md +147 -0
- package/agents/swift-executor.md +226 -0
- package/connections/android-emulator.md +107 -0
- package/connections/connections.md +6 -0
- package/connections/openrouter.md +86 -0
- package/connections/xcode-simulator.md +108 -0
- package/hooks/budget-enforcer.ts +103 -0
- package/package.json +3 -2
- package/reference/gdd-threat-model.md +63 -0
- package/reference/native-platforms.md +273 -0
- package/reference/openrouter-tier-mapping.md +98 -0
- package/reference/prices.openrouter.md +26 -0
- package/reference/registry.json +21 -0
- package/scripts/lib/authority-watcher/index.cjs +147 -0
- package/scripts/lib/budget-enforcer.cjs +16 -0
- package/scripts/lib/design-tokens/_native-shared.cjs +206 -0
- package/scripts/lib/design-tokens/compose.cjs +150 -0
- package/scripts/lib/design-tokens/flutter.cjs +128 -0
- package/scripts/lib/design-tokens/index.cjs +13 -0
- package/scripts/lib/design-tokens/swift.cjs +122 -0
- package/scripts/lib/openrouter/catalog-fetcher.cjs +326 -0
- package/scripts/lib/tier-resolver-openrouter.cjs +343 -0
- package/sdk/event-stream/types.ts +24 -2
- package/skills/openrouter-status/SKILL.md +86 -0
|
@@ -17,6 +17,12 @@ const { readJsConst } = require('./js-const.cjs');
|
|
|
17
17
|
const { readTailwind } = require('./tailwind.cjs');
|
|
18
18
|
const { readFigma } = require('./figma.cjs');
|
|
19
19
|
|
|
20
|
+
// Phase 34.1 — native theme emitters (token-bridge, D-02: extend the engine,
|
|
21
|
+
// do not fork a native IR). See reference/native-platforms.md.
|
|
22
|
+
const { emitSwift, reextractSwift } = require('./swift.cjs');
|
|
23
|
+
const { emitCompose, reextractCompose } = require('./compose.cjs');
|
|
24
|
+
const { emitFlutter, reextractFlutter } = require('./flutter.cjs');
|
|
25
|
+
|
|
20
26
|
/**
|
|
21
27
|
* @typedef {Object} TokenSet
|
|
22
28
|
* @property {Object<string, string>} tokens
|
|
@@ -97,4 +103,11 @@ module.exports = {
|
|
|
97
103
|
readJsConst,
|
|
98
104
|
readTailwind,
|
|
99
105
|
readFigma,
|
|
106
|
+
// Phase 34.1 native token-bridge emitters + symmetric re-extractors.
|
|
107
|
+
emitSwift,
|
|
108
|
+
emitCompose,
|
|
109
|
+
emitFlutter,
|
|
110
|
+
reextractSwift,
|
|
111
|
+
reextractCompose,
|
|
112
|
+
reextractFlutter,
|
|
100
113
|
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design-tokens/swift.cjs — SwiftUI theme emitter + re-extractor
|
|
3
|
+
* (Phase 34.1 Plan 01).
|
|
4
|
+
*
|
|
5
|
+
* Consumes the canonical Phase-23 token map ({ tokens: Record<string,string> })
|
|
6
|
+
* and emits a deterministic SwiftUI theme source string: an `enum GDDTheme`
|
|
7
|
+
* exposing static `Color` / `CGFloat` (pt) / `String` constants per the
|
|
8
|
+
* PRECISION CONTRACT in reference/native-platforms.md.
|
|
9
|
+
*
|
|
10
|
+
* Precision contract (the round-trip's definition of "identity preserved"):
|
|
11
|
+
* COLOR hex #RGB/#RRGGBB/#RRGGBBAA -> Color(red:G/255 ...). 8-bit
|
|
12
|
+
* channels EXACT (numerator-over-255 form avoids float drift);
|
|
13
|
+
* #RGB expands to #RRGGBB; alpha opaque (255) when absent.
|
|
14
|
+
* DIMENSION Npx -> CGFloat integer points (round-half-up).
|
|
15
|
+
* TYPOGRAPHY font-family / strings -> String literal, pass-through.
|
|
16
|
+
* NON-MAPPABLE var()/calc()/gradient/rem/em -> verbatim trailing comment,
|
|
17
|
+
* EXCLUDED from the round-trip identity set.
|
|
18
|
+
*
|
|
19
|
+
* Each emitted token line carries a `// token: <original-key>` marker so the
|
|
20
|
+
* symmetric `reextractSwift(source)` recovers the exact canonical key + value.
|
|
21
|
+
* Pure: no fs, no network, no Date, no process.env, no child_process (D-10).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
TOKEN_MARKER,
|
|
28
|
+
NONMAPPABLE_MARKER,
|
|
29
|
+
readTokens,
|
|
30
|
+
sortedEntries,
|
|
31
|
+
classify,
|
|
32
|
+
parseHexChannels,
|
|
33
|
+
channelsToHex,
|
|
34
|
+
pxToInt,
|
|
35
|
+
swiftIdent,
|
|
36
|
+
} = require('./_native-shared.cjs');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Emit a SwiftUI theme source string from a token set.
|
|
40
|
+
*
|
|
41
|
+
* @param {{tokens: Record<string,string>}|Record<string,string>} tokenSet
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
function emitSwift(tokenSet) {
|
|
45
|
+
const tokens = readTokens(tokenSet);
|
|
46
|
+
const lines = [];
|
|
47
|
+
lines.push('// Generated by get-design-done — SwiftUI theme tokens.');
|
|
48
|
+
lines.push('// See reference/native-platforms.md (token-bridge precision contract).');
|
|
49
|
+
lines.push('import SwiftUI');
|
|
50
|
+
lines.push('');
|
|
51
|
+
lines.push('enum GDDTheme {');
|
|
52
|
+
for (const [key, value] of sortedEntries(tokens)) {
|
|
53
|
+
const ident = swiftIdent(key);
|
|
54
|
+
const kind = classify(key, value);
|
|
55
|
+
if (kind === 'color') {
|
|
56
|
+
const { r, g, b, a, hadAlpha } = parseHexChannels(value);
|
|
57
|
+
const alphaSlot = hadAlpha
|
|
58
|
+
? `, opacity: ${a}.0/255.0`
|
|
59
|
+
: `, opacity: 255.0/255.0`;
|
|
60
|
+
lines.push(
|
|
61
|
+
` static let ${ident} = Color(red: ${r}.0/255.0, green: ${g}.0/255.0, blue: ${b}.0/255.0${alphaSlot}) ${TOKEN_MARKER}${key} hadAlpha=${hadAlpha ? 1 : 0}`,
|
|
62
|
+
);
|
|
63
|
+
} else if (kind === 'dimension') {
|
|
64
|
+
lines.push(
|
|
65
|
+
` static let ${ident}: CGFloat = ${pxToInt(value)} ${TOKEN_MARKER}${key}`,
|
|
66
|
+
);
|
|
67
|
+
} else if (kind === 'typography') {
|
|
68
|
+
lines.push(
|
|
69
|
+
` static let ${ident} = ${JSON.stringify(value)} ${TOKEN_MARKER}${key}`,
|
|
70
|
+
);
|
|
71
|
+
} else {
|
|
72
|
+
// non-mappable — verbatim, excluded from identity set
|
|
73
|
+
lines.push(` ${NONMAPPABLE_MARKER}${key} = ${value}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
lines.push('}');
|
|
77
|
+
lines.push('');
|
|
78
|
+
return lines.join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const SWIFT_COLOR_RE = new RegExp(
|
|
82
|
+
String.raw`Color\(red:\s*(\d+)\.0/255\.0,\s*green:\s*(\d+)\.0/255\.0,\s*blue:\s*(\d+)\.0/255\.0,\s*opacity:\s*(\d+)\.0/255\.0\)\s*` +
|
|
83
|
+
TOKEN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
|
84
|
+
String.raw`(\S+)\s+hadAlpha=([01])`,
|
|
85
|
+
);
|
|
86
|
+
const SWIFT_DIM_RE = new RegExp(
|
|
87
|
+
String.raw`:\s*CGFloat\s*=\s*(\d+)\s*` +
|
|
88
|
+
TOKEN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
|
89
|
+
String.raw`(\S+)`,
|
|
90
|
+
);
|
|
91
|
+
const SWIFT_STR_RE = new RegExp(
|
|
92
|
+
String.raw`static let \w+ = ("(?:[^"\\]|\\.)*")\s*` +
|
|
93
|
+
TOKEN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
|
94
|
+
String.raw`(\S+)`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Recover the canonical token map from an emitted SwiftUI source string.
|
|
99
|
+
* Non-mappable lines (NONMAPPABLE_MARKER) are intentionally NOT recovered.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} source
|
|
102
|
+
* @returns {{tokens: Record<string,string>}}
|
|
103
|
+
*/
|
|
104
|
+
function reextractSwift(source) {
|
|
105
|
+
/** @type {Record<string,string>} */
|
|
106
|
+
const tokens = {};
|
|
107
|
+
for (const line of String(source).split(/\r?\n/)) {
|
|
108
|
+
if (line.includes(NONMAPPABLE_MARKER)) continue;
|
|
109
|
+
let m;
|
|
110
|
+
if ((m = SWIFT_COLOR_RE.exec(line))) {
|
|
111
|
+
const [, r, g, b, a, key, hadAlpha] = m;
|
|
112
|
+
tokens[key] = channelsToHex(+r, +g, +b, +a, hadAlpha === '1');
|
|
113
|
+
} else if ((m = SWIFT_DIM_RE.exec(line))) {
|
|
114
|
+
tokens[m[2]] = `${m[1]}px`;
|
|
115
|
+
} else if ((m = SWIFT_STR_RE.exec(line))) {
|
|
116
|
+
tokens[m[2]] = JSON.parse(m[1]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { tokens };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { emitSwift, reextractSwift };
|
|
@@ -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;
|