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.
- package/CHANGELOG.md +386 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/client.js +3921 -0
- package/cordis.patch.yml +147 -0
- package/lib/agnescode-llm-adapter-DDvQ-xvK.js +135 -0
- package/lib/agnescode-models-CUcL9UwK.js +713 -0
- package/lib/host-config-BNGgm_j7.js +456 -0
- package/lib/index.js +7648 -0
- package/lib/llm-adapter-qvvVg7Gb.js +168 -0
- package/lib/llm-error-fix-DQUng8AL.js +247 -0
- package/lib/llm-models-CRYZmwHg.js +1070 -0
- package/lib/raccoon-llm-adapter-n6ZdIdvk.js +143 -0
- package/lib/raccoon-models-DV1sQN4n.js +491 -0
- package/package.json +90 -0
- package/screenshots.json +4 -0
|
@@ -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 };
|