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.
@@ -12,8 +12,49 @@
12
12
  * @module dsh-plugin-subscription-oauth/providers/responses-stream
13
13
  */
14
14
  import { EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm';
15
+ import { allowanceScheduler } from '../auth/allowance-scheduler.js';
15
16
  import { streamResponses } from '../translate/responses.js';
16
17
  import { httpLlmError, idleWatchdog, mapFetchFailure } from './common.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 function accountPoolHooks(input) {
31
+ const { provider, tokens, readUsage } = input;
32
+ return {
33
+ accounts: () => tokens.accounts(),
34
+ // Steer away from an account that has no allowance: read its live figures when a
35
+ // reader is wired (so a 100 % window is avoided before it fails there), else fall
36
+ // back to the remembered reset time from a past failure.
37
+ isAccountSpent: async (account) => {
38
+ if (readUsage !== undefined) {
39
+ try {
40
+ const windows = await readUsage(account);
41
+ const spentWindow = windows.find((window) => (window.usedPercent ?? 0) >= 100);
42
+ if (spentWindow !== undefined) {
43
+ allowanceScheduler.noteReset(provider, account, spentWindow.resetsAt);
44
+ return true;
45
+ }
46
+ return false;
47
+ }
48
+ catch {
49
+ // A failed read is not a reason to avoid the account; fall through to memory.
50
+ }
51
+ }
52
+ return allowanceScheduler.isSpentUntilReset(provider, account);
53
+ },
54
+ noteAccountSpent: (account, resetsAt) => { allowanceScheduler.noteReset(provider, account, resetsAt); },
55
+ resetsAt: async (account) => allowanceScheduler.resetAt(provider, account),
56
+ };
57
+ }
17
58
  /**
18
59
  * Whether a refusal says "this model cannot serve you now", as opposed to "this
19
60
  * request is wrong".
@@ -60,20 +101,92 @@ export async function* streamProviderResponses(options, input) {
60
101
  if (models.length === 0) {
61
102
  throw new LlmError(`${label} has no enabled model to dispatch to`, EMPTY_RESPONSE_CODE);
62
103
  }
63
- let token = await tokens.resolve();
64
- for (const [index, model] of models.entries()) {
65
- try {
66
- let response = await request(options, model, token.accessToken, watchdog.signal);
67
- if (response.status === 401 || response.status === 403) {
68
- token = await tokens.resolve(token.account, true);
69
- response = await request(options, model, token.accessToken, watchdog.signal);
104
+ // When the provider meters per account and an account's allowance can run out,
105
+ // every account is a candidate: a spent one falls through to the next, so one
106
+ // model id draws on the whole pool's allowance. Accounts are tried in order;
107
+ // the first that serves the request wins.
108
+ let accounts = input.accounts === undefined
109
+ ? undefined
110
+ : [...await input.accounts()];
111
+ // Send work straight to an account that has allowance: one already known to be
112
+ // spent until a future reset is moved to the back, so a fresh request does not
113
+ // fail against it once to find out. This is a preference, not a skip — a lone
114
+ // account still serves, and the spent-retry below catches a stale record.
115
+ if (accounts !== undefined && accounts.length > 1 && input.isAccountSpent !== undefined) {
116
+ const fresh = [];
117
+ const tired = [];
118
+ for (const account of accounts) {
119
+ if (await input.isAccountSpent(account))
120
+ tired.push(account);
121
+ else
122
+ fresh.push(account);
123
+ }
124
+ if (tired.length > 0 && fresh.length > 0) {
125
+ accounts = [...fresh, ...tired];
126
+ }
127
+ }
128
+ const tryAccounts = accounts === undefined || accounts.length === 0
129
+ ? [undefined]
130
+ : accounts;
131
+ for (const accountKey of tryAccounts) {
132
+ let token = await tokens.resolve(accountKey);
133
+ // Track which accounts a spent-allowance already moved past, so a later model
134
+ // in the chain does not re-try an account known to be exhausted.
135
+ const spent = new Set();
136
+ for (const [index, model] of models.entries()) {
137
+ try {
138
+ if (spent.has(token.account))
139
+ continue;
140
+ let response = await request(options, model, token.accessToken, watchdog.signal);
70
141
  if (response.status === 401 || response.status === 403) {
71
- input.onCredentialRejected?.(token.account);
142
+ token = await tokens.resolve(token.account, true);
143
+ response = await request(options, model, token.accessToken, watchdog.signal);
144
+ if (response.status === 401 || response.status === 403) {
145
+ input.onCredentialRejected?.(token.account);
146
+ }
147
+ }
148
+ // A spent allowance moves to the next account (when the provider supports
149
+ // it), not to the next model: the model is fine, the account is not.
150
+ if (!response.ok
151
+ && input.isAllowanceExhausted !== undefined
152
+ && input.accounts !== undefined
153
+ && input.isAllowanceExhausted(response.status)
154
+ && tryAccounts.length > 1) {
155
+ spent.add(token.account);
156
+ // Remember when this account's allowance refills, so later requests go
157
+ // straight to another account until then instead of failing here again.
158
+ input.noteAccountSpent?.(token.account, await input.resetsAt?.(token.account));
159
+ const next = tryAccounts.find((candidate) => candidate !== undefined && !spent.has(candidate));
160
+ if (next !== undefined) {
161
+ lastError = await httpLlmError(response, label);
162
+ input.onAccountSwitch?.(token.account, next, reasonOf(lastError));
163
+ token = await tokens.resolve(next);
164
+ // Re-try this same model on the fresh account.
165
+ response = await request(options, model, token.accessToken, watchdog.signal);
166
+ }
167
+ }
168
+ if (!response.ok) {
169
+ const error = await httpLlmError(response, label);
170
+ if (models.length > 1 && isModelFallbackWorthy(response.status) && index < models.length - 1) {
171
+ const next = models[index + 1];
172
+ if (next !== undefined) {
173
+ lastError = error;
174
+ input.onFallback?.(model, next, reasonOf(error));
175
+ continue;
176
+ }
177
+ }
178
+ throw error;
72
179
  }
180
+ if (response.body === null) {
181
+ throw new LlmError(`${label} returned no response body`, EMPTY_RESPONSE_CODE);
182
+ }
183
+ yield* streamResponses(response.body, () => { watchdog.pulse(); });
184
+ return;
73
185
  }
74
- if (!response.ok) {
75
- const error = await httpLlmError(response, label);
76
- if (models.length > 1 && isModelFallbackWorthy(response.status) && index < models.length - 1) {
186
+ catch (error) {
187
+ // A transport failure on a chain is also worth another model.
188
+ const transport = error instanceof TypeError || error.name === 'AbortError';
189
+ if (transport && options.signal?.aborted !== true && index < models.length - 1) {
77
190
  const next = models[index + 1];
78
191
  if (next !== undefined) {
79
192
  lastError = error;
@@ -81,26 +194,8 @@ export async function* streamProviderResponses(options, input) {
81
194
  continue;
82
195
  }
83
196
  }
84
- throw error;
85
- }
86
- if (response.body === null) {
87
- throw new LlmError(`${label} returned no response body`, EMPTY_RESPONSE_CODE);
88
- }
89
- yield* streamResponses(response.body, () => { watchdog.pulse(); });
90
- return;
91
- }
92
- catch (error) {
93
- // A transport failure on a chain is also worth another model.
94
- const transport = error instanceof TypeError || error.name === 'AbortError';
95
- if (transport && options.signal?.aborted !== true && index < models.length - 1) {
96
- const next = models[index + 1];
97
- if (next !== undefined) {
98
- lastError = error;
99
- input.onFallback?.(model, next, reasonOf(error));
100
- continue;
101
- }
197
+ throw input.describeFailure === undefined ? error : input.describeFailure(error);
102
198
  }
103
- throw input.describeFailure === undefined ? error : input.describeFailure(error);
104
199
  }
105
200
  }
106
201
  throw lastError ?? new LlmError(`${label} served none of the requested models`, EMPTY_RESPONSE_CODE);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subscription-oauth",
3
- "version": "0.6.1",
3
+ "version": "0.8.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",