dsh-plugin-subscription-oauth 0.2.1 → 0.3.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/README.md +14 -1
- package/README.zh.md +371 -0
- package/lib/auth/ark-control.d.ts +180 -0
- package/lib/auth/ark-control.js +304 -0
- package/lib/auth/ark-flow.d.ts +134 -0
- package/lib/auth/ark-flow.js +239 -0
- package/lib/client/SubscriptionsSection.js +1 -9
- package/lib/client.js +1 -5
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +2 -2
- package/lib/index.js +15 -2
- package/lib/providers/ark-provider.d.ts +42 -0
- package/lib/providers/ark-provider.js +166 -0
- package/lib/providers/ark.d.ts +154 -0
- package/lib/providers/ark.js +412 -0
- package/lib/providers/auto.js +16 -0
- package/lib/providers/usage.js +49 -4
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/lib/providers/auto.js
CHANGED
|
@@ -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);
|
package/lib/providers/usage.js
CHANGED
|
@@ -49,9 +49,42 @@ function toInstant(value) {
|
|
|
49
49
|
return undefined;
|
|
50
50
|
return numeric < 1e12 ? numeric * 1000 : numeric;
|
|
51
51
|
}
|
|
52
|
-
/**
|
|
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.
|
|
3
|
+
"version": "0.3.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",
|