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/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 { existsSync } from 'node:fs';
4
- import { delimiter as pathDelimiter, dirname, join, resolve } from 'node:path';
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
- const LOCATE_SCRIPT = [
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
- const message = error?.message;
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
- if (typeof process === 'undefined' || !Array.isArray(process.argv))
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
- const probed = this.probeInProcessModule();
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 Error('DSH credential service unavailable');
161
+ throw new SubscriptionError('credentials-unavailable');
306
162
  }
307
163
  const modulePath = await this.locateAuthModule();
308
164
  const shell = this.shell();
309
- if (!modulePath || shell === undefined) {
310
- notify({ message: '当前 DSH 环境缺少 OpenAI 登录组件,请更新 DSH 后重试。' });
311
- throw new Error('OpenAI login component unavailable');
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 spec = shell.resolve({
315
- command: 'node --input-type=module --eval ' + this.shq(DRIVER_DEVICE) + ' ' + this.shq(modulePath),
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
- const proc = shell.start(spec);
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 Error('Provider credential write failed');
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 Error('DSH credential service unavailable');
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 ((current === undefined || current.kind !== 'grant') && (adapterRecord === undefined || adapterRecord.kind !== 'grant')) {
473
- notify({ message: '尚未登录 ChatGPT 账号,请先完成设备授权。' });
474
- throw new Error('ChatGPT authorization not found');
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 Error('ChatGPT authorization cannot be refreshed');
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 (!modulePath || shell === undefined) {
494
- notify({ message: '当前 DSH 环境缺少 OpenAI 登录组件,请更新 DSH 后重试。' });
495
- throw new Error('OpenAI login component unavailable');
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: 'node --input-type=module --eval ' + this.shq(DRIVER_REFRESH) + ' ' + this.shq(modulePath),
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
- if (result.exitCode === 0) {
520
- const lines = (result.stdout.text || '').split('\n');
521
- for (let i = lines.length - 1; i >= 0; i--) {
522
- const line = (lines[i] ?? '').trim();
523
- if (!line)
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 (!msg || msg.type !== 'result' || !msg.credential || typeof msg.credential !== 'object') {
535
- notify({ message: '刷新失败,请检查网络后重试;若问题持续,请退出后重新登录。' });
536
- throw new Error('OpenAI authorization refresh failed');
537
- }
538
- const next = normalizeOAuthCredential(msg.credential, previous);
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 Error('Provider authorization changed during refresh');
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 !== undefined && latest.kind !== 'grant')
551
- throw new Error('Plugin authorization changed during refresh');
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 Error('Plugin authorization changed during refresh');
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 Error('Provider authorization was removed during refresh');
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 Error('Provider authorization changed during refresh');
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
- return false;
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 Error('DSH 凭证服务不可用,请重启后重试');
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 Error('DSH 模型设置服务不可用,请重启后重试');
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 Error('尚未通过此插件连接 ChatGPT,请先完成登录');
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 Error('尚未连接 ChatGPT,请先完成登录');
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 Error('DSH 模型适配器未启用,请更新 DSH 后重试');
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 Error('现有模型列表由配置管理,请在 ChatGPT 设置页确认同步');
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 llm.discoverModels('llm-pi-ai', { provider: 'openai-codex' }, signal);
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 Error('无法读取 DSH 内置模型目录,现有设置未更改');
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 Error('模型同步已取消');
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 Error('ChatGPT 连接已被移除');
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 Error('ChatGPT 连接在同步期间发生变化,请重试');
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 Error('DSH 模型适配器未启用,请更新 DSH 后重试');
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 Error('现有模型列表由配置管理,请在 ChatGPT 设置页确认同步');
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 Error('模型同步已取消');
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 Error('模型设置刚刚发生变化,请重试同步');
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 Error('ChatGPT 连接在同步期间被移除');
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 Error('ChatGPT 账号在同步期间发生变化');
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 Error('模型设置刚刚发生变化,请重试同步');
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 Error('DSH 模型设置服务不可用,尚未断开连接');
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 Error('DSH 模型适配器未启用,尚未断开连接');
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 Error('模型设置持续发生变化,尚未断开连接');
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 (this.pendingBridge !== null) {
823
- if (this.pendingBridge.done)
824
- this.pendingBridge = null;
825
- else
826
- return { started: false, error: '已有一个进行中的授权流程' };
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({ kind: notice.kind, message: notice.message, url: notice.url, code: notice.code });
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 Error('未知的登录方式:' + method);
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
- state.error = controller.signal.aborted ? null : '授权失败,请根据提示重试。';
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
- const timer = this.timer();
855
- if (timer !== undefined) {
856
- void timer.timeout(30000).then(() => {
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
- const notify = (notice) => session.notify(notice);
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
- if (session.method === 'device_code') {
890
- await this.runDevice(control, notify);
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 { configured: false, ready: !!modulePath };
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
- if (recordOf(record)?.kind !== 'grant' || typeof adapter.access !== 'string' || !adapter.access) {
918
- return { configured: false, ready: !!modulePath };
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
- ready: !!modulePath,
938
- refreshable: typeof adapter.refresh === 'string' && adapter.refresh.length > 0,
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 : 'device_code');
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
- const notices = bridge.notices.splice(0);
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
- // This explicit UI action is the opt-in for treating an existing allow-list
969
- // as locally owned additions on top of the live and installed catalogs.
970
- return this.synchronizeModels(undefined, true);
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
- /** Cancel active authorization and remove plugin-managed credentials and settings. */
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 Error('DSH credential service unavailable');
833
+ throw new SubscriptionError('credentials-unavailable');
996
834
  const record = await credentials.readRecord(KEY);
997
835
  if (recordOf(record)?.kind !== 'grant')
998
- throw new Error('ChatGPT 连接不属于此插件');
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 Error('ChatGPT 连接在断开期间发生变化');
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 Error('ChatGPT 连接在断开期间发生变化');
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 Error('ChatGPT 连接在断开期间发生变化');
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);