dsh-plugin-subscription-oauth 0.16.2 → 0.17.0

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 (43) hide show
  1. package/README.dev.md +1 -1
  2. package/README.md +7 -6
  3. package/README.zh.dev.md +1 -1
  4. package/README.zh.md +7 -6
  5. package/lib/auth/workbuddy-flow.d.ts +1 -1
  6. package/lib/auth/workbuddy-flow.js +14 -9
  7. package/lib/client/index.js +10 -1
  8. package/lib/client.js +6 -1
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +21 -0
  11. package/lib/providers/antigravity.d.ts +34 -4
  12. package/lib/providers/antigravity.js +100 -28
  13. package/lib/providers/ark-provider.d.ts +10 -0
  14. package/lib/providers/ark-provider.js +6 -0
  15. package/lib/providers/ark.d.ts +96 -10
  16. package/lib/providers/ark.js +176 -48
  17. package/lib/providers/catalog.d.ts +91 -0
  18. package/lib/providers/catalog.js +190 -0
  19. package/lib/providers/common.d.ts +14 -0
  20. package/lib/providers/common.js +23 -0
  21. package/lib/providers/glm-provider.d.ts +10 -0
  22. package/lib/providers/glm-provider.js +6 -0
  23. package/lib/providers/glm.d.ts +92 -16
  24. package/lib/providers/glm.js +172 -85
  25. package/lib/providers/grok.d.ts +106 -27
  26. package/lib/providers/grok.js +312 -128
  27. package/lib/providers/kimi-provider.d.ts +10 -0
  28. package/lib/providers/kimi-provider.js +8 -3
  29. package/lib/providers/kimi.d.ts +104 -32
  30. package/lib/providers/kimi.js +198 -129
  31. package/lib/providers/mimo-provider.d.ts +14 -1
  32. package/lib/providers/mimo-provider.js +17 -6
  33. package/lib/providers/mimo.d.ts +164 -19
  34. package/lib/providers/mimo.js +318 -19
  35. package/lib/providers/model-cache.d.ts +57 -0
  36. package/lib/providers/model-cache.js +105 -0
  37. package/lib/providers/responses-stream.d.ts +8 -0
  38. package/lib/providers/responses-stream.js +21 -5
  39. package/lib/providers/workbuddy-provider.d.ts +17 -0
  40. package/lib/providers/workbuddy-provider.js +14 -6
  41. package/lib/providers/workbuddy.d.ts +140 -13
  42. package/lib/providers/workbuddy.js +520 -55
  43. package/package.json +1 -1
@@ -11,7 +11,10 @@
11
11
  * malformed one is told so (`401 The API key format is incorrect`). So the
12
12
  * credential is a UUID key, sent as a plain bearer — no request signing.
13
13
  * - `/api/coding/v3` is documented by the official CLI as "OpenTOP + CodingPlan
14
- * API", and the catalogue below is the plan's roster.
14
+ * API", and its `/models` listing — read once and kept in this plugin's cache, see
15
+ * `hydrate` and `fetchCatalog` — is the roster this route publishes. Nothing in this
16
+ * module decides which models the plan serves, so one released, renamed or retired
17
+ * server-side reaches the picker without a plugin update.
15
18
  *
16
19
  * @module dsh-plugin-subscription-oauth/providers/ark
17
20
  */
@@ -20,10 +23,10 @@ import { REAUTH_REQUIRED, ProviderError } from '../errors.js';
20
23
  import { finiteInstant, nonEmpty } from '../auth/store.js';
21
24
  import { callArkControl, parseArkFreeUsage, parseArkPlanUsage } from '../auth/ark-control.js';
22
25
  import { proxiedFetch } from '../http.js';
23
- import { findEntry, resolveCatalog } from './catalog.js';
26
+ import { composeCatalog, fallbackEntry, findEntry, liveEntry, liveModelFacts, resolveCatalog } from './catalog.js';
24
27
  import { streamProviderResponses, accountPoolHooks } from './responses-stream.js';
25
28
  import { toResponsesInput, toResponsesTools } from '../translate/responses.js';
26
- import { isImageModel } from './grok.js';
29
+ import { baseModelOf, FastVariantIndex, isFastVariant, isImageModel } from './grok.js';
27
30
  /** Model ids that are clearly not chat models. */
28
31
  const NON_CHAT_ID = /(?:^|[-_/])(?:image|video|imagine|embed|embedding|whisper|tts|vision-only)/i;
29
32
  /** Provider route this module serves. */
@@ -43,23 +46,12 @@ export const ARK_DEFAULT_MODEL = 'ark-code-latest';
43
46
  /**
44
47
  * Context capacity advertised for the plan's models.
45
48
  *
46
- * The Coding Plan is a curated, text-first roster and states no window per model;
47
- * discovery refines this where the endpoint reports one.
49
+ * The plan states no window in this build; the endpoint's own listing supplies one
50
+ * where it names a model, and discovery refines this where it does not.
48
51
  */
49
52
  export const ARK_CONTEXT_WINDOW = 262_144;
50
53
  /** Output budget used when the caller sets no cap. */
51
54
  export const ARK_DEFAULT_MAX_TOKENS = 32_000;
52
- /** The plan's model roster, as the reference registry records it. */
53
- export const ARK_MODELS = [
54
- { id: 'ark-code-latest', name: 'Ark Code (latest)', description: 'Plan alias for the current coding model', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text'] },
55
- { id: 'doubao-seed-2.0-code', name: 'Doubao Seed 2.0 Code', description: 'Coding-tuned Doubao', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text'] },
56
- { id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', description: 'Fast DeepSeek', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text'] },
57
- { id: 'glm-5.3', name: 'GLM-5.3', description: 'Zhipu flagship', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text'] },
58
- { id: 'glm-5.3-flash', name: 'GLM-5.3 Flash', description: 'Fast GLM, reads images', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text', 'image'] },
59
- { id: 'glm-5.2', name: 'GLM-5.2', description: 'Previous Zhipu flagship', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text'] },
60
- { id: 'kimi-k2.6', name: 'Kimi K2.6', description: 'Moonshot model on Ark', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text'] },
61
- { id: 'minimax-m3', name: 'MiniMax M3', description: 'MiniMax model on Ark', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text', 'image'] },
62
- ];
63
55
  /**
64
56
  * Coerce one decoded credential entry into an Ark session.
65
57
  *
@@ -108,20 +100,107 @@ function stsFromSession(value) {
108
100
  }
109
101
  return { accessKeyId, secretAccessKey, sessionToken, expiresAt };
110
102
  }
111
- /** An entry for an id this build ships no metadata for. */
112
- function extraEntry(id) {
113
- return { id, name: id, contextWindow: ARK_CONTEXT_WINDOW };
103
+ /**
104
+ * The rows of one cached listing.
105
+ *
106
+ * The cache holds the rows this plugin stored; a payload written as the endpoint's own
107
+ * envelope is read too, so a differently shaped cache cannot silently empty the picker.
108
+ *
109
+ * @param payload - whatever `cachedListing` returned.
110
+ * @returns the rows to adopt, or an empty list when the payload names none.
111
+ */
112
+ function listingRows(payload) {
113
+ if (Array.isArray(payload))
114
+ return payload;
115
+ const data = typeof payload === 'object' && payload !== null
116
+ ? payload.data
117
+ : undefined;
118
+ return Array.isArray(data) ? data : [];
114
119
  }
115
120
  /** The adapter behind the `ark` route. */
116
121
  export class ArkAdapter extends LlmAdapter {
117
122
  options;
123
+ /**
124
+ * The catalog this route serves: entries derived from the endpoint's own listing,
125
+ * either the one just read or the one the cache kept from the last read.
126
+ *
127
+ * Undefined means neither source has produced a listing yet, so only the configured
128
+ * default model is offered until one does.
129
+ */
118
130
  live;
119
- /** Image-generation model id, when discovery reported one. */
131
+ /** Whether the cached listing has been read; it is read at most once per session. */
132
+ hydrated = false;
133
+ /** Image-generation model id, when the listing reported one. */
120
134
  imageModel;
135
+ /**
136
+ * Fast-variant wire id per picker row.
137
+ *
138
+ * Derived from the listing rather than a curated alias map: a row whose name is a
139
+ * listed model plus the provider's own fast word rides that row instead of becoming
140
+ * one of its own.
141
+ */
142
+ fastVariants = new FastVariantIndex();
121
143
  constructor(options) {
122
144
  super();
123
145
  this.options = options;
124
146
  }
147
+ /**
148
+ * Adopt the rows of one live listing as this route's catalog.
149
+ *
150
+ * Everything the picker shows is derived here from the endpoint's own answer: the
151
+ * rows that become picker entries and the fast variant that pairs with each row.
152
+ * Nothing about which models exist, or what each one is, is decided by this build.
153
+ *
154
+ * @param rows - decoded rows of the endpoint's listing, in the endpoint's order.
155
+ * @returns the entries to publish, in picker order.
156
+ */
157
+ adoptListing(rows) {
158
+ const facts = rows
159
+ .map((row) => liveModelFacts(row))
160
+ .filter((fact) => fact !== undefined);
161
+ // Image models are never picker rows: they are agent-called, always on.
162
+ this.imageModel = facts.find((fact) => isImageModel(fact.id))?.id;
163
+ this.fastVariants.clear();
164
+ for (const fact of facts)
165
+ this.fastVariants.noteListed(fact.id);
166
+ for (const fact of facts) {
167
+ const base = this.fastVariants.pair(fact.id);
168
+ this.fastVariants.note(fact.id, () => base ?? baseModelOf(fact.id));
169
+ }
170
+ // Fold a variant onto its row and drop the ids that are not chat models, keeping the
171
+ // endpoint's order. A row's own facts describe the row; a variant's describe the
172
+ // variant, so a row that is also listed is never overwritten by its variant.
173
+ const byId = new Map();
174
+ for (const fact of facts) {
175
+ if (NON_CHAT_ID.test(fact.id))
176
+ continue;
177
+ const id = isFastVariant(fact.id) ? this.fastVariants.pair(fact.id) ?? baseModelOf(fact.id) : fact.id;
178
+ if (id === fact.id || !byId.has(id))
179
+ byId.set(id, { ...fact, id });
180
+ }
181
+ return [...byId.values()].map((fact) => liveEntry(fact, [], ARK_CONTEXT_WINDOW, 'Reported by the Ark model list'));
182
+ }
183
+ /**
184
+ * Read the cached listing, once, so the picker works without asking the endpoint.
185
+ *
186
+ * Called before the first catalog query. A read failure is a cache miss, never a
187
+ * failure: the route simply has no listing until one is fetched.
188
+ */
189
+ async hydrate() {
190
+ if (this.hydrated)
191
+ return;
192
+ this.hydrated = true;
193
+ if (this.live !== undefined || this.options.cachedListing === undefined)
194
+ return;
195
+ try {
196
+ const rows = listingRows(await this.options.cachedListing());
197
+ if (rows.length > 0)
198
+ this.live = this.adoptListing(rows);
199
+ }
200
+ catch (error) {
201
+ this.options.onWarn?.(`cached model catalog unavailable (${error instanceof Error ? error.message : String(error)})`);
202
+ }
203
+ }
125
204
  providerInfo(provider) {
126
205
  return { id: provider, name: ARK_PROVIDER_NAME };
127
206
  }
@@ -133,6 +212,7 @@ export class ArkAdapter extends LlmAdapter {
133
212
  // must not appear in the picker, and Auto must not try it.
134
213
  if (await this.options.tokens.session() === undefined)
135
214
  return [];
215
+ await this.hydrate();
136
216
  const hidden = await this.hidden();
137
217
  // Image models are never picker rows: they are agent-called, always on.
138
218
  const imageless = this.catalog().filter((entry) => !isImageModel(entry.id));
@@ -149,9 +229,10 @@ export class ArkAdapter extends LlmAdapter {
149
229
  return this.imageModel;
150
230
  }
151
231
  async resolveModel(_provider, model) {
232
+ await this.hydrate();
152
233
  const entry = findEntry(this.catalog(), model);
153
234
  if (entry === undefined) {
154
- const template = findEntry(this.catalog(), ARK_DEFAULT_MODEL) ?? this.catalog()[0];
235
+ const template = findEntry(this.catalog(), this.defaultModel()) ?? this.catalog()[0];
155
236
  return {
156
237
  provider: ARK_PROVIDER_ID,
157
238
  id: model,
@@ -200,19 +281,30 @@ export class ArkAdapter extends LlmAdapter {
200
281
  catalog() {
201
282
  return [...this.shippedCatalog(), ...this.addedEntries()];
202
283
  }
284
+ /**
285
+ * The catalog to present: the models the endpoint's listing named, in its own order,
286
+ * each with the metadata that listing stated, then the configured extras.
287
+ *
288
+ * There is no shipped roster. A listing is read once and cached (see {@link hydrate}
289
+ * and {@link fetchCatalog}), and that cached table is what the picker shows until the
290
+ * next read, so a model released, renamed or retired server-side is reflected without
291
+ * a plugin update. Only the configured default is always offered, because the picker
292
+ * preselects it.
293
+ */
203
294
  shippedCatalog() {
204
- const shipped = this.options.extraModels === undefined
205
- ? [...ARK_MODELS]
206
- : [...ARK_MODELS, ...this.options.extraModels.map(extraEntry)];
207
- const live = this.live;
208
- if (live === undefined)
209
- return shipped;
210
- const merged = [...shipped];
211
- for (const id of live) {
212
- if (findEntry(merged, id) === undefined)
213
- merged.push(extraEntry(id));
214
- }
215
- return merged;
295
+ return composeCatalog({
296
+ curated: [],
297
+ ...this.live === undefined ? {} : { live: this.live },
298
+ ...this.options.extraModels === undefined ? {} : { extra: this.options.extraModels },
299
+ defaultModel: this.defaultModel(),
300
+ fallbackContextWindow: ARK_CONTEXT_WINDOW,
301
+ liveDescription: 'Reported by the Ark model list',
302
+ extraDescription: 'Extra model from the plugin configuration',
303
+ });
304
+ }
305
+ /** The model the picker preselects, from configuration. */
306
+ defaultModel() {
307
+ return this.options.defaultModel ?? ARK_DEFAULT_MODEL;
216
308
  }
217
309
  addedEntries() {
218
310
  const own = this.shippedCatalog();
@@ -221,7 +313,7 @@ export class ArkAdapter extends LlmAdapter {
221
313
  return [];
222
314
  return (Array.isArray(added) ? added : [])
223
315
  .filter((id) => findEntry(own, id) === undefined)
224
- .map(extraEntry);
316
+ .map((id) => fallbackEntry(id, 'Added by hand in Settings', ARK_CONTEXT_WINDOW));
225
317
  }
226
318
  async hidden() {
227
319
  if (this.options.hiddenModels === undefined)
@@ -233,15 +325,34 @@ export class ArkAdapter extends LlmAdapter {
233
325
  return new Set();
234
326
  }
235
327
  }
236
- /** Adopt the endpoint's live catalog. */
328
+ /**
329
+ * Adopt the ids a caller reports as the served set, without a listing.
330
+ *
331
+ * {@link fetchCatalog} already derived the entries from the rows it read, and the
332
+ * controller reports those same ids back here; the entries it recorded are kept. A
333
+ * caller that only has ids (a test, another discovery path) gets conservative entries
334
+ * for them.
335
+ *
336
+ * @param ids - model ids the caller reports.
337
+ */
237
338
  discover(ids) {
238
- this.live = [...ids];
339
+ const known = new Map((this.live ?? []).map((entry) => [entry.id, entry]));
340
+ this.live = ids.map((id) => known.get(id)
341
+ ?? liveEntry({ id }, [], ARK_CONTEXT_WINDOW, 'Reported by the Ark model list'));
239
342
  }
240
343
  /**
241
- * Read the plan's live model list.
344
+ * Read the plan's live model list and adopt it as this route's catalog.
345
+ *
346
+ * The Coding Plan answers the OpenAI models shape on its own base, so the rows are
347
+ * parsed exactly as the other routes parse theirs: what the listing names is what the
348
+ * picker offers, with the display name, window, modalities and ladder it states. The
349
+ * rows are also handed to {@link ArkAdapterOptions.saveListing}, so the next session
350
+ * starts from this table instead of asking again — the Settings "refresh models"
351
+ * action is what re-reads it.
242
352
  *
243
- * The Coding Plan answers the OpenAI models shape on its own base; an endpoint
244
- * that does not serve it leaves the shipped roster in place.
353
+ * @param apiKey - bearer for the account.
354
+ * @param signal - optional cancellation.
355
+ * @returns the model ids now served, or undefined when the listing is unavailable.
245
356
  */
246
357
  async fetchCatalog(apiKey, signal) {
247
358
  if (!this.options.discovery)
@@ -253,23 +364,40 @@ export class ArkAdapter extends LlmAdapter {
253
364
  ...signal === undefined ? {} : { signal },
254
365
  });
255
366
  if (!response.ok) {
256
- this.options.onWarn?.(`Ark model list unavailable (HTTP ${response.status})`);
367
+ this.options.onWarn?.(`Ark model list unavailable (HTTP ${response.status}); keeping the last catalog`);
257
368
  return undefined;
258
369
  }
259
- const body = await response.json();
260
- const allIds = (body.data ?? [])
261
- .map((entry) => (typeof entry?.id === 'string' ? entry.id.trim() : ''))
262
- .filter((id) => id !== '');
263
- // Image models are never picker rows: they are agent-called, always on.
264
- this.imageModel = allIds.find((id) => isImageModel(id));
265
- const ids = allIds.filter((id) => !NON_CHAT_ID.test(id));
266
- return ids.length === 0 ? undefined : ids;
370
+ const payload = await response.json();
371
+ const rows = Array.isArray(payload.data) ? payload.data : [];
372
+ const entries = this.adoptListing(rows);
373
+ if (entries.length === 0) {
374
+ this.options.onWarn?.('Ark model discovery returned an empty catalog; keeping the last catalog');
375
+ return undefined;
376
+ }
377
+ this.live = entries;
378
+ // The cached table is the endpoint's answer, kept so the next session does not ask
379
+ // again. A cache failure never fails the read that produced it.
380
+ await this.saveListing(rows);
381
+ return entries.map((entry) => entry.id);
267
382
  }
268
383
  catch (error) {
384
+ if (signal?.aborted === true)
385
+ return undefined;
269
386
  this.options.onWarn?.(`Ark model list unavailable (${error instanceof Error ? error.message : String(error)})`);
270
387
  return undefined;
271
388
  }
272
389
  }
390
+ /** Persist one live listing, reporting a cache failure without failing the read. */
391
+ async saveListing(rows) {
392
+ if (this.options.saveListing === undefined)
393
+ return;
394
+ try {
395
+ await this.options.saveListing(rows);
396
+ }
397
+ catch (error) {
398
+ this.options.onWarn?.(`model catalog cache write failed (${error instanceof Error ? error.message : String(error)})`);
399
+ }
400
+ }
273
401
  /** Send one streaming request over the Responses wire. */
274
402
  async request(options, model, apiKey, signal) {
275
403
  const resolveImage = this.imageResolver(signal);
@@ -54,6 +54,97 @@ export interface ResolvedCatalog {
54
54
  /** Every entry in catalog order, hidden ids included (the Settings list). */
55
55
  ranked: CatalogEntry[];
56
56
  }
57
+ /**
58
+ * One model as a live listing stated it.
59
+ *
60
+ * Every field but the id is optional, and an absent field means the listing was
61
+ * silent rather than that the model lacks the capability: a row states what it
62
+ * states, and a curated entry fills the gaps.
63
+ */
64
+ export interface LiveModelFacts {
65
+ readonly id: string;
66
+ readonly name?: string | undefined;
67
+ readonly description?: string | undefined;
68
+ readonly contextWindow?: number | undefined;
69
+ readonly inputModalities?: readonly ModelModality[] | undefined;
70
+ readonly reasoningEfforts?: readonly string[] | undefined;
71
+ readonly defaultEffort?: string | undefined;
72
+ }
73
+ /**
74
+ * Read the facts one live listing row states.
75
+ *
76
+ * The field names are the ones these providers actually use, including Kimi's
77
+ * `display_name`/`context_length`/`think_efforts` and the OpenAI-compatible
78
+ * `context_window`/`name` spellings; anything unrecognized is left absent rather than
79
+ * guessed, so a curated entry can still describe the model.
80
+ *
81
+ * @param row - one decoded row of a `/models` payload.
82
+ * @returns the facts, or undefined when the row names no model.
83
+ */
84
+ export declare function liveModelFacts(row: unknown): LiveModelFacts | undefined;
85
+ /**
86
+ * The entry to publish for one id the endpoint served.
87
+ *
88
+ * What the endpoint stated wins; the curated table fills only what the listing was
89
+ * silent about, so a live figure — the window a model actually serves today — is
90
+ * never overridden by a shipped guess, and a retired figure never survives a live
91
+ * read that contradicts it.
92
+ *
93
+ * @param facts - what the listing stated for this id.
94
+ * @param curated - the curated metadata table.
95
+ * @param fallbackContextWindow - window to state when neither source names one.
96
+ * @param description - picker description when neither source states one.
97
+ * @returns the merged entry.
98
+ */
99
+ export declare function liveEntry(facts: LiveModelFacts, curated: readonly CatalogEntry[], fallbackContextWindow: number, description: string): CatalogEntry;
100
+ /**
101
+ * How one provider's catalog is composed from its curated table and the entries the
102
+ * endpoint itself reported.
103
+ *
104
+ * The endpoint decides membership. A curated table is metadata, not a roster: the ids
105
+ * in it describe what the build knows about a model's name, window and ladder, and it
106
+ * is the membership only while no live list has been read. That is what lets a model
107
+ * released or retired server-side reach the picker without a plugin update, which a
108
+ * roster can never do.
109
+ */
110
+ export interface CatalogPlan {
111
+ /** Curated entries, in curated order; metadata first, fallback membership second. */
112
+ readonly curated: readonly CatalogEntry[];
113
+ /**
114
+ * Entries the endpoint reported, in its own order.
115
+ *
116
+ * Undefined means discovery has not run (or failed), so the curated table decides
117
+ * membership and the picker is never empty.
118
+ */
119
+ readonly live?: readonly CatalogEntry[] | undefined;
120
+ /** Ids the plugin configuration adds, on top of the membership. */
121
+ readonly extra?: readonly string[] | undefined;
122
+ /** The model the picker preselects, and therefore always offers. */
123
+ readonly defaultModel?: string | undefined;
124
+ /** Context advertised for an id no curated entry describes. */
125
+ readonly fallbackContextWindow: number;
126
+ /** Description shown for a live id no curated entry describes. */
127
+ readonly liveDescription?: string | undefined;
128
+ /** Description shown for a configured id no curated entry describes. */
129
+ readonly extraDescription?: string | undefined;
130
+ /** Description shown for the default model when no curated entry describes it. */
131
+ readonly defaultDescription?: string | undefined;
132
+ }
133
+ /**
134
+ * Compose one provider's published catalog.
135
+ *
136
+ * The membership is the live entries in the endpoint's own order when a listing has
137
+ * been read, and the curated order otherwise. The configured default comes first —
138
+ * the picker preselects it, so it must be selectable — and configured extras last.
139
+ * Metadata always comes from the source that described the id: a live row is never
140
+ * shadowed by the curated table or by the default seed, so what the endpoint states
141
+ * about a model is what the picker shows.
142
+ *
143
+ * @param plan - curated metadata, the live membership, and the fallbacks.
144
+ * @param refine - widens an entry with provider-side metadata, when the provider has it.
145
+ * @returns the entries to publish, in picker order.
146
+ */
147
+ export declare function composeCatalog(plan: CatalogPlan, refine?: (entry: CatalogEntry) => CatalogEntry): CatalogEntry[];
57
148
  /**
58
149
  * Apply the user's model order and visibility to one provider's catalog.
59
150
  *
@@ -42,6 +42,196 @@ export function fallbackEntry(id, description, contextWindow) {
42
42
  defaultEffort: 'max',
43
43
  };
44
44
  }
45
+ /** Read a non-empty string field. */
46
+ function stringField(value) {
47
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
48
+ }
49
+ /** Read a positive integer field. */
50
+ function numberField(value) {
51
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0)
52
+ return Math.floor(value);
53
+ if (typeof value === 'string') {
54
+ const parsed = Number(value);
55
+ if (Number.isFinite(parsed) && parsed > 0)
56
+ return Math.floor(parsed);
57
+ }
58
+ return undefined;
59
+ }
60
+ /**
61
+ * The input modalities a listing states, in the harness vocabulary.
62
+ *
63
+ * Listings name modalities the harness has no word for (`video`, `audio`); those are
64
+ * dropped rather than smuggled through the type, so a capability is advertised only
65
+ * where the harness can act on it.
66
+ */
67
+ function modalitiesOf(row) {
68
+ const stated = [];
69
+ const modalities = row['modalities'];
70
+ if (typeof modalities === 'object' && modalities !== null) {
71
+ const input = modalities['input'];
72
+ if (Array.isArray(input))
73
+ stated.push(...input);
74
+ }
75
+ for (const [field, modality] of [['supports_image_in', 'image'], ['supports_image_input', 'image']]) {
76
+ if (row[field] === true)
77
+ stated.push(modality);
78
+ }
79
+ if (stated.length === 0)
80
+ return undefined;
81
+ const out = [];
82
+ for (const value of stated) {
83
+ if (value === 'text' && !out.includes('text'))
84
+ out.push('text');
85
+ if (value === 'image' && !out.includes('image'))
86
+ out.push('image');
87
+ }
88
+ return out.length === 0 ? undefined : out;
89
+ }
90
+ /** The reasoning ladder a listing states, from either of the shapes these APIs use. */
91
+ function effortsOf(row) {
92
+ const think = row['think_efforts'];
93
+ if (typeof think === 'object' && think !== null) {
94
+ const record = think;
95
+ const valid = Array.isArray(record['valid_efforts'])
96
+ ? record['valid_efforts'].filter((effort) => typeof effort === 'string' && effort !== '')
97
+ : [];
98
+ const fallback = stringField(record['default_effort']);
99
+ return {
100
+ ...valid.length === 0 ? {} : { efforts: valid },
101
+ ...fallback === undefined ? {} : { defaultEffort: fallback },
102
+ };
103
+ }
104
+ const reasoning = row['reasoning'];
105
+ if (typeof reasoning === 'object' && reasoning !== null) {
106
+ const effort = reasoning['effort'];
107
+ if (typeof effort === 'object' && effort !== null) {
108
+ const record = effort;
109
+ const valid = Array.isArray(record['valid'])
110
+ ? record['valid'].filter((value) => typeof value === 'string' && value !== '')
111
+ : [];
112
+ const fallback = stringField(record['default']);
113
+ return {
114
+ ...valid.length === 0 ? {} : { efforts: valid },
115
+ ...fallback === undefined ? {} : { defaultEffort: fallback },
116
+ };
117
+ }
118
+ }
119
+ return {};
120
+ }
121
+ /**
122
+ * Read the facts one live listing row states.
123
+ *
124
+ * The field names are the ones these providers actually use, including Kimi's
125
+ * `display_name`/`context_length`/`think_efforts` and the OpenAI-compatible
126
+ * `context_window`/`name` spellings; anything unrecognized is left absent rather than
127
+ * guessed, so a curated entry can still describe the model.
128
+ *
129
+ * @param row - one decoded row of a `/models` payload.
130
+ * @returns the facts, or undefined when the row names no model.
131
+ */
132
+ export function liveModelFacts(row) {
133
+ if (typeof row !== 'object' || row === null || Array.isArray(row))
134
+ return undefined;
135
+ const record = row;
136
+ const id = stringField(record['id']) ?? stringField(record['model']);
137
+ if (id === undefined)
138
+ return undefined;
139
+ const name = stringField(record['display_name']) ?? stringField(record['name']);
140
+ const description = stringField(record['description']);
141
+ const limit = record['limit'];
142
+ const contextWindow = numberField(record['context_length'])
143
+ ?? numberField(record['context_window'])
144
+ ?? numberField(record['max_context_length'])
145
+ ?? numberField(typeof limit === 'object' && limit !== null
146
+ ? limit['context']
147
+ : undefined);
148
+ const inputModalities = modalitiesOf(record);
149
+ const { efforts, defaultEffort } = effortsOf(record);
150
+ return {
151
+ id,
152
+ ...name === undefined ? {} : { name },
153
+ ...description === undefined ? {} : { description },
154
+ ...contextWindow === undefined ? {} : { contextWindow },
155
+ ...inputModalities === undefined ? {} : { inputModalities },
156
+ ...efforts === undefined ? {} : { reasoningEfforts: efforts },
157
+ ...defaultEffort === undefined ? {} : { defaultEffort },
158
+ };
159
+ }
160
+ /**
161
+ * The entry to publish for one id the endpoint served.
162
+ *
163
+ * What the endpoint stated wins; the curated table fills only what the listing was
164
+ * silent about, so a live figure — the window a model actually serves today — is
165
+ * never overridden by a shipped guess, and a retired figure never survives a live
166
+ * read that contradicts it.
167
+ *
168
+ * @param facts - what the listing stated for this id.
169
+ * @param curated - the curated metadata table.
170
+ * @param fallbackContextWindow - window to state when neither source names one.
171
+ * @param description - picker description when neither source states one.
172
+ * @returns the merged entry.
173
+ */
174
+ export function liveEntry(facts, curated, fallbackContextWindow, description) {
175
+ const known = findEntry(curated, facts.id);
176
+ const contextWindow = facts.contextWindow ?? known?.contextWindow ?? fallbackContextWindow;
177
+ const name = facts.name ?? known?.name ?? facts.id;
178
+ const summary = facts.description ?? known?.description ?? description;
179
+ const inputModalities = facts.inputModalities ?? known?.inputModalities;
180
+ const reasoningEfforts = facts.reasoningEfforts ?? known?.reasoningEfforts;
181
+ const defaultEffort = facts.defaultEffort ?? known?.defaultEffort;
182
+ return {
183
+ id: known?.id ?? facts.id,
184
+ name,
185
+ description: summary,
186
+ contextWindow,
187
+ ...inputModalities === undefined ? {} : { inputModalities },
188
+ ...reasoningEfforts === undefined ? {} : { reasoningEfforts },
189
+ ...defaultEffort === undefined ? {} : { defaultEffort },
190
+ };
191
+ }
192
+ /**
193
+ * Compose one provider's published catalog.
194
+ *
195
+ * The membership is the live entries in the endpoint's own order when a listing has
196
+ * been read, and the curated order otherwise. The configured default comes first —
197
+ * the picker preselects it, so it must be selectable — and configured extras last.
198
+ * Metadata always comes from the source that described the id: a live row is never
199
+ * shadowed by the curated table or by the default seed, so what the endpoint states
200
+ * about a model is what the picker shows.
201
+ *
202
+ * @param plan - curated metadata, the live membership, and the fallbacks.
203
+ * @param refine - widens an entry with provider-side metadata, when the provider has it.
204
+ * @returns the entries to publish, in picker order.
205
+ */
206
+ export function composeCatalog(plan, refine = (entry) => entry) {
207
+ const membership = plan.live ?? plan.curated;
208
+ const described = new Map(membership.map((entry) => [entry.id, entry]));
209
+ const source = plan.live === undefined
210
+ ? 'Built-in catalog'
211
+ : plan.liveDescription ?? 'Reported by the provider model list';
212
+ const wanted = [];
213
+ if (plan.defaultModel !== undefined && plan.defaultModel !== '') {
214
+ wanted.push({ id: plan.defaultModel, description: plan.defaultDescription ?? 'Default model for this route' });
215
+ }
216
+ for (const entry of membership)
217
+ wanted.push({ id: entry.id, description: source });
218
+ for (const id of plan.extra ?? []) {
219
+ wanted.push({ id, description: plan.extraDescription ?? 'Extra model from the plugin configuration' });
220
+ }
221
+ const rows = [];
222
+ const seen = new Set();
223
+ for (const { id, description } of wanted) {
224
+ const canonical = findEntry(plan.curated, id)?.id ?? id;
225
+ if (seen.has(canonical))
226
+ continue;
227
+ seen.add(canonical);
228
+ const entry = described.get(canonical)
229
+ ?? findEntry(plan.curated, id)
230
+ ?? fallbackEntry(id, description, plan.fallbackContextWindow);
231
+ rows.push(refine({ ...entry, id: canonical }));
232
+ }
233
+ return rows;
234
+ }
45
235
  /**
46
236
  * Apply the user's model order and visibility to one provider's catalog.
47
237
  *
@@ -38,6 +38,20 @@ export declare function idleWatchdog(signal: AbortSignal | undefined, idleMs: nu
38
38
  export declare function httpLlmError(response: Response, label: string): Promise<LlmError>;
39
39
  /** Whether a failure means the stored credential no longer works. */
40
40
  export declare function isMissingOrInvalidCredential(error: unknown): boolean;
41
+ /**
42
+ * The model id a provider's failure names as the one to use instead.
43
+ *
44
+ * A 401/403 is not always about the credential: a provider also answers it when the
45
+ * request names a model id the account does not serve — Kimi says `Your model id does
46
+ * not exist, recognized as other:k3[1m]. Please set model id as \`k3\``. That message
47
+ * is the endpoint's own answer to "which id should I have used", so it is worth acting
48
+ * on rather than reporting: refreshing a healthy token, or marking the account as
49
+ * needing a new login, would both be wrong answers to a model rename.
50
+ *
51
+ * @param message - the failure text.
52
+ * @returns the replacement id, or undefined when the failure is not about the model id.
53
+ */
54
+ export declare function unknownModelReplacement(message: string): string | undefined;
41
55
  /**
42
56
  * Map a thrown transport failure onto a harness error, distinguishing caller
43
57
  * cancellation from an idle timeout from a genuine network fault.
@@ -141,6 +141,29 @@ export function isMissingOrInvalidCredential(error) {
141
141
  }
142
142
  return false;
143
143
  }
144
+ /** Body text that says the request named a model the account does not serve. */
145
+ const UNKNOWN_MODEL = /model id does not exist|unknown model|model (?:id )?not found|does not exist, recognized as/i;
146
+ /** Body text that names the model id to use instead. */
147
+ const MODEL_REPLACEMENT = /set model id as\s*`?([A-Za-z0-9][A-Za-z0-9._:@[\]/-]*)`?/i;
148
+ /**
149
+ * The model id a provider's failure names as the one to use instead.
150
+ *
151
+ * A 401/403 is not always about the credential: a provider also answers it when the
152
+ * request names a model id the account does not serve — Kimi says `Your model id does
153
+ * not exist, recognized as other:k3[1m]. Please set model id as \`k3\``. That message
154
+ * is the endpoint's own answer to "which id should I have used", so it is worth acting
155
+ * on rather than reporting: refreshing a healthy token, or marking the account as
156
+ * needing a new login, would both be wrong answers to a model rename.
157
+ *
158
+ * @param message - the failure text.
159
+ * @returns the replacement id, or undefined when the failure is not about the model id.
160
+ */
161
+ export function unknownModelReplacement(message) {
162
+ if (!UNKNOWN_MODEL.test(message))
163
+ return undefined;
164
+ const replacement = MODEL_REPLACEMENT.exec(message)?.[1];
165
+ return replacement === undefined || replacement === '' ? undefined : replacement;
166
+ }
144
167
  /**
145
168
  * Map a thrown transport failure onto a harness error, distinguishing caller
146
169
  * cancellation from an idle timeout from a genuine network fault.