dsh-connect-agnes-token-plan 0.5.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.
@@ -0,0 +1,1070 @@
1
+ import { c as num, p as str, u as obj } from "./host-config-BNGgm_j7.js";
2
+
3
+ //#region src/host/parsers.ts
4
+ /**
5
+ * Console response parsing and shape-drift detection.
6
+ *
7
+ * The parsers stay forgiving so a poll never throws because a field moved;
8
+ * that forgiveness is also how a platform-side rename becomes a serene "no
9
+ * data yet" screen, so `EXPECTED_SHAPES` + `checkShape` are what let the panel
10
+ * say "the upstream shape changed" instead of "you used nothing".
11
+ *
12
+ * Every function here takes the payload AFTER `console-client.ts` unwrapped
13
+ * the `{code, message, data}` envelope — that is, it sees `data`, not the
14
+ * wrapper. The one exception is `parsePlans`, whose `data` IS the array.
15
+ * @module dsh-connect-agnes-token-plan/parsers
16
+ */
17
+ /**
18
+ * The top-level keys each console contract is expected to carry.
19
+ *
20
+ * The parsers below stay forgiving so that a poll never throws because a field
21
+ * moved. That forgiveness is also how a platform-side rename becomes a serene
22
+ * "no data yet" screen, so this declaration is what lets the panel say
23
+ * "the upstream shape changed" instead of "you used nothing".
24
+ *
25
+ * `subscription` deliberately lists NOTHING: no session token was available to
26
+ * observe that payload (see `docs/AGNES-API.md`), and inventing an
27
+ * expectation for a contract nobody has seen would report drift on every poll.
28
+ * The plan catalogue is checked separately — its `data` is an ARRAY, which
29
+ * `checkShape` cannot describe.
30
+ */
31
+ const EXPECTED_SHAPES = Object.freeze({
32
+ "usage-overview": ["total_requests", "total_tokens"],
33
+ "usage-series": ["items"],
34
+ "subscription": []
35
+ });
36
+ /**
37
+ * Parse one numeric field the console returns as a string or a number.
38
+ *
39
+ * Agnes mixes the two: plan limits arrive as real JSON numbers, while usage
40
+ * counters have been seen as strings on the sibling gateway routes. `Number`
41
+ * accepts both, and anything unparseable becomes 0 so the panel shows a
42
+ * missing figure rather than `NaN`.
43
+ */
44
+ function countOf(value) {
45
+ const parsed = Number(value);
46
+ return Number.isFinite(parsed) ? parsed : 0;
47
+ }
48
+ /**
49
+ * Read one timestamp as seconds since the epoch, from any shape the platform
50
+ * might send it in: epoch seconds, epoch millis, a decimal string of either,
51
+ * or an ISO-8601 date.
52
+ *
53
+ * The millis-vs-seconds guess is safe because the two ranges do not overlap in
54
+ * practice: a seconds stamp above 1e12 would be the year 33658, and a millis
55
+ * stamp below it would be 1970. Returns `null` for anything else — a missing
56
+ * expiry must read as "unknown", never as 1970.
57
+ */
58
+ function timestampSeconds(value) {
59
+ if (value === void 0 || value === null || value === "") return null;
60
+ if (typeof value === "number") {
61
+ if (!Number.isFinite(value) || value <= 0) return null;
62
+ return Math.floor(value > 0xe8d4a51000 ? value / 1e3 : value);
63
+ }
64
+ const text = String(value).trim();
65
+ if (text === "") return null;
66
+ if (/^\d+(\.\d+)?$/.test(text)) return timestampSeconds(Number(text));
67
+ const parsed = Date.parse(text);
68
+ return Number.isFinite(parsed) ? Math.floor(parsed / 1e3) : null;
69
+ }
70
+ /**
71
+ * Report which expected top-level keys a console payload is missing.
72
+ * @param {unknown} body - the parsed console response (already unwrapped).
73
+ * @param {string} kind - a key of {@link EXPECTED_SHAPES}.
74
+ * @returns {{ok: boolean, missing: string[]}} the drift report.
75
+ */
76
+ function checkShape(body, kind) {
77
+ const expected = EXPECTED_SHAPES[kind] ?? [];
78
+ const source = obj(body);
79
+ const missing = expected.filter((key) => source[key] === void 0);
80
+ return {
81
+ ok: missing.length === 0,
82
+ missing
83
+ };
84
+ }
85
+ /**
86
+ * Normalize `GET /api/usage/overview` into the panel's totals row.
87
+ *
88
+ * These are CUMULATIVE figures for whatever period the platform reports — the
89
+ * parser does not claim which. They are therefore shown as their own facts and
90
+ * are never subtracted from a plan limit: a rolling 5-hour window cannot be
91
+ * derived from a running total, and inventing `limit - total` would print a
92
+ * remaining figure that is simply wrong.
93
+ * @param {unknown} body - the unwrapped `data` object.
94
+ * @returns {{totalRequests: number, totalTokens: number, totalImages: number, totalVideoSeconds: number, activeDays: number}}
95
+ */
96
+ function parseUsageOverview(body) {
97
+ const source = obj(body);
98
+ return {
99
+ totalRequests: countOf(source.total_requests),
100
+ totalTokens: countOf(source.total_tokens),
101
+ totalImages: countOf(source.total_images),
102
+ totalVideoSeconds: countOf(source.total_video_seconds),
103
+ activeDays: countOf(source.active_days)
104
+ };
105
+ }
106
+ /**
107
+ * Normalize `GET /api/usage/series` into per-bucket rows plus their sum.
108
+ *
109
+ * Each `items[]` entry IS one bucket (hour or day, per the platform's own
110
+ * `bucket` label), so unlike the SenseNova trend — where several points made
111
+ * up one model's row — nothing is summed per row. `totals` sums ACROSS buckets
112
+ * because that is a fact the platform does not state for the window, and the
113
+ * panel needs it for its "in the last N days" line.
114
+ *
115
+ * Agnes offers no per-model breakdown at all (the console's own usage page
116
+ * never reads a `model` field), so there is no `models` list to build.
117
+ * @param {unknown} body - the unwrapped `data` object.
118
+ * @param {number} days - the window the Host asked for, echoed for the label.
119
+ * @returns {{days: number, totals: object, buckets: object[]}}
120
+ */
121
+ function parseUsageSeries(body, days) {
122
+ const items = Array.isArray(obj(body).items) ? obj(body).items : [];
123
+ const buckets = [];
124
+ for (const entry of items) {
125
+ const source = obj(entry);
126
+ const bucket = str(source.bucket, "");
127
+ if (bucket === "") continue;
128
+ buckets.push({
129
+ bucket,
130
+ requestCount: countOf(source.request_count),
131
+ textTokens: countOf(source.text_tokens),
132
+ imageCount: countOf(source.image_count),
133
+ videoSeconds: countOf(source.video_seconds)
134
+ });
135
+ }
136
+ buckets.sort((a, b) => a.bucket < b.bucket ? -1 : a.bucket > b.bucket ? 1 : 0);
137
+ return {
138
+ days,
139
+ totals: buckets.reduce((acc, bucket) => ({
140
+ totalRequests: acc.totalRequests + bucket.requestCount,
141
+ totalTokens: acc.totalTokens + bucket.textTokens,
142
+ totalImages: acc.totalImages + bucket.imageCount,
143
+ totalVideoSeconds: acc.totalVideoSeconds + bucket.videoSeconds
144
+ }), {
145
+ totalRequests: 0,
146
+ totalTokens: 0,
147
+ totalImages: 0,
148
+ totalVideoSeconds: 0
149
+ }),
150
+ buckets
151
+ };
152
+ }
153
+ /**
154
+ * Normalize the plan catalogue (`GET /api/cn/user/subscription/plans`).
155
+ *
156
+ * Verified live 2026-10-01 against an anonymous request: six entries —
157
+ * `入门版` / `专业版` / `高级版`, each in a monthly and a yearly variant. The
158
+ * numbers below are exactly the platform's field names; the daily image and
159
+ * video caps are identical across all three tiers (4000 / 500), which is why
160
+ * only the request dimensions actually differentiate the plans.
161
+ *
162
+ * `data` is an ARRAY here, not an object — the only Agnes response shaped that
163
+ * way. An unexpected shape yields `[]`, never a throw.
164
+ * @param {unknown} body - the unwrapped `data` value.
165
+ * @returns {object[]} the catalogue, in the platform's own order.
166
+ */
167
+ function parsePlans(body) {
168
+ return (Array.isArray(body) ? body : []).map((entry) => {
169
+ const source = obj(entry);
170
+ return {
171
+ uuid: str(source.uuid, ""),
172
+ planId: countOf(source.id),
173
+ name: str(source.name, ""),
174
+ displayName: str(source.display_name, str(source.name, "")),
175
+ billingCycle: str(source.billing_cycle, ""),
176
+ displayCycle: str(source.display_cycle, ""),
177
+ priceMinor: countOf(source.price_minor),
178
+ currency: str(source.currency, ""),
179
+ concurrencyLimit: countOf(source.concurrency_limit),
180
+ concurrencyWindowH: countOf(source.concurrency_window_h),
181
+ textWeeklyLimit: countOf(source.text_weekly_limit),
182
+ imageDailyLimit: countOf(source.image_daily_limit),
183
+ videoDailyLimit: countOf(source.video_daily_limit),
184
+ usageLimitText: str(source.usage_limit_text, ""),
185
+ featureTexts: Array.isArray(source.feature_texts) ? source.feature_texts.filter((text) => typeof text === "string") : []
186
+ };
187
+ });
188
+ }
189
+ /**
190
+ * Keys a subscription payload might name the current plan under.
191
+ *
192
+ * Deliberately a fixed list rather than "scan the whole payload for a string
193
+ * that looks like a plan name": the subscription object also carries the
194
+ * user's own `name`, an order list, and possibly a catalogue of every plan, so
195
+ * a whole-payload scan would match `入门版` on an account that is on `高级版`.
196
+ */
197
+ const PLAN_IDENTITY_KEYS = Object.freeze([
198
+ "plan_uuid",
199
+ "planUuid",
200
+ "uuid",
201
+ "plan_name",
202
+ "planName",
203
+ "plan",
204
+ "plan_code",
205
+ "planCode",
206
+ "subscription_plan",
207
+ "subscriptionPlan",
208
+ "product",
209
+ "product_name",
210
+ "productName",
211
+ "name",
212
+ "display_name",
213
+ "displayName",
214
+ "tier"
215
+ ]);
216
+ /** Every scalar anywhere in a value, as strings — used for uuid matching only. */
217
+ function collectScalars(value, depth, out) {
218
+ if (depth > 3) return out;
219
+ if (Array.isArray(value)) {
220
+ for (const item of value) collectScalars(item, depth + 1, out);
221
+ return out;
222
+ }
223
+ if (value && typeof value === "object") {
224
+ for (const item of Object.values(value)) collectScalars(item, depth + 1, out);
225
+ return out;
226
+ }
227
+ if (typeof value === "string" || typeof value === "number") {
228
+ const text = String(value).trim();
229
+ if (text !== "") out.add(text);
230
+ }
231
+ return out;
232
+ }
233
+ /** The values held under {@link PLAN_IDENTITY_KEYS}, at the top two levels. */
234
+ function collectIdentities(value, depth, out) {
235
+ if (depth > 2) return out;
236
+ const source = obj(value);
237
+ for (const [key, entry] of Object.entries(source)) {
238
+ if (PLAN_IDENTITY_KEYS.includes(key) && (typeof entry === "string" || typeof entry === "number")) {
239
+ const text = str(String(entry), "");
240
+ if (text !== "") out.add(text);
241
+ }
242
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) collectIdentities(entry, depth + 1, out);
243
+ }
244
+ return out;
245
+ }
246
+ /**
247
+ * Find the catalogue entry the signed-in account is currently on.
248
+ *
249
+ * Two signals, strongest first:
250
+ *
251
+ * 1. UUID — a 36-character plan uuid may be matched ANYWHERE in the payload,
252
+ * because nothing else on the platform looks like one. This is what the
253
+ * platform's own plan objects use as their stable identity.
254
+ * 2. NAME — matched only against the values held under
255
+ * {@link PLAN_IDENTITY_KEYS}, never against the whole payload.
256
+ *
257
+ * A numeric plan id is deliberately NOT a signal: the ids are 1–6, and a
258
+ * subscription object is full of small integers, so `planId === 1` would
259
+ * "match" the entry tier on almost every account.
260
+ *
261
+ * A name match hits BOTH billing cycles (入门版 exists as monthly and yearly),
262
+ * so ties are broken with whatever cycle the payload states, defaulting to
263
+ * monthly — the cheaper variant, and the one a fresh signup lands on.
264
+ *
265
+ * @param {unknown} subscription - the unwrapped `/api/cn/user/subscription` data.
266
+ * @param {object[]} plans - the {@link parsePlans} result.
267
+ * @returns {object|null} the matching catalogue entry, or null when the payload
268
+ * does not name a plan this plugin can recognise.
269
+ */
270
+ function matchCurrentPlan(subscription, plans) {
271
+ const list = Array.isArray(plans) ? plans.filter((plan) => plan && typeof plan === "object") : [];
272
+ if (list.length === 0 || subscription === void 0 || subscription === null) return null;
273
+ const everywhere = collectScalars(subscription, 0, /* @__PURE__ */ new Set());
274
+ const identities = collectIdentities(subscription, 0, /* @__PURE__ */ new Set());
275
+ const scoreOf = (plan) => {
276
+ if (plan.uuid !== "" && (everywhere.has(plan.uuid) || identities.has(plan.uuid))) return 3;
277
+ for (const value of [plan.name, plan.displayName]) if (value !== "" && identities.has(value)) return 2;
278
+ return 0;
279
+ };
280
+ let bestScore = 0;
281
+ for (const plan of list) bestScore = Math.max(bestScore, scoreOf(plan));
282
+ if (bestScore === 0) return null;
283
+ const tied = list.filter((plan) => scoreOf(plan) === bestScore);
284
+ if (tied.length === 1) return tied[0];
285
+ const stated = [...everywhere].find((value) => value === "monthly" || value === "yearly" || value === "annual");
286
+ const cycle = stated === "annual" ? "yearly" : stated;
287
+ return tied.find((plan) => plan.billingCycle === cycle) ?? tied.find((plan) => plan.billingCycle === "monthly") ?? tied[0];
288
+ }
289
+ /**
290
+ * Read a subscription's expiry, from whichever of the plausible key names the
291
+ * platform actually uses.
292
+ *
293
+ * No session token was available to observe the real field name, so several
294
+ * are tried in order of likelihood. `null` means "the payload did not state
295
+ * one", which the panel renders as nothing rather than as an error.
296
+ * @param {unknown} subscription - the unwrapped subscription data.
297
+ * @returns {number|null} seconds since the epoch, or null.
298
+ */
299
+ function readSubscriptionExpiry(subscription) {
300
+ const source = obj(subscription);
301
+ for (const key of [
302
+ "expires_at",
303
+ "expiresAt",
304
+ "expire_at",
305
+ "expired_at",
306
+ "end_at",
307
+ "end_date",
308
+ "current_period_end",
309
+ "next_billing_at",
310
+ "next_billing_date"
311
+ ]) {
312
+ const value = timestampSeconds(source[key]);
313
+ if (value !== null) return value;
314
+ }
315
+ return null;
316
+ }
317
+ /**
318
+ * Turn one catalogue entry into the panel's quota windows.
319
+ *
320
+ * Agnes caps four dimensions, each a WINDOW rather than a credit balance, so
321
+ * the shape is "N per W hours" and there is no balance to decrement:
322
+ *
323
+ * | key | limit field | window |
324
+ * |---|---|---|
325
+ * | `requests5h` | `concurrency_limit` | `concurrency_window_h` (5 on every plan) |
326
+ * | `requestsWeekly` | `text_weekly_limit` | 168 h |
327
+ * | `imagesDaily` | `image_daily_limit` | 24 h |
328
+ * | `videoDaily` | `video_daily_limit` | 24 h |
329
+ *
330
+ * The platform's own `usage_limit_text` renders the first one as
331
+ * "1500 次模型请求 / 5 小时", which is where the 5-hour window comes from.
332
+ *
333
+ * `unit` is `"requests"` / `"images"` / `"video"` and carries NO seconds claim
334
+ * for video: the field is named `video_daily_limit` while the usage side
335
+ * counts `video_seconds`, and the platform never states which one the limit
336
+ * is in. The panel prints the bare number for that reason.
337
+ *
338
+ * A zero or absent limit is dropped rather than shown as "0 / 日" — an
339
+ * unstated cap must not read as "you may do nothing".
340
+ * @param {unknown} plan - one catalogue entry (or a subscription's own limits).
341
+ * @returns {object[]} the windows, in the order the panel lists them.
342
+ */
343
+ function quotaWindows(plan) {
344
+ const source = obj(plan);
345
+ const windows = [];
346
+ const requests5h = countOf(source.concurrencyLimit);
347
+ if (requests5h > 0) windows.push({
348
+ key: "requests5h",
349
+ unit: "requests",
350
+ limit: requests5h,
351
+ windowHours: countOf(source.concurrencyWindowH) || 5
352
+ });
353
+ const weekly = countOf(source.textWeeklyLimit);
354
+ if (weekly > 0) windows.push({
355
+ key: "requestsWeekly",
356
+ unit: "requests",
357
+ limit: weekly,
358
+ windowHours: 168
359
+ });
360
+ const images = countOf(source.imageDailyLimit);
361
+ if (images > 0) windows.push({
362
+ key: "imagesDaily",
363
+ unit: "images",
364
+ limit: images,
365
+ windowHours: 24
366
+ });
367
+ const video = countOf(source.videoDailyLimit);
368
+ if (video > 0) windows.push({
369
+ key: "videoDaily",
370
+ unit: "video",
371
+ limit: video,
372
+ windowHours: 24
373
+ });
374
+ return windows;
375
+ }
376
+ /**
377
+ * Whether one `GET /v1/models` entry can take image input, and WHY.
378
+ *
379
+ * This is the first step of the vision plan (ARCHITECTURE.md §5.1): the panel
380
+ * shows which of this key's callable models accept pictures, so the user
381
+ * knows which one to ask for image input.
382
+ *
383
+ * Two signals, in priority order:
384
+ *
385
+ * 1. STRUCTURED — the Agnes catalog declares `input_modalities` (an
386
+ * array, e.g. `["text","image"]`) on every entry. This is CONFIRMED the
387
+ * platform ships it (2026-09 probe), so it is the authoritative answer:
388
+ * a model is vision-capable iff `"image"` appears in its input
389
+ * modalities. The name fallback below stops mattering on this platform.
390
+ * `inputTypes` / `modality` / `capabilities` are kept as the fallback for
391
+ * other providers that spell the same idea differently — no parser
392
+ * change needed when they arrive.
393
+ * 2. NAME PATTERN — only when NO structured modality field is present at
394
+ * all: naming conventions for the multimodal/vision families. Marked
395
+ * `source: "name"` so the panel can say "inferred from the name" and
396
+ * never pretend the platform declared it.
397
+ *
398
+ * @param {object} entry - one catalog entry (id + any extra fields).
399
+ * @returns {{"id": string, "vision": boolean, "source": "field"|"name"|null}}
400
+ */
401
+ function identifyVisionModel(entry) {
402
+ const source = obj(entry);
403
+ const id = str(source.id, "");
404
+ const modalities = modalitiesOf(source);
405
+ if (modalities !== void 0) return {
406
+ id,
407
+ vision: modalities.some((modality) => /image/i.test(modality)),
408
+ source: "field"
409
+ };
410
+ const byName = VISION_NAME_PATTERNS.some((pattern) => pattern.test(id));
411
+ return {
412
+ id,
413
+ vision: byName,
414
+ source: byName ? "name" : null
415
+ };
416
+ }
417
+ /**
418
+ * Read the first modality-listing field off a catalog entry, or undefined.
419
+ * The Agnes platform's confirmed field is `input_modalities` (array of
420
+ * strings, e.g. `["text","image"]`); the others are the spellings other
421
+ * providers are expected to use. Accepts string or array values so whatever
422
+ * the platform ships parses.
423
+ * @param {object} source - one catalog entry.
424
+ * @returns {string[]|undefined} the modality names, or undefined.
425
+ */
426
+ function modalitiesOf(source) {
427
+ for (const key of [
428
+ "input_modalities",
429
+ "inputTypes",
430
+ "modality",
431
+ "capabilities"
432
+ ]) {
433
+ const value = source[key];
434
+ if (Array.isArray(value)) return value.map((modality) => String(modality));
435
+ if (typeof value === "string" && value !== "") return value.split(/[,|]/).map((modality) => modality.trim());
436
+ }
437
+ }
438
+ /**
439
+ * Name patterns used ONLY when no structured modality field is present.
440
+ * `flash-lite` was the legacy guess from before the platform confirmed
441
+ * `input_modalities`; it is no longer a reliable signal (the name now maps
442
+ * to a model family whose actual modality mix the platform field decides),
443
+ * so it is dropped from the fallback set.
444
+ */
445
+ const VISION_NAME_PATTERNS = Object.freeze([
446
+ /-vl(-|\b)/i,
447
+ /vision/i,
448
+ /qwen.*vl/i,
449
+ /glm-4v/i
450
+ ]);
451
+
452
+ //#endregion
453
+ //#region src/host/modality.ts
454
+ /**
455
+ * The single source of truth for "what can this catalog entry produce?".
456
+ *
457
+ * WHY THIS MODULE EXISTS — a design premise that did not survive contact with
458
+ * the live gateway.
459
+ *
460
+ * ARCHITECTURE §5.4 decided model identification would be STRUCTURED rather
461
+ * than name-regex, to fix `dsh-draw-router` missing `Agnes-u1.5-lite`. That
462
+ * decision was taken against the SenseNova catalog, whose `/v1/models` entries
463
+ * carry `output_modalities` / `input_modalities` / `context_length` /
464
+ * `supported_features`. The Agnes gateway (new-api lineage — its `success` and
465
+ * `supported_endpoint_types` fields are the tell) returns NONE of them
466
+ * (live-verified 2026-10-01):
467
+ *
468
+ * {"id":"agnes-image-2.5-flash","object":"model","created":1626777600,
469
+ * "owned_by":"custom","supported_endpoint_types":["openai"]}
470
+ *
471
+ * On Agnes the structured premise is therefore void, and BOTH readers of the
472
+ * absent field silently went wrong in OPPOSITE directions — exactly the
473
+ * disagreement §5.4 promised could never happen:
474
+ *
475
+ * - `isImageGenModel` was strict ("missing = unknown = not an image model"),
476
+ * so the draw tool could never pick a model at all — it reported "catalog
477
+ * 中没有出图模型" while the catalog was full of them;
478
+ * - `isChatModel` was permissive ("missing = chat"), so every image and video
479
+ * model landed in the chat picker, and each one 400s on first use
480
+ * ("模型 agnes-video-v2.0 是 video 模型,请使用 /v1/videos").
481
+ *
482
+ * Both predicates now resolve through {@link outputModalitiesOf}, so the two
483
+ * lists cannot disagree by construction — the §5.4 intent, restored on a
484
+ * catalog that has no field to be structured about.
485
+ *
486
+ * THREE RESOLUTION LEVELS, in precedence order:
487
+ *
488
+ * 1. DECLARED — `output_modalities` (or a known sibling spelling) is an
489
+ * array. Always wins, so a gateway that starts shipping the field takes
490
+ * over automatically with no code change here.
491
+ * 2. INFERRED — no field, but the id carries the family segment the platform
492
+ * itself uses: `agnes-image-*` → image, `agnes-video-*` → video.
493
+ * 3. ASSUMED — no field, no family segment: `["text"]`, i.e. a chat model.
494
+ *
495
+ * The name patterns are deliberately NARROW: they match a whole `image` /
496
+ * `video` path segment, never a substring. The `dsh-draw-router` lesson still
497
+ * holds — a loose substring regex is how its `/u1-fast/i` missed `u1.5-lite` —
498
+ * so the fallback matches the platform's own naming convention exactly instead
499
+ * of guessing at shapes it has never seen. `agnes-2.5-pro` and
500
+ * `agnes-3.0-flash` are untouched by it, which is the point.
501
+ *
502
+ * @module dsh-connect-agnes-token-plan/modality
503
+ */
504
+ /** The modality token meaning "this entry produces images". */
505
+ const IMAGE_MODALITY = "image";
506
+ /** The modality token meaning "this entry produces video". */
507
+ const VIDEO_MODALITY = "video";
508
+ /** The modality token meaning "this entry produces text" — the chat default. */
509
+ const TEXT_MODALITY = "text";
510
+ /**
511
+ * The field spellings that may carry an entry's OUTPUT modalities.
512
+ *
513
+ * Mirrors `parsers.ts`'s input-side key list (`input_modalities` +
514
+ * `inputTypes`): the snake_case platform field first, then the camelCase
515
+ * sibling other gateways are expected to use. An unknown spelling is not
516
+ * guessed at — it degrades to the name fallback, which is visible in the
517
+ * resolution `source`.
518
+ */
519
+ const OUTPUT_MODALITY_KEYS = Object.freeze(["output_modalities", "outputTypes"]);
520
+ /**
521
+ * The Agnes family segment marking an image-generation model.
522
+ *
523
+ * Matches `agnes-image-2.1-flash` / `agnes-image-2.5-flash`; requires the
524
+ * segment to stand alone (bounded by `-`, `_`, or the string ends) so a
525
+ * hypothetical `imageservice` cannot be caught by accident.
526
+ */
527
+ const IMAGE_ID_PATTERN = /(?:^|[-_])image(?:[-_]|$)/i;
528
+ /**
529
+ * The Agnes family segment marking a video-generation model.
530
+ *
531
+ * Matches `agnes-video-2.5`, `agnes-video-2.5-flash` and `agnes-video-v2.0`.
532
+ */
533
+ const VIDEO_ID_PATTERN = /(?:^|[-_])video(?:[-_]|$)/i;
534
+ /**
535
+ * Read the DECLARED output modalities off a catalog entry, or `null`.
536
+ *
537
+ * `null` means "the platform said nothing" — the caller falls through to the
538
+ * name fallback. An array the platform DID send is honoured verbatim, empty
539
+ * array included: "declared nothing" is still a declaration, and overriding it
540
+ * with a name guess would put the fallback above the platform's own word.
541
+ * @param {object} entry - one normalized catalog entry.
542
+ * @returns {string[]|null} lowercased modality names, or `null` when absent.
543
+ */
544
+ function declaredOutputModalities(entry) {
545
+ if (entry === null || typeof entry !== "object") return null;
546
+ for (const key of OUTPUT_MODALITY_KEYS) {
547
+ const value = entry[key];
548
+ if (!Array.isArray(value)) continue;
549
+ return value.map((item) => str(item, "").toLowerCase()).filter((item) => item !== "");
550
+ }
551
+ return null;
552
+ }
553
+ /**
554
+ * Resolve an entry's output modalities, and how they were decided.
555
+ *
556
+ * The `source` is part of the contract, not a debug extra: `"inferred"` and
557
+ * `"assumed"` mean the answer is this plugin's reading of a naming convention
558
+ * rather than the platform's own statement, and any surface that presents the
559
+ * answer as fact (the panel's per-model capability line, a future video tool's
560
+ * "not available" message) is expected to say so.
561
+ * @param {object} entry - one normalized catalog entry.
562
+ * @returns {{modalities: string[], source: "declared"|"inferred"|"assumed"}}
563
+ */
564
+ function outputModalitiesOf(entry) {
565
+ const declared = declaredOutputModalities(entry);
566
+ if (declared !== null) return {
567
+ modalities: declared,
568
+ source: "declared"
569
+ };
570
+ const id = str(entry?.id, "");
571
+ const inferred = [];
572
+ if (IMAGE_ID_PATTERN.test(id)) inferred.push(IMAGE_MODALITY);
573
+ if (VIDEO_ID_PATTERN.test(id)) inferred.push(VIDEO_MODALITY);
574
+ if (inferred.length > 0) return {
575
+ modalities: inferred,
576
+ source: "inferred"
577
+ };
578
+ return {
579
+ modalities: [TEXT_MODALITY],
580
+ source: "assumed"
581
+ };
582
+ }
583
+ /**
584
+ * Whether one catalog entry is an image-GENERATION model.
585
+ *
586
+ * The predicate the draw tool picks its candidates with. It stays on the
587
+ * conservative side of a false positive — sending a draw request to a model
588
+ * that cannot answer produces an error the agent cannot act on — but "no
589
+ * signal at all" no longer means "no" on a gateway that never sends a signal.
590
+ * @param {object} entry - one normalized catalog entry.
591
+ * @returns {boolean}
592
+ */
593
+ function isImageGenModel(entry) {
594
+ return outputModalitiesOf(entry).modalities.includes(IMAGE_MODALITY);
595
+ }
596
+ /**
597
+ * Whether one catalog entry is a video-generation model.
598
+ *
599
+ * Exists so the chat exclusion can name what it is excluding, and so a future
600
+ * video tool has one predicate to share rather than a second regex of its own.
601
+ * @param {object} entry - one normalized catalog entry.
602
+ * @returns {boolean}
603
+ */
604
+ function isVideoGenModel(entry) {
605
+ return outputModalitiesOf(entry).modalities.includes(VIDEO_MODALITY);
606
+ }
607
+ /**
608
+ * Whether a catalog entry can be addressed as a CHAT model on this provider's
609
+ * OpenAI-compatible endpoint.
610
+ *
611
+ * Image and video models answer 400 on `/v1/chat/completions` with a message
612
+ * naming the endpoint they DO want ("请使用 /v1/images/generations"), so
613
+ * offering them in the picker only produces errors in DSH. This shares
614
+ * {@link outputModalitiesOf} with {@link isImageGenModel} precisely so the
615
+ * chat roster and the draw candidate list can never disagree about which
616
+ * entries exist — the invariant §5.4 wanted, now enforced in one place instead
617
+ * of promised across two.
618
+ * @param {object} entry - one normalized catalog entry.
619
+ * @returns {boolean} whether the entry is usable as a chat model.
620
+ */
621
+ function isChatModel(entry) {
622
+ const { modalities } = outputModalitiesOf(entry);
623
+ return !modalities.includes("image") && !modalities.includes("video");
624
+ }
625
+
626
+ //#endregion
627
+ //#region src/host/llm-models.ts
628
+ /**
629
+ * Catalog entry -> pi-ai model descriptor mapping — the pure half of the
630
+ * directly-registered Agnes LLM provider ("one-stop service", step three).
631
+ *
632
+ * This module deliberately imports NO runtime peer (`@earendil-works/pi-ai`,
633
+ * `@deepseek-ai/dsh-llm-pi-ai`): it builds plain objects only, so the mapping
634
+ * decisions are testable on a clean checkout the same way the qoder fork keeps
635
+ * its own pi-ai model mapping peer-free. `llm-adapter.ts` is the peer-dependent half
636
+ * that hands these descriptors to `createProvider`.
637
+ *
638
+ * Two decisions carried here are load-bearing rather than cosmetic:
639
+ *
640
+ * 1. `compat.supportsDeveloperRole: false`. pi-ai picks the system-prompt role
641
+ * as `reasoning && supportsDeveloperRole ? "developer" : "system"`, and when
642
+ * the flag is unset it AUTO-DETECTS, returning true for anything that does
643
+ * not look like a known non-standard provider. Agnes's direct endpoint
644
+ * does not speak the developer role, so an unset flag makes every request
645
+ * 403 forever. Setting it false is the fix the qoder route proved necessary.
646
+ * 2. No `maxTokens` VALUE is declared. A declared value becomes the output
647
+ * ceiling and pi-ai sends it as `max_tokens`, truncating long replies with
648
+ * `finish: max-tokens`. Only the field NAME (`max_tokens`) is pinned.
649
+ * 3. `reasoning: true` + a `thinkingLevelMap`. Agnes chat models think by
650
+ * default (verified 2026-09-29: default reasoning_effort high, thinking
651
+ * text returned as `reasoning` on flash-lite and `reasoning_content` on
652
+ * deepseek/glm/kimi — pi-ai reads both spellings). `reasoning: true` is
653
+ * what makes DSH offer the 思考强度 selector and what makes pi-ai surface
654
+ * the thinking. Note this is set UNCONDITIONALLY rather than read off a
655
+ * capability flag: the Agnes catalog carries no `supported_features` field
656
+ * at all (live-verified 2026-10-01), so there is nothing to read. The map
657
+ * pins picker levels to platform-valid wire values:
658
+ * `off: "none"` (the platform's off spelling — "off" itself 400s),
659
+ * `minimal: null` (unverified on this gateway), and `max` only on glm-5.2
660
+ * (probed 200; rejected 400 on flash-lite / deepseek-v4-flash).
661
+ *
662
+ * @module dsh-connect-agnes-token-plan/llm-models
663
+ */
664
+ /**
665
+ * The provider id this plugin registers under.
666
+ *
667
+ * It must NOT be the bare `"Agnes"`: a hand-written `llm-pi-ai` row using
668
+ * that id can already exist in an operator's profile (apiKeyEnv
669
+ * `AGNES_TOKEN_PLAN_API_KEY`, base `https://api.agnes-ai.cn/v1`), and
670
+ * `registerAdapter` with a colliding id is refused as a duplicate. This own
671
+ * slug-shaped id cannot collide with that row or with another plugin.
672
+ */
673
+ const LLM_PROVIDER_ID = "agnes-token-plan";
674
+ /** What the DSH model picker shows as the provider's name. */
675
+ const LLM_DISPLAY_NAME = "Agnes Token Plan";
676
+ /**
677
+ * The thinking effort the profile pins as DSH's "Default" on this provider.
678
+ *
679
+ * One constant for two claims: `llm-adapter.ts` pins it into the profile, and
680
+ * the snapshot echoes it to the panel roster, so the number the user reads is
681
+ * the number the adapter dispatches. Editing one without the other is now
682
+ * impossible by construction.
683
+ */
684
+ const DEFAULT_REASONING_EFFORT = "high";
685
+ /**
686
+ * Per-token prices are unknowable for a quota plan; report zero everywhere.
687
+ *
688
+ * ⚠️ The zeros are a SENTINEL, not "free". Agnes Token Plan is a credit
689
+ * pool billed by pool usage, so a per-token USD price simply does not exist on
690
+ * this route — but the model still burns credits. A panel row showing
691
+ * "$0.00" is describing "no per-token price known", never "this model costs
692
+ * nothing". Keep this comment next to the set so a future reader does not
693
+ * "fix" it to real prices or, worse, to `null` (which pi-ai may render as an
694
+ * unknown-cost row and break the picker's cost arithmetic).
695
+ */
696
+ const NO_COST = Object.freeze({
697
+ input: 0,
698
+ output: 0,
699
+ cacheRead: 0,
700
+ cacheWrite: 0
701
+ });
702
+ /**
703
+ * Advertised context window when the catalog declares no usable one.
704
+ *
705
+ * pi-ai's options builder does arithmetic on `model.contextWindow`, so an
706
+ * undefined value behaves like zero rather than "unknown" and breaks max-token
707
+ * calculation; the qoder route therefore always supplies a positive number.
708
+ * 128k is the conservative Agnes-family default; a catalog field that
709
+ * states a real window always wins.
710
+ */
711
+ const FALLBACK_CONTEXT_WINDOW = 128e3;
712
+ /**
713
+ * Read a positive context window off the catalog entry's known spellings.
714
+ *
715
+ * `context_length` is the field the platform actually emits (verified against
716
+ * the live catalog, 2026-09); the other spellings are kept as fallbacks in
717
+ * case the platform ever reverts to a different name. Agnes's `/v1/models`
718
+ * entries are kept whole by `console-client.ts`, so a field the platform adds
719
+ * later needs no parser change here — only its name has to be added to this
720
+ * list.
721
+ * @param {object} entry - one normalized catalog entry.
722
+ * @returns {number} the declared window, or the fallback.
723
+ */
724
+ function contextWindowOf(entry) {
725
+ for (const key of [
726
+ "context_length",
727
+ "context_window",
728
+ "contextWindow",
729
+ "max_context_tokens"
730
+ ]) {
731
+ const value = Math.floor(num(entry?.[key], 0));
732
+ if (value > 0) return value;
733
+ }
734
+ return FALLBACK_CONTEXT_WINDOW;
735
+ }
736
+ /**
737
+ * Read the platform's declared per-request output ceiling, 0 when unknown.
738
+ *
739
+ * This is a DISPLAY fact only. The descriptors deliberately declare no
740
+ * `maxTokens` value (module header, decision 2), so this figure never becomes
741
+ * a request parameter — it says what the platform can emit at most, so the
742
+ * user learns why a long reply can still stop with `finish_reason: length`.
743
+ * Same spelling-first policy as {@link contextWindowOf}.
744
+ * @param {object} entry - one normalized catalog entry.
745
+ * @returns {number} the declared ceiling, or 0 when the entry states none.
746
+ */
747
+ function maxOutputLengthOf(entry) {
748
+ for (const key of [
749
+ "max_output_length",
750
+ "maxOutputLength",
751
+ "max_output_tokens"
752
+ ]) {
753
+ const value = Math.floor(num(entry?.[key], 0));
754
+ if (value > 0) return value;
755
+ }
756
+ return 0;
757
+ }
758
+ /**
759
+ * The picker's 思考强度 levels, pinned to platform-valid wire spellings.
760
+ *
761
+ * DSH's picker offers levels from `getSupportedThinkingLevels(model)`
762
+ * (`off`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`), and pi-ai's
763
+ * openai-completions dispatch sends `reasoning_effort = map[level] ?? level`.
764
+ * Agnes's OpenAI-compat gateway accepts `none`/`low`/`medium`/`high`/
765
+ * `xhigh` on every chat model, rejects `off` (the OpenAI spelling) and
766
+ * `minimal`, and rejects `max` everywhere except glm-5.2 (all probed
767
+ * 2026-09-29; the platform's own error lists `low, medium, high, xhigh,
768
+ * none`). So:
769
+ *
770
+ * - `off: "none"` — the picker's "关闭" must send `none`, not `off`;
771
+ * - `low`/`medium` — per-model, gated on the PROBED_EFFORT table. A model
772
+ * PRESENT in that table with a level set `false` keeps it closed; a model
773
+ * ABSENT from it (an unknown id) is given the Agnes safe-set
774
+ * `low`/`medium`/`high` so the picker is never empty. The live-contract
775
+ * replay (`test/live-contract.mjs`) probes the per-model levels and flips
776
+ * the table cells once a model's 200 is recorded.
777
+ * - `max` — `"max"` on glm-5.2 only, `null` elsewhere.
778
+ *
779
+ * A value of `null` means "the picker must not offer this level"; a string is
780
+ * the wire spelling the level dispatches to.
781
+ * @param {object} entry - one normalized catalog entry.
782
+ * @returns {object} the thinkingLevelMap.
783
+ */
784
+ /**
785
+ * Per-model 思考档位 probe table (frozen 2026-09-29, mirrored from
786
+ * `test/baselines/agnes-contract.json` §reasoningEffort).
787
+ *
788
+ * The baseline records which `reasoning_effort` values the platform
789
+ * answered 200 for per model:
790
+ * - `high` — the platform default for every chat model;
791
+ * - `none` — 关思考, probed 200 on every model;
792
+ * - `xhigh` — ONLY probed 200 on deepseek-v4-flash;
793
+ * - `max` — ONLY probed 200 on glm-5.2;
794
+ * - `low` / `medium` — 2026-09-30 live-contract replay probed 200 on
795
+ * Agnes-6.8-flash-lite / deepseek-v4-flash / glm-5.2 (both) and on
796
+ * deepseek-flash's `medium` (its `low` cell is still INDEFINITE — the
797
+ * probe hit a 429 rpm window, not a 400, so it is "not measured" not
798
+ * "unsupported"); deepseek-v4-pro's low/medium are also INDEFINITE for
799
+ * the same reason. INDEFINITE cells stay closed in this table until a
800
+ * clean re-run records a 200 (or a 400, which would close them
801
+ * permanently).
802
+ *
803
+ * The panel roster line must not quote a level the platform may 400 on.
804
+ * For Agnes, a model ABSENT from this table (an unknown / future id) is
805
+ * offered the safe OpenAI-compatible set the provider row advertises —
806
+ * `off`→`none`, plus `low`/`medium`/`high` — while `xhigh`/`max` stay closed
807
+ * until a live-contract probe proves them on a specific model. A model
808
+ * PRESENT here (even all-`false`) is a known id whose extended levels were
809
+ * never confirmed, so its closed levels stay closed. A new model that turns
810
+ * out to accept an extra level is added here WITH its probe evidence (see the
811
+ * baseline's `driftLog` discipline), never assumed.
812
+ */
813
+ const PROBED_EFFORT = Object.freeze({
814
+ "deepseek-v4-flash": {
815
+ low: true,
816
+ medium: true,
817
+ high: true,
818
+ xhigh: true,
819
+ max: false
820
+ },
821
+ "glm-5.2": {
822
+ low: true,
823
+ medium: true,
824
+ high: true,
825
+ xhigh: false,
826
+ max: true
827
+ },
828
+ "Agnes-6.8-flash-lite": {
829
+ low: true,
830
+ medium: true,
831
+ high: true,
832
+ xhigh: false,
833
+ max: false
834
+ },
835
+ "deepseek-v4-pro": {
836
+ low: false,
837
+ medium: false,
838
+ high: true,
839
+ xhigh: false,
840
+ max: false
841
+ },
842
+ "deepseek-flash": {
843
+ low: false,
844
+ medium: true,
845
+ high: true,
846
+ xhigh: false,
847
+ max: false
848
+ },
849
+ "kimi-k3": {
850
+ low: false,
851
+ medium: true,
852
+ high: true,
853
+ xhigh: false,
854
+ max: false
855
+ },
856
+ "deepseek-v4.1-flash": {
857
+ low: false,
858
+ medium: false,
859
+ high: true,
860
+ xhigh: false,
861
+ max: false
862
+ }
863
+ });
864
+ function thinkingLevelMapFor(entry) {
865
+ const id = str(entry?.id, "");
866
+ const probed = PROBED_EFFORT[id];
867
+ if (!probed) return {
868
+ off: "none",
869
+ minimal: null,
870
+ low: "low",
871
+ medium: "medium",
872
+ high: "high",
873
+ xhigh: null,
874
+ max: null
875
+ };
876
+ return {
877
+ off: "none",
878
+ minimal: null,
879
+ low: probed?.low === true ? "low" : null,
880
+ medium: probed?.medium === true ? "medium" : null,
881
+ high: "high",
882
+ xhigh: probed?.xhigh === true ? "xhigh" : null,
883
+ max: probed?.max === true ? "max" : null
884
+ };
885
+ }
886
+ /** pi-ai's escalation ladder (`EXTENDED_THINKING_LEVELS`, dist/models.js:550). */
887
+ const THINKING_LADDER = [
888
+ "off",
889
+ "minimal",
890
+ "low",
891
+ "medium",
892
+ "high",
893
+ "xhigh",
894
+ "max"
895
+ ];
896
+ /**
897
+ * The thinking levels DSH's selector will actually offer for one model.
898
+ *
899
+ * This mirrors pi-ai's `getSupportedThinkingLevels` (dist/models.js:551)
900
+ * against OUR map: walk the ladder, drop levels the map pins to `null`, and
901
+ * treat `xhigh`/`max` as opt-in (they must be present and non-null). DSH
902
+ * builds the model-settings effort list through exactly that function, so a
903
+ * roster row quoting this list cannot disagree with what the picker lets the
904
+ * user select — one contract, both ends. If pi-ai's rule ever changes, this
905
+ * filter changes with it (pinned by test/render + routes).
906
+ * @param {object} entry - one normalized catalog entry.
907
+ * @returns {string[]} level ids in escalation order, e.g. ["off","low",...].
908
+ */
909
+ function supportedThinkingLevels(entry) {
910
+ const map = thinkingLevelMapFor(entry);
911
+ return THINKING_LADDER.filter((level) => {
912
+ const mapped = map[level];
913
+ if (mapped === null) return false;
914
+ if (level === "xhigh" || level === "max") return mapped !== void 0;
915
+ return true;
916
+ });
917
+ }
918
+ /**
919
+ * Map one catalog entry onto the pi-ai model descriptor the adapter offers.
920
+ *
921
+ * Vision is the SAME identification the snapshot publishes
922
+ * (`identifyVisionModel`: the platform's `input_modalities` first, the name
923
+ * fallback only when no structured field exists) — so the model picker cannot
924
+ * disagree with the panel's vision list about which models accept images.
925
+ * @param {object} entry - one normalized catalog entry (must carry `id`).
926
+ * @param {object} [options] - wiring.
927
+ * @param {string} [options.providerId] - the provider id the descriptor belongs to.
928
+ * @param {string} [options.baseUrl] - the OpenAI-compatible base URL.
929
+ * @returns {object} the pi-ai descriptor.
930
+ */
931
+ function toPiDescriptor(entry, options = {}) {
932
+ const { providerId = LLM_PROVIDER_ID, baseUrl } = options;
933
+ const id = str(entry?.id, "");
934
+ if (id === "") throw new Error("toPiDescriptor: catalog entry has no id");
935
+ const vision = identifyVisionModel(entry).vision === true;
936
+ return {
937
+ id,
938
+ name: str(entry.name, id),
939
+ api: "openai-completions",
940
+ provider: providerId,
941
+ baseUrl,
942
+ input: vision ? ["text", "image"] : ["text"],
943
+ reasoning: true,
944
+ thinkingLevelMap: thinkingLevelMapFor(entry),
945
+ cost: { ...NO_COST },
946
+ contextWindow: contextWindowOf(entry),
947
+ compat: {
948
+ maxTokensField: "max_tokens",
949
+ supportsDeveloperRole: false
950
+ }
951
+ };
952
+ }
953
+ /**
954
+ * Narrow a catalog to the models the user enabled.
955
+ *
956
+ * An **empty list means "no filter"**: a fresh install has curated nothing and
957
+ * must still be offered every model (the WorkBuddy convention). Once non-empty
958
+ * the list is an allow-list; an id that names no current catalog entry is
959
+ * harmless — it simply matches nothing this catalog.
960
+ * @param {object[]} entries - the normalized catalog entries.
961
+ * @param {string[]} [enabledIds] - the allow-list; empty/absent disables it.
962
+ * @returns {object[]} the entries still offered, in catalog order.
963
+ */
964
+ function filterByEnabled(entries, enabledIds) {
965
+ const list = Array.isArray(enabledIds) ? enabledIds : [];
966
+ if (list.length === 0) return Array.isArray(entries) ? entries : [];
967
+ const allow = new Set(list);
968
+ return (Array.isArray(entries) ? entries : []).filter((entry) => allow.has(str(entry?.id, "")));
969
+ }
970
+ /**
971
+ * Build the whole descriptor list for one catalog.
972
+ *
973
+ * Entries without an id are dropped (they could not be addressed on the wire)
974
+ * and duplicate ids keep the LAST occurrence, matching the catalog store's
975
+ * normalization so the adapter and the persisted catalog can never diverge.
976
+ * @param {object[]} entries - the normalized catalog entries.
977
+ * @param {object} options - `{ providerId, baseUrl, enabledIds }`.
978
+ * @returns {object[]} the pi-ai descriptors, in first-seen order.
979
+ */
980
+ function buildDescriptors(entries, options = {}) {
981
+ const { providerId = LLM_PROVIDER_ID, baseUrl, enabledIds = [], unavailableModelIds = [] } = options;
982
+ const blocked = new Set(Array.isArray(unavailableModelIds) ? unavailableModelIds : []);
983
+ const filtered = filterByEnabled(entries, enabledIds).filter(isChatModel);
984
+ const seen = /* @__PURE__ */ new Map();
985
+ const out = [];
986
+ for (const entry of Array.isArray(filtered) ? filtered : []) {
987
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) continue;
988
+ const id = str(entry.id, "");
989
+ if (id === "") continue;
990
+ if (blocked.has(id)) continue;
991
+ if (!seen.has(id)) {
992
+ seen.set(id, out.length);
993
+ out.push(void 0);
994
+ }
995
+ out[seen.get(id)] = toPiDescriptor({
996
+ ...entry,
997
+ id
998
+ }, {
999
+ providerId,
1000
+ baseUrl
1001
+ });
1002
+ }
1003
+ return out;
1004
+ }
1005
+ /**
1006
+ * The panel-facing roster with per-model availability.
1007
+ *
1008
+ * Like {@link rosterOf} it projects one row per chat-model id, but each row also
1009
+ * carries whether the model is currently callable — the "清单自带识别" the
1010
+ * provider advertises to the panel. Unlike the PICKER (which drops blocked
1011
+ * models via `buildDescriptors` so no doomed request is dispatched), the panel
1012
+ * keeps them in the list, greyed, so the user can see *why* a model is missing
1013
+ * from the picker rather than wondering where it went.
1014
+ *
1015
+ * `blockedIds` is handed IN rather than derived here, because what makes a
1016
+ * model unavailable is a platform fact, not a roster fact. On Agnes the answer
1017
+ * is currently always "nothing is blocked": the platform allocates no quota per
1018
+ * model, and its account-wide request window cannot be read as a remaining
1019
+ * figure (the console reports cumulative usage, not a balance), so silently
1020
+ * emptying the picker would hide the models for a reason the panel could not
1021
+ * explain. Account-level exhaustion is surfaced as a panel line instead.
1022
+ * @param {object[]} entries - the normalized catalog entries.
1023
+ * @param {string[]} [blockedIds] - model ids to report as unavailable.
1024
+ * @returns {{id: string, name: string, vision: boolean, available: boolean, quotaExhausted: boolean, contextWindow: number, maxOutputLength: number, thinkingLevels: string[]}[]}
1025
+ */
1026
+ function rosterWithAvailability(entries, blockedIds = []) {
1027
+ const blocked = new Set(Array.isArray(blockedIds) ? blockedIds : []);
1028
+ const position = /* @__PURE__ */ new Map();
1029
+ const out = [];
1030
+ for (const entry of Array.isArray(entries) ? entries : []) {
1031
+ if (!isChatModel(entry)) continue;
1032
+ const id = str(entry?.id, "");
1033
+ if (id === "") continue;
1034
+ const row = {
1035
+ id,
1036
+ name: str(entry.name, id),
1037
+ vision: identifyVisionModel(entry).vision === true,
1038
+ available: !blocked.has(id),
1039
+ quotaExhausted: blocked.has(id),
1040
+ contextWindow: contextWindowOf(entry),
1041
+ maxOutputLength: maxOutputLengthOf(entry),
1042
+ thinkingLevels: supportedThinkingLevels(entry)
1043
+ };
1044
+ if (position.has(id)) out[position.get(id)] = row;
1045
+ else {
1046
+ position.set(id, out.length);
1047
+ out.push(row);
1048
+ }
1049
+ }
1050
+ return out;
1051
+ }
1052
+ /**
1053
+ * Counts the provider-registration status reports: how many models the catalog
1054
+ * offered and how many of them accept image input, keyed by the same vision
1055
+ * identification the descriptors use.
1056
+ * @param {object[]} entries - the normalized catalog entries.
1057
+ * @returns {{modelCount: number, visionCount: number, visionIds: string[]}}
1058
+ */
1059
+ function summarizeCatalog(entries) {
1060
+ const list = (Array.isArray(entries) ? entries : []).filter(isChatModel);
1061
+ const visionIds = list.filter((entry) => str(entry?.id, "") !== "").map((entry) => identifyVisionModel(entry)).filter((entry) => entry.vision === true).map((entry) => entry.id);
1062
+ return {
1063
+ modelCount: list.filter((entry) => str(entry?.id, "") !== "").length,
1064
+ visionCount: visionIds.length,
1065
+ visionIds
1066
+ };
1067
+ }
1068
+
1069
+ //#endregion
1070
+ export { readSubscriptionExpiry as _, filterByEnabled as a, isImageGenModel as c, identifyVisionModel as d, matchCurrentPlan as f, quotaWindows as g, parseUsageSeries as h, buildDescriptors as i, isVideoGenModel as l, parseUsageOverview as m, LLM_DISPLAY_NAME as n, rosterWithAvailability as o, parsePlans as p, LLM_PROVIDER_ID as r, summarizeCatalog as s, DEFAULT_REASONING_EFFORT as t, checkShape as u };