dsh-plugin-subscription-oauth 0.2.2 → 0.4.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,412 @@
1
+ /**
2
+ * The `ark` route: Volcengine Ark's Coding Plan over the OpenAI Responses wire.
3
+ *
4
+ * Everything in this module is pinned to what the endpoints themselves said:
5
+ *
6
+ * - `POST /api/coding/v3/responses` exists and wants a credential (401).
7
+ * - It accepts a **bearer Ark API key** and nothing else. An SSO identity token in
8
+ * the same header is refused with `403 Unsupported zti authentication method`,
9
+ * and an AK/SK signature with `403 Unsupported aksk authentication method`, while
10
+ * a UUID-shaped key is looked up (`401 The API key doesn't exist`) and a
11
+ * malformed one is told so (`401 The API key format is incorrect`). So the
12
+ * credential is a UUID key, sent as a plain bearer — no request signing.
13
+ * - `/api/coding/v3` is documented by the official CLI as "OpenTOP + CodingPlan
14
+ * API", and the catalogue below is the plan's roster.
15
+ *
16
+ * @module dsh-plugin-subscription-oauth/providers/ark
17
+ */
18
+ import { LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
19
+ import { REAUTH_REQUIRED, ProviderError } from '../errors.js';
20
+ import { finiteInstant, nonEmpty } from '../auth/store.js';
21
+ import { callArkControl, parseArkFreeUsage, parseArkPlanUsage } from '../auth/ark-control.js';
22
+ import { proxiedFetch } from '../http.js';
23
+ import { findEntry, resolveCatalog } from './catalog.js';
24
+ import { streamProviderResponses } from './responses-stream.js';
25
+ import { toResponsesInput, toResponsesTools } from '../translate/responses.js';
26
+ /** Provider route this module serves. */
27
+ export const ARK_PROVIDER_ID = 'ark';
28
+ /** Display name for the picker group and the Settings card. */
29
+ export const ARK_PROVIDER_NAME = 'Volcengine Ark';
30
+ /** Page a user opens to manage the plan. */
31
+ export const ARK_CODE_URL = 'https://console.volcengine.com/ark/region:ark+cn-beijing/overview';
32
+ /** Coding-plan base; the Responses endpoint is `<base>/responses`. */
33
+ export const ARK_API_BASE = 'https://ark.cn-beijing.volces.com/api/coding/v3';
34
+ /** Origin the account-level endpoints hang off. */
35
+ export const ARK_ORIGIN = 'https://ark.cn-beijing.volces.com';
36
+ /** Path appended to the API base for a streaming request. */
37
+ export const ARK_RESPONSES_PATH = '/responses';
38
+ /** Model the picker preselects: the plan's own alias for its coding model. */
39
+ export const ARK_DEFAULT_MODEL = 'ark-code-latest';
40
+ /**
41
+ * Context capacity advertised for the plan's models.
42
+ *
43
+ * The Coding Plan is a curated, text-first roster and states no window per model;
44
+ * discovery refines this where the endpoint reports one.
45
+ */
46
+ export const ARK_CONTEXT_WINDOW = 262_144;
47
+ /** Output budget used when the caller sets no cap. */
48
+ export const ARK_DEFAULT_MAX_TOKENS = 32_000;
49
+ /** The plan's model roster, as the reference registry records it. */
50
+ export const ARK_MODELS = [
51
+ { id: 'ark-code-latest', name: 'Ark Code (latest)', description: 'Plan alias for the current coding model', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text'] },
52
+ { id: 'doubao-seed-2.0-code', name: 'Doubao Seed 2.0 Code', description: 'Coding-tuned Doubao', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text'] },
53
+ { id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', description: 'Fast DeepSeek', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text'] },
54
+ { id: 'glm-5.3', name: 'GLM-5.3', description: 'Zhipu flagship', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text'] },
55
+ { id: 'glm-5.3-flash', name: 'GLM-5.3 Flash', description: 'Fast GLM, reads images', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text', 'image'] },
56
+ { id: 'glm-5.2', name: 'GLM-5.2', description: 'Previous Zhipu flagship', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text'] },
57
+ { id: 'kimi-k2.6', name: 'Kimi K2.6', description: 'Moonshot model on Ark', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text'] },
58
+ { id: 'minimax-m3', name: 'MiniMax M3', description: 'MiniMax model on Ark', contextWindow: ARK_CONTEXT_WINDOW, inputModalities: ['text', 'image'] },
59
+ ];
60
+ /**
61
+ * Coerce one decoded credential entry into an Ark session.
62
+ *
63
+ * @param value - decoded JSON value for one account.
64
+ * @returns the session, or undefined when required fields are missing.
65
+ */
66
+ export function parseArkSession(value) {
67
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
68
+ return undefined;
69
+ const record = value;
70
+ const apiKey = nonEmpty(record.apiKey);
71
+ const expiresAt = finiteInstant(record.expiresAt);
72
+ if (apiKey === undefined || expiresAt === undefined)
73
+ return undefined;
74
+ const idToken = nonEmpty(record.idToken);
75
+ const refreshToken = nonEmpty(record.refreshToken);
76
+ const apiBase = nonEmpty(record.apiBase);
77
+ const accountId = nonEmpty(record.accountId);
78
+ const email = nonEmpty(record.email);
79
+ const sts = stsFromSession(record.sts);
80
+ return {
81
+ // The data plane carries the API key; `accessToken` is the same value so the
82
+ // shared token machinery has one bearer to resolve.
83
+ accessToken: apiKey,
84
+ apiKey,
85
+ expiresAt,
86
+ ...sts === undefined ? {} : { sts },
87
+ ...idToken === undefined ? {} : { idToken },
88
+ ...refreshToken === undefined ? {} : { refreshToken },
89
+ ...apiBase === undefined ? {} : { apiBase },
90
+ ...accountId === undefined ? {} : { accountId },
91
+ ...email === undefined ? {} : { email },
92
+ };
93
+ }
94
+ /** Read a stored STS credential set, tolerating a partial one. */
95
+ function stsFromSession(value) {
96
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
97
+ return undefined;
98
+ const record = value;
99
+ const accessKeyId = nonEmpty(record.accessKeyId);
100
+ const secretAccessKey = nonEmpty(record.secretAccessKey);
101
+ const sessionToken = nonEmpty(record.sessionToken);
102
+ const expiresAt = finiteInstant(record.expiresAt);
103
+ if (accessKeyId === undefined || secretAccessKey === undefined || sessionToken === undefined || expiresAt === undefined) {
104
+ return undefined;
105
+ }
106
+ return { accessKeyId, secretAccessKey, sessionToken, expiresAt };
107
+ }
108
+ /** An entry for an id this build ships no metadata for. */
109
+ function extraEntry(id) {
110
+ return { id, name: id, contextWindow: ARK_CONTEXT_WINDOW };
111
+ }
112
+ /** The adapter behind the `ark` route. */
113
+ export class ArkAdapter extends LlmAdapter {
114
+ options;
115
+ live;
116
+ constructor(options) {
117
+ super();
118
+ this.options = options;
119
+ }
120
+ providerInfo(provider) {
121
+ return { id: provider, name: ARK_PROVIDER_NAME };
122
+ }
123
+ providerRetryPolicy() {
124
+ return undefined;
125
+ }
126
+ async listModels(_provider) {
127
+ // Nothing to offer without a signed-in account: a model that cannot be called
128
+ // must not appear in the picker, and Auto must not try it.
129
+ if (await this.options.tokens.session() === undefined)
130
+ return [];
131
+ const hidden = await this.hidden();
132
+ return resolveCatalog(this.catalog(), [], hidden).entries.map((entry) => ({
133
+ provider: ARK_PROVIDER_ID,
134
+ id: entry.id,
135
+ name: entry.name,
136
+ ...entry.description === undefined ? {} : { description: entry.description },
137
+ ...entry.inputModalities === undefined ? {} : { inputModalities: entry.inputModalities },
138
+ }));
139
+ }
140
+ async resolveModel(_provider, model) {
141
+ const entry = findEntry(this.catalog(), model);
142
+ if (entry === undefined) {
143
+ const template = findEntry(this.catalog(), ARK_DEFAULT_MODEL) ?? this.catalog()[0];
144
+ return {
145
+ provider: ARK_PROVIDER_ID,
146
+ id: model,
147
+ name: model,
148
+ context: { contextWindow: template?.contextWindow ?? ARK_CONTEXT_WINDOW },
149
+ defaultMaxTokens: ARK_DEFAULT_MAX_TOKENS,
150
+ };
151
+ }
152
+ const reasoning = reasoningInfo(entry);
153
+ return {
154
+ provider: ARK_PROVIDER_ID,
155
+ id: entry.id,
156
+ name: entry.name,
157
+ ...entry.description === undefined ? {} : { description: entry.description },
158
+ ...entry.inputModalities === undefined ? {} : { inputModalities: entry.inputModalities },
159
+ context: { contextWindow: entry.contextWindow },
160
+ defaultMaxTokens: ARK_DEFAULT_MAX_TOKENS,
161
+ ...reasoning === undefined ? {} : { reasoning },
162
+ };
163
+ }
164
+ stream(options) {
165
+ return streamProviderResponses(options, {
166
+ label: 'Ark API',
167
+ tokens: this.options.tokens,
168
+ request: (call, model, accessToken, signal) => this.request(call, model, accessToken, signal),
169
+ streamIdleTimeoutMs: this.options.streamIdleTimeoutMs,
170
+ ...this.options.onCredentialRejected === undefined
171
+ ? {}
172
+ : { onCredentialRejected: this.options.onCredentialRejected },
173
+ onFallback: (from, to, reason) => {
174
+ this.options.onWarn?.(`ark: model ${from} unavailable (${reason}); falling back to ${to}`);
175
+ },
176
+ describeFailure: (error) => this.refreshHint(error),
177
+ });
178
+ }
179
+ /** Translate an auth failure into the message a user can act on. */
180
+ refreshHint(error) {
181
+ if (error instanceof ProviderError && error.code === REAUTH_REQUIRED) {
182
+ return new LlmError(`Ark sign-in expired; connect the account again from Settings → Subscriptions (${ARK_CODE_URL})`, 'INVALID_CREDENTIAL', { status: 401 });
183
+ }
184
+ return error;
185
+ }
186
+ catalog() {
187
+ return [...this.shippedCatalog(), ...this.addedEntries()];
188
+ }
189
+ shippedCatalog() {
190
+ const shipped = this.options.extraModels === undefined
191
+ ? [...ARK_MODELS]
192
+ : [...ARK_MODELS, ...this.options.extraModels.map(extraEntry)];
193
+ const live = this.live;
194
+ if (live === undefined)
195
+ return shipped;
196
+ const merged = [...shipped];
197
+ for (const id of live) {
198
+ if (findEntry(merged, id) === undefined)
199
+ merged.push(extraEntry(id));
200
+ }
201
+ return merged;
202
+ }
203
+ addedEntries() {
204
+ const own = this.shippedCatalog();
205
+ const added = this.options.userModels?.();
206
+ if (added === undefined)
207
+ return [];
208
+ return (Array.isArray(added) ? added : [])
209
+ .filter((id) => findEntry(own, id) === undefined)
210
+ .map(extraEntry);
211
+ }
212
+ async hidden() {
213
+ if (this.options.hiddenModels === undefined)
214
+ return new Set();
215
+ try {
216
+ return await this.options.hiddenModels();
217
+ }
218
+ catch {
219
+ return new Set();
220
+ }
221
+ }
222
+ /** Adopt the endpoint's live catalog. */
223
+ discover(ids) {
224
+ this.live = [...ids];
225
+ }
226
+ /**
227
+ * Read the plan's live model list.
228
+ *
229
+ * The Coding Plan answers the OpenAI models shape on its own base; an endpoint
230
+ * that does not serve it leaves the shipped roster in place.
231
+ */
232
+ async fetchCatalog(apiKey, signal) {
233
+ if (!this.options.discovery)
234
+ return undefined;
235
+ const fetchFn = this.options.fetchFn ?? proxiedFetch;
236
+ try {
237
+ const response = await fetchFn(`${this.options.apiBase}/models`, {
238
+ headers: { authorization: `Bearer ${apiKey}`, accept: 'application/json' },
239
+ ...signal === undefined ? {} : { signal },
240
+ });
241
+ if (!response.ok) {
242
+ this.options.onWarn?.(`Ark model list unavailable (HTTP ${response.status})`);
243
+ return undefined;
244
+ }
245
+ const body = await response.json();
246
+ const ids = (body.data ?? [])
247
+ .map((entry) => (typeof entry?.id === 'string' ? entry.id.trim() : ''))
248
+ .filter((id) => id !== '');
249
+ return ids.length === 0 ? undefined : ids;
250
+ }
251
+ catch (error) {
252
+ this.options.onWarn?.(`Ark model list unavailable (${error instanceof Error ? error.message : String(error)})`);
253
+ return undefined;
254
+ }
255
+ }
256
+ /** Send one streaming request over the Responses wire. */
257
+ async request(options, model, apiKey, signal) {
258
+ const resolveImage = this.imageResolver(signal);
259
+ const resolved = await toResponsesInput(options.messages, options.system, resolveImage);
260
+ const body = {
261
+ model,
262
+ ...resolved.instructions === undefined ? {} : { instructions: resolved.instructions },
263
+ input: resolved.input,
264
+ ...options.tools === undefined || options.tools.length === 0
265
+ ? {}
266
+ : { tools: toResponsesTools(options.tools), tool_choice: 'auto', parallel_tool_calls: true },
267
+ ...options.maxTokens === undefined ? {} : { max_output_tokens: options.maxTokens },
268
+ ...options.reasoningEffort === undefined ? {} : { reasoning: { effort: String(options.reasoningEffort) } },
269
+ ...options.sessionId === undefined ? {} : { prompt_cache_key: String(options.sessionId) },
270
+ stream: true,
271
+ };
272
+ const fetchFn = this.options.fetchFn ?? proxiedFetch;
273
+ return fetchFn(`${this.options.apiBase}${ARK_RESPONSES_PATH}`, {
274
+ method: 'POST',
275
+ headers: {
276
+ 'authorization': `Bearer ${apiKey}`,
277
+ 'accept': 'text/event-stream',
278
+ 'content-type': 'application/json',
279
+ },
280
+ body: JSON.stringify(body),
281
+ signal,
282
+ });
283
+ }
284
+ /** Resolve image blocks through the attachment service, when it is present. */
285
+ imageResolver(signal) {
286
+ const store = this.options.resolveAttachments?.();
287
+ if (store === undefined || store === null)
288
+ return undefined;
289
+ return async (ref) => {
290
+ try {
291
+ const stored = await store.readImage(ref, signal);
292
+ return { mediaType: ref.mediaType, base64: Buffer.from(stored.data).toString('base64') };
293
+ }
294
+ catch (error) {
295
+ this.options.onWarn?.(`image attachment unavailable (${error instanceof Error ? error.message : String(error)})`);
296
+ return undefined;
297
+ }
298
+ };
299
+ }
300
+ /** The catalog the controller publishes without hand-added ids. */
301
+ ownCatalogEntries() {
302
+ return this.shippedCatalog();
303
+ }
304
+ /** The catalog the controller publishes. */
305
+ catalogEntries() {
306
+ return this.catalog();
307
+ }
308
+ }
309
+ /** The reasoning block for a catalog entry, or undefined when it supports none. */
310
+ function reasoningInfo(entry) {
311
+ const efforts = entry.reasoningEfforts;
312
+ if (efforts === undefined || efforts.length === 0)
313
+ return undefined;
314
+ return {
315
+ efforts: efforts.map((effort) => ({ id: ReasoningEffortId(effort), name: String(effort), description: '' })),
316
+ ...entry.defaultEffort === undefined ? {} : { defaultEffort: ReasoningEffortId(entry.defaultEffort) },
317
+ };
318
+ }
319
+ /**
320
+ * Read the plan's allowance.
321
+ *
322
+ * Two allowances exist, and both live on the **control plane** — a different host
323
+ * from the data plane, reached with the STS AK/SK the SSO identity was exchanged
324
+ * for (see `auth/ark-control.ts`) rather than with the plan API key:
325
+ *
326
+ * - the **subscription** periods (`GetCodingPlanUsage` → `coding_usage.periods[]`,
327
+ * each `{ label, used, total, percent, reset_at }`), which an account without a
328
+ * plan answers as `Status: Reclaimed`;
329
+ * - the **free inference allowance** per model (`ListModelChargeItems` →
330
+ * `InferenceFreeUsage { Consumed, Total }`), which exists with no subscription.
331
+ *
332
+ * The free allowance is what a plan-less account actually has, so it is reported
333
+ * even when the subscription is absent — otherwise the card would say "no quota"
334
+ * while the account still had tokens to spend.
335
+ *
336
+ * @param input - the signed control-plane client.
337
+ * @returns the windows the service stated, subscription first.
338
+ */
339
+ export async function readArkUsage(input) {
340
+ const windows = [];
341
+ let plan;
342
+ let planError;
343
+ try {
344
+ const payload = await callArkControl({
345
+ action: 'GetCodingPlanUsage',
346
+ credentials: input.credentials,
347
+ fetchFn: input.fetchFn,
348
+ ...input.signal === undefined ? {} : { signal: input.signal },
349
+ });
350
+ plan = parseArkPlanUsage(payload);
351
+ for (const period of plan.periods) {
352
+ const usedPercent = period.percent
353
+ ?? (period.used !== undefined && period.total !== undefined && period.total > 0
354
+ ? (period.used / period.total) * 100
355
+ : undefined);
356
+ windows.push({
357
+ // The plan's own period names become the window labels the page already
358
+ // knows: `5h`, `weekly`, `monthly`.
359
+ label: period.label,
360
+ ...usedPercent === undefined ? {} : { usedPercent: Math.max(0, Math.min(100, usedPercent)) },
361
+ ...period.used === undefined ? {} : { used: period.used },
362
+ ...period.total === undefined ? {} : { limit: period.total },
363
+ ...period.resetsAt === undefined ? {} : { resetsAt: period.resetsAt },
364
+ });
365
+ }
366
+ }
367
+ catch (error) {
368
+ planError = error instanceof Error ? error.message : String(error);
369
+ }
370
+ try {
371
+ const payload = await callArkControl({
372
+ action: 'ListModelChargeItems',
373
+ credentials: input.credentials,
374
+ fetchFn: input.fetchFn,
375
+ body: 'Modality=LLM&PageSize=50',
376
+ ...input.signal === undefined ? {} : { signal: input.signal },
377
+ });
378
+ const free = parseArkFreeUsage(payload);
379
+ if (free.length > 0) {
380
+ // One row per model would bury the plan figures; the page shows the free
381
+ // allowance as the pool it is, spent across the models that grant it.
382
+ const consumed = free.reduce((sum, entry) => sum + entry.consumed, 0);
383
+ const total = free.reduce((sum, entry) => sum + entry.total, 0);
384
+ windows.push({
385
+ label: 'free',
386
+ usedPercent: total > 0 ? Math.max(0, Math.min(100, (consumed / total) * 100)) : 0,
387
+ used: consumed,
388
+ limit: total,
389
+ });
390
+ }
391
+ }
392
+ catch (error) {
393
+ // The free pool is a bonus, not the subscription: failing to read it must not
394
+ // hide the plan figures that did come back.
395
+ if (plan === undefined || plan.periods.length === 0) {
396
+ return {
397
+ windows,
398
+ usageError: error instanceof Error ? error.message : String(error),
399
+ };
400
+ }
401
+ }
402
+ return {
403
+ windows,
404
+ ...plan === undefined || plan.subscribed
405
+ ? {}
406
+ : {
407
+ // A plan-less account is a state, not a failure: say so, and let the free
408
+ // pool above speak for itself.
409
+ usageError: planError ?? 'no Ark Coding Plan on this account; showing the free inference allowance',
410
+ },
411
+ };
412
+ }
@@ -198,6 +198,22 @@ export class AutoAdapter extends LlmAdapter {
198
198
  // Auto cannot chain to itself; that would be a loop, not a fallback.
199
199
  if (entry.id === AUTO_PROVIDER_ID)
200
200
  continue;
201
+ // A route this plugin owns but has not signed in offers nothing, even though
202
+ // the harness still answers with its catalog: the harness has no notion of an
203
+ // account, so Auto is the layer that has to ask. Host providers are left
204
+ // alone — Auto has no way to judge somebody else's readiness, and dropping
205
+ // them would silently empty the chain.
206
+ const owned = this.options.registry.get(entry.id);
207
+ if (owned !== undefined) {
208
+ try {
209
+ if (!(await owned.status()).loggedIn)
210
+ continue;
211
+ }
212
+ catch (error) {
213
+ this.options.onWarn?.(`auto could not read ${entry.id}'s account state (${errorChain(error)})`);
214
+ continue;
215
+ }
216
+ }
201
217
  try {
202
218
  for (const model of await host.models(entry.id)) {
203
219
  const resolved = await host.resolve(entry.id, model.id);
@@ -49,9 +49,42 @@ function toInstant(value) {
49
49
  return undefined;
50
50
  return numeric < 1e12 ? numeric * 1000 : numeric;
51
51
  }
52
- /** The first present reset instant among a row's usual field names. */
52
+ /**
53
+ * Read one `usages.<key>` row.
54
+ *
55
+ * Kimi states these as a spent fraction (`used_ratio`, 0–1) plus `reset_time`,
56
+ * rather than the `limit`/`used` pair the other rows use, so the percentage is the
57
+ * ratio itself.
58
+ *
59
+ * @param usages - the payload's `usages` object.
60
+ * @param key - `limit_5h` or `limit_7d`.
61
+ * @returns the row, or undefined when it states no figure.
62
+ */
63
+ function readUsagesRow(usages, key) {
64
+ if (!isRecord(usages))
65
+ return undefined;
66
+ const row = usages[key];
67
+ if (!isRecord(row))
68
+ return undefined;
69
+ const ratio = toNumber(row.used_ratio);
70
+ if (ratio === undefined)
71
+ return undefined;
72
+ const resetsAt = resetInstant(row);
73
+ return {
74
+ usedPercent: Math.max(0, Math.min(100, ratio * 100)),
75
+ ...resetsAt === undefined ? {} : { resetsAt },
76
+ };
77
+ }
78
+ /**
79
+ * The first present reset instant among a row's usual field names.
80
+ *
81
+ * `reset_time` is in the list because that is what Kimi's own `/usages` body uses —
82
+ * `usages.limit_5h.reset_time` — and omitting it was why the Kimi card showed its
83
+ * windows without any reset at all. The other spellings cover the shapes the other
84
+ * routes and the earlier payloads use.
85
+ */
53
86
  function resetInstant(row) {
54
- for (const key of ['resetsAt', 'resets_at', 'resetAt', 'reset_at', 'end', 'endsAt', 'expiresAt']) {
87
+ for (const key of ['resetsAt', 'resets_at', 'resetAt', 'reset_at', 'resetTime', 'reset_time', 'end', 'endsAt', 'expiresAt']) {
55
88
  const instant = toInstant(row[key]);
56
89
  if (instant !== undefined)
57
90
  return instant;
@@ -131,8 +164,8 @@ function unwrapKimiUsage(value) {
131
164
  if (!isRecord(nested))
132
165
  return value;
133
166
  const usable = (field) => field !== undefined && field !== null;
134
- const outer = usable(value.usage) || usable(value.limits) || usable(value.totalQuota);
135
- const inner = usable(nested.usage) || usable(nested.limits) || usable(nested.totalQuota);
167
+ const outer = usable(value.usage) || usable(value.limits) || usable(value.totalQuota) || usable(value.usages);
168
+ const inner = usable(nested.usage) || usable(nested.limits) || usable(nested.totalQuota) || usable(nested.usages);
136
169
  return !outer && inner ? nested : value;
137
170
  }
138
171
  /** The label a Kimi limit row carries, lowercased. */
@@ -217,6 +250,18 @@ export async function readKimiUsage(input) {
217
250
  windows.push(toWindow('5h', fiveHour));
218
251
  if (weekly !== undefined)
219
252
  windows.push(toWindow('weekly', weekly));
253
+ // Kimi's own `/usages` body states the two metered windows as
254
+ // `usages.limit_5h` / `usages.limit_7d` with `used_ratio` and `reset_time`. Those
255
+ // are read last so a `limits[]` row, when present, keeps its more specific
256
+ // figures; this is the fallback that stops a payload from rendering as two
257
+ // windows with no reset at all.
258
+ if (windows.length === 0) {
259
+ for (const [key, label] of [['limit_5h', '5h'], ['limit_7d', 'weekly']]) {
260
+ const row = readUsagesRow(body.usages, key);
261
+ if (row !== undefined)
262
+ windows.push(toWindow(label, row));
263
+ }
264
+ }
220
265
  if (windows.length === 0)
221
266
  return undefined;
222
267
  const plan = typeof body.plan === 'string' && body.plan.length > 0 ? body.plan : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subscription-oauth",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "Use subscription accounts as DeepSeek Harness LLM providers — Kimi Code (OAuth device flow), Grok / X Premium (OIDC + PKCE), Google Antigravity (Google OAuth + Cloud Code Assist) and GLM / BigModel (ZCode's browser sign-in) — with per-model visibility, drag-to-reorder Auto fallback and plan quotas from the web Settings page",
5
5
  "license": "MIT",
6
6
  "author": "EricFL1998",