dsh-plugin-subscription-oauth 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +413 -2
- package/cordis.patch.yml +13 -0
- package/lib/auth/device-flow.d.ts +112 -0
- package/lib/auth/device-flow.js +319 -0
- package/lib/auth/device-id.d.ts +31 -0
- package/lib/auth/device-id.js +58 -0
- package/lib/auth/flow.d.ts +33 -0
- package/lib/auth/flow.js +12 -0
- package/lib/auth/glm-flow.d.ts +99 -0
- package/lib/auth/glm-flow.js +194 -0
- package/lib/auth/headers.d.ts +61 -0
- package/lib/auth/headers.js +106 -0
- package/lib/auth/pkce-flow.d.ts +78 -0
- package/lib/auth/pkce-flow.js +205 -0
- package/lib/auth/provider-controller.d.ts +241 -0
- package/lib/auth/provider-controller.js +502 -0
- package/lib/auth/registry.d.ts +91 -0
- package/lib/auth/registry.js +149 -0
- package/lib/auth/rpc.d.ts +31 -0
- package/lib/auth/rpc.js +200 -0
- package/lib/auth/store.d.ts +99 -0
- package/lib/auth/store.js +213 -0
- package/lib/auth/tokens.d.ts +76 -0
- package/lib/auth/tokens.js +103 -0
- package/lib/client/SubscriptionsSection.d.ts +34 -0
- package/lib/client/SubscriptionsSection.js +742 -0
- package/lib/client/index.d.ts +36 -0
- package/lib/client/index.js +47 -0
- package/lib/client/locales.d.ts +187 -0
- package/lib/client/locales.js +185 -0
- package/lib/client/styles.d.ts +18 -0
- package/lib/client/styles.js +257 -0
- package/lib/client/subscriptions-rpc.d.ts +24 -0
- package/lib/client/subscriptions-rpc.js +44 -0
- package/lib/client.js +2078 -0
- package/lib/client.js.map +1 -0
- package/lib/constants.d.ts +79 -0
- package/lib/constants.js +91 -0
- package/lib/errors.d.ts +28 -0
- package/lib/errors.js +34 -0
- package/lib/http.d.ts +24 -0
- package/lib/http.js +98 -0
- package/lib/index.d.ts +57 -0
- package/lib/index.js +185 -0
- package/lib/protocol.d.ts +316 -0
- package/lib/protocol.js +29 -0
- package/lib/providers/antigravity-catalog.d.ts +53 -0
- package/lib/providers/antigravity-catalog.js +269 -0
- package/lib/providers/antigravity.d.ts +331 -0
- package/lib/providers/antigravity.js +821 -0
- package/lib/providers/auto.d.ts +151 -0
- package/lib/providers/auto.js +351 -0
- package/lib/providers/catalog.d.ts +69 -0
- package/lib/providers/catalog.js +78 -0
- package/lib/providers/common.d.ts +51 -0
- package/lib/providers/common.js +169 -0
- package/lib/providers/glm-provider.d.ts +41 -0
- package/lib/providers/glm-provider.js +165 -0
- package/lib/providers/glm.d.ts +159 -0
- package/lib/providers/glm.js +445 -0
- package/lib/providers/grok.d.ts +224 -0
- package/lib/providers/grok.js +700 -0
- package/lib/providers/kimi-provider.d.ts +45 -0
- package/lib/providers/kimi-provider.js +148 -0
- package/lib/providers/kimi.d.ts +151 -0
- package/lib/providers/kimi.js +400 -0
- package/lib/providers/responses-stream.d.ts +58 -0
- package/lib/providers/responses-stream.js +115 -0
- package/lib/providers/usage.d.ts +65 -0
- package/lib/providers/usage.js +309 -0
- package/lib/settings.d.ts +122 -0
- package/lib/settings.js +333 -0
- package/lib/translate/antigravity-schema.d.ts +24 -0
- package/lib/translate/antigravity-schema.js +174 -0
- package/lib/translate/antigravity.d.ts +184 -0
- package/lib/translate/antigravity.js +419 -0
- package/lib/translate/responses.d.ts +156 -0
- package/lib/translate/responses.js +395 -0
- package/lib/translate/sse.d.ts +27 -0
- package/lib/translate/sse.js +82 -0
- package/package.json +126 -4
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-provider login/lifecycle controller: the single place one route's
|
|
3
|
+
* Settings state is derived from.
|
|
4
|
+
*
|
|
5
|
+
* A login is completed by the *browser*, so `login()` returns as soon as a
|
|
6
|
+
* prompt exists and the attempt settles in the background; every later
|
|
7
|
+
* `status()` call reports whether it is still pending, and a failure is recorded
|
|
8
|
+
* so the card can show why instead of silently returning to "logged out".
|
|
9
|
+
*
|
|
10
|
+
* Everything provider-specific arrives through {@link ProviderSpec}, the token
|
|
11
|
+
* manager, and the login flow, so the same controller serves a device-flow
|
|
12
|
+
* provider and a loopback-redirect provider.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-subscription-oauth/auth/provider-controller
|
|
15
|
+
*/
|
|
16
|
+
import { ACCOUNT_NOT_FOUND, BAD_REQUEST, MODEL_NOT_FOUND, REAUTH_REQUIRED, ProviderError } from '../errors.js';
|
|
17
|
+
import { entryMatches, resolveCatalog } from '../providers/catalog.js';
|
|
18
|
+
import { accountLabelOf, accountKeyOf } from './store.js';
|
|
19
|
+
/** Human-readable message for any thrown value. */
|
|
20
|
+
function messageOf(error) {
|
|
21
|
+
return error instanceof Error ? error.message : String(error);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Owns one provider route's login lifecycle and its Settings state.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Whether a failure means the stored grant can never be renewed.
|
|
28
|
+
*
|
|
29
|
+
* OAuth answers `invalid_grant` once a refresh token is revoked, expired or
|
|
30
|
+
* already used — the one case where retrying cannot help and the user must sign in
|
|
31
|
+
* again, so it is worth distinguishing from a transient failure.
|
|
32
|
+
*
|
|
33
|
+
* @param message - the failure text.
|
|
34
|
+
* @returns whether the login is dead.
|
|
35
|
+
*/
|
|
36
|
+
function isDeadGrant(message) {
|
|
37
|
+
return /invalid_grant|invalid grant|authorization grant is invalid|refresh token (?:is )?(?:invalid|expired|revoked)/i.test(message);
|
|
38
|
+
}
|
|
39
|
+
/** Context advertised for a hand-added id on a route that states none. */
|
|
40
|
+
const DEFAULT_FALLBACK_CONTEXT_WINDOW = 262_144;
|
|
41
|
+
/**
|
|
42
|
+
* How long a usage reading may be reused without asking again.
|
|
43
|
+
*
|
|
44
|
+
* Short on purpose: it exists to collapse a burst of reads (a card opening, a
|
|
45
|
+
* poll and a refresh arriving together) into one request, not to keep a figure.
|
|
46
|
+
* Allowances move minute to minute, so anything longer makes the page report the
|
|
47
|
+
* state of an older read as if it were current.
|
|
48
|
+
*/
|
|
49
|
+
const USAGE_FRESH_MS = 30_000;
|
|
50
|
+
export class ProviderController {
|
|
51
|
+
options;
|
|
52
|
+
/** Live catalog ids, once discovery succeeded this session. */
|
|
53
|
+
discovered;
|
|
54
|
+
/** Last background failure a user should see. */
|
|
55
|
+
lastError;
|
|
56
|
+
/** Most recent eager-refresh failure, cleared once a refresh succeeds. */
|
|
57
|
+
lastRefreshError;
|
|
58
|
+
/**
|
|
59
|
+
* Usage figures recently read, keyed by account.
|
|
60
|
+
*
|
|
61
|
+
* The entry is a short-lived coalescing cache, not a stored reading: an
|
|
62
|
+
* allowance moves as the account is used, so a figure kept for the whole
|
|
63
|
+
* session would keep answering with the state of the first read and the card
|
|
64
|
+
* would look like it never noticed an update.
|
|
65
|
+
*/
|
|
66
|
+
usageByAccount = new Map();
|
|
67
|
+
/** Why the last usage read failed, when it did. */
|
|
68
|
+
usageError;
|
|
69
|
+
/** Accounts whose grant the provider refused, so only a new login helps. */
|
|
70
|
+
refused = new Set();
|
|
71
|
+
constructor(options) {
|
|
72
|
+
this.options = options;
|
|
73
|
+
}
|
|
74
|
+
/** Provider route key. */
|
|
75
|
+
get id() {
|
|
76
|
+
return this.options.spec.id;
|
|
77
|
+
}
|
|
78
|
+
/** Human name for selectors and diagnostics. */
|
|
79
|
+
get displayName() {
|
|
80
|
+
return this.options.spec.displayName;
|
|
81
|
+
}
|
|
82
|
+
/** Absolute path of the credential file this route reads. */
|
|
83
|
+
get credentialFile() {
|
|
84
|
+
return this.options.spec.credentialFile;
|
|
85
|
+
}
|
|
86
|
+
/** Current state of the route. */
|
|
87
|
+
async status() {
|
|
88
|
+
// Keep the access token current while a user is looking at the card, so the
|
|
89
|
+
// expiry it shows is the live one rather than a stamp that lapsed minutes
|
|
90
|
+
// after login. Refreshing is de-duplicated per account by the token manager,
|
|
91
|
+
// and a failure must never make `status` itself fail.
|
|
92
|
+
await this.refreshIfLapsing();
|
|
93
|
+
const accounts = await this.options.store.list();
|
|
94
|
+
const defaultAccount = await this.options.store.defaultAccount();
|
|
95
|
+
const pending = this.options.flow.pending();
|
|
96
|
+
const keys = Object.keys(accounts);
|
|
97
|
+
const spec = this.options.spec;
|
|
98
|
+
return {
|
|
99
|
+
provider: spec.id,
|
|
100
|
+
displayName: spec.displayName,
|
|
101
|
+
...spec.codeUrl === undefined ? {} : { codeUrl: spec.codeUrl },
|
|
102
|
+
loggedIn: keys.length > 0,
|
|
103
|
+
busy: pending !== undefined,
|
|
104
|
+
...pending === undefined ? {} : { pending: pending.prompt },
|
|
105
|
+
accounts: keys.map((account) => {
|
|
106
|
+
const session = accounts[account];
|
|
107
|
+
return {
|
|
108
|
+
account,
|
|
109
|
+
label: accountLabelOf(session),
|
|
110
|
+
expiresAt: session.expiresAt,
|
|
111
|
+
expired: session.expiresAt <= Date.now(),
|
|
112
|
+
isDefault: account === defaultAccount,
|
|
113
|
+
...this.refused.has(account) ? { needsLogin: true } : {},
|
|
114
|
+
};
|
|
115
|
+
}),
|
|
116
|
+
...defaultAccount === undefined ? {} : { activeAccount: defaultAccount },
|
|
117
|
+
credentialFile: spec.credentialFile,
|
|
118
|
+
discovery: spec.discovery,
|
|
119
|
+
...this.discovered === undefined ? {} : { discovered: this.discovered },
|
|
120
|
+
models: await this.modelSummaries(),
|
|
121
|
+
...spec.defaultModel === undefined ? {} : { defaultModel: spec.defaultModel },
|
|
122
|
+
...this.usageByAccount.size === 0 ? {} : { usage: [...this.usageByAccount.values()] },
|
|
123
|
+
...this.usageError === undefined ? {} : { usageError: this.usageError },
|
|
124
|
+
details: spec.details(),
|
|
125
|
+
...this.lastError === undefined ? {} : { lastError: this.lastError },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Refresh the default account's token when it is at or near its margin.
|
|
130
|
+
*
|
|
131
|
+
* Best-effort by design: a transient failure leaves the stored session alone
|
|
132
|
+
* and the next model call retries. A permanent failure is recorded so the card
|
|
133
|
+
* can tell the user to sign in again instead of silently showing a dead token.
|
|
134
|
+
*/
|
|
135
|
+
async refreshIfLapsing() {
|
|
136
|
+
const account = await this.options.store.defaultAccount();
|
|
137
|
+
if (account === undefined)
|
|
138
|
+
return;
|
|
139
|
+
const session = await this.options.store.get(account);
|
|
140
|
+
if (session === undefined || session.refreshToken === undefined)
|
|
141
|
+
return;
|
|
142
|
+
try {
|
|
143
|
+
await this.options.tokens.resolve(account);
|
|
144
|
+
if (isReauthRequired(this.lastRefreshError))
|
|
145
|
+
this.lastRefreshError = undefined;
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
this.lastRefreshError = error;
|
|
149
|
+
if (isReauthRequired(error)) {
|
|
150
|
+
this.lastError = `${this.displayName} sign-in has lapsed; connect again`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** The catalog as the page lists it, in the user's order. */
|
|
155
|
+
async modelSummaries() {
|
|
156
|
+
const [hidden, order, added] = await Promise.all([
|
|
157
|
+
this.options.preferences.hiddenIds(this.options.spec.id),
|
|
158
|
+
this.options.preferences.orderOf(this.options.spec.id),
|
|
159
|
+
this.options.preferences.extraIds(this.options.spec.id),
|
|
160
|
+
]);
|
|
161
|
+
const own = new Set((this.options.spec.catalogOwn?.() ?? this.options.spec.catalog()).map((entry) => entry.id));
|
|
162
|
+
// A model the route serves is a synced row even while the manual entry that
|
|
163
|
+
// introduced it is still stored, so the marker follows the route, not the file.
|
|
164
|
+
const userAdded = new Set(added.filter((id) => !own.has(id)));
|
|
165
|
+
const listed = this.resolveOrdered(hidden, order).ranked;
|
|
166
|
+
const known = new Set(listed.map((entry) => entry.id));
|
|
167
|
+
// A hand-added id the adapter has not merged yet is appended here, with the
|
|
168
|
+
// conservative metadata this build can honestly state. The adapter carries the
|
|
169
|
+
// same ids for the picker; this list must not depend on its async refresh.
|
|
170
|
+
const rows = listed.map((entry) => ({
|
|
171
|
+
id: entry.id,
|
|
172
|
+
name: entry.name,
|
|
173
|
+
...entry.description === undefined ? {} : { description: entry.description },
|
|
174
|
+
contextWindow: entry.contextWindow,
|
|
175
|
+
...entry.inputModalities === undefined ? {} : { inputModalities: entry.inputModalities },
|
|
176
|
+
...entry.defaultEffort === undefined ? {} : { defaultEffort: entry.defaultEffort },
|
|
177
|
+
visible: !hidden.has(entry.id),
|
|
178
|
+
...userAdded.has(entry.id) ? { userAdded: true } : {},
|
|
179
|
+
}));
|
|
180
|
+
for (const id of added) {
|
|
181
|
+
if (known.has(id) || own.has(id))
|
|
182
|
+
continue;
|
|
183
|
+
known.add(id);
|
|
184
|
+
rows.push({
|
|
185
|
+
id,
|
|
186
|
+
name: id,
|
|
187
|
+
description: 'Added by hand in Settings',
|
|
188
|
+
contextWindow: this.options.spec.fallbackContextWindow ?? DEFAULT_FALLBACK_CONTEXT_WINDOW,
|
|
189
|
+
visible: !hidden.has(id),
|
|
190
|
+
userAdded: true,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return rows;
|
|
194
|
+
}
|
|
195
|
+
/** This route's catalog after the user's order and visibility are applied. */
|
|
196
|
+
resolveOrdered(hidden, order) {
|
|
197
|
+
return resolveCatalog(this.options.spec.catalog(), order, hidden);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Begin an interactive login, returning the prompt to display.
|
|
201
|
+
*
|
|
202
|
+
* The returned status is already `busy`, because the attempt is registered
|
|
203
|
+
* before this resolves; the login itself settles in the background.
|
|
204
|
+
*
|
|
205
|
+
* @param account - existing account key to replace, for a re-login.
|
|
206
|
+
* @returns the pending prompt plus a fresh status.
|
|
207
|
+
*/
|
|
208
|
+
async login(account) {
|
|
209
|
+
const attempt = await this.options.flow.start();
|
|
210
|
+
this.lastError = undefined;
|
|
211
|
+
// A new sign-in replaces the credential, so a previous refusal no longer
|
|
212
|
+
// describes what is stored.
|
|
213
|
+
this.refused.clear();
|
|
214
|
+
void this.completeLogin(attempt.session, account);
|
|
215
|
+
return { prompt: attempt.prompt, status: await this.status() };
|
|
216
|
+
}
|
|
217
|
+
/** Persist the account once the browser approves, or record why it did not. */
|
|
218
|
+
async completeLogin(session, account) {
|
|
219
|
+
try {
|
|
220
|
+
const settled = await session;
|
|
221
|
+
await this.options.store.save(account ?? accountKeyOf(settled), settled);
|
|
222
|
+
this.lastError = undefined;
|
|
223
|
+
// A fresh login can reveal models this build has not seen yet.
|
|
224
|
+
await this.discover(true);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (isCancellation(error))
|
|
228
|
+
return;
|
|
229
|
+
this.lastError = messageOf(error);
|
|
230
|
+
this.options.onWarn?.(`login failed: ${this.lastError}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Abandon the pending authorization, when one exists. */
|
|
234
|
+
async cancel() {
|
|
235
|
+
this.options.flow.pending()?.cancel();
|
|
236
|
+
return this.status();
|
|
237
|
+
}
|
|
238
|
+
/** Forget one account (the default one when none is named). */
|
|
239
|
+
async logout(account) {
|
|
240
|
+
this.refused.delete(account ?? '');
|
|
241
|
+
const key = account ?? await this.options.store.defaultAccount();
|
|
242
|
+
if (key !== undefined)
|
|
243
|
+
await this.options.store.remove(key);
|
|
244
|
+
return this.status();
|
|
245
|
+
}
|
|
246
|
+
/** Promote one account to default. */
|
|
247
|
+
async setDefault(account) {
|
|
248
|
+
const accounts = await this.options.store.list();
|
|
249
|
+
if (accounts[account] === undefined) {
|
|
250
|
+
throw new ProviderError(`${this.displayName} account ${account} is not signed in`, ACCOUNT_NOT_FOUND);
|
|
251
|
+
}
|
|
252
|
+
await this.options.store.setDefault(account);
|
|
253
|
+
return this.status();
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Refresh one account's access token now.
|
|
257
|
+
*
|
|
258
|
+
* @param account - account to refresh; the default one when omitted.
|
|
259
|
+
* @returns the refreshed expiry and a fresh status.
|
|
260
|
+
* @throws {ProviderError} with `REAUTH_REQUIRED` when the account must sign in again.
|
|
261
|
+
*/
|
|
262
|
+
async refresh(account) {
|
|
263
|
+
const token = await this.options.tokens.resolve(account, true);
|
|
264
|
+
this.lastError = undefined;
|
|
265
|
+
return { account: token.account, expiresAt: token.expiresAt, status: await this.status() };
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Re-read the endpoint's live model list.
|
|
269
|
+
*
|
|
270
|
+
* @param force - fetch again even when a list was already read this session.
|
|
271
|
+
* @returns a fresh status.
|
|
272
|
+
*/
|
|
273
|
+
async discover(force = false) {
|
|
274
|
+
const spec = this.options.spec;
|
|
275
|
+
if (!spec.discovery)
|
|
276
|
+
return this.status();
|
|
277
|
+
if (!force && this.discovered !== undefined)
|
|
278
|
+
return this.status();
|
|
279
|
+
// A logged-out install has nothing to discover, and warning about it on
|
|
280
|
+
// every startup would be noise.
|
|
281
|
+
if (!await this.options.store.hasAny())
|
|
282
|
+
return this.status();
|
|
283
|
+
let accessToken;
|
|
284
|
+
try {
|
|
285
|
+
accessToken = (await this.options.tokens.resolve()).accessToken;
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
// Discovery is a convenience: an unusable session is reported by the
|
|
289
|
+
// card's account list, not as a second failure here.
|
|
290
|
+
this.options.onWarn?.(`${spec.id} model discovery skipped: ${messageOf(error)}`);
|
|
291
|
+
return this.status();
|
|
292
|
+
}
|
|
293
|
+
const ids = await spec.discover(accessToken);
|
|
294
|
+
if (ids !== undefined) {
|
|
295
|
+
this.discovered = ids;
|
|
296
|
+
spec.applyDiscovery?.(ids);
|
|
297
|
+
// A model this route now serves is no longer hand-added: keeping the extra
|
|
298
|
+
// entry would list it as manual and invite a pointless "remove" later.
|
|
299
|
+
await this.forgetDiscovered(ids);
|
|
300
|
+
this.options.onCatalogChanged?.();
|
|
301
|
+
}
|
|
302
|
+
return this.status();
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Drop hand-added ids the route's own catalog now covers.
|
|
306
|
+
*
|
|
307
|
+
* Discovery is the authority on what the endpoint serves, so once it reports an
|
|
308
|
+
* id the manual entry has done its job.
|
|
309
|
+
*
|
|
310
|
+
* @param ids - the ids discovery reported.
|
|
311
|
+
*/
|
|
312
|
+
async forgetDiscovered(ids) {
|
|
313
|
+
// Everything the route itself serves — this run's discovery plus whatever an
|
|
314
|
+
// earlier one already put in the shipped catalog.
|
|
315
|
+
const served = new Set(ids);
|
|
316
|
+
for (const entry of this.options.spec.catalogOwn?.() ?? [])
|
|
317
|
+
served.add(entry.id);
|
|
318
|
+
for (const id of await this.options.preferences.extraIds(this.options.spec.id)) {
|
|
319
|
+
if (served.has(id))
|
|
320
|
+
await this.options.preferences.removeExtra(this.options.spec.id, id);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Set whether one model appears in the session model picker.
|
|
325
|
+
*
|
|
326
|
+
* The route is re-announced so the picker re-queries the catalog; a model
|
|
327
|
+
* hidden here stays resolvable, so a session that already selected it keeps
|
|
328
|
+
* working.
|
|
329
|
+
*
|
|
330
|
+
* @param id - model id as the catalog reports it.
|
|
331
|
+
* @param visible - whether the picker should offer it.
|
|
332
|
+
* @returns a fresh status.
|
|
333
|
+
* @throws {ProviderError} with `MODEL_NOT_FOUND` when the id is not in the catalog.
|
|
334
|
+
*/
|
|
335
|
+
async setModelVisible(id, visible) {
|
|
336
|
+
const known = this.options.spec.catalog().some((entry) => entryMatches(entry, id))
|
|
337
|
+
// A hand-added id is part of the list too, whether or not the adapter has
|
|
338
|
+
// merged it into its own catalog yet.
|
|
339
|
+
|| (await this.options.preferences.extraIds(this.options.spec.id)).includes(id);
|
|
340
|
+
if (!known) {
|
|
341
|
+
throw new ProviderError(`${this.displayName} model ${id} is not in the catalog`, MODEL_NOT_FOUND);
|
|
342
|
+
}
|
|
343
|
+
if (await this.options.preferences.setVisible(this.options.spec.id, id, visible)) {
|
|
344
|
+
this.options.onCatalogChanged?.();
|
|
345
|
+
}
|
|
346
|
+
return this.status();
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Replace this route's model order with the arrangement the user dropped into
|
|
350
|
+
* place.
|
|
351
|
+
*
|
|
352
|
+
* The page sends the complete list it displays, so a drop is one atomic
|
|
353
|
+
* statement of intent rather than a sequence of moves. Ids are validated
|
|
354
|
+
* against the catalog first: a stale page could otherwise persist a list
|
|
355
|
+
* naming a model this build no longer has.
|
|
356
|
+
*
|
|
357
|
+
* @param ids - every catalog model id, in the order the user arranged them.
|
|
358
|
+
* @returns a fresh status.
|
|
359
|
+
* @throws {ProviderError} when an id is not a rankable catalog model.
|
|
360
|
+
*/
|
|
361
|
+
async setModelOrder(ids) {
|
|
362
|
+
const catalog = this.options.spec.catalog();
|
|
363
|
+
const rankable = new Set(catalog.map((entry) => entry.id));
|
|
364
|
+
// Hand-added ids are rankable and droppable like any other row.
|
|
365
|
+
for (const id of await this.options.preferences.extraIds(this.options.spec.id))
|
|
366
|
+
rankable.add(id);
|
|
367
|
+
const next = [];
|
|
368
|
+
for (const id of ids) {
|
|
369
|
+
if (!rankable.has(id)) {
|
|
370
|
+
throw new ProviderError(`${this.displayName} model ${id} is not in the catalog`, MODEL_NOT_FOUND);
|
|
371
|
+
}
|
|
372
|
+
if (!next.includes(id))
|
|
373
|
+
next.push(id);
|
|
374
|
+
}
|
|
375
|
+
// Ids the page did not send keep their catalog position after the ranked
|
|
376
|
+
// ones, so a model added since the page loaded is never dropped.
|
|
377
|
+
for (const entry of catalog) {
|
|
378
|
+
if (!next.includes(entry.id))
|
|
379
|
+
next.push(entry.id);
|
|
380
|
+
}
|
|
381
|
+
if (await this.options.preferences.setOrder(this.options.spec.id, next)) {
|
|
382
|
+
this.options.onCatalogChanged?.();
|
|
383
|
+
}
|
|
384
|
+
return this.status();
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Add one model id by hand, for a model this build's catalog does not know yet.
|
|
388
|
+
*
|
|
389
|
+
* The endpoint is the only authority on which ids it serves, so the id is
|
|
390
|
+
* checked for shape rather than against a list: a model released after this
|
|
391
|
+
* build must be usable without waiting for a plugin update.
|
|
392
|
+
*
|
|
393
|
+
* @param id - the model id to publish on this route.
|
|
394
|
+
* @returns a fresh status.
|
|
395
|
+
* @throws {ProviderError} when the id is unusable or already known.
|
|
396
|
+
*/
|
|
397
|
+
async addModel(id) {
|
|
398
|
+
const trimmed = id.trim();
|
|
399
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:/[\]-]{0,127}$/.test(trimmed)) {
|
|
400
|
+
throw new ProviderError(`"${id}" is not a usable model id`, BAD_REQUEST);
|
|
401
|
+
}
|
|
402
|
+
if (this.options.spec.catalog().some((entry) => entryMatches(entry, trimmed))) {
|
|
403
|
+
throw new ProviderError(`${this.displayName} already offers ${trimmed}`, BAD_REQUEST);
|
|
404
|
+
}
|
|
405
|
+
// Already added by hand: the click is idempotent, not an error.
|
|
406
|
+
const added = await this.options.preferences.extraIds(this.options.spec.id);
|
|
407
|
+
if (added.includes(trimmed))
|
|
408
|
+
return this.status();
|
|
409
|
+
if (await this.options.preferences.addExtra(this.options.spec.id, trimmed)) {
|
|
410
|
+
this.options.onCatalogChanged?.();
|
|
411
|
+
}
|
|
412
|
+
return this.status();
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Forget one hand-added model id.
|
|
416
|
+
* @param id - the id to drop from this route.
|
|
417
|
+
* @returns a fresh status.
|
|
418
|
+
*/
|
|
419
|
+
async removeModel(id) {
|
|
420
|
+
if (await this.options.preferences.removeExtra(this.options.spec.id, id)) {
|
|
421
|
+
this.options.onCatalogChanged?.();
|
|
422
|
+
}
|
|
423
|
+
return this.status();
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Read every account's plan and metered allowances.
|
|
427
|
+
*
|
|
428
|
+
* A provider that states no usage answers with an empty list rather than an
|
|
429
|
+
* error, and one account's failure never hides the others' figures.
|
|
430
|
+
*
|
|
431
|
+
* @param force - read again even when figures were already read this session.
|
|
432
|
+
* @returns the figures plus a fresh status.
|
|
433
|
+
*/
|
|
434
|
+
async usage(force = false) {
|
|
435
|
+
const spec = this.options.spec;
|
|
436
|
+
if (spec.usage === undefined) {
|
|
437
|
+
return { usage: [], status: await this.status() };
|
|
438
|
+
}
|
|
439
|
+
const accounts = Object.keys(await this.options.store.list());
|
|
440
|
+
const collected = [];
|
|
441
|
+
let error;
|
|
442
|
+
for (const account of accounts) {
|
|
443
|
+
const cached = this.usageByAccount.get(account);
|
|
444
|
+
// A reading is reused only briefly, and never when it was a substitute for
|
|
445
|
+
// the route's own accounting: that one has to be replaced by a real read
|
|
446
|
+
// before it can be shown again.
|
|
447
|
+
if (!force && cached !== undefined && Date.now() - cached.fetchedAt < USAGE_FRESH_MS && cached.substitute !== true) {
|
|
448
|
+
collected.push(cached);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
const token = await this.options.tokens.resolve(account);
|
|
453
|
+
const read = await spec.usage(token.accessToken, account);
|
|
454
|
+
if (read === undefined)
|
|
455
|
+
continue;
|
|
456
|
+
const entry = {
|
|
457
|
+
account,
|
|
458
|
+
windows: read.windows,
|
|
459
|
+
fetchedAt: Date.now(),
|
|
460
|
+
...read.plan === undefined ? {} : { plan: read.plan },
|
|
461
|
+
...read.renewsAt === undefined ? {} : { renewsAt: read.renewsAt },
|
|
462
|
+
...read.usageError === undefined ? {} : { substitute: true },
|
|
463
|
+
};
|
|
464
|
+
this.usageByAccount.set(account, entry);
|
|
465
|
+
collected.push(entry);
|
|
466
|
+
// A route may publish a substitute for its primary accounting and say so;
|
|
467
|
+
// that note travels with the figures rather than being dropped here.
|
|
468
|
+
if (read.usageError !== undefined && error === undefined)
|
|
469
|
+
error = read.usageError;
|
|
470
|
+
}
|
|
471
|
+
catch (caught) {
|
|
472
|
+
const message = messageOf(caught);
|
|
473
|
+
// A refresh answered with an invalid grant is terminal for this credential:
|
|
474
|
+
// no retry can renew it, and saying "usage failed" would understate it.
|
|
475
|
+
if (isDeadGrant(message)) {
|
|
476
|
+
this.refused.add(account);
|
|
477
|
+
error = `${spec.displayName} sign-in has expired; connect the account again`;
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
error = message;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
this.usageError = error;
|
|
485
|
+
const status = await this.status();
|
|
486
|
+
return { usage: collected, ...error === undefined ? {} : { error }, status };
|
|
487
|
+
}
|
|
488
|
+
/** Re-read the credential file and the preferences, after an external change. */
|
|
489
|
+
async reload() {
|
|
490
|
+
await this.options.store.reload();
|
|
491
|
+
await this.options.preferences.reload();
|
|
492
|
+
return this.status();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/** Whether a login settled because the user cancelled it. */
|
|
496
|
+
function isCancellation(error) {
|
|
497
|
+
return error instanceof Error && error.message === 'login cancelled';
|
|
498
|
+
}
|
|
499
|
+
/** Whether a failure means the stored credential can never be used again. */
|
|
500
|
+
function isReauthRequired(error) {
|
|
501
|
+
return error instanceof ProviderError && error.code === REAUTH_REQUIRED;
|
|
502
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider registry: one erased façade over every route's controller.
|
|
3
|
+
*
|
|
4
|
+
* The RPC layer talks to this object only, so it never has to know how many
|
|
5
|
+
* providers exist, what their routes are called, or which login shape each one
|
|
6
|
+
* uses. Each route's controller is generic over its own session type; this
|
|
7
|
+
* façade is the single place that erasure happens.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-plugin-subscription-oauth/auth/registry
|
|
10
|
+
*/
|
|
11
|
+
import type { LoginStart, ProviderStatus, RefreshResult, SubscriptionStatus, UsageResult } from '../protocol.js';
|
|
12
|
+
import type { LlmAdapter } from '@deepseek-ai/dsh-llm';
|
|
13
|
+
import type { ProviderLoginStart, ProviderRefreshResult, ProviderUsageResult } from './provider-controller.js';
|
|
14
|
+
/** Everything the registry needs from one provider route. */
|
|
15
|
+
export interface ProviderRoute {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly displayName: string;
|
|
18
|
+
readonly credentialFile: string;
|
|
19
|
+
/** The wire adapter this route registered with the harness. */
|
|
20
|
+
readonly adapter: LlmAdapter;
|
|
21
|
+
status(): Promise<ProviderStatus>;
|
|
22
|
+
login(account?: string): Promise<ProviderLoginStart>;
|
|
23
|
+
cancel(): Promise<ProviderStatus>;
|
|
24
|
+
logout(account?: string): Promise<ProviderStatus>;
|
|
25
|
+
setDefault(account: string): Promise<ProviderStatus>;
|
|
26
|
+
refresh(account?: string): Promise<ProviderRefreshResult>;
|
|
27
|
+
discover(force?: boolean): Promise<ProviderStatus>;
|
|
28
|
+
setModelVisible(id: string, visible: boolean): Promise<ProviderStatus>;
|
|
29
|
+
setModelOrder(ids: readonly string[]): Promise<ProviderStatus>;
|
|
30
|
+
addModel(id: string): Promise<ProviderStatus>;
|
|
31
|
+
removeModel(id: string): Promise<ProviderStatus>;
|
|
32
|
+
usage(force?: boolean): Promise<ProviderUsageResult>;
|
|
33
|
+
reload(): Promise<ProviderStatus>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Routes every Settings call to the provider it names.
|
|
37
|
+
*
|
|
38
|
+
* A provider key the plugin does not serve is a request error, not an internal
|
|
39
|
+
* failure: the message names the routes that do exist.
|
|
40
|
+
*/
|
|
41
|
+
export declare class SubscriptionRegistry {
|
|
42
|
+
private readonly byId;
|
|
43
|
+
constructor(routes: readonly ProviderRoute[]);
|
|
44
|
+
/** Route keys this registry serves, in display order. */
|
|
45
|
+
ids(): readonly string[];
|
|
46
|
+
/** Every route, in registration order. */
|
|
47
|
+
all(): readonly ProviderRoute[];
|
|
48
|
+
/** One route by key, without the request-error path. */
|
|
49
|
+
get(id: string): ProviderRoute | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Add one route.
|
|
52
|
+
*
|
|
53
|
+
* `auto` is built last on purpose: it stands in for the others, so it is
|
|
54
|
+
* registered once their catalogs exist, without a circular construction order.
|
|
55
|
+
* That is a construction detail, not a display one — the page shows `auto`
|
|
56
|
+
* first, so the reading order is stated here rather than inherited from the
|
|
57
|
+
* order the routes happened to be built in.
|
|
58
|
+
*
|
|
59
|
+
* @param route - the route to serve from now on.
|
|
60
|
+
* @throws {ProviderError} when the key is already served.
|
|
61
|
+
*/
|
|
62
|
+
register(route: ProviderRoute): void;
|
|
63
|
+
/** One route, or a request error naming the known ones. */
|
|
64
|
+
private route;
|
|
65
|
+
/** Every provider's state, in registration order. */
|
|
66
|
+
status(): Promise<SubscriptionStatus>;
|
|
67
|
+
/** Start a login on one route. */
|
|
68
|
+
login(provider: string, account?: string): Promise<LoginStart>;
|
|
69
|
+
/** Abandon one route's pending login. */
|
|
70
|
+
cancel(provider: string): Promise<SubscriptionStatus>;
|
|
71
|
+
/** Forget one account of one route. */
|
|
72
|
+
logout(provider: string, account?: string): Promise<SubscriptionStatus>;
|
|
73
|
+
/** Promote one account to default. */
|
|
74
|
+
setDefault(provider: string, account: string): Promise<SubscriptionStatus>;
|
|
75
|
+
/** Refresh one account's token now. */
|
|
76
|
+
refresh(provider: string, account?: string): Promise<RefreshResult>;
|
|
77
|
+
/** Re-read one route's live model list. */
|
|
78
|
+
discover(provider: string, force?: boolean): Promise<SubscriptionStatus>;
|
|
79
|
+
/** Set one model's picker visibility. */
|
|
80
|
+
setModelVisible(provider: string, id: string, visible: boolean): Promise<SubscriptionStatus>;
|
|
81
|
+
/** Replace one route's model order with one dropped arrangement. */
|
|
82
|
+
setModelOrder(provider: string, ids: readonly string[]): Promise<SubscriptionStatus>;
|
|
83
|
+
/** Add one model id by hand to one route. */
|
|
84
|
+
addModel(provider: string, id: string): Promise<SubscriptionStatus>;
|
|
85
|
+
/** Forget one hand-added model id. */
|
|
86
|
+
removeModel(provider: string, id: string): Promise<SubscriptionStatus>;
|
|
87
|
+
/** Read one route's plan and metered allowances. */
|
|
88
|
+
usage(provider: string, force?: boolean): Promise<UsageResult>;
|
|
89
|
+
/** Re-read every credential file and the shared preferences. */
|
|
90
|
+
reload(): Promise<SubscriptionStatus>;
|
|
91
|
+
}
|