dsh-plugin-subscription-oauth 0.6.1 → 0.8.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/lib/index.d.ts CHANGED
@@ -45,16 +45,22 @@ export interface Config {
45
45
  */
46
46
  auto?: boolean;
47
47
  /**
48
- * The credential reference a non-subscription provider's balance query uses, by
49
- * provider id.
48
+ * The balance source a non-subscription provider uses, by provider id.
50
49
  *
51
50
  * A provider that is not a subscription this plugin serves has no usage windows to
52
51
  * meter, so the chip shows its balance instead — read by cc-switch's query, which needs
53
- * the provider's API key. The provider's profile names that key by reference
54
- * (`apiKeyEnv`), and the host's credential service resolves the name; this map is the
55
- * one place a provider id is tied to that reference. A provider with no entry, or
56
- * whose key does not resolve, gets no balance and the chip stays silent.
52
+ * the provider's API key and its own base URL (the balance endpoint lives beside the
53
+ * provider's OpenAI-compatible wire, not on DeepSeek's). Each entry names the
54
+ * credential reference the provider's profile uses (`apiKeyEnv`) and the base URL that
55
+ * profile connects to; the host's credential service resolves the reference. A provider
56
+ * with no entry, or whose key does not resolve, gets no balance and the chip stays
57
+ * silent.
57
58
  */
59
+ balanceSources?: Record<string, {
60
+ credentialRef: string;
61
+ baseUrl: string;
62
+ }>;
63
+ /** @deprecated Use {@link balanceSources}, which carries the base URL too. */
58
64
  balanceCredentialRefs?: Record<string, string>;
59
65
  }
60
66
  /** Configuration schema, with the config file's own defaults applied. */
package/lib/index.js CHANGED
@@ -42,6 +42,8 @@ export const Config = z.object({
42
42
  defaultModel: z.string().default(KIMI_DEFAULT_MODEL),
43
43
  extraModels: z.array(z.string()).default([]),
44
44
  auto: z.boolean().default(true),
45
+ balanceSources: z.dict(z.object({ credentialRef: z.string(), baseUrl: z.string() })).default({}),
46
+ balanceCredentialRefs: z.dict(z.string()).default({}),
45
47
  });
46
48
  /**
47
49
  * Register every configured provider route, its credential store, and — when
@@ -188,13 +190,18 @@ export function apply(ctx, config = {}) {
188
190
  handles.set(route.id, handle);
189
191
  ctx.effect(() => () => handle(), `${name}: ${route.id} provider route`);
190
192
  }
191
- // A non-subscription provider's balance is read by cc-switch's query, which needs the
192
- // provider's API key. The provider's profile names that key by reference (`apiKeyEnv`),
193
- // and the configured map ties a provider id to that reference; the host's credential
194
- // service resolves it. The map lives in server config because that is where the rest
195
- // of the plugin's wiring is, and the client learns a provider's balance by id, never
196
- // by its credential reference.
197
- const credentialRefs = config.balanceCredentialRefs ?? {};
193
+ // A non-subscription provider's balance is read by cc-switch's query against the
194
+ // provider's own base URL (the balance endpoint lives beside its OpenAI-compatible
195
+ // wire, not on DeepSeek's), which needs the provider's API key. Each configured source
196
+ // names the credential reference the provider's profile uses (`apiKeyEnv`) and the base
197
+ // URL that profile connects to; the host's credential service resolves the reference.
198
+ const balanceSources = config.balanceSources ?? {};
199
+ const credentialRefs = { ...config.balanceCredentialRefs };
200
+ const baseUrls = {};
201
+ for (const [provider, source] of Object.entries(balanceSources)) {
202
+ credentialRefs[provider] = source.credentialRef;
203
+ baseUrls[provider] = source.baseUrl;
204
+ }
198
205
  // The DeepSeek account's balance is read on the node half through the host's
199
206
  // `deepseekAccount` service; the browser reaches it through the plugin's own RPC, the
200
207
  // same channel as the rest of the chip's data. The service is read lazily, per request
@@ -202,6 +209,7 @@ export function apply(ctx, config = {}) {
202
209
  // without it still serves every other endpoint, and the balance endpoint answers nothing.
203
210
  const balanceContext = {
204
211
  credentialRefs,
212
+ baseUrls,
205
213
  resolveDeepSeekAccount: () => {
206
214
  try {
207
215
  return ctx.get('deepseekAccount');
@@ -213,7 +221,20 @@ export function apply(ctx, config = {}) {
213
221
  resolveCredential: async (ref) => {
214
222
  try {
215
223
  const credentials = ctx.get('credentials');
216
- return (await credentials?.resolve?.(ref))?.value;
224
+ const hit = await credentials?.resolve?.(ref);
225
+ // The resolve answer is `{ value, source }`; an env-var source may name the var
226
+ // rather than carry the value, so both are considered before giving up.
227
+ if (typeof hit === 'object' && hit !== null) {
228
+ const record = hit;
229
+ if (typeof record.value === 'string' && record.value.trim() !== '')
230
+ return record.value;
231
+ if (typeof record.source === 'string') {
232
+ const fromEnv = process.env[record.source];
233
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '')
234
+ return fromEnv;
235
+ }
236
+ }
237
+ return undefined;
217
238
  }
218
239
  catch {
219
240
  return undefined;
@@ -218,6 +218,12 @@ export declare class AntigravityAdapter extends LlmAdapter {
218
218
  private shippedCatalog;
219
219
  resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
220
220
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
221
+ /** Whether an account is remembered as spent until a future reset. */
222
+ private isSpent;
223
+ /** The moment an account's allowance is known to reset, when recorded. */
224
+ private resetAt;
225
+ /** Remember an account as spent (its exact reset is learned separately). */
226
+ private noteSpent;
221
227
  /** Translate an auth failure into the message a user can act on. */
222
228
  private refreshHint;
223
229
  /** Build and send one streaming request for one model. */
@@ -12,6 +12,7 @@
12
12
  import { EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, } from '@deepseek-ai/dsh-llm';
13
13
  import { REAUTH_REQUIRED, ProviderError } from '../errors.js';
14
14
  import { attributedHeaders } from '../auth/headers.js';
15
+ import { allowanceScheduler } from '../auth/allowance-scheduler.js';
15
16
  import { LoopbackFlow } from '../auth/pkce-flow.js';
16
17
  import { TokenStore, finiteInstant, nonEmpty } from '../auth/store.js';
17
18
  import { TokenManager } from '../auth/tokens.js';
@@ -415,25 +416,53 @@ export class AntigravityAdapter extends LlmAdapter {
415
416
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
416
417
  try {
417
418
  // `auto` owns the fallback chain now, so this route serves exactly the
418
- // model it was asked for.
419
+ // model it was asked for — but it draws on every account the provider holds, so a
420
+ // spent allowance moves to the next account rather than failing the call.
419
421
  const model = options.model;
422
+ // Accounts come from the manager when it can list them; a minimal manager (or a
423
+ // test double) without `accounts()` means a single account, which is just the
424
+ // default one.
425
+ const accounts = typeof this.options.tokens.accounts === 'function'
426
+ ? await this.options.tokens.accounts()
427
+ : [];
428
+ const tried = new Set();
420
429
  let token = await this.options.tokens.resolve();
421
- let response = await this.request(options, model, token.account, token.accessToken, watchdog.signal);
422
- if (response.status === 401 || response.status === 403) {
423
- // The stored token was accepted locally but refused on the wire; force
424
- // one refresh, then report the credential as dead if it still fails.
425
- token = await this.options.tokens.resolve(token.account, true);
426
- response = await this.request(options, model, token.account, token.accessToken, watchdog.signal);
430
+ // Move an account known to be spent (from a past failure) to the back, so a fresh
431
+ // request starts on one that has allowance. A lone account still serves.
432
+ const order = [...accounts.filter((a) => a !== token.account && !this.isSpent(a)), token.account,
433
+ ...accounts.filter((a) => a !== token.account && this.isSpent(a))];
434
+ for (const accountKey of order) {
435
+ if (tried.has(accountKey))
436
+ continue;
437
+ tried.add(accountKey);
438
+ token = await this.options.tokens.resolve(accountKey);
439
+ let response = await this.request(options, model, token.account, token.accessToken, watchdog.signal);
427
440
  if (response.status === 401 || response.status === 403) {
428
- this.options.onCredentialRejected?.(token.account);
441
+ // The stored token was accepted locally but refused on the wire; force
442
+ // one refresh, then report the credential as dead if it still fails.
443
+ token = await this.options.tokens.resolve(token.account, true);
444
+ response = await this.request(options, model, token.account, token.accessToken, watchdog.signal);
445
+ if (response.status === 401 || response.status === 403) {
446
+ this.options.onCredentialRejected?.(token.account);
447
+ }
429
448
  }
449
+ // A spent allowance (402/429) moves to the next account, remembering when this
450
+ // one refills so later requests skip it until then.
451
+ if (!response.ok && (response.status === 402 || response.status === 429) && order.length > 1) {
452
+ this.noteSpent(token.account);
453
+ const resetAt = await this.resetAt(token.account);
454
+ allowanceScheduler.noteReset(ANTIGRAVITY_PROVIDER_ID, token.account, resetAt);
455
+ continue;
456
+ }
457
+ if (!response.ok)
458
+ throw await httpLlmError(response, 'Antigravity API');
459
+ if (response.body === null) {
460
+ throw new LlmError('Antigravity API returned no response body', EMPTY_RESPONSE_CODE);
461
+ }
462
+ yield* streamAntigravity(response.body, () => { watchdog.pulse(); });
463
+ return;
430
464
  }
431
- if (!response.ok)
432
- throw await httpLlmError(response, 'Antigravity API');
433
- if (response.body === null) {
434
- throw new LlmError('Antigravity API returned no response body', EMPTY_RESPONSE_CODE);
435
- }
436
- yield* streamAntigravity(response.body, () => { watchdog.pulse(); });
465
+ throw new LlmError('Antigravity API: every account\'s allowance is spent', EMPTY_RESPONSE_CODE);
437
466
  }
438
467
  catch (error) {
439
468
  throw mapFetchFailure('Antigravity API', this.refreshHint(error), watchdog, options.signal);
@@ -442,6 +471,28 @@ export class AntigravityAdapter extends LlmAdapter {
442
471
  watchdog.stop();
443
472
  }
444
473
  }
474
+ /** Whether an account is remembered as spent until a future reset. */
475
+ isSpent(account) {
476
+ return allowanceScheduler.isSpentUntilReset(ANTIGRAVITY_PROVIDER_ID, account);
477
+ }
478
+ /** The moment an account's allowance is known to reset, when recorded. */
479
+ async resetAt(account) {
480
+ const held = await this.options.tokens.session(account);
481
+ if (held === undefined)
482
+ return undefined;
483
+ try {
484
+ const read = await this.usage(held.session.accessToken, held.session.projectId ?? '', held.session.email);
485
+ const spent = read.windows.find((window) => (window.usedPercent ?? 0) >= 100);
486
+ return spent?.resetsAt;
487
+ }
488
+ catch {
489
+ return undefined;
490
+ }
491
+ }
492
+ /** Remember an account as spent (its exact reset is learned separately). */
493
+ noteSpent(account) {
494
+ this.options.onWarn?.(`antigravity: account ${account} allowance spent; moving to another account`);
495
+ }
445
496
  /** Translate an auth failure into the message a user can act on. */
446
497
  refreshHint(error) {
447
498
  if (error instanceof ProviderError && error.code === REAUTH_REQUIRED) {
@@ -100,6 +100,14 @@ export function createArkProvider(options) {
100
100
  discovery: options.discovery,
101
101
  streamIdleTimeoutMs: options.streamIdleTimeoutMs,
102
102
  tokens,
103
+ readUsage: async (account) => {
104
+ const held = await tokens.session(account);
105
+ const sts = held?.session.sts;
106
+ if (sts === undefined)
107
+ return [];
108
+ const read = await readArkUsage({ credentials: sts, fetchFn });
109
+ return read.windows;
110
+ },
103
111
  ...options.onWarn === undefined ? {} : { onWarn: options.onWarn },
104
112
  ...options.resolveAttachments === undefined ? {} : { resolveAttachments: options.resolveAttachments },
105
113
  hiddenModels: () => options.preferences.hiddenIds(ARK_PROVIDER_ID),
@@ -22,6 +22,7 @@ import type { TokenManager } from '../auth/tokens.js';
22
22
  import type { ArkStsCredentials } from '../auth/ark-control.js';
23
23
  import type { FetchFn } from '../http.js';
24
24
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
25
+ import type { UsageWindow } from '../protocol.js';
25
26
  import type { CatalogEntry } from './catalog.js';
26
27
  import type { UsageRead } from './usage.js';
27
28
  /** Provider route this module serves. */
@@ -91,6 +92,8 @@ export interface ArkAdapterOptions {
91
92
  resolveAttachments?: () => AttachmentStore | undefined;
92
93
  onWarn?: (message: string) => void;
93
94
  onCredentialRejected?: (account: string) => void;
95
+ /** Read one account's metered windows, so the streamer can steer away from a spent one. */
96
+ readUsage?: ((account: string) => Promise<readonly UsageWindow[]>) | undefined;
94
97
  fetchFn?: FetchFn;
95
98
  }
96
99
  /** The adapter behind the `ark` route. */
@@ -21,7 +21,7 @@ import { finiteInstant, nonEmpty } from '../auth/store.js';
21
21
  import { callArkControl, parseArkFreeUsage, parseArkPlanUsage } from '../auth/ark-control.js';
22
22
  import { proxiedFetch } from '../http.js';
23
23
  import { findEntry, resolveCatalog } from './catalog.js';
24
- import { streamProviderResponses } from './responses-stream.js';
24
+ import { streamProviderResponses, accountPoolHooks } from './responses-stream.js';
25
25
  import { toResponsesInput, toResponsesTools } from '../translate/responses.js';
26
26
  /** Provider route this module serves. */
27
27
  export const ARK_PROVIDER_ID = 'ark';
@@ -167,6 +167,9 @@ export class ArkAdapter extends LlmAdapter {
167
167
  tokens: this.options.tokens,
168
168
  request: (call, model, accessToken, signal) => this.request(call, model, accessToken, signal),
169
169
  streamIdleTimeoutMs: this.options.streamIdleTimeoutMs,
170
+ // A 402/429 means this account's allowance is spent: draw on the next account's.
171
+ isAllowanceExhausted: (status) => status === 402 || status === 429,
172
+ ...accountPoolHooks({ provider: ARK_PROVIDER_ID, tokens: this.options.tokens, readUsage: this.options.readUsage }),
170
173
  ...this.options.onCredentialRejected === undefined
171
174
  ? {}
172
175
  : { onCredentialRejected: this.options.onCredentialRejected },
@@ -110,6 +110,11 @@ export function createGlmProvider(options) {
110
110
  ...options.resolveAttachments === undefined ? {} : { resolveAttachments: options.resolveAttachments },
111
111
  hiddenModels: () => options.preferences.hiddenIds(GLM_PROVIDER_ID),
112
112
  userModels: () => options.preferences.extraIds(GLM_PROVIDER_ID),
113
+ readUsage: async (account) => {
114
+ const token = await tokens.resolve(account);
115
+ const read = await readGlmUsage({ origin: GLM_ORIGIN, accessToken: token.accessToken, fetchFn });
116
+ return read.windows;
117
+ },
113
118
  ...options.extraModels === undefined ? {} : { extraModels: options.extraModels },
114
119
  fetchFn,
115
120
  });
@@ -86,6 +86,8 @@ export interface GlmAdapterOptions {
86
86
  resolveAttachments?: () => AttachmentStore | undefined;
87
87
  onWarn?: (message: string) => void;
88
88
  onCredentialRejected?: (account: string) => void;
89
+ /** Read one account's metered windows, so the streamer can steer away from a spent one. */
90
+ readUsage?: ((account: string) => Promise<readonly UsageWindow[]>) | undefined;
89
91
  /** Fetch implementation; defaults to the proxy-aware fetch. */
90
92
  fetchFn?: FetchFn;
91
93
  }
@@ -13,7 +13,7 @@ import { REAUTH_REQUIRED, ProviderError } from '../errors.js';
13
13
  import { finiteInstant, nonEmpty } from '../auth/store.js';
14
14
  import { proxiedFetch } from '../http.js';
15
15
  import { findEntry, resolveCatalog } from './catalog.js';
16
- import { streamProviderResponses } from './responses-stream.js';
16
+ import { streamProviderResponses, accountPoolHooks } from './responses-stream.js';
17
17
  import { toResponsesInput, toResponsesTools } from '../translate/responses.js';
18
18
  /** Provider route this module serves. */
19
19
  export const GLM_PROVIDER_ID = 'glm';
@@ -278,6 +278,9 @@ export class GlmAdapter extends LlmAdapter {
278
278
  tokens: this.options.tokens,
279
279
  request: (call, model, accessToken, signal) => this.request(call, model, accessToken, signal),
280
280
  streamIdleTimeoutMs: this.options.streamIdleTimeoutMs,
281
+ // A 402/429 means this account's allowance is spent: draw on the next account's.
282
+ isAllowanceExhausted: (status) => status === 402 || status === 429,
283
+ ...accountPoolHooks({ provider: GLM_PROVIDER_ID, tokens: this.options.tokens, readUsage: this.options.readUsage }),
281
284
  ...this.options.onCredentialRejected === undefined
282
285
  ? {}
283
286
  : { onCredentialRejected: this.options.onCredentialRejected },
@@ -18,6 +18,7 @@ import type { ProviderRoute } from '../auth/registry.js';
18
18
  import type { ModelPreferences } from '../settings.js';
19
19
  import type { FetchFn } from '../http.js';
20
20
  import type { CatalogEntry } from './catalog.js';
21
+ import type { UsageWindow } from '../protocol.js';
21
22
  /** Route key this module registers. */
22
23
  export declare const GROK_PROVIDER_ID = "grok";
23
24
  /** Display name for selectors and diagnostics. */
@@ -135,6 +136,8 @@ export interface GrokAdapterOptions {
135
136
  modelOrder?: () => Promise<readonly string[]>;
136
137
  fetchFn?: FetchFn;
137
138
  onCredentialRejected?: (account: string) => void;
139
+ /** Read one account's metered windows, so the streamer can steer away from a spent one. */
140
+ readUsage?: ((account: string) => Promise<readonly UsageWindow[]>) | undefined;
138
141
  }
139
142
  /** Grok wire adapter: one instance serves the `grok` provider route. */
140
143
  export declare class GrokAdapter extends LlmAdapter {
@@ -18,7 +18,7 @@ import { ProviderController } from '../auth/provider-controller.js';
18
18
  import { proxiedFetch } from '../http.js';
19
19
  import { fallbackEntry, findEntry, resolveCatalog } from './catalog.js';
20
20
  import { isMissingOrInvalidCredential } from './common.js';
21
- import { streamProviderResponses } from './responses-stream.js';
21
+ import { streamProviderResponses, accountPoolHooks } from './responses-stream.js';
22
22
  import { readGrokUsage } from './usage.js';
23
23
  import { toResponsesInput, toResponsesTools } from '../translate/responses.js';
24
24
  /** Route key this module registers. */
@@ -334,6 +334,9 @@ export class GrokAdapter extends LlmAdapter {
334
334
  tokens: this.options.tokens,
335
335
  request: (call, model, accessToken, signal) => this.request(call, model, accessToken, signal),
336
336
  streamIdleTimeoutMs: this.options.streamIdleTimeoutMs,
337
+ // A 402/429 means this account's allowance is spent: draw on the next account's.
338
+ isAllowanceExhausted: (status) => status === 402 || status === 429,
339
+ ...accountPoolHooks({ provider: GROK_PROVIDER_ID, tokens: this.options.tokens, readUsage: this.options.readUsage }),
337
340
  ...this.options.onCredentialRejected === undefined
338
341
  ? {}
339
342
  : { onCredentialRejected: this.options.onCredentialRejected },
@@ -648,6 +651,11 @@ export function createGrokProvider(options) {
648
651
  hiddenModels: () => options.preferences.hiddenIds(GROK_PROVIDER_ID),
649
652
  userModels: () => options.preferences.extraIds(GROK_PROVIDER_ID),
650
653
  modelOrder: () => options.preferences.orderOf(GROK_PROVIDER_ID),
654
+ readUsage: async (account) => {
655
+ const token = await tokens.resolve(account);
656
+ const read = await readGrokUsage({ accessToken: token.accessToken, fetchFn: options.discoveryFetchFn ?? proxiedFetch });
657
+ return read === undefined ? [] : read.windows;
658
+ },
651
659
  ...options.discoveryFetchFn === undefined ? {} : { fetchFn: options.discoveryFetchFn },
652
660
  });
653
661
  const spec = {
@@ -90,6 +90,11 @@ export function createKimiProvider(options) {
90
90
  ...options.onWarn === undefined ? {} : { onWarn: options.onWarn },
91
91
  ...options.resolveAttachments === undefined ? {} : { resolveAttachments: options.resolveAttachments },
92
92
  identityHeaders: async () => kimiIdentityHeaders(await loadOrCreateDeviceId(options.dataDir)),
93
+ readUsage: async (account) => {
94
+ const token = await tokens.resolve(account);
95
+ const read = await readKimiUsage({ apiBase: options.apiBase, accessToken: token.accessToken, fetchFn });
96
+ return read === undefined ? [] : read.windows;
97
+ },
93
98
  hiddenModels: () => options.preferences.hiddenIds(KIMI_PROVIDER_ID),
94
99
  userModels: () => options.preferences.extraIds(KIMI_PROVIDER_ID),
95
100
  modelOrder: () => options.preferences.orderOf(KIMI_PROVIDER_ID),
@@ -10,6 +10,7 @@ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
10
10
  import type { FetchFn } from '../http.js';
11
11
  import type { TokenManager } from '../auth/tokens.js';
12
12
  import type { SessionBase } from '../auth/store.js';
13
+ import type { UsageWindow } from '../protocol.js';
13
14
  import type { CatalogEntry } from './catalog.js';
14
15
  /** One catalog entry exposed as a selectable model. */
15
16
  export type KimiModelEntry = CatalogEntry;
@@ -62,6 +63,14 @@ export interface KimiAdapterOptions {
62
63
  fetchFn?: FetchFn;
63
64
  /** Called when a call proved the stored credential unusable. */
64
65
  onCredentialRejected?: (account: string) => void;
66
+ /**
67
+ * Read one account's metered windows, so the streamer can steer away from an account
68
+ * whose allowance is spent before it fails there.
69
+ *
70
+ * Resolved per request; absence means the streamer only learns an account is spent by
71
+ * failing against it once.
72
+ */
73
+ readUsage?: (account: string) => Promise<readonly UsageWindow[]>;
65
74
  /** Extra model ids from the plugin configuration, appended to the catalog. */
66
75
  extraModels?: readonly string[];
67
76
  /**
@@ -11,7 +11,7 @@ import { kimiRequestHeaders } from '../auth/headers.js';
11
11
  import { REAUTH_REQUIRED, ProviderError } from '../errors.js';
12
12
  import { finiteInstant, nonEmpty } from '../auth/store.js';
13
13
  import { idleWatchdog, httpLlmError, isMissingOrInvalidCredential, mapFetchFailure } from './common.js';
14
- import { streamProviderResponses } from './responses-stream.js';
14
+ import { streamProviderResponses, accountPoolHooks } from './responses-stream.js';
15
15
  import { fallbackEntry, findEntry, resolveCatalog } from './catalog.js';
16
16
  import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
17
17
  import { prepareMoonshotTool } from '../translate/moonshot-schema.js';
@@ -228,6 +228,13 @@ export class KimiAdapter extends LlmAdapter {
228
228
  tokens: this.options.tokens,
229
229
  request: (call, model, accessToken, signal) => this.request(call, model, accessToken, signal),
230
230
  streamIdleTimeoutMs: this.options.streamIdleTimeoutMs,
231
+ // A 402/429 means this account's allowance is spent: draw on the next account's
232
+ // before giving up, so one model id uses the whole pool's quota.
233
+ isAllowanceExhausted: (status) => status === 402 || status === 429,
234
+ ...accountPoolHooks({ provider: KIMI_PROVIDER_ID, tokens: this.options.tokens, readUsage: this.options.readUsage }),
235
+ onAccountSwitch: (from, to, reason) => {
236
+ this.options.onWarn?.(`kimi: account ${from} allowance spent (${reason}); switching to ${to}`);
237
+ },
231
238
  ...this.options.onCredentialRejected === undefined
232
239
  ? {}
233
240
  : { onCredentialRejected: this.options.onCredentialRejected },
@@ -14,6 +14,24 @@
14
14
  import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
15
15
  import type { SessionBase } from '../auth/store.js';
16
16
  import type { TokenManager } from '../auth/tokens.js';
17
+ import type { UsageWindow } from '../protocol.js';
18
+ /**
19
+ * Build the cross-account hooks one route needs to draw on its whole account pool.
20
+ *
21
+ * Every Responses-wire route (Kimi, Grok, GLM, Ark) meters per account and can run out;
22
+ * this wires the three account hooks identically, so a route only supplies how to read
23
+ * one account's allowance and its provider id. A route with no `readUsage` still rotates
24
+ * accounts on failure, it just cannot predict exhaustion before the first failure.
25
+ *
26
+ * @param provider - the provider route id, the scheduler's key.
27
+ * @param tokens - the route's token manager, enumerates accounts.
28
+ * @param readUsage - read one account's metered windows, when wired.
29
+ */
30
+ export declare function accountPoolHooks<S extends SessionBase>(input: {
31
+ provider: string;
32
+ tokens: TokenManager<S>;
33
+ readUsage?: ((account: string) => Promise<readonly UsageWindow[]>) | undefined;
34
+ }): Pick<ResponsesStreamInput<S>, 'accounts' | 'isAccountSpent' | 'noteAccountSpent' | 'resetsAt'>;
17
35
  /** Constructor inputs for {@link streamProviderResponses}. */
18
36
  export interface ResponsesStreamInput<S extends SessionBase> {
19
37
  /** Endpoint name used in failure messages, e.g. `Kimi API`. */
@@ -37,6 +55,32 @@ export interface ResponsesStreamInput<S extends SessionBase> {
37
55
  describeFailure?(error: unknown): unknown;
38
56
  /** Called when a model in the chain was skipped, with the reason. */
39
57
  onFallback?: (from: string, to: string, reason: string) => void;
58
+ /**
59
+ * Whether a 402/429 on this provider means "this account's allowance is spent",
60
+ * so the call should move to the next account rather than fail.
61
+ *
62
+ * When omitted, an exhausted allowance fails the call (the old behaviour). When
63
+ * set, the caller enumerates the provider's other accounts and retries each until
64
+ * one serves the request or every account is spent.
65
+ */
66
+ isAllowanceExhausted?: (status: number) => boolean;
67
+ /** Enumerate the provider's accounts, most preferred first; enables cross-account retry. */
68
+ accounts?: () => Promise<readonly string[]>;
69
+ /** Called when a call moved to another account because the first was spent. */
70
+ onAccountSwitch?: (from: string, to: string, reason: string) => void;
71
+ /**
72
+ * Whether one account's allowance is best not used for a fresh request right now.
73
+ *
74
+ * The caller supplies this to steer a request away from an account that is nearly
75
+ * spent (or already spent until a remembered reset), so the request starts on a fuller
76
+ * account instead of failing once to find out. It may read the account's live figures;
77
+ * an account flagged here is tried last, not skipped, so a lone account still serves.
78
+ */
79
+ isAccountSpent?: (account: string) => Promise<boolean>;
80
+ /** Remember that an account's allowance is spent until a given reset moment. */
81
+ noteAccountSpent?: (account: string, resetsAt: number | undefined) => void;
82
+ /** The moment one account's allowance is known to reset, when it was recorded. */
83
+ resetsAt?: (account: string) => Promise<number | undefined>;
40
84
  }
41
85
  /**
42
86
  * Run one model call over the Responses wire, walking a model chain.