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.
Files changed (70) hide show
  1. package/CHANGELOG.md +70 -1
  2. package/README.md +112 -45
  3. package/bin/statusline.mjs +30 -6
  4. package/bin/usage.mjs +6 -5
  5. package/dist/async-utils.d.ts +8 -0
  6. package/dist/async-utils.d.ts.map +1 -0
  7. package/dist/async-utils.js +27 -0
  8. package/dist/async-utils.js.map +1 -0
  9. package/dist/auth.d.ts +2 -1
  10. package/dist/auth.d.ts.map +1 -1
  11. package/dist/auth.js +26 -7
  12. package/dist/auth.js.map +1 -1
  13. package/dist/cost.d.ts +2 -1
  14. package/dist/cost.d.ts.map +1 -1
  15. package/dist/cost.js +163 -63
  16. package/dist/cost.js.map +1 -1
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/errors.js +5 -2
  19. package/dist/errors.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +187 -41
  23. package/dist/index.js.map +1 -1
  24. package/dist/language-model.d.ts +25 -0
  25. package/dist/language-model.d.ts.map +1 -1
  26. package/dist/language-model.js +938 -183
  27. package/dist/language-model.js.map +1 -1
  28. package/dist/logger.d.ts +1 -0
  29. package/dist/logger.d.ts.map +1 -1
  30. package/dist/logger.js +35 -5
  31. package/dist/logger.js.map +1 -1
  32. package/dist/mcp-bridge.d.ts.map +1 -1
  33. package/dist/mcp-bridge.js +71 -7
  34. package/dist/mcp-bridge.js.map +1 -1
  35. package/dist/models.d.ts +25 -6
  36. package/dist/models.d.ts.map +1 -1
  37. package/dist/models.js +444 -85
  38. package/dist/models.js.map +1 -1
  39. package/dist/prompt-builder.d.ts.map +1 -1
  40. package/dist/prompt-builder.js +241 -43
  41. package/dist/prompt-builder.js.map +1 -1
  42. package/dist/sdk-auth.d.ts +3 -3
  43. package/dist/sdk-auth.d.ts.map +1 -1
  44. package/dist/sdk-auth.js +13 -9
  45. package/dist/sdk-auth.js.map +1 -1
  46. package/dist/sdk-session.d.ts.map +1 -1
  47. package/dist/sdk-session.js +26 -2
  48. package/dist/sdk-session.js.map +1 -1
  49. package/dist/session-store.d.ts +14 -3
  50. package/dist/session-store.d.ts.map +1 -1
  51. package/dist/session-store.js +451 -46
  52. package/dist/session-store.js.map +1 -1
  53. package/dist/state-dir.d.ts.map +1 -1
  54. package/dist/state-dir.js +2 -1
  55. package/dist/state-dir.js.map +1 -1
  56. package/dist/tool-normalizer.d.ts.map +1 -1
  57. package/dist/tool-normalizer.js +11 -2
  58. package/dist/tool-normalizer.js.map +1 -1
  59. package/dist/tui-register.d.ts.map +1 -1
  60. package/dist/tui-register.js +88 -37
  61. package/dist/tui-register.js.map +1 -1
  62. package/dist/tui.d.ts.map +1 -1
  63. package/dist/tui.js +42 -17
  64. package/dist/tui.js.map +1 -1
  65. package/dist/types.d.ts +16 -4
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/usage.d.ts.map +1 -1
  68. package/dist/usage.js +54 -29
  69. package/dist/usage.js.map +1 -1
  70. 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
- const MODEL_CACHE_FILE = join(STATE_DIR, "models.json");
39
- function addToIndex(m) {
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
- export function getModel(id) {
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
- return models.filter((m) => !!m && typeof m.value === "string" && m.value.length > 0 && m.isEnabled !== false);
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 ?? 1.0;
69
- const context = m.maxInputTokens ?? CONTEXT;
70
- const output = m.maxOutputTokens ?? OUTPUT;
71
- const vl = m.isVl ?? true;
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.displayName,
134
+ name: displayName || m.value,
75
135
  attachment: vl,
76
- reasoning: m.isReasoning ?? false,
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
- let cachedDynamicModels = loadCachedModels();
92
- function loadCachedModels() {
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(MODEL_CACHE_FILE))
296
+ if (!existsSync(state.cacheFile))
95
297
  return null;
96
- const parsed = JSON.parse(readFileSync(MODEL_CACHE_FILE, "utf8"));
97
- if (!Array.isArray(parsed))
298
+ const info = lstatSync(state.cacheFile);
299
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_MODEL_CACHE_BYTES)
98
300
  return null;
99
- const models = parsed.filter((m) => {
100
- if (!m || typeof m !== "object")
101
- return false;
102
- const x = m;
103
- return typeof x.id === "string" && typeof x.name === "string" && typeof x.attachment === "boolean"
104
- && typeof x.reasoning === "boolean" && typeof x.toolCall === "boolean" && typeof x.limit === "object"
105
- && typeof x.cost === "object" && typeof x.modalities === "object";
106
- });
107
- for (const model of models)
108
- addToIndex(model);
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
- return cachedDynamicModels;
121
- }
122
- let inflightFetch = null;
123
- export async function fetchDynamicModels(force = false) {
124
- if (cachedDynamicModels && !force)
125
- return cachedDynamicModels;
126
- if (inflightFetch)
127
- return inflightFetch;
128
- inflightFetch = doFetchDynamicModels().finally(() => {
129
- inflightFetch = null;
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
- return inflightFetch;
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 writeCacheFile(models) {
134
- mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
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
- chmodSync(STATE_DIR, 0o700);
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 doFetchDynamicModels() {
144
- const cli = findQoderCLI();
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 sceneEnv = process.env.QODER_SCENE
150
- ? { env: { QODER_SCENE: process.env.QODER_SCENE } }
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 = query({
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
- ...(cli ? { pathToQoderCLIExecutable: cli } : {}),
160
- ...sceneEnv,
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
- // "live" forces a server refresh inside the CLI and falls back to the
164
- // CLI's cached catalog when the server returns nothing. The previous
165
- // "cache" strategy could serve an empty or stale subset, which hid
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("Live model catalog unavailable; keeping cached/fallback models:", describeError(error));
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
- try {
190
- await q?.return(undefined);
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