@ebony-vinyl/dsh-our-free-model 2.8.0 → 2.8.2

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/src/bun-fetch.js CHANGED
@@ -1,15 +1,17 @@
1
- import { accessSync, constants, statSync, writeFileSync, mkdtempSync } from 'node:fs';
1
+ import { accessSync, constants, existsSync, mkdirSync, statSync, writeFileSync, mkdtempSync } from 'node:fs';
2
2
  import { homedir, tmpdir } from 'node:os';
3
3
  import { delimiter, join, resolve } from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { PassThrough, Readable } from 'node:stream';
6
+ import { DATA_DIR_NAME, resolveDshHome } from './store.js';
6
7
 
7
8
  /**
8
9
  * The worker source, inlined so the published package stays inside the
9
10
  * historical updater's 80-file ceiling (adding a second file overran it).
10
- * It is written once to a private temporary directory and reused for the
11
- * process lifetime. It speaks one JSON line on stdin and returns one metadata
12
- * line plus raw response bytes on stdout; no request content is evaluated.
11
+ * It is written once to the plugin data directory and reused for the process
12
+ * lifetime, re-created when an external sweep deletes it. It speaks one JSON
13
+ * line on stdin and returns one metadata line plus raw response bytes on
14
+ * stdout; no request content is evaluated.
13
15
  */
14
16
  const WORKER_SOURCE = `import { Readable } from 'node:stream';
15
17
  import { pipeline } from 'node:stream/promises';
@@ -37,13 +39,34 @@ try {
37
39
 
38
40
  let workerPath;
39
41
 
40
- /** Materialize the inlined worker once; never executes request content. */
42
+ /**
43
+ * Materialize the inlined worker; never executes request content.
44
+ *
45
+ * The file lives under the plugin's own data directory rather than the OS temp
46
+ * directory, and its existence is re-checked on every call. Both details are
47
+ * load-bearing: the path used to be memoized into `tmpdir()`, so a temp sweep
48
+ * that deleted `ofm-bun-worker-*` left every later request in that process
49
+ * failing with `Bun worker failed before completing the response` — bun exits 1
50
+ * on a missing entry script, before it can reach the network — until the whole
51
+ * plugin was restarted. Re-materializing a missing file makes the sweep
52
+ * self-healing, and an unwritable home still falls back to temp so the lane
53
+ * degrades to the previous behavior rather than failing to start.
54
+ */
41
55
  export function bunWorkerPath() {
42
- if (workerPath !== undefined) return workerPath;
43
- const dir = mkdtempSync(join(tmpdir(), 'ofm-bun-worker-'));
44
- const file = join(dir, 'worker.mjs');
45
- writeFileSync(file, WORKER_SOURCE, { mode: 0o600 });
46
- workerPath = file;
56
+ if (workerPath !== undefined && existsSync(workerPath)) return workerPath;
57
+ try {
58
+ const dir = join(resolveDshHome(), DATA_DIR_NAME, 'run');
59
+ mkdirSync(dir, { recursive: true });
60
+ const file = join(dir, 'worker.mjs');
61
+ writeFileSync(file, WORKER_SOURCE, { mode: 0o600 });
62
+ workerPath = file;
63
+ } catch {
64
+ // mkdir may succeed for an existing directory even when writing the worker
65
+ // fails. Both operations must be covered by the fallback.
66
+ const dir = mkdtempSync(join(tmpdir(), 'ofm-bun-worker-'));
67
+ workerPath = join(dir, 'worker.mjs');
68
+ writeFileSync(workerPath, WORKER_SOURCE, { mode: 0o600 });
69
+ }
47
70
  return workerPath;
48
71
  }
49
72
 
package/src/catalog.js CHANGED
@@ -17,8 +17,11 @@ import { baseModelId, isResponsesModel } from './upstream.js'
17
17
  /** Free-tier ids the listing does not mark with a `-free` suffix. `big-pickle`
18
18
  * is the lane's own long-standing free id (9router keeps the same id in its
19
19
  * `KNOWN_FREE_OPENCODE_MODELS` for exactly this reason); models.dev prices it
20
- * at 0/0. */
21
- const ALWAYS_FREE = new Set(['union-alpha', 'space-bunny-free', 'big-pickle'])
20
+ * at 0/0. `union-alpha` (the lane's Messages-wire id) and `space-bunny-free`
21
+ * retired from the live listing in the 2026-10-31 churn and are no longer
22
+ * advertised — they stay classified here so a listing that revives them is
23
+ * still served, and so the wire tests keep an honest Messages vehicle. */
24
+ const ALWAYS_FREE = new Set(['big-pickle', 'union-alpha', 'space-bunny-free'])
22
25
 
23
26
  /**
24
27
  * Local capability baseline. `contextWindow`/`maxOutput` are the provider's
@@ -126,12 +129,36 @@ const FREE_ID_OVERRIDES = {
126
129
  effortPatch: { reasoning_effort: '$effort' },
127
130
  effortOffPatch: { reasoning_effort: 'none' },
128
131
  },
129
- // models.dev: effort low|medium|high|xhigh|max; 1M window, 524288 output.
130
- 'space-bunny-free': {
131
- vision: true, contextWindow: 1048576, maxOutput: 524288,
132
+ // models.dev (2026-11-06 roster): the live free set as the Oct-31 churn left
133
+ // it. Numbers are models.dev's published limits; region-gated ids (beam,
134
+ // crouton, pulse) are marked in REGION_SENSITIVE, not here.
135
+ 'beam-free': {
136
+ vision: false, contextWindow: 1048576, maxOutput: 131072,
137
+ efforts: ['low', 'medium', 'high', 'xhigh', 'max'], effortDefault: 'high',
138
+ effortPatch: { reasoning_effort: '$effort' },
139
+ },
140
+ 'flint-40b-free': {
141
+ vision: false, contextWindow: 262144, maxOutput: 65536,
132
142
  efforts: ['low', 'medium', 'high', 'xhigh', 'max'], effortDefault: 'high',
133
143
  effortPatch: { reasoning_effort: '$effort' },
134
144
  },
145
+ 'pulse-alpha-free': {
146
+ vision: true, contextWindow: 1048576, maxOutput: 131072,
147
+ efforts: ['low', 'high', 'max'], effortDefault: 'high',
148
+ effortPatch: { reasoning_effort: '$effort' },
149
+ },
150
+ // models.dev: 1M window, 270000 output, effort minimal..max (no `none` —
151
+ // thinking does not switch off in practice, so the menu starts at minimal).
152
+ 'crouton-alpha-free': {
153
+ vision: true, contextWindow: 1048576, maxOutput: 262144,
154
+ efforts: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'], effortDefault: 'high',
155
+ effortPatch: { reasoning_effort: '$effort' },
156
+ },
157
+ // models.dev: 262144/262144, reasoning_options empty → no level menu, so the
158
+ // free lane's token ladder applies.
159
+ 'nemotron-3.5-lightning-free': {
160
+ vision: false, contextWindow: 262144, maxOutput: 262144,
161
+ },
135
162
  // models.dev: 1M window, 131072 output, reasoning_options `toggle` only →
136
163
  // no level menu, so the free lane's token ladder applies.
137
164
  'longcat-2.5-preview-free': {
@@ -145,22 +172,19 @@ const FREE_ID_OVERRIDES = {
145
172
  /** Human-facing display names, so a raw upstream id never reaches the picker. */
146
173
  const DISPLAY_NAMES = {
147
174
  'mimo-v2.6-flash-free': 'MiMo V2.6 Flash',
148
- 'mimo-v2.5-free': 'MiMo V2.5',
149
- 'muse-spark-1.3-contributor-free': 'Muse Spark 1.3',
150
- 'muse-spark-1.2-contributor-free': 'Muse Spark 1.2',
151
- 'nemotron-3-ultra-free': 'Nemotron 3 Ultra',
175
+ 'beam-free': 'Beam Free',
176
+ 'flint-40b-free': 'Flint 40B Free',
177
+ 'pulse-alpha-free': 'Pulse Alpha Free',
178
+ 'crouton-alpha-free': 'Crouton Alpha Free',
152
179
  'nemotron-3.5-lightning-free': 'Nemotron 3.5 Lightning',
153
- 'ling-3.0-flash-fin-free': 'Ling 3.0 Flash Fin',
154
- 'space-bunny-free': 'Space Bunny',
155
- 'union-alpha': 'Union Alpha',
156
- 'deepseek-v4-flash-free': 'DeepSeek V4 Flash',
157
180
  'deepseek-v4.1-flash-free': 'DeepSeek V4.1 Flash',
158
181
  'glm-5.3-flash-free': 'GLM 5.3 Flash',
159
- 'jev-1.13-free': 'Jev 1.13',
160
182
  }
161
183
 
162
- /** Ids whose regional availability is known to be egress-dependent. */
163
- const REGION_SENSITIVE = [/^muse.?spark/]
184
+ /** Ids whose regional availability is known to be egress-dependent. The 2.2
185
+ * docs name Beam Free, Crouton Alpha Free and Pulse Alpha Free as region-gated;
186
+ * `muse-spark` kept its marker from the era it served. */
187
+ const REGION_SENSITIVE = [/^muse.?spark/, /^beam/, /crouton/, /pulse-alpha/]
164
188
 
165
189
  /**
166
190
  * Is this id on the免密 lane? The gateway's listing mixes paid and free ids;
@@ -666,3 +690,103 @@ export function reviveKiloCatalog(stored) {
666
690
  export function isKiloEntry(entry) {
667
691
  return entry?.channel === KILO_CHANNEL
668
692
  }
693
+
694
+ // ── the extension lane ─────────────────────────────────────────────────────────
695
+
696
+ /** Catalog tag for models an installed extension serves. */
697
+ export const EXTENSION_CHANNEL = 'extension'
698
+
699
+ /** The built-in lanes an extension may take over (`replaces`). Kept in step
700
+ * with the marketplace's policy and with the runtime's takeover: only `kilo`
701
+ * is actually stood down (`laneStandsDown` in src/core/runtime.js), and the
702
+ * marketplace validator refuses any other claim — listing a lane here that
703
+ * nothing implements would hand an extension the built-in ids and then lose
704
+ * its rows to the built-in they collide with. */
705
+ const REPLACEABLE_LANES = new Set(['kilo'])
706
+
707
+ /** Wire spellings an extension channel may speak. */
708
+ const EXTENSION_WIRES = new Set(['chat', 'responses', 'messages'])
709
+
710
+ /**
711
+ * The catalog id one extension model answers to. Extension models are
712
+ * namespaced (`ext:<plugin>/<model>`) so a third-party roster can never shadow
713
+ * a built-in id by accident — unless the extension `replaces` that lane, in
714
+ * which case its models keep the lane's own ids and the built-in rows stand
715
+ * down (the kilo-free official extension demonstrates exactly this).
716
+ */
717
+ export function extensionModelId(manifestId, modelId, replaces) {
718
+ const clean = String(modelId ?? '').trim()
719
+ if (replaces !== undefined && REPLACEABLE_LANES.has(replaces)) return clean
720
+ return `ext:${String(manifestId ?? '').trim()}/${clean}`
721
+ }
722
+
723
+ /** Is this catalog row served by an installed extension? */
724
+ export function isExtensionEntry(entry) {
725
+ return entry?.channel === EXTENSION_CHANNEL
726
+ }
727
+
728
+ /** Normalize one `listModels`/manifest model row into a catalog entry, or null. */
729
+ function extensionEntryOf(manifest, row) {
730
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) return null
731
+ const modelId = typeof row.id === 'string' ? row.id.trim() : ''
732
+ if (modelId === '' || modelId.length > 128) return null
733
+ const wire = EXTENSION_WIRES.has(row.wire) ? row.wire : 'chat'
734
+ const contextWindow = number(row.contextWindow) ?? 131072
735
+ const entry = {
736
+ id: extensionModelId(manifest.id, modelId, manifest.replaces),
737
+ name: typeof row.name === 'string' && row.name.trim() !== '' ? row.name.trim() : displayModelName(modelId),
738
+ channel: EXTENSION_CHANNEL,
739
+ extensionId: manifest.id,
740
+ extensionName: typeof manifest.name === 'string' ? manifest.name : manifest.id,
741
+ modelId,
742
+ wire,
743
+ vision: row.vision === true,
744
+ reasoning: row.reasoning !== false,
745
+ contextWindow,
746
+ maxOutput: number(row.maxOutput) ?? Math.min(32768, contextWindow),
747
+ canDisableThinking: true,
748
+ regionSensitive: false,
749
+ source: 'manifest',
750
+ }
751
+ if (Array.isArray(row.efforts) && row.efforts.length > 0) {
752
+ const efforts = row.efforts.filter(level => typeof level === 'string' && level !== '').slice(0, 8)
753
+ if (efforts.length > 0) {
754
+ entry.efforts = efforts
755
+ entry.effortDefault = efforts.includes(row.effortDefault) ? row.effortDefault : efforts[efforts.length - 1]
756
+ // The declared level rides the wire as the model's own effort field — the
757
+ // extension names its own spelling via `effortKey` when it is not the
758
+ // common `reasoning_effort`/`reasoning.effort` shapes.
759
+ const key = typeof row.effortKey === 'string' && row.effortKey.trim() !== '' ? row.effortKey.trim() : null
760
+ entry.effortPatch = key !== null ? { [key]: '$effort' } : { reasoning_effort: '$effort' }
761
+ entry.canDisableThinking = efforts.includes('disabled') || efforts.includes('none')
762
+ }
763
+ }
764
+ return entry
765
+ }
766
+
767
+ /**
768
+ * Build the extension lane's catalog rows from one installed channel
769
+ * extension's model list. Rows that repeat an id are dropped.
770
+ */
771
+ export function buildExtensionCatalog(manifest, rows) {
772
+ if (!manifest || typeof manifest !== 'object' || typeof manifest.id !== 'string') return []
773
+ const seen = new Set()
774
+ const entries = []
775
+ for (const row of Array.isArray(rows) ? rows : []) {
776
+ const entry = extensionEntryOf(manifest, row)
777
+ if (entry === null || seen.has(entry.id)) continue
778
+ seen.add(entry.id)
779
+ entries.push(entry)
780
+ }
781
+ return entries
782
+ }
783
+
784
+ /**
785
+ * Which built-in lane an enabled extension takes over, or null. Only one
786
+ * extension may replace a lane; the first enabled claim wins and the rest are
787
+ * ignored until the conflict is resolved.
788
+ */
789
+ export function extensionReplaces(manifest) {
790
+ const replaces = typeof manifest?.replaces === 'string' ? manifest.replaces : ''
791
+ return REPLACEABLE_LANES.has(replaces) ? replaces : null
792
+ }
package/src/core.js CHANGED
@@ -199,15 +199,14 @@ import { fetchKiloListing } from "./kilo.js";
199
199
  import { sessionForConversation } from "./upstream.js";
200
200
  import { isSubscriptionEntry, subscriptionCatalog } from "./subscription-adapter.js";
201
201
  var FALLBACK_CATALOG = buildCatalog([
202
+ "deepseek-v4.1-flash-free",
203
+ "glm-5.3-flash-free",
202
204
  "mimo-v2.6-flash-free",
203
- "mimo-v2.5-free",
204
- "ling-3.0-flash-fin-free",
205
- "nemotron-3-ultra-free",
206
205
  "nemotron-3.5-lightning-free",
207
- "space-bunny-free",
208
- "muse-spark-1.3-contributor-free",
209
- "muse-spark-1.2-contributor-free"
206
+ "nemotron-3.5-super-vl-free",
207
+ "flint-40b-free"
210
208
  ]);
209
+ var LANE_QUOTA_DEFAULT_MS = 6 * 60 * 60 * 1e3;
211
210
  function createRuntimeStores({ dataDir, logger = console }) {
212
211
  if (typeof dataDir !== "string" || !path.isAbsolute(dataDir)) {
213
212
  throw new TypeError("dataDir must be an absolute path");
@@ -252,7 +251,8 @@ function createModelRuntime({
252
251
  },
253
252
  onTopology = () => {
254
253
  },
255
- subscriptionRuntime
254
+ subscriptionRuntime,
255
+ extensionRuntime = null
256
256
  }) {
257
257
  let catalog = materializeCatalog(catalogStore.get().entries ?? []);
258
258
  let sealedCatalog = sealedCredential() === null ? [] : buildEacCatalog(catalogStore.get().sealIds ?? []);
@@ -260,6 +260,7 @@ function createModelRuntime({
260
260
  let aggregateCatalog = [];
261
261
  let subscriptionEntries = [];
262
262
  let comboCatalog = [];
263
+ let extensionCatalog = [];
263
264
  let freeCatalog = catalog;
264
265
  let egress = availability.get().egress ?? null;
265
266
  let gatewayLatency = { ms: 0, at: 0 };
@@ -267,10 +268,13 @@ function createModelRuntime({
267
268
  const lifetime = new AbortController();
268
269
  let egressGeneration = 0;
269
270
  const rebuildCatalog = () => {
271
+ const replacedLane = extensionRuntime?.replacesLane ?? null;
272
+ const laneStandsDown = (entry) => replacedLane === "kilo" && isKiloEntry(entry);
270
273
  const seen = /* @__PURE__ */ new Set();
271
274
  const merged = [];
272
- for (const entry of [...freeCatalog, ...sealedCatalog, ...kiloCatalog, ...aggregateCatalog, ...subscriptionEntries]) {
275
+ for (const entry of [...freeCatalog, ...sealedCatalog, ...kiloCatalog, ...aggregateCatalog, ...extensionCatalog, ...subscriptionEntries]) {
273
276
  if (entry === null || entry === void 0 || seen.has(entry.id)) continue;
277
+ if (laneStandsDown(entry)) continue;
274
278
  seen.add(entry.id);
275
279
  merged.push(entry);
276
280
  }
@@ -287,6 +291,7 @@ function createModelRuntime({
287
291
  const mergeCatalogs = () => {
288
292
  aggregateCatalog = buildAggregateCatalog(settings.get().aggregate ?? {});
289
293
  subscriptionEntries = subscriptionCatalog(subscriptionRuntime);
294
+ extensionCatalog = extensionRuntime === null ? [] : extensionRuntime.catalog ?? [];
290
295
  rebuildCatalog();
291
296
  };
292
297
  mergeCatalogs();
@@ -295,6 +300,7 @@ function createModelRuntime({
295
300
  membership: computeMembership(catalog, availability.get(), settings.get()),
296
301
  settings: settings.get(),
297
302
  attributionUserAgent,
303
+ lane: laneRow(),
298
304
  routing: {
299
305
  quota: subscriptionRuntime?.routingQuota?.() ?? {},
300
306
  usage: Object.fromEntries(catalog.map((row) => [row.id, stats.get().models?.[row.id]?.calls ?? 0]))
@@ -303,11 +309,71 @@ function createModelRuntime({
303
309
  const emitTopology = () => {
304
310
  if (!disposed) onTopology();
305
311
  };
312
+ function laneRow() {
313
+ return availability.get().lane ?? null;
314
+ }
315
+ function setLaneState(laneState, extra = {}) {
316
+ availability.edit((value) => ({ ...value, lane: { state: laneState, at: Date.now(), ...extra } }));
317
+ availability.flush();
318
+ emitTopology();
319
+ }
320
+ function laneGate() {
321
+ const lane = laneRow();
322
+ if (!lane || lane.state === "ok" || lane.state === void 0) return { ok: true };
323
+ if (lane.state === "quota") {
324
+ if (!Number.isFinite(lane.retryAt) || lane.retryAt <= Date.now()) return { ok: true };
325
+ return { ok: false, state: "quota", retryAt: lane.retryAt, message: lane.message ?? "" };
326
+ }
327
+ if (lane.state === "locked") {
328
+ return { ok: false, state: "locked", message: lane.message ?? "" };
329
+ }
330
+ return { ok: true };
331
+ }
332
+ function clearLaneBreaker() {
333
+ const lane = laneRow();
334
+ if (!lane || lane.state === "ok") return false;
335
+ setLaneState("ok");
336
+ return true;
337
+ }
338
+ function recordPassiveResult(model, resultState, extra = {}) {
339
+ availability.edit((value) => ({
340
+ ...value,
341
+ results: {
342
+ ...value.results,
343
+ [model]: { ...value.results?.[model] ?? {}, state: resultState, source: "passive", at: Date.now(), ...extra }
344
+ }
345
+ }));
346
+ availability.flush();
347
+ }
348
+ function noteFreeLaneOutcome({ entry, ok, error } = {}) {
349
+ if (disposed || !entry || entry.channel) return;
350
+ if (ok === true) {
351
+ const lane = laneRow();
352
+ if (lane && lane.state !== "ok") setLaneState("ok");
353
+ recordPassiveResult(entry.id, STATE.available);
354
+ return;
355
+ }
356
+ const code = error?.code;
357
+ if (code === CODE.quota) {
358
+ const retryAfter = Number(error?.providerRetryAfterMs);
359
+ const retryAt = Number.isFinite(retryAfter) && retryAfter > 0 ? Date.now() + retryAfter : Date.now() + LANE_QUOTA_DEFAULT_MS;
360
+ setLaneState("quota", { retryAt, message: String(error?.message ?? "free usage limit reached").slice(0, 300) });
361
+ recordPassiveResult(entry.id, STATE.throttled);
362
+ return;
363
+ }
364
+ if (code === CODE.laneLocked) {
365
+ setLaneState("locked", { message: String(error?.message ?? "the free tier answers this client only").slice(0, 300) });
366
+ recordPassiveResult(entry.id, STATE.unavailable);
367
+ return;
368
+ }
369
+ if (code === CODE.region) recordPassiveResult(entry.id, STATE.regionBlocked);
370
+ }
306
371
  const adapter = new FreeModelAdapter({
307
372
  state,
308
373
  resolveImage,
309
374
  subscriptionRuntime,
310
375
  sealedCredential,
376
+ extensionRuntime,
311
377
  recordUsage: (record) => {
312
378
  if (disposed) return;
313
379
  recordUsage(stats, record);
@@ -316,7 +382,12 @@ function createModelRuntime({
316
382
  recordTurn: (record) => {
317
383
  if (!disposed) recordTurn(stats, record);
318
384
  },
319
- warn: (message) => logger.warn?.(message) ?? logger.log?.(message)
385
+ warn: (message) => logger.warn?.(message) ?? logger.log?.(message),
386
+ freeLaneGate: laneGate,
387
+ onFreeLaneOutcome: noteFreeLaneOutcome,
388
+ onRegionBlocked: (modelId) => {
389
+ if (!disposed) recordPassiveResult(modelId, STATE.regionBlocked);
390
+ }
320
391
  // Automatic probing is disabled by owner policy: a region refusal from a
321
392
  // real turn stays a turn failure and never fires a background probe.
322
393
  });
@@ -324,7 +395,7 @@ function createModelRuntime({
324
395
  let catalogRefreshForced = false;
325
396
  let catalogRefreshProbed = false;
326
397
  async function refreshCatalog(opts) {
327
- const { probe = false, force = false, report = false } = opts ?? {};
398
+ const { probe = false, force = false, report = false, throttleListing = false } = opts ?? {};
328
399
  const result = (value) => report ? value : value.catalog;
329
400
  if (disposed) return result({ catalog, sources: {} });
330
401
  while (catalogRefresh !== null) {
@@ -334,7 +405,7 @@ function createModelRuntime({
334
405
  const value = await shared;
335
406
  if (disposed || (!force || sharedForced) && (!probe || sharedProbed)) return result(value);
336
407
  }
337
- const run = refreshCatalogOnce({ probe, force });
408
+ const run = refreshCatalogOnce({ probe, force, throttleListing });
338
409
  catalogRefresh = run;
339
410
  catalogRefreshForced = force;
340
411
  catalogRefreshProbed = probe;
@@ -348,13 +419,21 @@ function createModelRuntime({
348
419
  }
349
420
  }
350
421
  }
351
- async function refreshCatalogOnce({ probe, force }) {
422
+ const LISTING_MIN_INTERVAL_MS = 10 * 6e4;
423
+ let lastListingAt = 0;
424
+ async function refreshCatalogOnce({ probe, force, throttleListing }) {
352
425
  const sources = {};
353
426
  let ids = [];
354
- try {
355
- ids = parseListing(await fetchListing());
356
- } catch (error) {
357
- if (!disposed) logger.warn?.(`our-free-model: model listing refresh failed (${error?.message ?? error}); keeping the cached catalog`);
427
+ if (throttleListing === true && !force && Date.now() - lastListingAt < LISTING_MIN_INTERVAL_MS && freeCatalog.length > 0) {
428
+ ids = freeCatalog.map((entry) => entry.id);
429
+ sources.free = { ok: true, count: freeCatalog.length, skipped: "recent" };
430
+ } else {
431
+ try {
432
+ ids = parseListing(await fetchListing());
433
+ lastListingAt = Date.now();
434
+ } catch (error) {
435
+ if (!disposed) logger.warn?.(`our-free-model: model listing refresh failed (${error?.message ?? error}); keeping the cached catalog`);
436
+ }
358
437
  }
359
438
  if (disposed) return { catalog, sources };
360
439
  if (ids.length > 0) {
@@ -365,7 +444,7 @@ function createModelRuntime({
365
444
  } else {
366
445
  freeCatalog = materializeCatalog(catalogStore.get().entries ?? []);
367
446
  }
368
- sources.free = { ok: ids.length > 0, count: freeCatalog.length };
447
+ if (sources.free === void 0) sources.free = { ok: ids.length > 0, count: freeCatalog.length };
369
448
  sources.sealed = await refreshSealedRoster();
370
449
  if (disposed) return { catalog, sources };
371
450
  sources.kilo = { ok: await refreshKiloRoster(), count: kiloCatalog.length };
@@ -436,12 +515,13 @@ function createModelRuntime({
436
515
  }
437
516
  let probeRound = null;
438
517
  let probeRoundGeneration = 0;
439
- let probeThrottleStreak = 0;
440
518
  let probeBackoffUntil = 0;
441
- async function runProbeRound() {
519
+ async function runProbeRound(onlyModel) {
442
520
  const generation = egressGeneration;
443
- const probeable = catalog.filter((entry) => !isEacEntry(entry) && !isKiloEntry(entry) && !isAggregateEntry(entry) && !isComboEntry(entry) && !isSubscriptionEntry(entry));
444
- const results = await probeCatalog(probeable, { attributionUserAgent, signal: lifetime.signal }, (id, result) => {
521
+ const probeable = catalog.filter((entry) => !entry.channel && !isComboEntry(entry));
522
+ const targets = typeof onlyModel === "string" && onlyModel !== "" ? probeable.filter((entry) => entry.id === onlyModel) : probeable.filter((entry) => entry.pinned === true).slice(0, 1).length > 0 ? probeable.filter((entry) => entry.pinned === true).slice(0, 1) : probeable.slice(0, 1);
523
+ if (targets.length === 0) return {};
524
+ const results = await probeCatalog(targets, { attributionUserAgent, signal: lifetime.signal }, (id, result) => {
445
525
  if (disposed || generation !== egressGeneration) return;
446
526
  availability.edit((value) => ({
447
527
  ...value,
@@ -449,6 +529,7 @@ function createModelRuntime({
449
529
  ...value.results,
450
530
  [id]: {
451
531
  state: result.state,
532
+ source: "manual",
452
533
  ...result.detail === void 0 ? {} : { detail: result.detail },
453
534
  ...result.ttftMs === void 0 ? {} : { ttftMs: result.ttftMs },
454
535
  latencyMs: result.latencyMs,
@@ -457,28 +538,29 @@ function createModelRuntime({
457
538
  }
458
539
  }
459
540
  }));
460
- }, 2);
541
+ }, 1);
461
542
  if (disposed || generation !== egressGeneration) return {};
462
543
  availability.update({ at: Date.now(), egress });
463
544
  availability.flush();
464
545
  const verdicts = Object.values(results);
465
- if (verdicts.length > 0 && verdicts.every((row) => row.state === STATE.unavailable)) {
466
- logger.warn?.(`our-free-model: the gateway refused all ${verdicts.length} models this round (${verdicts[0].detail ?? "no detail"}); keeping them advertised`);
467
- }
468
546
  const allThrottled = verdicts.length > 0 && verdicts.every((row) => row.state === STATE.throttled);
469
- probeThrottleStreak = allThrottled ? probeThrottleStreak + 1 : 0;
470
- probeBackoffUntil = allThrottled ? Date.now() + Math.min(30 * 2 ** (probeThrottleStreak - 1), 120) * 6e4 : 0;
471
547
  if (allThrottled) {
472
- logger.warn?.(`our-free-model: the probe round hit the lane's quota; availability probes pause for ${Math.round((probeBackoffUntil - Date.now()) / 6e4)} minutes (your own requests are unaffected, and the reprobe button forces a round)`);
548
+ probeBackoffUntil = Date.now() + 30 * 6e4;
549
+ logger.warn?.("our-free-model: the manual probe hit the lane quota; the breaker now carries the window");
550
+ } else {
551
+ probeBackoffUntil = 0;
473
552
  }
474
553
  emitTopology();
475
554
  return results;
476
555
  }
477
- async function refreshAvailability(force = false) {
478
- if (disposed || !force && probeBackoffUntil > Date.now()) return {};
556
+ async function refreshAvailability(force = false, { model } = {}) {
557
+ if (disposed) return {};
558
+ if (!force && probeBackoffUntil > Date.now()) return {};
559
+ const gate = laneGate();
560
+ if (!gate.ok) return { skipped: gate.state, retryAt: gate.retryAt, message: gate.message };
479
561
  if (probeRound !== null && probeRoundGeneration === egressGeneration) return probeRound;
480
562
  probeRoundGeneration = egressGeneration;
481
- const round = runProbeRound();
563
+ const round = runProbeRound(model);
482
564
  probeRound = round;
483
565
  try {
484
566
  return await round;
@@ -501,10 +583,10 @@ function createModelRuntime({
501
583
  egress: seen,
502
584
  at: 0,
503
585
  results: expire(value.results),
504
- manualResults: expire(value.manualResults)
586
+ manualResults: expire(value.manualResults),
587
+ lane: { state: "ok", at: Date.now() }
505
588
  }));
506
589
  availability.flush();
507
- probeThrottleStreak = 0;
508
590
  probeBackoffUntil = 0;
509
591
  emitTopology();
510
592
  logger.info?.(`our-free-model: egress changed to ${seen.ip}${seen.country ? ` (${seen.country})` : ""}; previous availability marked stale`);
@@ -558,6 +640,8 @@ function createModelRuntime({
558
640
  refreshAvailability,
559
641
  watchEgress,
560
642
  fetchListing,
643
+ laneGate,
644
+ clearLaneBreaker,
561
645
  complete: createForwardCompletion({
562
646
  adapter,
563
647
  signal: lifetime.signal,
@@ -581,6 +665,11 @@ function createModelRuntime({
581
665
  emitTopology();
582
666
  return catalog;
583
667
  },
668
+ // An extension install/enable/disable/uninstall lands here: the extension
669
+ // runtime has already rebuilt its own rows, so this is a merge + announce.
670
+ syncExtensionCatalog() {
671
+ return this.syncAggregateCatalog();
672
+ },
584
673
  get catalog() {
585
674
  return catalog;
586
675
  },