opencode-qoder-bridge 0.1.8 → 0.1.10
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/CHANGELOG.md +70 -1
- package/README.md +112 -45
- package/bin/statusline.mjs +30 -6
- package/bin/usage.mjs +6 -5
- package/dist/async-utils.d.ts +8 -0
- package/dist/async-utils.d.ts.map +1 -0
- package/dist/async-utils.js +27 -0
- package/dist/async-utils.js.map +1 -0
- package/dist/auth.d.ts +2 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +26 -7
- package/dist/auth.js.map +1 -1
- package/dist/cost.d.ts +2 -1
- package/dist/cost.d.ts.map +1 -1
- package/dist/cost.js +163 -63
- package/dist/cost.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +5 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +187 -41
- package/dist/index.js.map +1 -1
- package/dist/language-model.d.ts +25 -0
- package/dist/language-model.d.ts.map +1 -1
- package/dist/language-model.js +938 -183
- package/dist/language-model.js.map +1 -1
- package/dist/logger.d.ts +1 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +35 -5
- package/dist/logger.js.map +1 -1
- package/dist/mcp-bridge.d.ts.map +1 -1
- package/dist/mcp-bridge.js +71 -7
- package/dist/mcp-bridge.js.map +1 -1
- package/dist/models.d.ts +25 -6
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +444 -85
- package/dist/models.js.map +1 -1
- package/dist/prompt-builder.d.ts.map +1 -1
- package/dist/prompt-builder.js +241 -43
- package/dist/prompt-builder.js.map +1 -1
- package/dist/sdk-auth.d.ts +3 -3
- package/dist/sdk-auth.d.ts.map +1 -1
- package/dist/sdk-auth.js +13 -9
- package/dist/sdk-auth.js.map +1 -1
- package/dist/sdk-session.d.ts.map +1 -1
- package/dist/sdk-session.js +26 -2
- package/dist/sdk-session.js.map +1 -1
- package/dist/session-store.d.ts +14 -3
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +451 -46
- package/dist/session-store.js.map +1 -1
- package/dist/state-dir.d.ts.map +1 -1
- package/dist/state-dir.js +2 -1
- package/dist/state-dir.js.map +1 -1
- package/dist/tool-normalizer.d.ts.map +1 -1
- package/dist/tool-normalizer.js +11 -2
- package/dist/tool-normalizer.js.map +1 -1
- package/dist/tui-register.d.ts.map +1 -1
- package/dist/tui-register.js +88 -37
- package/dist/tui-register.js.map +1 -1
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +42 -17
- package/dist/tui.js.map +1 -1
- package/dist/types.d.ts +16 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +54 -29
- package/dist/usage.js.map +1 -1
- package/package.json +12 -4
package/dist/models.js
CHANGED
|
@@ -2,14 +2,26 @@ import { query } from "@qoder-ai/qoder-agent-sdk";
|
|
|
2
2
|
import { findQoderCLI } from "./auth.js";
|
|
3
3
|
import { idlePrompt } from "./sdk-session.js";
|
|
4
4
|
import { hasQoderCredential, qoderAuth } from "./sdk-auth.js";
|
|
5
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
|
|
6
|
-
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "node:fs";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { writeFile } from "node:fs/promises";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { resolveStateDir } from "./state-dir.js";
|
|
10
10
|
import { debug, describeError } from "./logger.js";
|
|
11
|
+
import { closeAsyncIterator, withTimeout } from "./async-utils.js";
|
|
11
12
|
const CONTEXT = 200_000;
|
|
12
13
|
const OUTPUT = 32_000;
|
|
14
|
+
const MAX_MODEL_CACHE_BYTES = 1_000_000;
|
|
15
|
+
const MAX_MODEL_TOKENS = 10_000_000;
|
|
16
|
+
const MAX_PRICE_FACTOR = 1_000_000;
|
|
17
|
+
const MAX_DYNAMIC_MODELS = 512;
|
|
18
|
+
const MODEL_CACHE_VERSION = 1;
|
|
19
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
20
|
+
const CLEANUP_GRACE_MS = 5_000;
|
|
21
|
+
const MODEL_REFRESH_INTERVAL_MS = 60_000;
|
|
22
|
+
const MODEL_RETRY_INTERVAL_MS = 5_000;
|
|
23
|
+
const UNSAFE_IDS = new Set(["__proto__", "prototype", "constructor"]);
|
|
24
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/;
|
|
13
25
|
function def(id, name, multiplier, opts = {}) {
|
|
14
26
|
return {
|
|
15
27
|
id,
|
|
@@ -35,9 +47,8 @@ export const FALLBACK_MODELS = [
|
|
|
35
47
|
export const DEFAULT_MODEL_ID = "auto";
|
|
36
48
|
const MODEL_INDEX = new Map(FALLBACK_MODELS.map((m) => [m.id, m]));
|
|
37
49
|
const STATE_DIR = resolveStateDir();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
MODEL_INDEX.set(m.id, {
|
|
50
|
+
function toModelDef(m) {
|
|
51
|
+
return {
|
|
41
52
|
id: m.id,
|
|
42
53
|
name: m.name,
|
|
43
54
|
multiplier: m.cost.input,
|
|
@@ -51,29 +62,78 @@ function addToIndex(m) {
|
|
|
51
62
|
cacheWrite: m.cost.cache_write,
|
|
52
63
|
},
|
|
53
64
|
limit: m.limit,
|
|
54
|
-
}
|
|
65
|
+
};
|
|
55
66
|
}
|
|
56
|
-
|
|
67
|
+
function rebuildIndex(models) {
|
|
68
|
+
MODEL_INDEX.clear();
|
|
69
|
+
for (const model of FALLBACK_MODELS)
|
|
70
|
+
MODEL_INDEX.set(model.id, model);
|
|
71
|
+
for (const model of models) {
|
|
72
|
+
const def = toModelDef(model);
|
|
73
|
+
MODEL_INDEX.set(model.id, def);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function getModel(id, environment = process.env, options = {}) {
|
|
77
|
+
const runtimeEnvironment = effectiveEnvironment(environment);
|
|
78
|
+
if (hasQoderCredential(runtimeEnvironment))
|
|
79
|
+
getCatalogState(runtimeEnvironment, options);
|
|
80
|
+
else if (activeCatalogScope && catalogStates.get(activeCatalogScope)?.liveUpdated) {
|
|
81
|
+
rebuildIndex(catalogStates.get(activeCatalogScope)?.models ?? []);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
rebuildIndex([]);
|
|
85
|
+
}
|
|
57
86
|
return MODEL_INDEX.get(id);
|
|
58
87
|
}
|
|
88
|
+
const catalogStates = new Map();
|
|
89
|
+
let activeCatalogScope;
|
|
90
|
+
let modelQueryFactory = query;
|
|
91
|
+
/** @internal Test seam for deterministic discovery lifecycle coverage. */
|
|
92
|
+
export function setModelDiscoveryQueryFactory(factory = query) {
|
|
93
|
+
modelQueryFactory = factory;
|
|
94
|
+
}
|
|
59
95
|
/**
|
|
60
96
|
* Keep catalog entries that are usable model ids. Disabled entries are
|
|
61
97
|
* dropped; anything else (BYOK, tagged, scene-filtered) stays so the bridge
|
|
62
98
|
* never hides a model the server actually serves.
|
|
63
99
|
*/
|
|
64
100
|
export function selectEnabledModels(models) {
|
|
65
|
-
|
|
101
|
+
if (!Array.isArray(models))
|
|
102
|
+
return [];
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
const selected = [];
|
|
105
|
+
for (const m of models) {
|
|
106
|
+
if (!m || typeof m !== "object" || Array.isArray(m))
|
|
107
|
+
continue;
|
|
108
|
+
const value = m.value;
|
|
109
|
+
if (typeof value !== "string"
|
|
110
|
+
|| value.trim().length === 0
|
|
111
|
+
|| value.length > 256
|
|
112
|
+
|| UNSAFE_IDS.has(value)
|
|
113
|
+
|| CONTROL_CHARS.test(value)
|
|
114
|
+
|| m.isEnabled === false
|
|
115
|
+
|| seen.has(value))
|
|
116
|
+
continue;
|
|
117
|
+
seen.add(value);
|
|
118
|
+
selected.push(m);
|
|
119
|
+
if (selected.length >= MAX_DYNAMIC_MODELS)
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
return selected;
|
|
66
123
|
}
|
|
67
124
|
function mapModelInfo(m) {
|
|
68
|
-
const factor = m.priceFactor
|
|
69
|
-
const context = m.maxInputTokens
|
|
70
|
-
const output = m.maxOutputTokens
|
|
71
|
-
const vl = m.isVl
|
|
125
|
+
const factor = finiteNonNegative(m.priceFactor, 1.0);
|
|
126
|
+
const context = finitePositiveInteger(m.maxInputTokens, CONTEXT);
|
|
127
|
+
const output = finitePositiveInteger(m.maxOutputTokens, OUTPUT);
|
|
128
|
+
const vl = m.isVl === undefined ? true : m.isVl === true;
|
|
129
|
+
const displayName = typeof m.displayName === "string" && m.displayName.trim()
|
|
130
|
+
? m.displayName.replace(CONTROL_CHARS, " ").trim().slice(0, 1024)
|
|
131
|
+
: "";
|
|
72
132
|
return {
|
|
73
133
|
id: m.value,
|
|
74
|
-
name: m.
|
|
134
|
+
name: displayName || m.value,
|
|
75
135
|
attachment: vl,
|
|
76
|
-
reasoning: m.isReasoning
|
|
136
|
+
reasoning: m.isReasoning === true,
|
|
77
137
|
toolCall: true,
|
|
78
138
|
limit: { context, output },
|
|
79
139
|
cost: {
|
|
@@ -88,108 +148,407 @@ function mapModelInfo(m) {
|
|
|
88
148
|
},
|
|
89
149
|
};
|
|
90
150
|
}
|
|
91
|
-
|
|
92
|
-
|
|
151
|
+
function finiteNonNegative(value, fallback) {
|
|
152
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_PRICE_FACTOR ? value : fallback;
|
|
153
|
+
}
|
|
154
|
+
function finitePositiveInteger(value, fallback) {
|
|
155
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > MAX_MODEL_TOKENS)
|
|
156
|
+
return fallback;
|
|
157
|
+
const normalized = Math.floor(value);
|
|
158
|
+
return normalized > 0 ? normalized : fallback;
|
|
159
|
+
}
|
|
160
|
+
function validCachedModel(value) {
|
|
161
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
162
|
+
return false;
|
|
163
|
+
const model = value;
|
|
164
|
+
if (typeof model.id !== "string" || model.id.length === 0 || model.id.length > 256 || UNSAFE_IDS.has(model.id) || CONTROL_CHARS.test(model.id))
|
|
165
|
+
return false;
|
|
166
|
+
if (typeof model.name !== "string" || model.name.length === 0 || model.name.length > 1024 || CONTROL_CHARS.test(model.name))
|
|
167
|
+
return false;
|
|
168
|
+
if (typeof model.attachment !== "boolean" || typeof model.reasoning !== "boolean" || typeof model.toolCall !== "boolean")
|
|
169
|
+
return false;
|
|
170
|
+
const limit = model.limit;
|
|
171
|
+
if (!limit || typeof limit !== "object" || Array.isArray(limit))
|
|
172
|
+
return false;
|
|
173
|
+
const l = limit;
|
|
174
|
+
if (finitePositiveInteger(l.context, 0) === 0 || finitePositiveInteger(l.output, 0) === 0)
|
|
175
|
+
return false;
|
|
176
|
+
const cost = model.cost;
|
|
177
|
+
if (!cost || typeof cost !== "object" || Array.isArray(cost))
|
|
178
|
+
return false;
|
|
179
|
+
const c = cost;
|
|
180
|
+
if (![c.input, c.output, c.cache_read, c.cache_write].every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= MAX_PRICE_FACTOR))
|
|
181
|
+
return false;
|
|
182
|
+
const modalities = model.modalities;
|
|
183
|
+
if (!modalities || typeof modalities !== "object" || Array.isArray(modalities))
|
|
184
|
+
return false;
|
|
185
|
+
const modes = modalities;
|
|
186
|
+
return [modes.input, modes.output].every((items) => Array.isArray(items)
|
|
187
|
+
&& items.length <= 8
|
|
188
|
+
&& items.every((item) => typeof item === "string" && item.length <= 64 && !CONTROL_CHARS.test(item)));
|
|
189
|
+
}
|
|
190
|
+
function cloneModel(model) {
|
|
191
|
+
return {
|
|
192
|
+
...model,
|
|
193
|
+
limit: { ...model.limit },
|
|
194
|
+
cost: { ...model.cost },
|
|
195
|
+
modalities: { input: [...model.modalities.input], output: [...model.modalities.output] },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function cloneModels(models) {
|
|
199
|
+
return models?.map(cloneModel) ?? null;
|
|
200
|
+
}
|
|
201
|
+
function effectiveEnvironment(environment) {
|
|
202
|
+
return { ...process.env, ...environment };
|
|
203
|
+
}
|
|
204
|
+
function safeOption(value) {
|
|
205
|
+
if (typeof value !== "string")
|
|
206
|
+
return undefined;
|
|
207
|
+
const normalized = value.trim();
|
|
208
|
+
return normalized && !CONTROL_CHARS.test(normalized) ? normalized : undefined;
|
|
209
|
+
}
|
|
210
|
+
function fingerprint(value) {
|
|
211
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Model catalogs are account/scene specific. Keep their cache and refresh
|
|
215
|
+
* state isolated without ever putting a credential itself in a path or log.
|
|
216
|
+
*/
|
|
217
|
+
function catalogScope(environment, options) {
|
|
218
|
+
const token = environment.QODER_PERSONAL_ACCESS_TOKEN?.trim();
|
|
219
|
+
const authScope = token
|
|
220
|
+
? `pat:${fingerprint(token)}`
|
|
221
|
+
: hasQoderCredential(environment) ? "local-login" : "anonymous";
|
|
222
|
+
const parts = [
|
|
223
|
+
`auth=${authScope}`,
|
|
224
|
+
`scene=${environment.QODER_SCENE ?? ""}`,
|
|
225
|
+
`vpc=${safeOption(options.vpcEndpoint) ?? environment.QODER_VPC_ENDPOINT ?? environment.QODERCN_VPC_ENDPOINT ?? ""}`,
|
|
226
|
+
`endpoint=${environment.QODER_API_URL ?? environment.QODER_BASE_URL ?? ""}`,
|
|
227
|
+
`proxy=${safeOption(options.proxy) ?? environment.HTTPS_PROXY ?? environment.HTTP_PROXY ?? ""}`,
|
|
228
|
+
];
|
|
229
|
+
return fingerprint(parts.join("\u0000"));
|
|
230
|
+
}
|
|
231
|
+
function cacheFileFor(scope) {
|
|
232
|
+
return join(STATE_DIR, `models.${scope}.json`);
|
|
233
|
+
}
|
|
234
|
+
function getCatalogState(environment = process.env, options = {}) {
|
|
235
|
+
const runtimeEnvironment = effectiveEnvironment(environment);
|
|
236
|
+
const scope = catalogScope(runtimeEnvironment, options);
|
|
237
|
+
let state = catalogStates.get(scope);
|
|
238
|
+
if (!state) {
|
|
239
|
+
state = {
|
|
240
|
+
scope,
|
|
241
|
+
cacheFile: cacheFileFor(scope),
|
|
242
|
+
models: null,
|
|
243
|
+
cacheLoaded: false,
|
|
244
|
+
inflightFetch: null,
|
|
245
|
+
requestGeneration: 0,
|
|
246
|
+
lastSuccessAt: 0,
|
|
247
|
+
lastFailureAt: 0,
|
|
248
|
+
pendingModelsToWrite: null,
|
|
249
|
+
activeWritePromise: null,
|
|
250
|
+
liveUpdated: false,
|
|
251
|
+
};
|
|
252
|
+
catalogStates.set(scope, state);
|
|
253
|
+
}
|
|
254
|
+
if (!state.cacheLoaded) {
|
|
255
|
+
state.cacheLoaded = true;
|
|
256
|
+
const cached = loadCachedModels(state);
|
|
257
|
+
if (cached)
|
|
258
|
+
state.models = cached;
|
|
259
|
+
}
|
|
260
|
+
if (activeCatalogScope !== scope) {
|
|
261
|
+
activeCatalogScope = scope;
|
|
262
|
+
rebuildIndex(state.models ?? []);
|
|
263
|
+
}
|
|
264
|
+
return state;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Dynamically apply live model updates received from SDK streaming events
|
|
268
|
+
* (`available_models_update`). Updates the in-memory index and cache file.
|
|
269
|
+
*/
|
|
270
|
+
export function applyLiveModelUpdates(models, environment = process.env, options = {}) {
|
|
271
|
+
const state = getCatalogState(environment, options);
|
|
272
|
+
const enabled = selectEnabledModels(models);
|
|
273
|
+
// Empty SDK events are commonly transient during auth/scene changes. Keep
|
|
274
|
+
// the current in-memory catalog so the model picker does not suddenly lose
|
|
275
|
+
// every account model; a later non-empty snapshot still replaces it.
|
|
276
|
+
if (enabled.length === 0) {
|
|
277
|
+
debug("Live model update was empty; retaining the current catalog");
|
|
278
|
+
return cloneModels(state.models) ?? [];
|
|
279
|
+
}
|
|
280
|
+
const mapped = enabled.map(mapModelInfo);
|
|
281
|
+
state.requestGeneration += 1;
|
|
282
|
+
state.models = mapped;
|
|
283
|
+
state.liveUpdated = true;
|
|
284
|
+
state.lastFailureAt = 0;
|
|
285
|
+
if (mapped.length > 0) {
|
|
286
|
+
state.lastSuccessAt = Date.now();
|
|
287
|
+
void queueModelCacheWriteForState(state, mapped);
|
|
288
|
+
}
|
|
289
|
+
if (activeCatalogScope === state.scope)
|
|
290
|
+
rebuildIndex(mapped);
|
|
291
|
+
debug(`Live model update: refreshed ${mapped.length} models`);
|
|
292
|
+
return cloneModels(state.models) ?? [];
|
|
293
|
+
}
|
|
294
|
+
function loadCachedModels(state) {
|
|
93
295
|
try {
|
|
94
|
-
if (!existsSync(
|
|
296
|
+
if (!existsSync(state.cacheFile))
|
|
95
297
|
return null;
|
|
96
|
-
const
|
|
97
|
-
if (!
|
|
298
|
+
const info = lstatSync(state.cacheFile);
|
|
299
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_MODEL_CACHE_BYTES)
|
|
98
300
|
return null;
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return models.length > 0 ? models : null;
|
|
301
|
+
const parsed = JSON.parse(readFileSync(state.cacheFile, "utf8"));
|
|
302
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
303
|
+
return null;
|
|
304
|
+
const payload = parsed;
|
|
305
|
+
if (payload.version !== MODEL_CACHE_VERSION || payload.scope !== state.scope || !Array.isArray(payload.models))
|
|
306
|
+
return null;
|
|
307
|
+
const models = payload.models.filter(validCachedModel).slice(0, MAX_DYNAMIC_MODELS);
|
|
308
|
+
if (models.length === 0)
|
|
309
|
+
return null;
|
|
310
|
+
return models;
|
|
110
311
|
}
|
|
111
312
|
catch (error) {
|
|
112
313
|
debug("Model cache unreadable; using fallback catalog:", describeError(error));
|
|
113
314
|
return null;
|
|
114
315
|
}
|
|
115
316
|
}
|
|
116
|
-
export function listModels() {
|
|
317
|
+
export function listModels(environment = process.env, options = {}) {
|
|
318
|
+
const runtimeEnvironment = effectiveEnvironment(environment);
|
|
319
|
+
if (hasQoderCredential(runtimeEnvironment))
|
|
320
|
+
getCatalogState(runtimeEnvironment, options);
|
|
321
|
+
else if (activeCatalogScope && catalogStates.get(activeCatalogScope)?.liveUpdated) {
|
|
322
|
+
rebuildIndex(catalogStates.get(activeCatalogScope)?.models ?? []);
|
|
323
|
+
}
|
|
324
|
+
else
|
|
325
|
+
rebuildIndex([]);
|
|
117
326
|
return [...MODEL_INDEX.values()].map((m) => ({ ...m, cost: { ...m.cost }, limit: { ...m.limit } }));
|
|
118
327
|
}
|
|
119
|
-
export function getCachedDynamicModels() {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
328
|
+
export function getCachedDynamicModels(environment = process.env, options = {}) {
|
|
329
|
+
const runtimeEnvironment = effectiveEnvironment(environment);
|
|
330
|
+
if (!hasQoderCredential(runtimeEnvironment))
|
|
331
|
+
return null;
|
|
332
|
+
return cloneModels(getCatalogState(runtimeEnvironment, options).models);
|
|
333
|
+
}
|
|
334
|
+
export async function fetchDynamicModels(force = false, environment = process.env, options = {}) {
|
|
335
|
+
const runtimeEnvironment = effectiveEnvironment(environment);
|
|
336
|
+
if (!hasQoderCredential(runtimeEnvironment)) {
|
|
337
|
+
debug("Skipping live model discovery: no Qoder credential is available");
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
const state = getCatalogState(runtimeEnvironment, options);
|
|
341
|
+
if (state.models && !force)
|
|
342
|
+
return cloneModels(state.models);
|
|
343
|
+
if (state.inflightFetch)
|
|
344
|
+
return state.inflightFetch;
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
if (state.lastSuccessAt > 0 && now - state.lastSuccessAt < MODEL_REFRESH_INTERVAL_MS) {
|
|
347
|
+
return cloneModels(state.models);
|
|
348
|
+
}
|
|
349
|
+
if (state.lastFailureAt > 0 && now - state.lastFailureAt < MODEL_RETRY_INTERVAL_MS) {
|
|
350
|
+
return cloneModels(state.models);
|
|
351
|
+
}
|
|
352
|
+
const generation = ++state.requestGeneration;
|
|
353
|
+
const operation = doFetchDynamicModels(state, runtimeEnvironment, options, generation);
|
|
354
|
+
let tracked;
|
|
355
|
+
tracked = operation.finally(() => {
|
|
356
|
+
if (state.inflightFetch === tracked)
|
|
357
|
+
state.inflightFetch = null;
|
|
130
358
|
});
|
|
131
|
-
|
|
359
|
+
state.inflightFetch = tracked;
|
|
360
|
+
return tracked;
|
|
361
|
+
}
|
|
362
|
+
async function writeCacheFile(state, models) {
|
|
363
|
+
let temporary;
|
|
364
|
+
try {
|
|
365
|
+
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
|
|
366
|
+
try {
|
|
367
|
+
chmodSync(STATE_DIR, 0o700);
|
|
368
|
+
}
|
|
369
|
+
catch { /* best-effort */ }
|
|
370
|
+
temporary = join(STATE_DIR, `.models.${state.scope}.${process.pid}.${randomUUID()}.tmp`);
|
|
371
|
+
const payload = {
|
|
372
|
+
version: MODEL_CACHE_VERSION,
|
|
373
|
+
scope: state.scope,
|
|
374
|
+
fetchedAt: Date.now(),
|
|
375
|
+
models,
|
|
376
|
+
};
|
|
377
|
+
await writeFile(temporary, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
|
|
378
|
+
renameSync(temporary, state.cacheFile);
|
|
379
|
+
temporary = undefined;
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
if (temporary) {
|
|
383
|
+
try {
|
|
384
|
+
unlinkSync(temporary);
|
|
385
|
+
}
|
|
386
|
+
catch { /* best-effort */ }
|
|
387
|
+
}
|
|
388
|
+
}
|
|
132
389
|
}
|
|
133
|
-
async function
|
|
134
|
-
|
|
390
|
+
async function processCacheWrite(state) {
|
|
391
|
+
while (state.pendingModelsToWrite !== null) {
|
|
392
|
+
const toWrite = state.pendingModelsToWrite;
|
|
393
|
+
state.pendingModelsToWrite = null;
|
|
394
|
+
try {
|
|
395
|
+
await writeCacheFile(state, toWrite);
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
debug("Model cache write failed:", describeError(error));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
state.activeWritePromise = null;
|
|
402
|
+
}
|
|
403
|
+
function queueModelCacheWriteForState(state, models) {
|
|
404
|
+
state.pendingModelsToWrite = models.map(cloneModel);
|
|
405
|
+
state.activeWritePromise ??= processCacheWrite(state);
|
|
406
|
+
return state.activeWritePromise;
|
|
407
|
+
}
|
|
408
|
+
export function queueModelCacheWrite(models, environment = process.env, options = {}) {
|
|
409
|
+
return queueModelCacheWriteForState(getCatalogState(environment, options), models);
|
|
410
|
+
}
|
|
411
|
+
export async function flushModelCache() {
|
|
412
|
+
const writes = [...catalogStates.values()]
|
|
413
|
+
.map((state) => state.activeWritePromise)
|
|
414
|
+
.filter((promise) => Boolean(promise));
|
|
415
|
+
if (writes.length > 0)
|
|
416
|
+
await Promise.all(writes);
|
|
417
|
+
}
|
|
418
|
+
async function doFetchDynamicModels(state, environment, options, generation) {
|
|
419
|
+
const timeoutMs = normalizeTimeout(options.timeoutMs);
|
|
420
|
+
const deadline = Date.now() + timeoutMs;
|
|
421
|
+
const abortController = new AbortController();
|
|
422
|
+
const timeout = setTimeout(() => abortController.abort(), timeoutMs);
|
|
423
|
+
if (typeof timeout.unref === "function")
|
|
424
|
+
timeout.unref();
|
|
425
|
+
const remaining = () => Math.max(1, deadline - Date.now());
|
|
426
|
+
const canCommit = () => state.requestGeneration === generation && !abortController.signal.aborted;
|
|
135
427
|
try {
|
|
136
|
-
|
|
428
|
+
// The Worker is the normal path and avoids any PATH dependency. If a
|
|
429
|
+
// separately installed qodercli is present, try it in parallel as an
|
|
430
|
+
// automatic compatibility fallback; neither path requires users to run a
|
|
431
|
+
// model-list command themselves.
|
|
432
|
+
const runtimes = [undefined];
|
|
433
|
+
const cli = findQoderCLI();
|
|
434
|
+
if (cli)
|
|
435
|
+
runtimes.push(cli);
|
|
436
|
+
const attempts = runtimes.map((runtimePath) => fetchFromRuntime(runtimePath, environment, options, deadline, abortController.signal));
|
|
437
|
+
let emptySnapshot = false;
|
|
438
|
+
let failures = 0;
|
|
439
|
+
const pending = attempts.map((promise, index) => ({
|
|
440
|
+
index,
|
|
441
|
+
promise: promise.then((models) => ({ index, models })),
|
|
442
|
+
}));
|
|
443
|
+
while (pending.length > 0) {
|
|
444
|
+
const result = await Promise.race(pending.map((item) => item.promise));
|
|
445
|
+
const pendingIndex = pending.findIndex((item) => item.index === result.index);
|
|
446
|
+
if (pendingIndex >= 0)
|
|
447
|
+
pending.splice(pendingIndex, 1);
|
|
448
|
+
if (Array.isArray(result.models)) {
|
|
449
|
+
if (result.models.length > 0) {
|
|
450
|
+
if (!canCommit())
|
|
451
|
+
return cloneModels(state.models);
|
|
452
|
+
const committed = commitDiscoveredModels(state, result.models, generation);
|
|
453
|
+
abortController.abort();
|
|
454
|
+
return committed;
|
|
455
|
+
}
|
|
456
|
+
emptySnapshot = true;
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
failures += 1;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (!canCommit())
|
|
463
|
+
return cloneModels(state.models);
|
|
464
|
+
if (emptySnapshot) {
|
|
465
|
+
state.lastSuccessAt = 0;
|
|
466
|
+
state.lastFailureAt = Date.now();
|
|
467
|
+
debug("Live model catalog was empty; retaining the current catalog");
|
|
468
|
+
}
|
|
469
|
+
if (failures > 0) {
|
|
470
|
+
state.lastFailureAt = Date.now();
|
|
471
|
+
debug(`Live model catalog unavailable through ${failures} runtime attempt(s)`);
|
|
472
|
+
}
|
|
473
|
+
return cloneModels(state.models);
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
if (state.requestGeneration === generation)
|
|
477
|
+
state.lastFailureAt = Date.now();
|
|
478
|
+
debug("Live model catalog unavailable; keeping cached/fallback models:", describeError(error));
|
|
479
|
+
return cloneModels(state.models);
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
clearTimeout(timeout);
|
|
483
|
+
abortController.abort();
|
|
137
484
|
}
|
|
138
|
-
catch { /* best-effort */ }
|
|
139
|
-
const temporary = join(STATE_DIR, `.models.${process.pid}.${randomUUID()}.tmp`);
|
|
140
|
-
await writeFile(temporary, JSON.stringify(models, null, 2) + "\n", { mode: 0o600 });
|
|
141
|
-
renameSync(temporary, MODEL_CACHE_FILE);
|
|
142
485
|
}
|
|
143
|
-
async function
|
|
144
|
-
|
|
145
|
-
if (!hasQoderCredential())
|
|
486
|
+
async function fetchFromRuntime(runtimePath, environment, options, deadline, overallSignal) {
|
|
487
|
+
if (overallSignal.aborted)
|
|
146
488
|
return null;
|
|
147
|
-
let q;
|
|
148
489
|
const abortController = new AbortController();
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
490
|
+
const abortAttempt = () => abortController.abort();
|
|
491
|
+
overallSignal.addEventListener("abort", abortAttempt, { once: true });
|
|
492
|
+
let q;
|
|
493
|
+
const remaining = () => Math.max(1, deadline - Date.now());
|
|
152
494
|
try {
|
|
153
|
-
q =
|
|
495
|
+
q = modelQueryFactory({
|
|
154
496
|
prompt: idlePrompt(abortController.signal),
|
|
155
497
|
options: {
|
|
156
|
-
auth: qoderAuth(),
|
|
498
|
+
auth: qoderAuth(environment),
|
|
157
499
|
model: "auto",
|
|
158
500
|
abortController,
|
|
159
|
-
|
|
160
|
-
|
|
501
|
+
persistSession: false,
|
|
502
|
+
env: environment,
|
|
503
|
+
...(runtimePath ? { pathToQoderCLIExecutable: runtimePath } : {}),
|
|
504
|
+
...(safeOption(options.proxy) ? { proxy: safeOption(options.proxy) } : {}),
|
|
505
|
+
...(safeOption(options.vpcEndpoint) ? { vpcEndpoint: safeOption(options.vpcEndpoint) } : {}),
|
|
506
|
+
...(safeOption(options.cwd) ? { cwd: safeOption(options.cwd) } : {}),
|
|
507
|
+
controlRequestTimeoutMs: Math.max(100, Math.min(5_000, remaining() - 100)),
|
|
508
|
+
closeGraceMs: Math.min(CLEANUP_GRACE_MS, 500),
|
|
161
509
|
},
|
|
162
510
|
});
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// models until a lucky refresh.
|
|
167
|
-
const models = await q.getAvailableModels({ fetchStrategy: "live" });
|
|
168
|
-
if (!Array.isArray(models))
|
|
169
|
-
return null;
|
|
170
|
-
const enabled = selectEnabledModels(models);
|
|
171
|
-
debug(`Model catalog: ${enabled.length} usable of ${models.length} reported`
|
|
172
|
-
+ (enabled.length === 0 ? "" : ` (${enabled.map((m) => m.value).join(", ")})`));
|
|
173
|
-
if (enabled.length === 0)
|
|
174
|
-
return null;
|
|
175
|
-
cachedDynamicModels = enabled.map(mapModelInfo);
|
|
176
|
-
for (const m of cachedDynamicModels)
|
|
177
|
-
addToIndex(m);
|
|
178
|
-
await writeCacheFile(cachedDynamicModels).catch((error) => {
|
|
179
|
-
debug("Model cache write failed:", describeError(error));
|
|
180
|
-
});
|
|
181
|
-
return cachedDynamicModels;
|
|
511
|
+
await withTimeout(q.initializationResult(), remaining(), `Qoder model discovery initialization exceeded ${Math.max(1, deadline - Date.now())}ms`);
|
|
512
|
+
const models = await withTimeout(q.getAvailableModels({ fetchStrategy: "live" }), remaining(), `Qoder model discovery exceeded ${Math.max(1, deadline - Date.now())}ms`);
|
|
513
|
+
return Array.isArray(models) ? models : null;
|
|
182
514
|
}
|
|
183
515
|
catch (error) {
|
|
184
|
-
debug(
|
|
516
|
+
debug(`Model discovery runtime${runtimePath ? " (qodercli fallback)" : " (bundled Worker)"} failed:`, describeError(error));
|
|
185
517
|
return null;
|
|
186
518
|
}
|
|
187
519
|
finally {
|
|
520
|
+
overallSignal.removeEventListener("abort", abortAttempt);
|
|
188
521
|
abortController.abort();
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
catch { /* ignore */ }
|
|
522
|
+
if (q)
|
|
523
|
+
void closeAsyncIterator(q, CLEANUP_GRACE_MS);
|
|
193
524
|
}
|
|
194
525
|
}
|
|
526
|
+
function commitDiscoveredModels(state, models, generation) {
|
|
527
|
+
if (state.requestGeneration !== generation)
|
|
528
|
+
return cloneModels(state.models);
|
|
529
|
+
const enabled = selectEnabledModels(models);
|
|
530
|
+
debug(`Model catalog: ${enabled.length} usable of ${models.length} reported`
|
|
531
|
+
+ (enabled.length === 0 ? "" : ` (${enabled.map((m) => m.value).join(", ")})`));
|
|
532
|
+
// An empty live response is ambiguous (transient auth/scene/server
|
|
533
|
+
// failures are returned this way by some SDK/CLI versions). Keep the last
|
|
534
|
+
// known-good catalog and let the built-ins cover a genuinely empty cache.
|
|
535
|
+
if (enabled.length === 0)
|
|
536
|
+
return cloneModels(state.models);
|
|
537
|
+
const mapped = enabled.map(mapModelInfo);
|
|
538
|
+
state.models = mapped;
|
|
539
|
+
state.liveUpdated = true;
|
|
540
|
+
state.lastSuccessAt = Date.now();
|
|
541
|
+
state.lastFailureAt = 0;
|
|
542
|
+
if (activeCatalogScope === state.scope)
|
|
543
|
+
rebuildIndex(mapped);
|
|
544
|
+
// Cache persistence is deliberately detached from startup registration;
|
|
545
|
+
// a slow filesystem must not hold OpenCode past the discovery deadline.
|
|
546
|
+
void queueModelCacheWriteForState(state, mapped);
|
|
547
|
+
return cloneModels(mapped);
|
|
548
|
+
}
|
|
549
|
+
function normalizeTimeout(value) {
|
|
550
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
|
551
|
+
return FETCH_TIMEOUT_MS;
|
|
552
|
+
return Math.min(FETCH_TIMEOUT_MS, Math.max(250, Math.floor(value)));
|
|
553
|
+
}
|
|
195
554
|
//# sourceMappingURL=models.js.map
|