dsh-plugin-subscription-oauth 0.8.13 → 0.8.15

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/lib/index.js CHANGED
@@ -243,6 +243,8 @@ export function apply(ctx, config = {}) {
243
243
  };
244
244
  registerSubscriptionRpc(ctx, registry, balanceContext);
245
245
  // Warm each route's live catalog for an install that is already signed in.
246
+ // Discovery uses the stored token as-is (no resolve, no refresh), so a dead grant
247
+ // is skipped silently rather than opening the browser.
246
248
  for (const id of registry.ids()) {
247
249
  void registry.discover(id, false).catch((error) => {
248
250
  onWarn(`${id} startup discovery failed: ${error instanceof Error ? error.message : String(error)}`);
package/lib/protocol.d.ts CHANGED
@@ -324,7 +324,7 @@ export interface RpcMap {
324
324
  value: CcSwitchBalanceResult;
325
325
  };
326
326
  'model-variants': {
327
- payload: DiscoverRequest;
327
+ payload: ModelVariantsRequest;
328
328
  value: ModelVariantsResult;
329
329
  };
330
330
  }
@@ -357,11 +357,23 @@ export type BalanceResult =
357
357
  export interface ModelVariants {
358
358
  /** Fast/lightspeed variant wire id, when the provider reported one. */
359
359
  fast?: string;
360
+ /**
361
+ * The base model the fast variant belongs to.
362
+ *
363
+ * Sent with `fast` so the picker button can switch back off when the selection is
364
+ * already the fast variant, which is not itself a listed row.
365
+ */
366
+ fastBase?: string;
360
367
  /** Image-generation model wire id, when the provider reported one. */
361
368
  image?: string;
362
369
  }
363
370
  /** Result of `model-variants`: the variants for one model id. */
364
371
  export type ModelVariantsResult = ModelVariants | undefined;
372
+ /** Payload of `model-variants`. */
373
+ export interface ModelVariantsRequest {
374
+ provider: string;
375
+ id: string;
376
+ }
365
377
  /** Payload of `deepseek-balance`. */
366
378
  export interface DeepSeekBalanceRequest {
367
379
  /** Client metadata the host's account service expects. */
@@ -14,20 +14,40 @@
14
14
  */
15
15
  import type { UsageWindow } from '../protocol.js';
16
16
  import type { CatalogEntry } from './catalog.js';
17
- /** True when a model id names a specific tier of a family, not a model with a ladder. */
18
- export declare function isTierMarked(id: string): boolean;
19
- /** The base family a tier-marked id belongs to (`gemini-3-flash-agent` → `gemini-3-flash`). */
20
- export declare function baseFamilyOf(id: string): string;
17
+ /** True when a model id names an image-generation model. */
18
+ export declare function isImageModel(id: string): boolean;
19
+ /**
20
+ * The wire id one gemini flash family and effort level map to when no live listing
21
+ * has been parsed yet.
22
+ *
23
+ * The live listing is the authority (see `wireIdFor`); this is the fallback that
24
+ * keeps a cold start — a persisted selection used before discovery ran — from
25
+ * sending a bare family id the endpoint does not serve. The service names tiers
26
+ * inconsistently: the High tier of 3.5 Flash is `gemini-3.5-flash-high` in some
27
+ * groups and `gemini-3.5-flash-agent` in others; 3.8 Flash is
28
+ * `gemini-3.8-flash-tiered`.
29
+ */
30
+ export declare function geminiTierId(base: string, effort: string): string;
21
31
  /** Antigravity's own reasoning ladder, per model family. */
22
32
  export declare function antigravityEffortsFor(model: string): readonly string[];
33
+ /**
34
+ * The wire id one picker row and effort level must dispatch.
35
+ *
36
+ * @param rowId - the row the picker offered.
37
+ * @param effort - the chosen level, when the caller picked one.
38
+ * @returns the wire id, or undefined when no live listing covers this row.
39
+ */
40
+ export declare function wireIdFor(rowId: string, effort?: string): string | undefined;
41
+ /** Collapse families into one row each, as magpie does. */
42
+ export declare function collapseFamilies(raw: CatalogEntry[]): CatalogEntry[];
23
43
  /**
24
44
  * Read the agent-callable catalog from a `fetchAvailableModels` response.
25
45
  *
26
- * Every listed id is a wire id the response itself named, so each row stays
27
- * dispatchable; the filter removes the surfaces the agent envelope cannot call
28
- * (tabs, commit messages, transcription, standalone image generation) and keeps the
29
- * response's own grouping order. A tier-suffixed id advertises no effort ladder,
30
- * because its suffix already fixes the tier.
46
+ * The response's own grouping decides which ids the agent envelope may call; this
47
+ * removes the surfaces it cannot (tabs, commit messages, transcription, standalone
48
+ * image generation) and keeps the response's own order. Tiers of one family then
49
+ * collapse into the single picker row that serves them, with a wire-id map so the
50
+ * request still names an id the account listed (see `wireIdFor`).
31
51
  *
32
52
  * @param payload - the decoded response.
33
53
  * @returns the catalog entries, in the response's order.
@@ -12,6 +12,7 @@
12
12
  *
13
13
  * @module dsh-plugin-subscription-oauth/providers/antigravity-catalog
14
14
  */
15
+ import { baseCandidatesOf } from './grok.js';
15
16
  /** Effort rungs that fold into one picker row. */
16
17
  const EFFORTS = ['low', 'medium', 'high'];
17
18
  /** True for a plain object usable as a JSON record. */
@@ -48,19 +49,33 @@ function modelInfo(raw) {
48
49
  ...raw.supportsImages === true ? { supportsImages: true } : {},
49
50
  };
50
51
  }
51
- /** A model id whose suffix or infix names a fixed reasoning tier, folded into its base family. */
52
- const TIER_MARKED = /(?:^|[-_])(?:extra-low|low|medium|high|max)(?:$|[-_])/i;
53
- /** True when a model id names a specific tier of a family, not a model with a ladder. */
54
- export function isTierMarked(id) {
55
- return TIER_MARKED.test(id);
52
+ /** Model ids that are image-generation models, never picker rows. */
53
+ const IMAGE_MODEL = /(?:^|[-_])(?:image|imagine|draw|pic|photo)(?:[-_]|$)/i;
54
+ /** True when a model id names an image-generation model. */
55
+ export function isImageModel(id) {
56
+ return IMAGE_MODEL.test(id);
56
57
  }
57
- /** The base family a tier-marked id belongs to (`gemini-3-flash-agent` → `gemini-3-flash`). */
58
- export function baseFamilyOf(id) {
59
- // Remove the tier token wherever it sits (suffix or infix), then tidy any trailing
60
- // separator the removal left behind.
61
- return id
62
- .replace(/[-_](?:extra-low|low|medium|high|max)(?=[-_]|$)/i, '')
63
- .replace(/[-_]$/, '');
58
+ /**
59
+ * The wire id one gemini flash family and effort level map to when no live listing
60
+ * has been parsed yet.
61
+ *
62
+ * The live listing is the authority (see `wireIdFor`); this is the fallback that
63
+ * keeps a cold start — a persisted selection used before discovery ran — from
64
+ * sending a bare family id the endpoint does not serve. The service names tiers
65
+ * inconsistently: the High tier of 3.5 Flash is `gemini-3.5-flash-high` in some
66
+ * groups and `gemini-3.5-flash-agent` in others; 3.8 Flash is
67
+ * `gemini-3.8-flash-tiered`.
68
+ */
69
+ export function geminiTierId(base, effort) {
70
+ if (effort === 'high') {
71
+ if (base === 'gemini-3.5-flash')
72
+ return 'gemini-3.5-flash-agent';
73
+ if (base === 'gemini-3.8-flash')
74
+ return 'gemini-3.8-flash-tiered';
75
+ if (base === 'gemini-3-flash')
76
+ return 'gemini-3-flash-agent';
77
+ }
78
+ return `${base}-${effort}`;
64
79
  }
65
80
  /** Antigravity's own reasoning ladder, per model family. */
66
81
  export function antigravityEffortsFor(model) {
@@ -72,6 +87,180 @@ export function antigravityEffortsFor(model) {
72
87
  return ['low', 'medium', 'high'];
73
88
  return [];
74
89
  }
90
+ // ── magpie-style family collapse ─────────────────────────────────────────
91
+ /** One effort word of Antigravity's ids and the level it means. */
92
+ const EFFORT_WORDS = [
93
+ { word: 'extra-high', level: 'xhigh' },
94
+ { word: 'extra-low', level: 'minimal' },
95
+ { word: 'xhigh', level: 'xhigh' },
96
+ { word: 'minimal', level: 'minimal' },
97
+ // A flash family's High tier is spelled `-high`, `-tiered` or `-agent` depending on
98
+ // the generation, so all three mean the same rung of the ladder.
99
+ { word: 'tiered', level: 'high' },
100
+ { word: 'agent', level: 'high' },
101
+ { word: 'low', level: 'low' },
102
+ { word: 'medium', level: 'medium' },
103
+ { word: 'high', level: 'high' },
104
+ { word: 'max', level: 'max' },
105
+ ];
106
+ /** A ladder's canonical order. */
107
+ const LEVEL_ORDER = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
108
+ /** An id's family and the level its effort word says, '' when it has none. */
109
+ function splitEffortId(id) {
110
+ for (const { word, level } of EFFORT_WORDS) {
111
+ if (id.endsWith(`-${word}`)) {
112
+ const base = id.slice(0, id.length - word.length - 1);
113
+ if (base.length > 0)
114
+ return { family: base, effort: level };
115
+ }
116
+ }
117
+ return { family: id, effort: '' };
118
+ }
119
+ /** The level a display name's bracketed suffix says ("Gemini 3.7 Flash (High)" → high). */
120
+ function nameEffort(name) {
121
+ const open = name.lastIndexOf(' (');
122
+ if (open < 0 || !name.endsWith(')'))
123
+ return { base: name, effort: '' };
124
+ const inner = name.slice(open + 2, -1).toLowerCase();
125
+ let effort = '';
126
+ if (inner === 'minimal' || inner === 'extra low')
127
+ effort = 'minimal';
128
+ else if (inner === 'low')
129
+ effort = 'low';
130
+ else if (inner === 'medium')
131
+ effort = 'medium';
132
+ else if (inner === 'high')
133
+ effort = 'high';
134
+ else if (inner === 'extra high' || inner === 'xhigh')
135
+ effort = 'xhigh';
136
+ if (effort === '')
137
+ return { base: name, effort: '' };
138
+ return { base: name.slice(0, open).trim(), effort };
139
+ }
140
+ /**
141
+ * Group raw entries into the families the service names.
142
+ *
143
+ * Grouping follows the display name, not the id, because the service names the family
144
+ * and lets the wire id drift: the High tier of Gemini 3.5 Flash is
145
+ * `gemini-3-flash-agent` — an id that names a different generation than the name it
146
+ * carries. A level is stated by the name's own bracket when it has one, and by the
147
+ * id's effort word otherwise; the family a variant belongs to is the longest listed
148
+ * prefix of its own id, so `gemini-3.5-flash-low` folds into whichever listed row its
149
+ * prefix names with no hand-written family map.
150
+ *
151
+ * @param raw - one entry per wire id the listing exposed.
152
+ * @returns the families, in the order their first member appeared.
153
+ */
154
+ function familiesOf(raw) {
155
+ const byName = new Map();
156
+ const order = [];
157
+ const listed = new Set(raw.map((entry) => entry.id));
158
+ const familyOf = (id, fallback) => {
159
+ for (const base of baseCandidatesOf(id)) {
160
+ if (listed.has(base))
161
+ return base;
162
+ }
163
+ return fallback;
164
+ };
165
+ for (const entry of raw) {
166
+ const { base: name, effort: named } = nameEffort(entry.name);
167
+ const { family: idBase, effort: idLevel } = splitEffortId(entry.id);
168
+ // The family's key: the name's bracket-free base, when the name states a level, so
169
+ // "Gemini 3.5 Flash (Medium)" is always that family even if no listed id starts
170
+ // with `gemini-3.5-flash`; otherwise the longest listed prefix of the id itself.
171
+ const familyBase = named !== '' ? name : (idBase !== entry.id ? familyOf(entry.id, name) : name);
172
+ let f = byName.get(familyBase);
173
+ if (f === undefined) {
174
+ f = { name: familyBase, variants: [], base: '' };
175
+ byName.set(familyBase, f);
176
+ order.push(f);
177
+ }
178
+ if (f.base === '' && idBase !== entry.id)
179
+ f.base = idBase;
180
+ f.variants.push({
181
+ id: entry.id,
182
+ contextWindow: entry.contextWindow,
183
+ inputModalities: entry.inputModalities ?? ['text'],
184
+ level: named !== '' ? named : idLevel,
185
+ });
186
+ }
187
+ return order;
188
+ }
189
+ /** The rungs a family defaults to, best first, when no variant is the bare model. */
190
+ const DEFAULT_ORDER = ['xhigh', 'high', 'medium', 'low', 'minimal', 'max'];
191
+ /** The variant whose id the family's row carries: the bare model, else its top rung. */
192
+ function defaultVariant(f) {
193
+ const bare = f.variants.find((v) => v.level === '');
194
+ if (bare !== undefined)
195
+ return bare;
196
+ for (const level of DEFAULT_ORDER) {
197
+ const found = f.variants.find((v) => v.level === level);
198
+ if (found !== undefined)
199
+ return found;
200
+ }
201
+ return f.variants[0];
202
+ }
203
+ /**
204
+ * The wire id each picker row must send per effort level, as the last parsed family
205
+ * listing reported it.
206
+ *
207
+ * Collapsing gives the picker one row per family, but the endpoint only serves the
208
+ * suffixed ids the listing named: the High tier of Gemini 3.5 Flash is
209
+ * `gemini-3-flash-agent`, and `gpt-oss-120b` only exists as `gpt-oss-120b-medium`.
210
+ * Nothing here is invented — a level the listing did not name falls back to the
211
+ * family's own default id, and only a tiered gemini family may synthesise the tier
212
+ * spelling the endpoint accepts (see `geminiTierId`).
213
+ */
214
+ const wireIds = new Map();
215
+ /**
216
+ * The wire id one picker row and effort level must dispatch.
217
+ *
218
+ * @param rowId - the row the picker offered.
219
+ * @param effort - the chosen level, when the caller picked one.
220
+ * @returns the wire id, or undefined when no live listing covers this row.
221
+ */
222
+ export function wireIdFor(rowId, effort) {
223
+ const row = wireIds.get(rowId);
224
+ if (row === undefined)
225
+ return undefined;
226
+ const chosen = effort === undefined ? undefined : row.levels.get(effort);
227
+ if (chosen !== undefined)
228
+ return chosen;
229
+ // A tiered gemini family the listing exposed only one way still takes its own tier
230
+ // spelling for the three levels this build knows (`gemini-3.8-flash` + low →
231
+ // `gemini-3.8-flash-low`). Nothing else is synthesised.
232
+ const tiered = effort === 'low' || effort === 'medium' || effort === 'high';
233
+ if (tiered && row.base !== '' && row.base !== rowId)
234
+ return geminiTierId(row.base, effort);
235
+ return row.levels.get('') ?? rowId;
236
+ }
237
+ /** Collapse families into one row each, as magpie does. */
238
+ export function collapseFamilies(raw) {
239
+ const out = [];
240
+ for (const f of familiesOf(raw)) {
241
+ const levels = new Map();
242
+ for (const v of f.variants)
243
+ if (!levels.has(v.level))
244
+ levels.set(v.level, v.id);
245
+ const def = defaultVariant(f);
246
+ levels.set('', def.id);
247
+ wireIds.set(def.id, { levels, base: f.base });
248
+ const aliases = f.variants.map((v) => v.id).filter((id) => id !== def.id);
249
+ out.push({
250
+ // The row keeps an id the account listed, so a selection made from it stays
251
+ // dispatchable; the name is what the picker shows.
252
+ id: def.id,
253
+ name: f.name,
254
+ ...aliases.length === 0 ? {} : { aliases },
255
+ contextWindow: Math.min(...f.variants.map((v) => v.contextWindow)),
256
+ inputModalities: def.inputModalities,
257
+ // Only the rungs this listing really serves: a level with no id of its own must
258
+ // not be offered as a choice the request would ignore.
259
+ reasoningEfforts: LEVEL_ORDER.filter((level) => levels.has(level)),
260
+ });
261
+ }
262
+ return out;
263
+ }
75
264
  /**
76
265
  * The ids the agent can actually call, in the order the response groups them.
77
266
  *
@@ -154,11 +343,11 @@ const NON_AGENT = /(?:^|[-_])(?:tab|tabs|commit|transcription|audio|embedding|co
154
343
  /**
155
344
  * Read the agent-callable catalog from a `fetchAvailableModels` response.
156
345
  *
157
- * Every listed id is a wire id the response itself named, so each row stays
158
- * dispatchable; the filter removes the surfaces the agent envelope cannot call
159
- * (tabs, commit messages, transcription, standalone image generation) and keeps the
160
- * response's own grouping order. A tier-suffixed id advertises no effort ladder,
161
- * because its suffix already fixes the tier.
346
+ * The response's own grouping decides which ids the agent envelope may call; this
347
+ * removes the surfaces it cannot (tabs, commit messages, transcription, standalone
348
+ * image generation) and keeps the response's own order. Tiers of one family then
349
+ * collapse into the single picker row that serves them, with a wire-id map so the
350
+ * request still names an id the account listed (see `wireIdFor`).
162
351
  *
163
352
  * @param payload - the decoded response.
164
353
  * @returns the catalog entries, in the response's order.
@@ -167,54 +356,21 @@ export function parseAntigravityModels(payload) {
167
356
  if (!isRecord(payload) || !isRecord(payload.models))
168
357
  return [];
169
358
  const models = payload.models;
170
- // TEMP diagnostics: write the raw model-id list to a file so an over-eager filter is
171
- // inspectable after a discovery run, since the node half's stdout is unreachable in
172
- // the desktop shell.
173
- try {
174
- const all = Object.keys(models);
175
- const kept = agentIds(payload);
176
- const { writeFileSync } = require('node:fs');
177
- const { homedir } = require('node:os');
178
- const { join } = require('node:path');
179
- writeFileSync(join(homedir(), '.dsh', 'antigravity-models-debug.json'), JSON.stringify({ all, kept, dropped: all.filter((id) => !kept.includes(id)) }, null, 2), 'utf8');
180
- }
181
- catch { /* diagnostics never break parsing */ }
182
- const entries = [];
183
- const seenBase = new Set();
359
+ const raw = [];
184
360
  for (const wireId of agentIds(payload)) {
185
- const info = modelInfo(models[wireId]);
186
- // Tier-marked ids (gemini-3.5-flash-low, gemini-3-flash-agent, …) are the family's
187
- // fixed tiers; they fold into the base family row so the picker shows one entry with
188
- // a ladder, not one row per tier. The base row is inserted the first time a tier of
189
- // it is seen.
190
- if (isTierMarked(wireId)) {
191
- const base = baseFamilyOf(wireId);
192
- if (seenBase.has(base))
193
- continue;
194
- seenBase.add(base);
195
- const baseInfo = modelInfo(models[base]) ?? info;
196
- entries.push({
197
- id: base,
198
- name: displayNameOf(base, models[base] ?? {}),
199
- contextWindow: baseInfo.maxTokens ?? baseInfo.maxInputTokens ?? baseInfo.inputTokenLimit ?? 1_048_576,
200
- inputModalities: baseInfo.supportsImages === true ? ['text', 'image'] : ['text'],
201
- reasoningEfforts: antigravityEffortsFor(base),
202
- });
361
+ // Image-generation models are never picker rows: they are agent-called, always on.
362
+ if (isImageModel(wireId))
203
363
  continue;
204
- }
205
- seenBase.add(wireId);
206
- const tiers = /-(?:low|medium|high)$/.test(wireId);
207
- const efforts = tiers ? [] : antigravityEffortsFor(wireId);
364
+ const info = modelInfo(models[wireId]);
208
365
  const contextWindow = info.maxTokens ?? info.maxInputTokens ?? info.inputTokenLimit ?? 1_048_576;
209
- entries.push({
366
+ raw.push({
210
367
  id: wireId,
211
368
  name: displayNameOf(wireId, models[wireId] ?? {}),
212
369
  contextWindow,
213
370
  inputModalities: info.supportsImages === true ? ['text', 'image'] : ['text'],
214
- ...efforts.length === 0 ? {} : { reasoningEfforts: efforts },
215
371
  });
216
372
  }
217
- return entries;
373
+ return collapseFamilies(raw);
218
374
  }
219
375
  /** A quota reading normalised to a percentage spent. */
220
376
  function usedPercentOf(raw) {
@@ -228,6 +228,8 @@ export declare class AntigravityAdapter extends LlmAdapter {
228
228
  private resetAt;
229
229
  /** Remember an account as spent (its exact reset is learned separately). */
230
230
  private noteSpent;
231
+ /** Resolve the default account's token without the preempt window. */
232
+ private resolveNoPreempt;
231
233
  /** Translate an auth failure into the message a user can act on. */
232
234
  private refreshHint;
233
235
  /** Build and send one streaming request for one model. */
@@ -20,7 +20,7 @@ import { ProviderController } from '../auth/provider-controller.js';
20
20
  import { proxiedFetch } from '../http.js';
21
21
  import { fallbackEntry, findEntry, resolveCatalog } from './catalog.js';
22
22
  import { idleWatchdog, mapFetchFailure, httpLlmError } from './common.js';
23
- import { parseAntigravityModelQuota, parseAntigravityModels, parseAntigravityQuotaSummary, } from './antigravity-catalog.js';
23
+ import { parseAntigravityModelQuota, parseAntigravityModels, parseAntigravityQuotaSummary, geminiTierId, wireIdFor, } from './antigravity-catalog.js';
24
24
  import { toAntigravityRequest, streamAntigravity, antigravityReasoning } from '../translate/antigravity.js';
25
25
  /** Route key this module registers. */
26
26
  export const ANTIGRAVITY_PROVIDER_ID = 'antigravity';
@@ -456,7 +456,14 @@ export class AntigravityAdapter extends LlmAdapter {
456
456
  // `auto` owns the fallback chain now, so this route serves exactly the
457
457
  // model it was asked for — but it draws on every account the provider holds, so a
458
458
  // spent allowance moves to the next account rather than failing the call.
459
- const model = options.model;
459
+ // A picker row is the family, not the wire id: `gemini-3.8-flash` is served as
460
+ // `gemini-3.8-flash-tiered` and `gpt-oss-120b` only exists as
461
+ // `gpt-oss-120b-medium`. The live listing's own mapping is the authority; the
462
+ // built-in tier names are the fallback for a cold start, before discovery ran.
463
+ const model = wireIdFor(options.model, options.reasoningEffort)
464
+ ?? (/^gemini-.*-flash$/.test(options.model)
465
+ ? geminiTierId(options.model, options.reasoningEffort ?? 'medium')
466
+ : options.model);
460
467
  // Accounts come from the manager when it can list them; a minimal manager (or a
461
468
  // test double) without `accounts()` means a single account, which is just the
462
469
  // default one.
@@ -467,7 +474,10 @@ export class AntigravityAdapter extends LlmAdapter {
467
474
  // diagnosable from the browser where the node stdout is hidden.
468
475
  const diag = [`pool=${accounts.join(',') || '(none)'}`];
469
476
  const tried = new Set();
470
- let token = await this.options.tokens.resolve();
477
+ // Resolve without preempt: use the stored token if it is still within its life,
478
+ // refresh only when it has actually expired. The 5-minute preempt was causing
479
+ // a refresh on every stream, which made Google show the consent page.
480
+ let token = await this.resolveNoPreempt();
471
481
  // Stay on the default account until it refuses. opencodex keeps account affinity —
472
482
  // a request stays on one account and only moves after a 429 — because switching
473
483
  // accounts on every request is what reads to Google as two accounts in play at once
@@ -571,6 +581,18 @@ export class AntigravityAdapter extends LlmAdapter {
571
581
  noteSpent(account) {
572
582
  this.options.onWarn?.(`antigravity: account ${account} allowance spent; moving to another account`);
573
583
  }
584
+ /** Resolve the default account's token without the preempt window. */
585
+ async resolveNoPreempt() {
586
+ const found = await this.options.tokens.session();
587
+ if (found === undefined) {
588
+ throw new ProviderError(`${ANTIGRAVITY_PROVIDER_NAME} no account is signed in`, REAUTH_REQUIRED);
589
+ }
590
+ const { account, session } = found;
591
+ if (Date.now() < session.expiresAt) {
592
+ return { account, accessToken: session.accessToken, expiresAt: session.expiresAt };
593
+ }
594
+ return await this.options.tokens.resolve(account, true);
595
+ }
574
596
  /** Translate an auth failure into the message a user can act on. */
575
597
  refreshHint(error) {
576
598
  if (error instanceof ProviderError && error.code === REAUTH_REQUIRED) {
@@ -170,5 +170,9 @@ export function createArkProvider(options) {
170
170
  removeModel: (id) => controller.removeModel(id),
171
171
  usage: (force) => controller.usage(force),
172
172
  reload: () => controller.reload(),
173
+ modelVariants: (id) => {
174
+ const image = adapter.imageModelId();
175
+ return image === undefined ? undefined : { image };
176
+ },
173
177
  };
174
178
  }
@@ -100,10 +100,14 @@ export interface ArkAdapterOptions {
100
100
  export declare class ArkAdapter extends LlmAdapter {
101
101
  private readonly options;
102
102
  private live;
103
+ /** Image-generation model id, when discovery reported one. */
104
+ private imageModel;
103
105
  constructor(options: ArkAdapterOptions);
104
106
  providerInfo(provider: string): LlmProviderInfo;
105
107
  providerRetryPolicy(): undefined;
106
108
  listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
109
+ /** The image-generation model id, when discovery reported one. */
110
+ imageModelId(): string | undefined;
107
111
  resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
108
112
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
109
113
  /** Translate an auth failure into the message a user can act on. */
@@ -23,6 +23,9 @@ import { proxiedFetch } from '../http.js';
23
23
  import { findEntry, resolveCatalog } from './catalog.js';
24
24
  import { streamProviderResponses, accountPoolHooks } from './responses-stream.js';
25
25
  import { toResponsesInput, toResponsesTools } from '../translate/responses.js';
26
+ import { isImageModel } from './grok.js';
27
+ /** Model ids that are clearly not chat models. */
28
+ const NON_CHAT_ID = /(?:^|[-_/])(?:image|video|imagine|embed|embedding|whisper|tts|vision-only)/i;
26
29
  /** Provider route this module serves. */
27
30
  export const ARK_PROVIDER_ID = 'ark';
28
31
  /** Display name for the picker group and the Settings card. */
@@ -113,6 +116,8 @@ function extraEntry(id) {
113
116
  export class ArkAdapter extends LlmAdapter {
114
117
  options;
115
118
  live;
119
+ /** Image-generation model id, when discovery reported one. */
120
+ imageModel;
116
121
  constructor(options) {
117
122
  super();
118
123
  this.options = options;
@@ -129,7 +134,9 @@ export class ArkAdapter extends LlmAdapter {
129
134
  if (await this.options.tokens.session() === undefined)
130
135
  return [];
131
136
  const hidden = await this.hidden();
132
- return resolveCatalog(this.catalog(), [], hidden).entries.map((entry) => ({
137
+ // Image models are never picker rows: they are agent-called, always on.
138
+ const imageless = this.catalog().filter((entry) => !isImageModel(entry.id));
139
+ return resolveCatalog(imageless, [], hidden).entries.map((entry) => ({
133
140
  provider: ARK_PROVIDER_ID,
134
141
  id: entry.id,
135
142
  name: entry.name,
@@ -137,6 +144,10 @@ export class ArkAdapter extends LlmAdapter {
137
144
  ...entry.inputModalities === undefined ? {} : { inputModalities: entry.inputModalities },
138
145
  }));
139
146
  }
147
+ /** The image-generation model id, when discovery reported one. */
148
+ imageModelId() {
149
+ return this.imageModel;
150
+ }
140
151
  async resolveModel(_provider, model) {
141
152
  const entry = findEntry(this.catalog(), model);
142
153
  if (entry === undefined) {
@@ -246,9 +257,12 @@ export class ArkAdapter extends LlmAdapter {
246
257
  return undefined;
247
258
  }
248
259
  const body = await response.json();
249
- const ids = (body.data ?? [])
260
+ const allIds = (body.data ?? [])
250
261
  .map((entry) => (typeof entry?.id === 'string' ? entry.id.trim() : ''))
251
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));
252
266
  return ids.length === 0 ? undefined : ids;
253
267
  }
254
268
  catch (error) {
@@ -166,5 +166,9 @@ export function createGlmProvider(options) {
166
166
  removeModel: (id) => controller.removeModel(id),
167
167
  usage: (force) => controller.usage(force),
168
168
  reload: () => controller.reload(),
169
+ modelVariants: (id) => {
170
+ const image = adapter.imageModelId();
171
+ return image === undefined ? undefined : { image };
172
+ },
169
173
  };
170
174
  }
@@ -120,6 +120,8 @@ export declare class GlmAdapter extends LlmAdapter {
120
120
  private readonly options;
121
121
  /** Live catalog ids, once discovery succeeded this session. */
122
122
  private live;
123
+ /** Image-generation model id, when discovery reported one. */
124
+ private imageModel;
123
125
  constructor(options: GlmAdapterOptions);
124
126
  providerInfo(provider: string): LlmProviderInfo;
125
127
  providerRetryPolicy(): undefined;
@@ -134,6 +136,8 @@ export declare class GlmAdapter extends LlmAdapter {
134
136
  private shippedCatalog;
135
137
  /** Ids the user published by hand that this route does not already serve. */
136
138
  private addedEntries;
139
+ /** The image-generation model id, when discovery reported one. */
140
+ imageModelId(): string | undefined;
137
141
  /** The ids the picker must currently omit. */
138
142
  private hidden;
139
143
  /**
@@ -15,6 +15,9 @@ import { proxiedFetch } from '../http.js';
15
15
  import { findEntry, resolveCatalog } from './catalog.js';
16
16
  import { streamProviderResponses, accountPoolHooks } from './responses-stream.js';
17
17
  import { toResponsesInput, toResponsesTools } from '../translate/responses.js';
18
+ import { isImageModel } from './grok.js';
19
+ /** Model ids that are clearly not chat models. */
20
+ const NON_CHAT_ID = /(?:^|[-_/])(?:image|video|imagine|embed|embedding|whisper|tts|vision-only)/i;
18
21
  /** Provider route this module serves. */
19
22
  export const GLM_PROVIDER_ID = 'glm';
20
23
  /** Display name for the picker group and the Settings card. */
@@ -220,6 +223,8 @@ export class GlmAdapter extends LlmAdapter {
220
223
  options;
221
224
  /** Live catalog ids, once discovery succeeded this session. */
222
225
  live;
226
+ /** Image-generation model id, when discovery reported one. */
227
+ imageModel;
223
228
  constructor(options) {
224
229
  super();
225
230
  this.options = options;
@@ -238,7 +243,9 @@ export class GlmAdapter extends LlmAdapter {
238
243
  if (await this.options.tokens.session() === undefined)
239
244
  return [];
240
245
  const hidden = await this.hidden();
241
- return resolveCatalog(this.catalog(), [], hidden).entries.map((entry) => ({
246
+ // Image models are never picker rows: they are agent-called, always on.
247
+ const imageless = this.catalog().filter((entry) => !isImageModel(entry.id));
248
+ return resolveCatalog(imageless, [], hidden).entries.map((entry) => ({
242
249
  provider: GLM_PROVIDER_ID,
243
250
  id: entry.id,
244
251
  name: entry.name,
@@ -326,6 +333,10 @@ export class GlmAdapter extends LlmAdapter {
326
333
  .filter((id) => findEntry(own, id) === undefined)
327
334
  .map((id) => extraEntry(id));
328
335
  }
336
+ /** The image-generation model id, when discovery reported one. */
337
+ imageModelId() {
338
+ return this.imageModel;
339
+ }
329
340
  /** The ids the picker must currently omit. */
330
341
  async hidden() {
331
342
  if (this.options.hiddenModels === undefined)
@@ -367,9 +378,12 @@ export class GlmAdapter extends LlmAdapter {
367
378
  return undefined;
368
379
  }
369
380
  const body = await response.json();
370
- const ids = (body.data ?? [])
381
+ const allIds = (body.data ?? [])
371
382
  .map((entry) => (typeof entry?.id === 'string' ? entry.id.trim() : ''))
372
383
  .filter((id) => id !== '');
384
+ // Image models are never picker rows: they are agent-called, always on.
385
+ this.imageModel = allIds.find((id) => isImageModel(id));
386
+ const ids = allIds.filter((id) => !NON_CHAT_ID.test(id));
373
387
  return ids.length === 0 ? undefined : ids;
374
388
  }
375
389
  catch (error) {