dsh-openai-subscription 0.1.3 → 0.1.4
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 +37 -8
- package/README.zh.md +37 -8
- package/dist/client.d.ts +8 -0
- package/dist/client.js +507 -170
- package/dist/driver.d.ts +12 -0
- package/dist/driver.js +136 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +58 -0
- package/dist/host.d.ts +27 -25
- package/dist/host.js +236 -398
- package/dist/models.js +91 -55
- package/dist/oauth.js +2 -2
- package/dist/platform.d.ts +17 -0
- package/dist/platform.js +279 -0
- package/package.json +2 -2
package/dist/host.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Host service for ChatGPT subscription authorization in DSH.
|
|
2
2
|
// OAuth is delegated to the OpenAI Codex integration bundled with DSH.
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import { resolveAuthModule, buildNodeCommand, runtimeSupported } from './platform.js';
|
|
4
|
+
export { authModuleCandidates } from './platform.js';
|
|
5
|
+
import { DRIVER_REFRESH, parseDriverMessage, runDeviceDriver } from './driver.js';
|
|
6
|
+
import { SubscriptionError, failureCode, oauthFailureCode, logFailure } from './errors.js';
|
|
5
7
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
6
8
|
import { normalizeOAuthCredential } from './oauth.js';
|
|
7
9
|
import { discoverOpenAIModels, mergeModelCatalog, removeManagedModels, } from './models.js';
|
|
@@ -9,69 +11,9 @@ import { discoverOpenAIModels, mergeModelCatalog, removeManagedModels, } from '.
|
|
|
9
11
|
const KEY = 'dsh-openai-subscription/chatgpt';
|
|
10
12
|
/** Credential consumed by the `openai-codex` model provider. */
|
|
11
13
|
const PI_AI_RECORD = 'llm-pi-ai/openai-codex';
|
|
12
|
-
|
|
13
|
-
'set -eu',
|
|
14
|
-
'CAND=""',
|
|
15
|
-
'B="$(command -v pi 2>/dev/null || true)"',
|
|
16
|
-
'if [ -n "$B" ]; then',
|
|
17
|
-
' R="$(readlink -f "$B" 2>/dev/null || printf "%s" "$B")"',
|
|
18
|
-
' D="$(dirname "$R")"',
|
|
19
|
-
' while [ "$D" != "/" ] && [ -n "$D" ]; do',
|
|
20
|
-
' if [ -f "$D/node_modules/@earendil-works/pi-ai/dist/auth/oauth/openai-codex.js" ]; then',
|
|
21
|
-
' CAND="$D/node_modules/@earendil-works/pi-ai/dist/auth/oauth/openai-codex.js"',
|
|
22
|
-
' break',
|
|
23
|
-
' fi',
|
|
24
|
-
' D="$(dirname "$D")"',
|
|
25
|
-
' done',
|
|
26
|
-
'fi',
|
|
27
|
-
'if [ -z "$CAND" ] && [ -n "${HOME:-}" ] && [ -f "$HOME/.bun/install/global/node_modules/@earendil-works/pi-ai/dist/auth/oauth/openai-codex.js" ]; then',
|
|
28
|
-
' CAND="$HOME/.bun/install/global/node_modules/@earendil-works/pi-ai/dist/auth/oauth/openai-codex.js"',
|
|
29
|
-
'fi',
|
|
30
|
-
'printf "%s" "$CAND"',
|
|
31
|
-
].join('\n');
|
|
32
|
-
/**
|
|
33
|
-
* The OpenAI Codex OAuth implementation, relative to a `node_modules` root.
|
|
34
|
-
* DSH's `llm-pi-ai` adapter already depends on this package, so the copy that
|
|
35
|
-
* matches the running DSH is the preferred source for device authorization.
|
|
36
|
-
*/
|
|
37
|
-
const AUTH_MODULE_PACKAGE_RELATIVE = '@earendil-works/pi-ai/dist/auth/oauth/openai-codex.js';
|
|
38
|
-
const DRIVER_DEVICE = [
|
|
39
|
-
"import { pathToFileURL } from 'node:url'",
|
|
40
|
-
"const { openaiCodexOAuth } = await import(pathToFileURL(process.argv[1]).href)",
|
|
41
|
-
"const out = (o) => { try { process.stdout.write(JSON.stringify(o) + '\\n') } catch {} }",
|
|
42
|
-
"process.stdout.on('error', () => {})",
|
|
43
|
-
"const signal = AbortSignal.timeout(15 * 60 * 1000)",
|
|
44
|
-
"const interaction = {",
|
|
45
|
-
" signal,",
|
|
46
|
-
" notify: (n) => out({ type: 'notice', ...n }),",
|
|
47
|
-
" prompt: async (p) => (p.type === 'select' ? 'device_code' : ''),",
|
|
48
|
-
"}",
|
|
49
|
-
"try {",
|
|
50
|
-
" const credential = await openaiCodexOAuth.login(interaction)",
|
|
51
|
-
" out({ type: 'result', credential })",
|
|
52
|
-
"} catch (error) {",
|
|
53
|
-
" out({ type: 'error', message: error instanceof Error ? error.message : String(error) })",
|
|
54
|
-
"}",
|
|
55
|
-
].join('\n');
|
|
56
|
-
const DRIVER_REFRESH = [
|
|
57
|
-
"import { readFileSync } from 'node:fs'",
|
|
58
|
-
"import { pathToFileURL } from 'node:url'",
|
|
59
|
-
"const { openaiCodexOAuth } = await import(pathToFileURL(process.argv[1]).href)",
|
|
60
|
-
"const out = (o) => { try { process.stdout.write(JSON.stringify(o) + '\\n') } catch {} }",
|
|
61
|
-
"process.stdout.on('error', () => {})",
|
|
62
|
-
"const credential = JSON.parse(readFileSync(0, 'utf8') || '{}')",
|
|
63
|
-
"const signal = AbortSignal.timeout(90 * 1000)",
|
|
64
|
-
"try {",
|
|
65
|
-
" const refreshed = await openaiCodexOAuth.refresh(credential, signal)",
|
|
66
|
-
" out({ type: 'result', credential: refreshed })",
|
|
67
|
-
"} catch (error) {",
|
|
68
|
-
" out({ type: 'error', message: error instanceof Error ? error.message : String(error) })",
|
|
69
|
-
"}",
|
|
70
|
-
].join('\n');
|
|
71
|
-
/** Format an unknown error for logs and user-facing messages. */
|
|
14
|
+
/** Log only machine-readable categories, never raw service diagnostics. */
|
|
72
15
|
function errorMessage(error) {
|
|
73
|
-
|
|
74
|
-
return message ? String(message) : String(error);
|
|
16
|
+
return failureCode(error);
|
|
75
17
|
}
|
|
76
18
|
function recordOf(value) {
|
|
77
19
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
@@ -122,68 +64,6 @@ function unionModelCatalog(remote, builtin) {
|
|
|
122
64
|
function settingsConflict(error) {
|
|
123
65
|
return recordOf(error)?.code === 'SETTINGS_CONFLICT';
|
|
124
66
|
}
|
|
125
|
-
/**
|
|
126
|
-
* Filesystem candidates for the OpenAI auth module, derived from the running
|
|
127
|
-
* DSH process. The first candidate that exists is used, so the module always
|
|
128
|
-
* matches the pi-ai version bundled with the installed DSH regardless of how
|
|
129
|
-
* `pi` (or DSH itself) was installed; the shell locator below only runs when
|
|
130
|
-
* none of these probes hit.
|
|
131
|
-
*/
|
|
132
|
-
export function authModuleCandidates(entryScript, nodeBinary, env) {
|
|
133
|
-
const candidates = [];
|
|
134
|
-
const seen = new Set();
|
|
135
|
-
const push = (value) => {
|
|
136
|
-
if (!value)
|
|
137
|
-
return;
|
|
138
|
-
const normalized = resolve(value);
|
|
139
|
-
if (seen.has(normalized))
|
|
140
|
-
return;
|
|
141
|
-
seen.add(normalized);
|
|
142
|
-
candidates.push(normalized);
|
|
143
|
-
};
|
|
144
|
-
// Walk up from the running DSH entry script. This finds the pi-ai copy
|
|
145
|
-
// bundled inside DSH's own installation for npm-, nvm-, and yarn-style
|
|
146
|
-
// layouts (global or workspace), even when `pi` is a standalone binary
|
|
147
|
-
// or missing entirely.
|
|
148
|
-
if (entryScript) {
|
|
149
|
-
let dir = dirname(entryScript);
|
|
150
|
-
for (let depth = 0; depth < 40 && dir; depth++) {
|
|
151
|
-
push(join(dir, 'node_modules', AUTH_MODULE_PACKAGE_RELATIVE));
|
|
152
|
-
const parent = dirname(dir);
|
|
153
|
-
if (parent === dir)
|
|
154
|
-
break;
|
|
155
|
-
dir = parent;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
// Node's global module roots, mirroring how `module.globalPaths` is built:
|
|
159
|
-
// NODE_PATH entries plus the lib/node_modules directory beside the Node
|
|
160
|
-
// binary (also covers nvm-managed Node on macOS and Linux).
|
|
161
|
-
const globalRoots = [];
|
|
162
|
-
if (nodeBinary) {
|
|
163
|
-
globalRoots.push(resolve(dirname(nodeBinary), '../lib/node_modules'));
|
|
164
|
-
globalRoots.push(join(dirname(nodeBinary), 'node_modules'));
|
|
165
|
-
}
|
|
166
|
-
const nodePath = env?.NODE_PATH;
|
|
167
|
-
if (nodePath) {
|
|
168
|
-
for (const entry of nodePath.split(pathDelimiter)) {
|
|
169
|
-
if (entry)
|
|
170
|
-
globalRoots.push(entry);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
for (const root of globalRoots) {
|
|
174
|
-
// A directly installed pi-ai, or the hoisted copy under a globally
|
|
175
|
-
// installed DSH (npm hoists transitive dependencies into DSH's own
|
|
176
|
-
// node_modules; the nested path covers version-conflicted installs).
|
|
177
|
-
push(join(root, 'node_modules', AUTH_MODULE_PACKAGE_RELATIVE));
|
|
178
|
-
push(join(root, AUTH_MODULE_PACKAGE_RELATIVE));
|
|
179
|
-
push(join(root, '@deepseek-ai/dsh/node_modules', AUTH_MODULE_PACKAGE_RELATIVE));
|
|
180
|
-
push(join(root, '@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-llm-pi-ai/node_modules', AUTH_MODULE_PACKAGE_RELATIVE));
|
|
181
|
-
}
|
|
182
|
-
// bun's global install root, matching the shell locator fallback.
|
|
183
|
-
if (env?.HOME)
|
|
184
|
-
push(join(env.HOME, '.bun/install/global/node_modules', AUTH_MODULE_PACKAGE_RELATIVE));
|
|
185
|
-
return candidates;
|
|
186
|
-
}
|
|
187
67
|
//#endregion
|
|
188
68
|
/** Register source-mode remote methods without decorator syntax. */
|
|
189
69
|
const REMOTE_METHODS = ['status', 'authorize', 'poll', 'cancel', 'syncModels', 'logout'];
|
|
@@ -213,9 +93,16 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
213
93
|
syncingModels = null;
|
|
214
94
|
modelSyncController = null;
|
|
215
95
|
modelDiscovery = discoverOpenAIModels;
|
|
96
|
+
disconnecting = null;
|
|
216
97
|
constructor(ctx) {
|
|
217
98
|
super(ctx, 'openaiSubscription', { namespace: 'openaiSubscription' });
|
|
218
99
|
// Resolve optional services per operation so late mounts and reloads work.
|
|
100
|
+
ctx.effect(() => async () => {
|
|
101
|
+
this.pendingBridge?.controller.abort();
|
|
102
|
+
this.modelSyncController?.abort();
|
|
103
|
+
await Promise.allSettled([this.pendingBridge?.task, this.syncingModels, this.disconnecting]);
|
|
104
|
+
this.pendingBridge = null;
|
|
105
|
+
});
|
|
219
106
|
}
|
|
220
107
|
credentials() {
|
|
221
108
|
return this.ctx.get('credentials');
|
|
@@ -223,9 +110,6 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
223
110
|
shell() {
|
|
224
111
|
return this.ctx.get('shell');
|
|
225
112
|
}
|
|
226
|
-
timer() {
|
|
227
|
-
return this.ctx.get('timer');
|
|
228
|
-
}
|
|
229
113
|
authorization() {
|
|
230
114
|
return this.ctx.get('authorization');
|
|
231
115
|
}
|
|
@@ -247,19 +131,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
247
131
|
* copy shipped inside the running DSH first; returns '' when nothing hits.
|
|
248
132
|
*/
|
|
249
133
|
probeInProcessModule() {
|
|
250
|
-
|
|
251
|
-
return '';
|
|
252
|
-
const candidates = authModuleCandidates(process.argv[1], process.execPath, process.env);
|
|
253
|
-
for (const candidate of candidates) {
|
|
254
|
-
try {
|
|
255
|
-
if (existsSync(candidate))
|
|
256
|
-
return candidate;
|
|
257
|
-
}
|
|
258
|
-
catch {
|
|
259
|
-
// Unreadable or unavailable path: keep probing the remaining roots.
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
return '';
|
|
134
|
+
return resolveAuthModule();
|
|
263
135
|
}
|
|
264
136
|
async locateAuthModule() {
|
|
265
137
|
if (this.cachedModule !== null)
|
|
@@ -267,23 +139,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
267
139
|
if (this.locatingModule !== null)
|
|
268
140
|
return this.locatingModule;
|
|
269
141
|
const pending = (async () => {
|
|
270
|
-
|
|
271
|
-
if (probed)
|
|
272
|
-
return probed;
|
|
273
|
-
const shell = this.shell();
|
|
274
|
-
if (shell === undefined)
|
|
275
|
-
return '';
|
|
276
|
-
try {
|
|
277
|
-
const spec = shell.resolve({ command: LOCATE_SCRIPT, timeoutMs: 20000, stdoutMaxBytes: 4096 });
|
|
278
|
-
const result = await shell.run(spec);
|
|
279
|
-
if (result.exitCode !== 0)
|
|
280
|
-
return '';
|
|
281
|
-
return (result.stdout.text || '').trim();
|
|
282
|
-
}
|
|
283
|
-
catch (error) {
|
|
284
|
-
console.error('[openai-subscription] locate OpenAI auth module failed: ' + errorMessage(error));
|
|
285
|
-
return '';
|
|
286
|
-
}
|
|
142
|
+
return this.probeInProcessModule();
|
|
287
143
|
})();
|
|
288
144
|
this.locatingModule = pending;
|
|
289
145
|
try {
|
|
@@ -302,122 +158,22 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
302
158
|
const credentials = this.credentials();
|
|
303
159
|
if (credentials === undefined) {
|
|
304
160
|
notify({ message: 'DSH 凭证服务不可用,请重启后重试。' });
|
|
305
|
-
throw new
|
|
161
|
+
throw new SubscriptionError('credentials-unavailable');
|
|
306
162
|
}
|
|
307
163
|
const modulePath = await this.locateAuthModule();
|
|
308
164
|
const shell = this.shell();
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
165
|
+
if (shell === undefined)
|
|
166
|
+
throw new SubscriptionError('shell-unavailable');
|
|
167
|
+
if (!modulePath)
|
|
168
|
+
throw new SubscriptionError('component-unavailable');
|
|
169
|
+
if (!runtimeSupported())
|
|
170
|
+
throw new SubscriptionError('runtime-unsupported');
|
|
313
171
|
notify({ kind: 'requesting-code', message: '正在向 OpenAI 请求设备登录码…' });
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
timeoutMs: 16 * 60 * 1000,
|
|
317
|
-
stdoutMaxBytes: 262144,
|
|
318
|
-
signal: control.signal,
|
|
172
|
+
const granted = await runDeviceDriver(shell, modulePath, control.signal, (code, url) => {
|
|
173
|
+
notify({ kind: 'enter-code', message: '请在 OpenAI 验证页面输入设备码。', code, url });
|
|
319
174
|
});
|
|
320
|
-
|
|
321
|
-
let buffer = '';
|
|
322
|
-
let credential = null;
|
|
323
|
-
let failure = null;
|
|
324
|
-
const deadline = Date.now() + 15 * 60 * 1000;
|
|
325
|
-
while (credential === null && failure === null) {
|
|
326
|
-
if (control.aborted()) {
|
|
327
|
-
try {
|
|
328
|
-
proc.kill();
|
|
329
|
-
}
|
|
330
|
-
catch { }
|
|
331
|
-
await proc.done;
|
|
332
|
-
return null;
|
|
333
|
-
}
|
|
334
|
-
let read;
|
|
335
|
-
try {
|
|
336
|
-
read = proc.readOutput();
|
|
337
|
-
}
|
|
338
|
-
catch {
|
|
339
|
-
read = { delta: '' };
|
|
340
|
-
}
|
|
341
|
-
buffer += read.delta || '';
|
|
342
|
-
let nl;
|
|
343
|
-
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
344
|
-
const line = buffer.slice(0, nl).trim();
|
|
345
|
-
buffer = buffer.slice(nl + 1);
|
|
346
|
-
if (!line)
|
|
347
|
-
continue;
|
|
348
|
-
let msg = null;
|
|
349
|
-
try {
|
|
350
|
-
msg = JSON.parse(line);
|
|
351
|
-
}
|
|
352
|
-
catch {
|
|
353
|
-
continue;
|
|
354
|
-
}
|
|
355
|
-
if (typeof msg.userCode === 'string' && msg.userCode) {
|
|
356
|
-
notify({
|
|
357
|
-
kind: 'enter-code',
|
|
358
|
-
message: '请打开链接,使用有 Codex 权限的 ChatGPT 账号登录并输入设备码。',
|
|
359
|
-
url: typeof msg.verificationUri === 'string' ? msg.verificationUri : 'https://auth.openai.com/codex/device',
|
|
360
|
-
code: msg.userCode,
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
else if (msg.type === 'result') {
|
|
364
|
-
credential = normalizeOAuthCredential(msg.credential);
|
|
365
|
-
if (credential === null)
|
|
366
|
-
failure = '登录模块返回了无效的授权凭证';
|
|
367
|
-
}
|
|
368
|
-
else if (msg.type === 'error') {
|
|
369
|
-
failure = typeof msg.message === 'string' ? msg.message : '登录流程异常结束';
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
if (credential === null && failure === null) {
|
|
373
|
-
const timer = this.timer();
|
|
374
|
-
if (Date.now() > deadline) {
|
|
375
|
-
failure = '登录超时(15 分钟)';
|
|
376
|
-
break;
|
|
377
|
-
}
|
|
378
|
-
if (proc.status !== 'running') {
|
|
379
|
-
failure = '登录进程意外退出';
|
|
380
|
-
break;
|
|
381
|
-
}
|
|
382
|
-
if (timer === undefined) {
|
|
383
|
-
failure = 'DSH 计时服务不可用';
|
|
384
|
-
break;
|
|
385
|
-
}
|
|
386
|
-
try {
|
|
387
|
-
await timer.timeout(1000);
|
|
388
|
-
}
|
|
389
|
-
catch {
|
|
390
|
-
failure = '登录轮询已停止';
|
|
391
|
-
break;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
if (failure !== null) {
|
|
396
|
-
try {
|
|
397
|
-
proc.kill();
|
|
398
|
-
}
|
|
399
|
-
catch { }
|
|
400
|
-
await proc.done;
|
|
401
|
-
const knownFailure = failure === '登录超时(15 分钟)'
|
|
402
|
-
|| failure === '登录进程意外退出'
|
|
403
|
-
|| failure === 'DSH 计时服务不可用'
|
|
404
|
-
|| failure === '登录轮询已停止'
|
|
405
|
-
|| failure === '登录模块返回了无效的授权凭证';
|
|
406
|
-
const summary = knownFailure ? failure : 'OpenAI 登录请求未完成';
|
|
407
|
-
const hint = /404|not enabled/i.test(failure)
|
|
408
|
-
? ' 请在 ChatGPT 安全设置中启用设备码授权后重试。'
|
|
409
|
-
: ' 请重试。';
|
|
410
|
-
notify({ message: summary + '。' + hint });
|
|
411
|
-
throw new Error('OpenAI authorization failed');
|
|
412
|
-
}
|
|
413
|
-
if (credential === null)
|
|
414
|
-
throw new Error('OpenAI authorization ended without credentials');
|
|
415
|
-
if (proc.status === 'running')
|
|
416
|
-
proc.kill();
|
|
417
|
-
await proc.done;
|
|
418
|
-
if (control.aborted())
|
|
175
|
+
if (granted === null || control.aborted())
|
|
419
176
|
return null;
|
|
420
|
-
const granted = credential;
|
|
421
177
|
await credentials.modifyRecord(KEY, async (current) => {
|
|
422
178
|
if (control.aborted())
|
|
423
179
|
return undefined;
|
|
@@ -430,6 +186,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
430
186
|
...currentPayload,
|
|
431
187
|
provider: 'openai',
|
|
432
188
|
loginMethod: 'device_code',
|
|
189
|
+
cleanupPending: false,
|
|
433
190
|
accountId: granted.accountId ?? null,
|
|
434
191
|
access: granted.access,
|
|
435
192
|
refresh: granted.refresh ?? '',
|
|
@@ -438,26 +195,26 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
438
195
|
managedPiRoute: currentPayload.managedPiRoute === true,
|
|
439
196
|
},
|
|
440
197
|
};
|
|
441
|
-
});
|
|
198
|
+
}).catch((error) => { throw new SubscriptionError(failureCode(error, 'credential-write-failed')); });
|
|
442
199
|
if (control.aborted())
|
|
443
200
|
return null;
|
|
444
201
|
if (!(await this.mirrorToPiAi(granted, undefined, control.signal))) {
|
|
445
202
|
if (control.aborted())
|
|
446
203
|
return null;
|
|
447
204
|
notify({ message: '授权已完成,但模型凭证保存失败,请重试。' });
|
|
448
|
-
throw new
|
|
205
|
+
throw new SubscriptionError('credential-write-failed');
|
|
449
206
|
}
|
|
450
207
|
if (control.aborted())
|
|
451
208
|
return null;
|
|
452
209
|
try {
|
|
453
|
-
await this.synchronizeModels(control.signal);
|
|
454
|
-
notify({ kind: 'models-synced', message: '可用模型已同步。' });
|
|
210
|
+
const sync = await this.synchronizeModels(control.signal);
|
|
211
|
+
notify({ kind: sync.warningCode ? 'models-sync-failed' : 'models-synced', errorCode: sync.warningCode, message: '可用模型已同步。' });
|
|
455
212
|
}
|
|
456
213
|
catch (error) {
|
|
457
214
|
if (control.aborted())
|
|
458
215
|
return null;
|
|
459
216
|
console.error('[openai-subscription] initial model sync failed: ' + errorMessage(error));
|
|
460
|
-
notify({ kind: 'models-sync-failed', message: '授权已保存,模型目录可稍后重试同步。' });
|
|
217
|
+
notify({ kind: 'models-sync-failed', errorCode: failureCode(error, 'settings-write-failed'), message: '授权已保存,模型目录可稍后重试同步。' });
|
|
461
218
|
}
|
|
462
219
|
return granted;
|
|
463
220
|
}
|
|
@@ -465,14 +222,14 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
465
222
|
const credentials = this.credentials();
|
|
466
223
|
if (credentials === undefined) {
|
|
467
224
|
notify({ message: 'DSH 凭证服务不可用,请重启后重试。' });
|
|
468
|
-
throw new
|
|
225
|
+
throw new SubscriptionError('credentials-unavailable');
|
|
469
226
|
}
|
|
470
227
|
const current = await credentials.readRecord(KEY);
|
|
471
228
|
const adapterRecord = await credentials.readRecord(PI_AI_RECORD);
|
|
472
|
-
if (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
229
|
+
if (current === undefined || current.kind !== 'grant' || grantPayload(current).cleanupPending === true)
|
|
230
|
+
throw new SubscriptionError('not-connected');
|
|
231
|
+
if (!runtimeSupported())
|
|
232
|
+
throw new SubscriptionError('runtime-unsupported');
|
|
476
233
|
const payload = current?.kind === 'grant' && current.payload && typeof current.payload === 'object'
|
|
477
234
|
? current.payload
|
|
478
235
|
: {};
|
|
@@ -483,17 +240,17 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
483
240
|
const secretPayload = typeof adapterPayload.refresh === 'string' && adapterPayload.refresh ? adapterPayload : payload;
|
|
484
241
|
if (typeof secretPayload.refresh !== 'string' || !secretPayload.refresh) {
|
|
485
242
|
notify({ message: '当前授权无法刷新,请退出后重新登录。' });
|
|
486
|
-
throw new
|
|
243
|
+
throw new SubscriptionError('not-refreshable');
|
|
487
244
|
}
|
|
488
245
|
const expectedMainRefresh = typeof payload.refresh === 'string' ? payload.refresh : null;
|
|
489
246
|
const expectedAdapterRefresh = secretPayload.refresh;
|
|
490
247
|
const adapterRecordRequired = adapterRecord?.kind === 'grant';
|
|
491
248
|
const modulePath = await this.locateAuthModule();
|
|
492
249
|
const shell = this.shell();
|
|
493
|
-
if (
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
250
|
+
if (shell === undefined)
|
|
251
|
+
throw new SubscriptionError('shell-unavailable');
|
|
252
|
+
if (!modulePath)
|
|
253
|
+
throw new SubscriptionError('component-unavailable');
|
|
497
254
|
const previous = {};
|
|
498
255
|
if (typeof secretPayload.access === 'string' && secretPayload.access)
|
|
499
256
|
previous.access = secretPayload.access;
|
|
@@ -505,7 +262,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
505
262
|
previous.accountId = secretPayload.accountId;
|
|
506
263
|
notify({ kind: 'refreshing', message: '正在刷新 ChatGPT 订阅授权…' });
|
|
507
264
|
const spec = shell.resolve({
|
|
508
|
-
command:
|
|
265
|
+
command: buildNodeCommand(DRIVER_REFRESH, modulePath),
|
|
509
266
|
timeoutMs: 120000,
|
|
510
267
|
stdoutMaxBytes: 65536,
|
|
511
268
|
signal: control.signal,
|
|
@@ -515,46 +272,43 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
515
272
|
const result = await shell.run(spec);
|
|
516
273
|
if (result.aborted || control.aborted())
|
|
517
274
|
return null;
|
|
275
|
+
if (result.sandbox?.denied)
|
|
276
|
+
throw new SubscriptionError('access-denied');
|
|
277
|
+
if (result.timedOut)
|
|
278
|
+
throw new SubscriptionError('timeout');
|
|
279
|
+
if (result.stdout.truncated)
|
|
280
|
+
throw new SubscriptionError('invalid-response');
|
|
281
|
+
if (result.exitCode !== 0)
|
|
282
|
+
throw new SubscriptionError('process-exited');
|
|
518
283
|
let msg = null;
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
continue;
|
|
525
|
-
try {
|
|
526
|
-
msg = JSON.parse(line);
|
|
527
|
-
}
|
|
528
|
-
catch {
|
|
529
|
-
continue;
|
|
530
|
-
}
|
|
284
|
+
const lines = (result.stdout.text || '').split('\n');
|
|
285
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
286
|
+
const candidate = parseDriverMessage(lines[i] ?? '');
|
|
287
|
+
if (candidate?.type === 'result' || candidate?.type === 'error') {
|
|
288
|
+
msg = candidate;
|
|
531
289
|
break;
|
|
532
290
|
}
|
|
533
291
|
}
|
|
534
|
-
if (
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
if (next === null) {
|
|
540
|
-
notify({ message: '刷新失败:登录服务返回了无效响应。' });
|
|
541
|
-
throw new Error('Invalid authorization response');
|
|
542
|
-
}
|
|
292
|
+
if (msg?.type === 'error')
|
|
293
|
+
throw new SubscriptionError(oauthFailureCode(msg.message));
|
|
294
|
+
const next = normalizeOAuthCredential(msg?.credential, previous);
|
|
295
|
+
if (next === null)
|
|
296
|
+
throw new SubscriptionError('invalid-response');
|
|
543
297
|
if (!(await this.mirrorToPiAi(next, { refresh: expectedAdapterRefresh, requireExisting: adapterRecordRequired }, control.signal))) {
|
|
544
298
|
notify({ message: '刷新期间授权记录已变化,已保留较新的凭证。' });
|
|
545
|
-
throw new
|
|
299
|
+
throw new SubscriptionError('credential-changed');
|
|
546
300
|
}
|
|
547
301
|
await credentials.modifyRecord(KEY, async (latest) => {
|
|
548
302
|
if (control.aborted())
|
|
549
303
|
return undefined;
|
|
550
|
-
if (latest
|
|
551
|
-
throw new
|
|
304
|
+
if (latest === undefined || latest.kind !== 'grant')
|
|
305
|
+
throw new SubscriptionError('credential-changed');
|
|
552
306
|
const latestPayload = latest?.kind === 'grant' && latest.payload && typeof latest.payload === 'object'
|
|
553
307
|
? latest.payload
|
|
554
308
|
: {};
|
|
555
309
|
const latestRefresh = typeof latestPayload.refresh === 'string' ? latestPayload.refresh : null;
|
|
556
310
|
if (latestRefresh !== expectedMainRefresh)
|
|
557
|
-
throw new
|
|
311
|
+
throw new SubscriptionError('credential-changed');
|
|
558
312
|
return {
|
|
559
313
|
kind: 'grant',
|
|
560
314
|
payload: {
|
|
@@ -570,18 +324,18 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
570
324
|
managedPiRoute: latestPayload.managedPiRoute === true,
|
|
571
325
|
},
|
|
572
326
|
};
|
|
573
|
-
});
|
|
327
|
+
}).catch((error) => { throw new SubscriptionError(failureCode(error, 'credential-write-failed')); });
|
|
574
328
|
if (control.aborted())
|
|
575
329
|
return null;
|
|
576
330
|
try {
|
|
577
|
-
await this.synchronizeModels(control.signal);
|
|
578
|
-
notify({ kind: 'models-synced', message: '授权与可用模型已更新。' });
|
|
331
|
+
const sync = await this.synchronizeModels(control.signal);
|
|
332
|
+
notify({ kind: sync.warningCode ? 'models-sync-failed' : 'models-synced', errorCode: sync.warningCode, message: '授权与可用模型已更新。' });
|
|
579
333
|
}
|
|
580
334
|
catch (error) {
|
|
581
335
|
if (control.aborted())
|
|
582
336
|
return null;
|
|
583
337
|
console.error('[openai-subscription] model sync after refresh failed: ' + errorMessage(error));
|
|
584
|
-
notify({ kind: 'models-sync-failed', message: '授权已更新,模型目录可稍后重试同步。' });
|
|
338
|
+
notify({ kind: 'models-sync-failed', errorCode: failureCode(error, 'settings-write-failed'), message: '授权已更新,模型目录可稍后重试同步。' });
|
|
585
339
|
}
|
|
586
340
|
return next;
|
|
587
341
|
}
|
|
@@ -603,12 +357,14 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
603
357
|
return undefined;
|
|
604
358
|
if (expected !== undefined) {
|
|
605
359
|
if (expected.requireExisting && (current === undefined || current.kind !== 'grant')) {
|
|
606
|
-
throw new
|
|
360
|
+
throw new SubscriptionError('credential-changed');
|
|
607
361
|
}
|
|
362
|
+
if (current !== undefined && current.kind !== 'grant')
|
|
363
|
+
throw new SubscriptionError('credential-changed');
|
|
608
364
|
if (current !== undefined && current.kind === 'grant') {
|
|
609
365
|
const currentPayload = current.payload && typeof current.payload === 'object' ? current.payload : {};
|
|
610
366
|
if (currentPayload.refresh !== expected.refresh)
|
|
611
|
-
throw new
|
|
367
|
+
throw new SubscriptionError('credential-changed');
|
|
612
368
|
}
|
|
613
369
|
}
|
|
614
370
|
return { kind: 'grant', payload };
|
|
@@ -617,7 +373,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
617
373
|
}
|
|
618
374
|
catch (error) {
|
|
619
375
|
console.error('[openai-subscription] mirror to llm-pi-ai/openai-codex failed: ' + errorMessage(error));
|
|
620
|
-
|
|
376
|
+
throw new SubscriptionError(failureCode(error, 'credential-write-failed'));
|
|
621
377
|
}
|
|
622
378
|
}
|
|
623
379
|
/** Keep one cancellable model sync in flight so login, refresh, and UI actions cannot race. */
|
|
@@ -644,25 +400,50 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
644
400
|
});
|
|
645
401
|
return pending;
|
|
646
402
|
}
|
|
403
|
+
/** Bound this read even if an older adapter ignores AbortSignal. No writes are raced. */
|
|
404
|
+
async readBuiltinModels(signal) {
|
|
405
|
+
const llm = this.llm();
|
|
406
|
+
if (llm === undefined || typeof llm.discoverModels !== 'function')
|
|
407
|
+
throw new SubscriptionError('models-unavailable');
|
|
408
|
+
const timeout = new AbortController();
|
|
409
|
+
const timer = setTimeout(() => timeout.abort(), 30_000);
|
|
410
|
+
const combined = AbortSignal.any([signal, timeout.signal]);
|
|
411
|
+
let onAbort = () => { };
|
|
412
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
413
|
+
onAbort = () => reject(new SubscriptionError(signal.aborted ? 'cancelled' : 'timeout'));
|
|
414
|
+
combined.addEventListener('abort', onAbort, { once: true });
|
|
415
|
+
if (combined.aborted)
|
|
416
|
+
onAbort();
|
|
417
|
+
});
|
|
418
|
+
try {
|
|
419
|
+
if (combined.aborted)
|
|
420
|
+
return await aborted;
|
|
421
|
+
return await Promise.race([aborted, llm.discoverModels('llm-pi-ai', { provider: 'openai-codex' }, combined)]);
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
clearTimeout(timer);
|
|
425
|
+
combined.removeEventListener('abort', onAbort);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
647
428
|
/** Fetch the account-scoped catalog and replace only this plugin's previous rows. */
|
|
648
429
|
async performModelSync(signal, adoptExistingModels) {
|
|
649
430
|
const credentials = this.credentials();
|
|
650
431
|
if (credentials === undefined)
|
|
651
|
-
throw new
|
|
432
|
+
throw new SubscriptionError('credentials-unavailable');
|
|
652
433
|
const settings = this.ctx.get('settings');
|
|
653
434
|
if (settings === undefined || typeof settings.mutate !== 'function') {
|
|
654
|
-
throw new
|
|
435
|
+
throw new SubscriptionError('settings-unavailable');
|
|
655
436
|
}
|
|
656
437
|
const [ownerRecord, adapterRecord] = await Promise.all([
|
|
657
438
|
credentials.readRecord(KEY),
|
|
658
439
|
credentials.readRecord(PI_AI_RECORD),
|
|
659
440
|
]);
|
|
660
|
-
if (recordOf(ownerRecord)?.kind !== 'grant')
|
|
661
|
-
throw new
|
|
441
|
+
if (recordOf(ownerRecord)?.kind !== 'grant' || grantPayload(ownerRecord).cleanupPending === true)
|
|
442
|
+
throw new SubscriptionError('not-connected');
|
|
662
443
|
const adapter = grantPayload(adapterRecord);
|
|
663
444
|
const access = typeof adapter.access === 'string' ? adapter.access : '';
|
|
664
445
|
if (!access)
|
|
665
|
-
throw new
|
|
446
|
+
throw new SubscriptionError('not-connected');
|
|
666
447
|
const accountId = typeof adapter.accountId === 'string' && adapter.accountId.length > 0
|
|
667
448
|
? adapter.accountId
|
|
668
449
|
: undefined;
|
|
@@ -673,49 +454,48 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
673
454
|
// user/profile allow-list has no prior plugin ownership snapshot.
|
|
674
455
|
const initialDescriptor = settings.describe({ redactSecrets: true }).find((entry) => entry.ns === 'llm-pi-ai');
|
|
675
456
|
if (initialDescriptor === undefined)
|
|
676
|
-
throw new
|
|
457
|
+
throw new SubscriptionError('settings-unavailable');
|
|
677
458
|
const initialPlugin = grantPayload(ownerRecord);
|
|
678
459
|
if (!adoptExistingModels
|
|
679
460
|
&& modelIds(initialPlugin.managedModels).length === 0
|
|
680
461
|
&& explicitRouteModels(initialDescriptor.user, initialDescriptor.base) !== undefined) {
|
|
681
|
-
throw new
|
|
462
|
+
throw new SubscriptionError('models-confirmation-required');
|
|
682
463
|
}
|
|
683
464
|
const remoteModels = await this.modelDiscovery(discoveryCredential, { signal });
|
|
684
|
-
const llm = this.llm();
|
|
685
|
-
if (llm === undefined || typeof llm.discoverModels !== 'function') {
|
|
686
|
-
throw new Error('DSH 模型目录服务不可用,请重启后重试');
|
|
687
|
-
}
|
|
688
465
|
let builtinModels;
|
|
689
466
|
try {
|
|
690
|
-
builtinModels = await
|
|
467
|
+
builtinModels = await this.readBuiltinModels(signal);
|
|
468
|
+
if (!Array.isArray(builtinModels) || builtinModels.some((candidate) => typeof recordOf(candidate)?.id !== 'string')) {
|
|
469
|
+
throw new SubscriptionError('invalid-response');
|
|
470
|
+
}
|
|
691
471
|
}
|
|
692
472
|
catch (error) {
|
|
693
473
|
console.error('[openai-subscription] read installed openai-codex catalog failed: ' + errorMessage(error));
|
|
694
|
-
throw new
|
|
474
|
+
throw new SubscriptionError(failureCode(error, 'models-unavailable'));
|
|
695
475
|
}
|
|
696
476
|
const discovered = unionModelCatalog(remoteModels, builtinModels);
|
|
697
477
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
698
478
|
if (signal.aborted)
|
|
699
|
-
throw new
|
|
479
|
+
throw new SubscriptionError('cancelled');
|
|
700
480
|
const [currentOwner, currentAdapter] = await Promise.all([
|
|
701
481
|
credentials.readRecord(KEY),
|
|
702
482
|
credentials.readRecord(PI_AI_RECORD),
|
|
703
483
|
]);
|
|
704
484
|
if (recordOf(currentOwner)?.kind !== 'grant')
|
|
705
|
-
throw new
|
|
485
|
+
throw new SubscriptionError('credential-changed');
|
|
706
486
|
const liveAdapter = grantPayload(currentAdapter);
|
|
707
487
|
const liveAccess = typeof liveAdapter.access === 'string' ? liveAdapter.access : '';
|
|
708
488
|
const liveAccountId = typeof liveAdapter.accountId === 'string' ? liveAdapter.accountId : undefined;
|
|
709
489
|
if (!liveAccess || (accountId === undefined ? liveAccess !== access : liveAccountId !== accountId)) {
|
|
710
|
-
throw new
|
|
490
|
+
throw new SubscriptionError('credential-changed');
|
|
711
491
|
}
|
|
712
492
|
const descriptor = settings.describe({ redactSecrets: true }).find((entry) => entry.ns === 'llm-pi-ai');
|
|
713
493
|
if (descriptor === undefined)
|
|
714
|
-
throw new
|
|
494
|
+
throw new SubscriptionError('settings-unavailable');
|
|
715
495
|
const plugin = grantPayload(currentOwner);
|
|
716
496
|
const explicitModels = explicitRouteModels(descriptor.user, descriptor.base);
|
|
717
497
|
if (!adoptExistingModels && modelIds(plugin.managedModels).length === 0 && explicitModels !== undefined) {
|
|
718
|
-
throw new
|
|
498
|
+
throw new SubscriptionError('models-confirmation-required');
|
|
719
499
|
}
|
|
720
500
|
const merged = mergeModelCatalog(explicitModels, plugin.managedModels, discovered, plugin.suppressedModelIds);
|
|
721
501
|
const createdRoute = piRoute(descriptor.value) === null;
|
|
@@ -724,23 +504,23 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
724
504
|
: { op: 'set', path: ['providers', 'openai-codex', 'models'], value: merged.models };
|
|
725
505
|
try {
|
|
726
506
|
if (signal.aborted)
|
|
727
|
-
throw new
|
|
507
|
+
throw new SubscriptionError('cancelled');
|
|
728
508
|
await settings.mutate('llm-pi-ai', [operation], descriptor.revision);
|
|
729
509
|
}
|
|
730
510
|
catch (error) {
|
|
731
511
|
if (attempt < 2 && settingsConflict(error))
|
|
732
512
|
continue;
|
|
733
513
|
if (settingsConflict(error))
|
|
734
|
-
throw new
|
|
735
|
-
throw error;
|
|
514
|
+
throw new SubscriptionError('settings-conflict');
|
|
515
|
+
throw new SubscriptionError(failureCode(error, 'settings-write-failed'));
|
|
736
516
|
}
|
|
737
517
|
try {
|
|
738
518
|
await credentials.modifyRecord(KEY, async (latest) => {
|
|
739
519
|
if (latest === undefined || latest.kind !== 'grant')
|
|
740
|
-
throw new
|
|
520
|
+
throw new SubscriptionError('credential-changed');
|
|
741
521
|
const payload = recordOf(latest.payload) ?? {};
|
|
742
522
|
if (accountId !== undefined && typeof payload.accountId === 'string' && payload.accountId !== accountId) {
|
|
743
|
-
throw new
|
|
523
|
+
throw new SubscriptionError('credential-changed');
|
|
744
524
|
}
|
|
745
525
|
return {
|
|
746
526
|
...latest,
|
|
@@ -758,10 +538,11 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
758
538
|
// Settings already contain the safe complete snapshot. A later sync will
|
|
759
539
|
// conservatively treat it as local rather than risk overwriting it.
|
|
760
540
|
console.error('[openai-subscription] remember managed model catalog failed: ' + errorMessage(error));
|
|
541
|
+
return { synced: true, count: modelIds(merged.models).length, warningCode: 'ownership-save-failed' };
|
|
761
542
|
}
|
|
762
543
|
return { synced: true, count: modelIds(merged.models).length };
|
|
763
544
|
}
|
|
764
|
-
throw new
|
|
545
|
+
throw new SubscriptionError('settings-conflict');
|
|
765
546
|
}
|
|
766
547
|
/** Remove only unchanged dynamic rows, preserving every local model and profile field. */
|
|
767
548
|
async removeManagedPiRoute(managedRoute, managedModels) {
|
|
@@ -770,12 +551,12 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
770
551
|
return;
|
|
771
552
|
const settings = this.ctx.get('settings');
|
|
772
553
|
if (settings === undefined || typeof settings.mutate !== 'function') {
|
|
773
|
-
throw new
|
|
554
|
+
throw new SubscriptionError('settings-unavailable');
|
|
774
555
|
}
|
|
775
556
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
776
557
|
const descriptor = settings.describe({ redactSecrets: true }).find((entry) => entry.ns === 'llm-pi-ai');
|
|
777
558
|
if (descriptor === undefined)
|
|
778
|
-
throw new
|
|
559
|
+
throw new SubscriptionError('settings-unavailable');
|
|
779
560
|
const route = piRoute(descriptor.user);
|
|
780
561
|
if (route === null)
|
|
781
562
|
return;
|
|
@@ -812,26 +593,33 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
812
593
|
if (attempt < 2 && settingsConflict(error))
|
|
813
594
|
continue;
|
|
814
595
|
if (settingsConflict(error))
|
|
815
|
-
throw new
|
|
816
|
-
throw error;
|
|
596
|
+
throw new SubscriptionError('settings-conflict');
|
|
597
|
+
throw new SubscriptionError(failureCode(error, 'settings-write-failed'));
|
|
817
598
|
}
|
|
818
599
|
}
|
|
819
600
|
}
|
|
820
|
-
beginLogin(method) {
|
|
601
|
+
beginLogin(method, observer) {
|
|
821
602
|
this.ensureAuthorizationFlow();
|
|
822
|
-
if (
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
603
|
+
if (method !== 'device_code' && method !== 'refresh') {
|
|
604
|
+
return { started: false, error: '[openai-subscription:invalid-method]', errorCode: 'invalid-method' };
|
|
605
|
+
}
|
|
606
|
+
if (this.disconnecting != null || this.syncingModels !== null || (this.pendingBridge !== null && !this.pendingBridge.done)) {
|
|
607
|
+
return { started: false, error: '[openai-subscription:busy]', errorCode: 'busy' };
|
|
827
608
|
}
|
|
609
|
+
this.pendingBridge = null;
|
|
828
610
|
const controller = new AbortController();
|
|
829
611
|
const state = { notices: [], done: false, outcome: null, error: null, controller, task: null };
|
|
830
612
|
this.pendingBridge = state;
|
|
831
613
|
const notify = (notice) => {
|
|
832
|
-
state.notices.push({
|
|
614
|
+
state.notices.push({ ...notice });
|
|
833
615
|
if (state.notices.length > 50)
|
|
834
616
|
state.notices.shift();
|
|
617
|
+
try {
|
|
618
|
+
observer?.(notice);
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
logFailure('notify', error);
|
|
622
|
+
}
|
|
835
623
|
};
|
|
836
624
|
const control = { signal: controller.signal, aborted: () => controller.signal.aborted };
|
|
837
625
|
const task = (async () => {
|
|
@@ -842,24 +630,24 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
842
630
|
else if (method === 'device_code')
|
|
843
631
|
credential = await this.runDevice(control, notify);
|
|
844
632
|
else
|
|
845
|
-
throw new
|
|
846
|
-
state.outcome = credential === null ? 'cancelled' : 'authorized';
|
|
633
|
+
throw new SubscriptionError('invalid-method');
|
|
634
|
+
state.outcome = controller.signal.aborted || credential === null ? 'cancelled' : 'authorized';
|
|
847
635
|
}
|
|
848
|
-
catch {
|
|
636
|
+
catch (error) {
|
|
849
637
|
state.outcome = controller.signal.aborted ? 'cancelled' : 'failed';
|
|
850
|
-
|
|
638
|
+
if (!controller.signal.aborted) {
|
|
639
|
+
state.errorCode = failureCode(error);
|
|
640
|
+
state.error = new SubscriptionError(state.errorCode).message;
|
|
641
|
+
if (state.errorCode === 'component-unavailable')
|
|
642
|
+
this.cachedModule = null;
|
|
643
|
+
logFailure('authorize', error);
|
|
644
|
+
}
|
|
851
645
|
}
|
|
852
646
|
finally {
|
|
853
647
|
state.done = true;
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
if (this.pendingBridge === state)
|
|
858
|
-
this.pendingBridge = null;
|
|
859
|
-
}).catch(() => {
|
|
860
|
-
// Fiber disposal cancels timer promises; the flow is already done.
|
|
861
|
-
});
|
|
862
|
-
}
|
|
648
|
+
state.completedAt = Date.now();
|
|
649
|
+
// Device codes are only useful while pending; terminal snapshots keep no code.
|
|
650
|
+
state.notices = state.notices.filter((notice) => notice.kind !== 'enter-code');
|
|
863
651
|
}
|
|
864
652
|
})();
|
|
865
653
|
state.task = task;
|
|
@@ -880,17 +668,25 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
880
668
|
{ id: 'refresh', label: '刷新授权' },
|
|
881
669
|
],
|
|
882
670
|
run: async (session) => {
|
|
883
|
-
|
|
884
|
-
const control = { signal: session.signal, aborted: () => session.signal.aborted };
|
|
885
|
-
if (session.method === 'refresh') {
|
|
886
|
-
await this.runRefresh(control, notify);
|
|
671
|
+
if (session.signal.aborted)
|
|
887
672
|
return;
|
|
673
|
+
const result = this.beginLogin(session.method, (notice) => session.notify(notice));
|
|
674
|
+
if (!result.started)
|
|
675
|
+
throw new SubscriptionError(result.errorCode);
|
|
676
|
+
const state = this.pendingBridge;
|
|
677
|
+
const abort = () => state.controller.abort();
|
|
678
|
+
session.signal.addEventListener('abort', abort, { once: true });
|
|
679
|
+
if (session.signal.aborted)
|
|
680
|
+
abort();
|
|
681
|
+
// The registered flow and Web settings share the same lock and task.
|
|
682
|
+
try {
|
|
683
|
+
await state.task;
|
|
684
|
+
if (state.outcome === 'failed')
|
|
685
|
+
throw new SubscriptionError(state.errorCode ?? 'unknown');
|
|
888
686
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
return;
|
|
687
|
+
finally {
|
|
688
|
+
session.signal.removeEventListener('abort', abort);
|
|
892
689
|
}
|
|
893
|
-
throw new Error('未知的登录方式:' + session.method);
|
|
894
690
|
},
|
|
895
691
|
});
|
|
896
692
|
this.registeredAuthorization = authorization;
|
|
@@ -899,24 +695,39 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
899
695
|
console.error('[openai-subscription] registerFlow failed: ' + errorMessage(error));
|
|
900
696
|
}
|
|
901
697
|
}
|
|
902
|
-
shq(value) {
|
|
903
|
-
return "'" + String(value).replace(/'/g, "'\\''") + "'";
|
|
904
|
-
}
|
|
905
698
|
async status() {
|
|
699
|
+
try {
|
|
700
|
+
return await this.readStatus();
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
throw new SubscriptionError(failureCode(error, 'credentials-unavailable'));
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
async readStatus() {
|
|
906
707
|
this.ensureAuthorizationFlow();
|
|
907
708
|
const modulePath = await this.locateAuthModule();
|
|
908
709
|
const credentials = this.credentials();
|
|
710
|
+
const unavailableReason = !runtimeSupported() ? 'runtime-unsupported'
|
|
711
|
+
: credentials === undefined ? 'credentials-unavailable'
|
|
712
|
+
: this.shell() === undefined ? 'shell-unavailable'
|
|
713
|
+
: !modulePath ? 'component-unavailable' : undefined;
|
|
714
|
+
const base = {
|
|
715
|
+
configured: false,
|
|
716
|
+
ready: unavailableReason === undefined,
|
|
717
|
+
flowPending: this.pendingBridge !== null && !this.pendingBridge.done,
|
|
718
|
+
...(unavailableReason ? { unavailableReason } : {}),
|
|
719
|
+
};
|
|
909
720
|
if (credentials === undefined)
|
|
910
|
-
return
|
|
721
|
+
return base;
|
|
911
722
|
const [record, adapterRecord] = await Promise.all([
|
|
912
723
|
credentials.readRecord(KEY),
|
|
913
724
|
credentials.readRecord(PI_AI_RECORD),
|
|
914
725
|
]);
|
|
915
726
|
const plugin = grantPayload(record);
|
|
916
727
|
const adapter = grantPayload(adapterRecord);
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
728
|
+
base.cleanupAvailable = recordOf(record)?.kind === 'grant';
|
|
729
|
+
if (!base.cleanupAvailable || plugin.cleanupPending === true || typeof adapter.access !== 'string' || !adapter.access.trim())
|
|
730
|
+
return base;
|
|
920
731
|
const managedIds = modelIds(plugin.managedModels);
|
|
921
732
|
const suppressed = new Set(Array.isArray(plugin.suppressedModelIds)
|
|
922
733
|
? plugin.suppressedModelIds.filter((id) => typeof id === 'string')
|
|
@@ -933,23 +744,29 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
933
744
|
}
|
|
934
745
|
const modelsSynced = managedIds.length > 0 && expectedIds.every((id) => configuredIds.includes(id));
|
|
935
746
|
return {
|
|
747
|
+
...base,
|
|
936
748
|
configured: true,
|
|
937
|
-
|
|
938
|
-
|
|
749
|
+
credentialState: typeof adapter.expires !== 'number' || !Number.isFinite(adapter.expires) || adapter.expires <= 0
|
|
750
|
+
? 'unknown' : adapter.expires <= Date.now() ? 'expired' : 'valid',
|
|
751
|
+
refreshable: typeof adapter.refresh === 'string' && adapter.refresh.trim().length > 0,
|
|
939
752
|
modelsSynced,
|
|
940
753
|
modelCount: modelsSynced ? configuredIds.length : 0,
|
|
941
754
|
};
|
|
942
755
|
}
|
|
943
|
-
async authorize(method) {
|
|
944
|
-
return this.beginLogin(typeof method === 'string' ? method : '
|
|
756
|
+
async authorize(method = 'device_code') {
|
|
757
|
+
return this.beginLogin(typeof method === 'string' ? method : '');
|
|
945
758
|
}
|
|
946
759
|
async poll() {
|
|
947
|
-
if (this.pendingBridge === null)
|
|
948
|
-
return { status: 'idle', notices: [] };
|
|
949
760
|
const bridge = this.pendingBridge;
|
|
950
|
-
|
|
761
|
+
if (bridge?.done && bridge.completedAt !== undefined && Date.now() - bridge.completedAt > 15 * 60 * 1000) {
|
|
762
|
+
this.pendingBridge = null;
|
|
763
|
+
}
|
|
764
|
+
if (this.pendingBridge === null || bridge === null)
|
|
765
|
+
return { status: 'idle', notices: [] };
|
|
766
|
+
// Repeatable snapshots let reconnecting pages/tabs recover progress safely.
|
|
767
|
+
const notices = bridge.notices.map((notice) => ({ ...notice }));
|
|
951
768
|
if (bridge.done)
|
|
952
|
-
return { status: 'done', notices, outcome: bridge.outcome, error: bridge.error };
|
|
769
|
+
return { status: 'done', notices, outcome: bridge.outcome, error: bridge.error, ...(bridge.errorCode ? { errorCode: bridge.errorCode } : {}) };
|
|
953
770
|
return { status: 'pending', notices };
|
|
954
771
|
}
|
|
955
772
|
async cancel() {
|
|
@@ -964,13 +781,34 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
964
781
|
}
|
|
965
782
|
return { ok: true };
|
|
966
783
|
}
|
|
967
|
-
async syncModels() {
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
784
|
+
async syncModels(confirmed = false) {
|
|
785
|
+
if (this.disconnecting != null || (this.pendingBridge !== null && !this.pendingBridge.done))
|
|
786
|
+
throw new SubscriptionError('busy');
|
|
787
|
+
try {
|
|
788
|
+
return await this.synchronizeModels(undefined, confirmed === true);
|
|
789
|
+
}
|
|
790
|
+
catch (error) {
|
|
791
|
+
throw new SubscriptionError(failureCode(error, 'settings-write-failed'));
|
|
792
|
+
}
|
|
971
793
|
}
|
|
972
|
-
/**
|
|
794
|
+
/** Serialize disconnect with authorization and sync, including other tabs. */
|
|
973
795
|
async logout() {
|
|
796
|
+
if (this.disconnecting != null)
|
|
797
|
+
return this.disconnecting;
|
|
798
|
+
const pending = this.performLogout().catch((error) => {
|
|
799
|
+
throw new SubscriptionError(failureCode(error, 'credential-write-failed'));
|
|
800
|
+
});
|
|
801
|
+
this.disconnecting = pending;
|
|
802
|
+
try {
|
|
803
|
+
return await pending;
|
|
804
|
+
}
|
|
805
|
+
finally {
|
|
806
|
+
if (this.disconnecting === pending)
|
|
807
|
+
this.disconnecting = null;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
/** Cancel active authorization and remove plugin-managed credentials and settings. */
|
|
811
|
+
async performLogout() {
|
|
974
812
|
const pending = this.pendingBridge;
|
|
975
813
|
if (pending !== null) {
|
|
976
814
|
pending.controller.abort();
|
|
@@ -992,10 +830,10 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
992
830
|
await activeSync.catch(() => { });
|
|
993
831
|
const credentials = this.credentials();
|
|
994
832
|
if (credentials === undefined)
|
|
995
|
-
throw new
|
|
833
|
+
throw new SubscriptionError('credentials-unavailable');
|
|
996
834
|
const record = await credentials.readRecord(KEY);
|
|
997
835
|
if (recordOf(record)?.kind !== 'grant')
|
|
998
|
-
throw new
|
|
836
|
+
throw new SubscriptionError('not-connected');
|
|
999
837
|
const payload = grantPayload(record);
|
|
1000
838
|
const managedPiRoute = payload.managedPiRoute === true;
|
|
1001
839
|
const managedModels = payload.managedModels;
|
|
@@ -1011,16 +849,16 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
1011
849
|
let staged = false;
|
|
1012
850
|
await credentials.modifyRecord(KEY, async (latest) => {
|
|
1013
851
|
if (latest === undefined || latest.kind !== 'grant')
|
|
1014
|
-
throw new
|
|
852
|
+
throw new SubscriptionError('credential-changed');
|
|
1015
853
|
const latestPayload = grantPayload(latest);
|
|
1016
854
|
if (latestPayload.access !== payload.access || latestPayload.refresh !== payload.refresh) {
|
|
1017
|
-
throw new
|
|
855
|
+
throw new SubscriptionError('credential-changed');
|
|
1018
856
|
}
|
|
1019
857
|
staged = true;
|
|
1020
858
|
return { kind: 'grant', payload: { provider: 'openai', cleanupPending: true } };
|
|
1021
859
|
});
|
|
1022
860
|
if (!staged)
|
|
1023
|
-
throw new
|
|
861
|
+
throw new SubscriptionError('credential-changed');
|
|
1024
862
|
}
|
|
1025
863
|
// The adapter credential is the authorization actually used for requests.
|
|
1026
864
|
await credentials.deleteRecord(PI_AI_RECORD);
|