dsh-openai-subscription 0.1.2 → 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,5 +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 { 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';
3
7
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
4
8
  import { normalizeOAuthCredential } from './oauth.js';
5
9
  import { discoverOpenAIModels, mergeModelCatalog, removeManagedModels, } from './models.js';
@@ -7,63 +11,9 @@ import { discoverOpenAIModels, mergeModelCatalog, removeManagedModels, } from '.
7
11
  const KEY = 'dsh-openai-subscription/chatgpt';
8
12
  /** Credential consumed by the `openai-codex` model provider. */
9
13
  const PI_AI_RECORD = 'llm-pi-ai/openai-codex';
10
- const LOCATE_SCRIPT = [
11
- 'set -eu',
12
- 'CAND=""',
13
- 'B="$(command -v pi 2>/dev/null || true)"',
14
- 'if [ -n "$B" ]; then',
15
- ' R="$(readlink -f "$B" 2>/dev/null || printf "%s" "$B")"',
16
- ' D="$(dirname "$R")"',
17
- ' while [ "$D" != "/" ] && [ -n "$D" ]; do',
18
- ' if [ -f "$D/node_modules/@earendil-works/pi-ai/dist/auth/oauth/openai-codex.js" ]; then',
19
- ' CAND="$D/node_modules/@earendil-works/pi-ai/dist/auth/oauth/openai-codex.js"',
20
- ' break',
21
- ' fi',
22
- ' D="$(dirname "$D")"',
23
- ' done',
24
- 'fi',
25
- 'if [ -z "$CAND" ] && [ -n "${HOME:-}" ] && [ -f "$HOME/.bun/install/global/node_modules/@earendil-works/pi-ai/dist/auth/oauth/openai-codex.js" ]; then',
26
- ' CAND="$HOME/.bun/install/global/node_modules/@earendil-works/pi-ai/dist/auth/oauth/openai-codex.js"',
27
- 'fi',
28
- 'printf "%s" "$CAND"',
29
- ].join('\n');
30
- const DRIVER_DEVICE = [
31
- "import { pathToFileURL } from 'node:url'",
32
- "const { openaiCodexOAuth } = await import(pathToFileURL(process.argv[1]).href)",
33
- "const out = (o) => { try { process.stdout.write(JSON.stringify(o) + '\\n') } catch {} }",
34
- "process.stdout.on('error', () => {})",
35
- "const signal = AbortSignal.timeout(15 * 60 * 1000)",
36
- "const interaction = {",
37
- " signal,",
38
- " notify: (n) => out({ type: 'notice', ...n }),",
39
- " prompt: async (p) => (p.type === 'select' ? 'device_code' : ''),",
40
- "}",
41
- "try {",
42
- " const credential = await openaiCodexOAuth.login(interaction)",
43
- " out({ type: 'result', credential })",
44
- "} catch (error) {",
45
- " out({ type: 'error', message: error instanceof Error ? error.message : String(error) })",
46
- "}",
47
- ].join('\n');
48
- const DRIVER_REFRESH = [
49
- "import { readFileSync } from 'node:fs'",
50
- "import { pathToFileURL } from 'node:url'",
51
- "const { openaiCodexOAuth } = await import(pathToFileURL(process.argv[1]).href)",
52
- "const out = (o) => { try { process.stdout.write(JSON.stringify(o) + '\\n') } catch {} }",
53
- "process.stdout.on('error', () => {})",
54
- "const credential = JSON.parse(readFileSync(0, 'utf8') || '{}')",
55
- "const signal = AbortSignal.timeout(90 * 1000)",
56
- "try {",
57
- " const refreshed = await openaiCodexOAuth.refresh(credential, signal)",
58
- " out({ type: 'result', credential: refreshed })",
59
- "} catch (error) {",
60
- " out({ type: 'error', message: error instanceof Error ? error.message : String(error) })",
61
- "}",
62
- ].join('\n');
63
- /** Format an unknown error for logs and user-facing messages. */
14
+ /** Log only machine-readable categories, never raw service diagnostics. */
64
15
  function errorMessage(error) {
65
- const message = error?.message;
66
- return message ? String(message) : String(error);
16
+ return failureCode(error);
67
17
  }
68
18
  function recordOf(value) {
69
19
  return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -143,9 +93,16 @@ class OpenAISubscriptionController extends TypertRemoteService {
143
93
  syncingModels = null;
144
94
  modelSyncController = null;
145
95
  modelDiscovery = discoverOpenAIModels;
96
+ disconnecting = null;
146
97
  constructor(ctx) {
147
98
  super(ctx, 'openaiSubscription', { namespace: 'openaiSubscription' });
148
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
+ });
149
106
  }
150
107
  credentials() {
151
108
  return this.ctx.get('credentials');
@@ -153,9 +110,6 @@ class OpenAISubscriptionController extends TypertRemoteService {
153
110
  shell() {
154
111
  return this.ctx.get('shell');
155
112
  }
156
- timer() {
157
- return this.ctx.get('timer');
158
- }
159
113
  authorization() {
160
114
  return this.ctx.get('authorization');
161
115
  }
@@ -172,26 +126,20 @@ class OpenAISubscriptionController extends TypertRemoteService {
172
126
  return;
173
127
  this.registerAuthorizationFlow(authorization);
174
128
  }
129
+ /**
130
+ * Locate the OpenAI auth module without spawning a shell. Probes the pi-ai
131
+ * copy shipped inside the running DSH first; returns '' when nothing hits.
132
+ */
133
+ probeInProcessModule() {
134
+ return resolveAuthModule();
135
+ }
175
136
  async locateAuthModule() {
176
137
  if (this.cachedModule !== null)
177
138
  return this.cachedModule;
178
139
  if (this.locatingModule !== null)
179
140
  return this.locatingModule;
180
141
  const pending = (async () => {
181
- const shell = this.shell();
182
- if (shell === undefined)
183
- return '';
184
- try {
185
- const spec = shell.resolve({ command: LOCATE_SCRIPT, timeoutMs: 20000, stdoutMaxBytes: 4096 });
186
- const result = await shell.run(spec);
187
- if (result.exitCode !== 0)
188
- return '';
189
- return (result.stdout.text || '').trim();
190
- }
191
- catch (error) {
192
- console.error('[openai-subscription] locate OpenAI auth module failed: ' + errorMessage(error));
193
- return '';
194
- }
142
+ return this.probeInProcessModule();
195
143
  })();
196
144
  this.locatingModule = pending;
197
145
  try {
@@ -210,122 +158,22 @@ class OpenAISubscriptionController extends TypertRemoteService {
210
158
  const credentials = this.credentials();
211
159
  if (credentials === undefined) {
212
160
  notify({ message: 'DSH 凭证服务不可用,请重启后重试。' });
213
- throw new Error('DSH credential service unavailable');
161
+ throw new SubscriptionError('credentials-unavailable');
214
162
  }
215
163
  const modulePath = await this.locateAuthModule();
216
164
  const shell = this.shell();
217
- if (!modulePath || shell === undefined) {
218
- notify({ message: '当前 DSH 环境缺少 OpenAI 登录组件,请更新 DSH 后重试。' });
219
- throw new Error('OpenAI login component unavailable');
220
- }
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');
221
171
  notify({ kind: 'requesting-code', message: '正在向 OpenAI 请求设备登录码…' });
222
- const spec = shell.resolve({
223
- command: 'node --input-type=module --eval ' + this.shq(DRIVER_DEVICE) + ' ' + this.shq(modulePath),
224
- timeoutMs: 16 * 60 * 1000,
225
- stdoutMaxBytes: 262144,
226
- signal: control.signal,
172
+ const granted = await runDeviceDriver(shell, modulePath, control.signal, (code, url) => {
173
+ notify({ kind: 'enter-code', message: '请在 OpenAI 验证页面输入设备码。', code, url });
227
174
  });
228
- const proc = shell.start(spec);
229
- let buffer = '';
230
- let credential = null;
231
- let failure = null;
232
- const deadline = Date.now() + 15 * 60 * 1000;
233
- while (credential === null && failure === null) {
234
- if (control.aborted()) {
235
- try {
236
- proc.kill();
237
- }
238
- catch { }
239
- await proc.done;
240
- return null;
241
- }
242
- let read;
243
- try {
244
- read = proc.readOutput();
245
- }
246
- catch {
247
- read = { delta: '' };
248
- }
249
- buffer += read.delta || '';
250
- let nl;
251
- while ((nl = buffer.indexOf('\n')) >= 0) {
252
- const line = buffer.slice(0, nl).trim();
253
- buffer = buffer.slice(nl + 1);
254
- if (!line)
255
- continue;
256
- let msg = null;
257
- try {
258
- msg = JSON.parse(line);
259
- }
260
- catch {
261
- continue;
262
- }
263
- if (typeof msg.userCode === 'string' && msg.userCode) {
264
- notify({
265
- kind: 'enter-code',
266
- message: '请打开链接,使用有 Codex 权限的 ChatGPT 账号登录并输入设备码。',
267
- url: typeof msg.verificationUri === 'string' ? msg.verificationUri : 'https://auth.openai.com/codex/device',
268
- code: msg.userCode,
269
- });
270
- }
271
- else if (msg.type === 'result') {
272
- credential = normalizeOAuthCredential(msg.credential);
273
- if (credential === null)
274
- failure = '登录模块返回了无效的授权凭证';
275
- }
276
- else if (msg.type === 'error') {
277
- failure = typeof msg.message === 'string' ? msg.message : '登录流程异常结束';
278
- }
279
- }
280
- if (credential === null && failure === null) {
281
- const timer = this.timer();
282
- if (Date.now() > deadline) {
283
- failure = '登录超时(15 分钟)';
284
- break;
285
- }
286
- if (proc.status !== 'running') {
287
- failure = '登录进程意外退出';
288
- break;
289
- }
290
- if (timer === undefined) {
291
- failure = 'DSH 计时服务不可用';
292
- break;
293
- }
294
- try {
295
- await timer.timeout(1000);
296
- }
297
- catch {
298
- failure = '登录轮询已停止';
299
- break;
300
- }
301
- }
302
- }
303
- if (failure !== null) {
304
- try {
305
- proc.kill();
306
- }
307
- catch { }
308
- await proc.done;
309
- const knownFailure = failure === '登录超时(15 分钟)'
310
- || failure === '登录进程意外退出'
311
- || failure === 'DSH 计时服务不可用'
312
- || failure === '登录轮询已停止'
313
- || failure === '登录模块返回了无效的授权凭证';
314
- const summary = knownFailure ? failure : 'OpenAI 登录请求未完成';
315
- const hint = /404|not enabled/i.test(failure)
316
- ? ' 请在 ChatGPT 安全设置中启用设备码授权后重试。'
317
- : ' 请重试。';
318
- notify({ message: summary + '。' + hint });
319
- throw new Error('OpenAI authorization failed');
320
- }
321
- if (credential === null)
322
- throw new Error('OpenAI authorization ended without credentials');
323
- if (proc.status === 'running')
324
- proc.kill();
325
- await proc.done;
326
- if (control.aborted())
175
+ if (granted === null || control.aborted())
327
176
  return null;
328
- const granted = credential;
329
177
  await credentials.modifyRecord(KEY, async (current) => {
330
178
  if (control.aborted())
331
179
  return undefined;
@@ -338,6 +186,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
338
186
  ...currentPayload,
339
187
  provider: 'openai',
340
188
  loginMethod: 'device_code',
189
+ cleanupPending: false,
341
190
  accountId: granted.accountId ?? null,
342
191
  access: granted.access,
343
192
  refresh: granted.refresh ?? '',
@@ -346,26 +195,26 @@ class OpenAISubscriptionController extends TypertRemoteService {
346
195
  managedPiRoute: currentPayload.managedPiRoute === true,
347
196
  },
348
197
  };
349
- });
198
+ }).catch((error) => { throw new SubscriptionError(failureCode(error, 'credential-write-failed')); });
350
199
  if (control.aborted())
351
200
  return null;
352
201
  if (!(await this.mirrorToPiAi(granted, undefined, control.signal))) {
353
202
  if (control.aborted())
354
203
  return null;
355
204
  notify({ message: '授权已完成,但模型凭证保存失败,请重试。' });
356
- throw new Error('Provider credential write failed');
205
+ throw new SubscriptionError('credential-write-failed');
357
206
  }
358
207
  if (control.aborted())
359
208
  return null;
360
209
  try {
361
- await this.synchronizeModels(control.signal);
362
- 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: '可用模型已同步。' });
363
212
  }
364
213
  catch (error) {
365
214
  if (control.aborted())
366
215
  return null;
367
216
  console.error('[openai-subscription] initial model sync failed: ' + errorMessage(error));
368
- notify({ kind: 'models-sync-failed', message: '授权已保存,模型目录可稍后重试同步。' });
217
+ notify({ kind: 'models-sync-failed', errorCode: failureCode(error, 'settings-write-failed'), message: '授权已保存,模型目录可稍后重试同步。' });
369
218
  }
370
219
  return granted;
371
220
  }
@@ -373,14 +222,14 @@ class OpenAISubscriptionController extends TypertRemoteService {
373
222
  const credentials = this.credentials();
374
223
  if (credentials === undefined) {
375
224
  notify({ message: 'DSH 凭证服务不可用,请重启后重试。' });
376
- throw new Error('DSH credential service unavailable');
225
+ throw new SubscriptionError('credentials-unavailable');
377
226
  }
378
227
  const current = await credentials.readRecord(KEY);
379
228
  const adapterRecord = await credentials.readRecord(PI_AI_RECORD);
380
- if ((current === undefined || current.kind !== 'grant') && (adapterRecord === undefined || adapterRecord.kind !== 'grant')) {
381
- notify({ message: '尚未登录 ChatGPT 账号,请先完成设备授权。' });
382
- throw new Error('ChatGPT authorization not found');
383
- }
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');
384
233
  const payload = current?.kind === 'grant' && current.payload && typeof current.payload === 'object'
385
234
  ? current.payload
386
235
  : {};
@@ -391,17 +240,17 @@ class OpenAISubscriptionController extends TypertRemoteService {
391
240
  const secretPayload = typeof adapterPayload.refresh === 'string' && adapterPayload.refresh ? adapterPayload : payload;
392
241
  if (typeof secretPayload.refresh !== 'string' || !secretPayload.refresh) {
393
242
  notify({ message: '当前授权无法刷新,请退出后重新登录。' });
394
- throw new Error('ChatGPT authorization cannot be refreshed');
243
+ throw new SubscriptionError('not-refreshable');
395
244
  }
396
245
  const expectedMainRefresh = typeof payload.refresh === 'string' ? payload.refresh : null;
397
246
  const expectedAdapterRefresh = secretPayload.refresh;
398
247
  const adapterRecordRequired = adapterRecord?.kind === 'grant';
399
248
  const modulePath = await this.locateAuthModule();
400
249
  const shell = this.shell();
401
- if (!modulePath || shell === undefined) {
402
- notify({ message: '当前 DSH 环境缺少 OpenAI 登录组件,请更新 DSH 后重试。' });
403
- throw new Error('OpenAI login component unavailable');
404
- }
250
+ if (shell === undefined)
251
+ throw new SubscriptionError('shell-unavailable');
252
+ if (!modulePath)
253
+ throw new SubscriptionError('component-unavailable');
405
254
  const previous = {};
406
255
  if (typeof secretPayload.access === 'string' && secretPayload.access)
407
256
  previous.access = secretPayload.access;
@@ -413,7 +262,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
413
262
  previous.accountId = secretPayload.accountId;
414
263
  notify({ kind: 'refreshing', message: '正在刷新 ChatGPT 订阅授权…' });
415
264
  const spec = shell.resolve({
416
- command: 'node --input-type=module --eval ' + this.shq(DRIVER_REFRESH) + ' ' + this.shq(modulePath),
265
+ command: buildNodeCommand(DRIVER_REFRESH, modulePath),
417
266
  timeoutMs: 120000,
418
267
  stdoutMaxBytes: 65536,
419
268
  signal: control.signal,
@@ -423,46 +272,43 @@ class OpenAISubscriptionController extends TypertRemoteService {
423
272
  const result = await shell.run(spec);
424
273
  if (result.aborted || control.aborted())
425
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');
426
283
  let msg = null;
427
- if (result.exitCode === 0) {
428
- const lines = (result.stdout.text || '').split('\n');
429
- for (let i = lines.length - 1; i >= 0; i--) {
430
- const line = (lines[i] ?? '').trim();
431
- if (!line)
432
- continue;
433
- try {
434
- msg = JSON.parse(line);
435
- }
436
- catch {
437
- continue;
438
- }
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;
439
289
  break;
440
290
  }
441
291
  }
442
- if (!msg || msg.type !== 'result' || !msg.credential || typeof msg.credential !== 'object') {
443
- notify({ message: '刷新失败,请检查网络后重试;若问题持续,请退出后重新登录。' });
444
- throw new Error('OpenAI authorization refresh failed');
445
- }
446
- const next = normalizeOAuthCredential(msg.credential, previous);
447
- if (next === null) {
448
- notify({ message: '刷新失败:登录服务返回了无效响应。' });
449
- throw new Error('Invalid authorization response');
450
- }
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');
451
297
  if (!(await this.mirrorToPiAi(next, { refresh: expectedAdapterRefresh, requireExisting: adapterRecordRequired }, control.signal))) {
452
298
  notify({ message: '刷新期间授权记录已变化,已保留较新的凭证。' });
453
- throw new Error('Provider authorization changed during refresh');
299
+ throw new SubscriptionError('credential-changed');
454
300
  }
455
301
  await credentials.modifyRecord(KEY, async (latest) => {
456
302
  if (control.aborted())
457
303
  return undefined;
458
- if (latest !== undefined && latest.kind !== 'grant')
459
- throw new Error('Plugin authorization changed during refresh');
304
+ if (latest === undefined || latest.kind !== 'grant')
305
+ throw new SubscriptionError('credential-changed');
460
306
  const latestPayload = latest?.kind === 'grant' && latest.payload && typeof latest.payload === 'object'
461
307
  ? latest.payload
462
308
  : {};
463
309
  const latestRefresh = typeof latestPayload.refresh === 'string' ? latestPayload.refresh : null;
464
310
  if (latestRefresh !== expectedMainRefresh)
465
- throw new Error('Plugin authorization changed during refresh');
311
+ throw new SubscriptionError('credential-changed');
466
312
  return {
467
313
  kind: 'grant',
468
314
  payload: {
@@ -478,18 +324,18 @@ class OpenAISubscriptionController extends TypertRemoteService {
478
324
  managedPiRoute: latestPayload.managedPiRoute === true,
479
325
  },
480
326
  };
481
- });
327
+ }).catch((error) => { throw new SubscriptionError(failureCode(error, 'credential-write-failed')); });
482
328
  if (control.aborted())
483
329
  return null;
484
330
  try {
485
- await this.synchronizeModels(control.signal);
486
- 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: '授权与可用模型已更新。' });
487
333
  }
488
334
  catch (error) {
489
335
  if (control.aborted())
490
336
  return null;
491
337
  console.error('[openai-subscription] model sync after refresh failed: ' + errorMessage(error));
492
- notify({ kind: 'models-sync-failed', message: '授权已更新,模型目录可稍后重试同步。' });
338
+ notify({ kind: 'models-sync-failed', errorCode: failureCode(error, 'settings-write-failed'), message: '授权已更新,模型目录可稍后重试同步。' });
493
339
  }
494
340
  return next;
495
341
  }
@@ -511,12 +357,14 @@ class OpenAISubscriptionController extends TypertRemoteService {
511
357
  return undefined;
512
358
  if (expected !== undefined) {
513
359
  if (expected.requireExisting && (current === undefined || current.kind !== 'grant')) {
514
- throw new Error('Provider authorization was removed during refresh');
360
+ throw new SubscriptionError('credential-changed');
515
361
  }
362
+ if (current !== undefined && current.kind !== 'grant')
363
+ throw new SubscriptionError('credential-changed');
516
364
  if (current !== undefined && current.kind === 'grant') {
517
365
  const currentPayload = current.payload && typeof current.payload === 'object' ? current.payload : {};
518
366
  if (currentPayload.refresh !== expected.refresh)
519
- throw new Error('Provider authorization changed during refresh');
367
+ throw new SubscriptionError('credential-changed');
520
368
  }
521
369
  }
522
370
  return { kind: 'grant', payload };
@@ -525,7 +373,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
525
373
  }
526
374
  catch (error) {
527
375
  console.error('[openai-subscription] mirror to llm-pi-ai/openai-codex failed: ' + errorMessage(error));
528
- return false;
376
+ throw new SubscriptionError(failureCode(error, 'credential-write-failed'));
529
377
  }
530
378
  }
531
379
  /** Keep one cancellable model sync in flight so login, refresh, and UI actions cannot race. */
@@ -552,25 +400,50 @@ class OpenAISubscriptionController extends TypertRemoteService {
552
400
  });
553
401
  return pending;
554
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
+ }
555
428
  /** Fetch the account-scoped catalog and replace only this plugin's previous rows. */
556
429
  async performModelSync(signal, adoptExistingModels) {
557
430
  const credentials = this.credentials();
558
431
  if (credentials === undefined)
559
- throw new Error('DSH 凭证服务不可用,请重启后重试');
432
+ throw new SubscriptionError('credentials-unavailable');
560
433
  const settings = this.ctx.get('settings');
561
434
  if (settings === undefined || typeof settings.mutate !== 'function') {
562
- throw new Error('DSH 模型设置服务不可用,请重启后重试');
435
+ throw new SubscriptionError('settings-unavailable');
563
436
  }
564
437
  const [ownerRecord, adapterRecord] = await Promise.all([
565
438
  credentials.readRecord(KEY),
566
439
  credentials.readRecord(PI_AI_RECORD),
567
440
  ]);
568
- if (recordOf(ownerRecord)?.kind !== 'grant')
569
- throw new Error('尚未通过此插件连接 ChatGPT,请先完成登录');
441
+ if (recordOf(ownerRecord)?.kind !== 'grant' || grantPayload(ownerRecord).cleanupPending === true)
442
+ throw new SubscriptionError('not-connected');
570
443
  const adapter = grantPayload(adapterRecord);
571
444
  const access = typeof adapter.access === 'string' ? adapter.access : '';
572
445
  if (!access)
573
- throw new Error('尚未连接 ChatGPT,请先完成登录');
446
+ throw new SubscriptionError('not-connected');
574
447
  const accountId = typeof adapter.accountId === 'string' && adapter.accountId.length > 0
575
448
  ? adapter.accountId
576
449
  : undefined;
@@ -581,49 +454,48 @@ class OpenAISubscriptionController extends TypertRemoteService {
581
454
  // user/profile allow-list has no prior plugin ownership snapshot.
582
455
  const initialDescriptor = settings.describe({ redactSecrets: true }).find((entry) => entry.ns === 'llm-pi-ai');
583
456
  if (initialDescriptor === undefined)
584
- throw new Error('DSH 模型适配器未启用,请更新 DSH 后重试');
457
+ throw new SubscriptionError('settings-unavailable');
585
458
  const initialPlugin = grantPayload(ownerRecord);
586
459
  if (!adoptExistingModels
587
460
  && modelIds(initialPlugin.managedModels).length === 0
588
461
  && explicitRouteModels(initialDescriptor.user, initialDescriptor.base) !== undefined) {
589
- throw new Error('现有模型列表由配置管理,请在 ChatGPT 设置页确认同步');
462
+ throw new SubscriptionError('models-confirmation-required');
590
463
  }
591
464
  const remoteModels = await this.modelDiscovery(discoveryCredential, { signal });
592
- const llm = this.llm();
593
- if (llm === undefined || typeof llm.discoverModels !== 'function') {
594
- throw new Error('DSH 模型目录服务不可用,请重启后重试');
595
- }
596
465
  let builtinModels;
597
466
  try {
598
- 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
+ }
599
471
  }
600
472
  catch (error) {
601
473
  console.error('[openai-subscription] read installed openai-codex catalog failed: ' + errorMessage(error));
602
- throw new Error('无法读取 DSH 内置模型目录,现有设置未更改');
474
+ throw new SubscriptionError(failureCode(error, 'models-unavailable'));
603
475
  }
604
476
  const discovered = unionModelCatalog(remoteModels, builtinModels);
605
477
  for (let attempt = 0; attempt < 3; attempt++) {
606
478
  if (signal.aborted)
607
- throw new Error('模型同步已取消');
479
+ throw new SubscriptionError('cancelled');
608
480
  const [currentOwner, currentAdapter] = await Promise.all([
609
481
  credentials.readRecord(KEY),
610
482
  credentials.readRecord(PI_AI_RECORD),
611
483
  ]);
612
484
  if (recordOf(currentOwner)?.kind !== 'grant')
613
- throw new Error('ChatGPT 连接已被移除');
485
+ throw new SubscriptionError('credential-changed');
614
486
  const liveAdapter = grantPayload(currentAdapter);
615
487
  const liveAccess = typeof liveAdapter.access === 'string' ? liveAdapter.access : '';
616
488
  const liveAccountId = typeof liveAdapter.accountId === 'string' ? liveAdapter.accountId : undefined;
617
489
  if (!liveAccess || (accountId === undefined ? liveAccess !== access : liveAccountId !== accountId)) {
618
- throw new Error('ChatGPT 连接在同步期间发生变化,请重试');
490
+ throw new SubscriptionError('credential-changed');
619
491
  }
620
492
  const descriptor = settings.describe({ redactSecrets: true }).find((entry) => entry.ns === 'llm-pi-ai');
621
493
  if (descriptor === undefined)
622
- throw new Error('DSH 模型适配器未启用,请更新 DSH 后重试');
494
+ throw new SubscriptionError('settings-unavailable');
623
495
  const plugin = grantPayload(currentOwner);
624
496
  const explicitModels = explicitRouteModels(descriptor.user, descriptor.base);
625
497
  if (!adoptExistingModels && modelIds(plugin.managedModels).length === 0 && explicitModels !== undefined) {
626
- throw new Error('现有模型列表由配置管理,请在 ChatGPT 设置页确认同步');
498
+ throw new SubscriptionError('models-confirmation-required');
627
499
  }
628
500
  const merged = mergeModelCatalog(explicitModels, plugin.managedModels, discovered, plugin.suppressedModelIds);
629
501
  const createdRoute = piRoute(descriptor.value) === null;
@@ -632,23 +504,23 @@ class OpenAISubscriptionController extends TypertRemoteService {
632
504
  : { op: 'set', path: ['providers', 'openai-codex', 'models'], value: merged.models };
633
505
  try {
634
506
  if (signal.aborted)
635
- throw new Error('模型同步已取消');
507
+ throw new SubscriptionError('cancelled');
636
508
  await settings.mutate('llm-pi-ai', [operation], descriptor.revision);
637
509
  }
638
510
  catch (error) {
639
511
  if (attempt < 2 && settingsConflict(error))
640
512
  continue;
641
513
  if (settingsConflict(error))
642
- throw new Error('模型设置刚刚发生变化,请重试同步');
643
- throw error;
514
+ throw new SubscriptionError('settings-conflict');
515
+ throw new SubscriptionError(failureCode(error, 'settings-write-failed'));
644
516
  }
645
517
  try {
646
518
  await credentials.modifyRecord(KEY, async (latest) => {
647
519
  if (latest === undefined || latest.kind !== 'grant')
648
- throw new Error('ChatGPT 连接在同步期间被移除');
520
+ throw new SubscriptionError('credential-changed');
649
521
  const payload = recordOf(latest.payload) ?? {};
650
522
  if (accountId !== undefined && typeof payload.accountId === 'string' && payload.accountId !== accountId) {
651
- throw new Error('ChatGPT 账号在同步期间发生变化');
523
+ throw new SubscriptionError('credential-changed');
652
524
  }
653
525
  return {
654
526
  ...latest,
@@ -666,10 +538,11 @@ class OpenAISubscriptionController extends TypertRemoteService {
666
538
  // Settings already contain the safe complete snapshot. A later sync will
667
539
  // conservatively treat it as local rather than risk overwriting it.
668
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' };
669
542
  }
670
543
  return { synced: true, count: modelIds(merged.models).length };
671
544
  }
672
- throw new Error('模型设置刚刚发生变化,请重试同步');
545
+ throw new SubscriptionError('settings-conflict');
673
546
  }
674
547
  /** Remove only unchanged dynamic rows, preserving every local model and profile field. */
675
548
  async removeManagedPiRoute(managedRoute, managedModels) {
@@ -678,12 +551,12 @@ class OpenAISubscriptionController extends TypertRemoteService {
678
551
  return;
679
552
  const settings = this.ctx.get('settings');
680
553
  if (settings === undefined || typeof settings.mutate !== 'function') {
681
- throw new Error('DSH 模型设置服务不可用,尚未断开连接');
554
+ throw new SubscriptionError('settings-unavailable');
682
555
  }
683
556
  for (let attempt = 0; attempt < 3; attempt++) {
684
557
  const descriptor = settings.describe({ redactSecrets: true }).find((entry) => entry.ns === 'llm-pi-ai');
685
558
  if (descriptor === undefined)
686
- throw new Error('DSH 模型适配器未启用,尚未断开连接');
559
+ throw new SubscriptionError('settings-unavailable');
687
560
  const route = piRoute(descriptor.user);
688
561
  if (route === null)
689
562
  return;
@@ -720,26 +593,33 @@ class OpenAISubscriptionController extends TypertRemoteService {
720
593
  if (attempt < 2 && settingsConflict(error))
721
594
  continue;
722
595
  if (settingsConflict(error))
723
- throw new Error('模型设置持续发生变化,尚未断开连接');
724
- throw error;
596
+ throw new SubscriptionError('settings-conflict');
597
+ throw new SubscriptionError(failureCode(error, 'settings-write-failed'));
725
598
  }
726
599
  }
727
600
  }
728
- beginLogin(method) {
601
+ beginLogin(method, observer) {
729
602
  this.ensureAuthorizationFlow();
730
- if (this.pendingBridge !== null) {
731
- if (this.pendingBridge.done)
732
- this.pendingBridge = null;
733
- else
734
- 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' };
735
608
  }
609
+ this.pendingBridge = null;
736
610
  const controller = new AbortController();
737
611
  const state = { notices: [], done: false, outcome: null, error: null, controller, task: null };
738
612
  this.pendingBridge = state;
739
613
  const notify = (notice) => {
740
- state.notices.push({ kind: notice.kind, message: notice.message, url: notice.url, code: notice.code });
614
+ state.notices.push({ ...notice });
741
615
  if (state.notices.length > 50)
742
616
  state.notices.shift();
617
+ try {
618
+ observer?.(notice);
619
+ }
620
+ catch (error) {
621
+ logFailure('notify', error);
622
+ }
743
623
  };
744
624
  const control = { signal: controller.signal, aborted: () => controller.signal.aborted };
745
625
  const task = (async () => {
@@ -750,24 +630,24 @@ class OpenAISubscriptionController extends TypertRemoteService {
750
630
  else if (method === 'device_code')
751
631
  credential = await this.runDevice(control, notify);
752
632
  else
753
- throw new Error('未知的登录方式:' + method);
754
- state.outcome = credential === null ? 'cancelled' : 'authorized';
633
+ throw new SubscriptionError('invalid-method');
634
+ state.outcome = controller.signal.aborted || credential === null ? 'cancelled' : 'authorized';
755
635
  }
756
- catch {
636
+ catch (error) {
757
637
  state.outcome = controller.signal.aborted ? 'cancelled' : 'failed';
758
- 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
+ }
759
645
  }
760
646
  finally {
761
647
  state.done = true;
762
- const timer = this.timer();
763
- if (timer !== undefined) {
764
- void timer.timeout(30000).then(() => {
765
- if (this.pendingBridge === state)
766
- this.pendingBridge = null;
767
- }).catch(() => {
768
- // Fiber disposal cancels timer promises; the flow is already done.
769
- });
770
- }
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');
771
651
  }
772
652
  })();
773
653
  state.task = task;
@@ -788,17 +668,25 @@ class OpenAISubscriptionController extends TypertRemoteService {
788
668
  { id: 'refresh', label: '刷新授权' },
789
669
  ],
790
670
  run: async (session) => {
791
- const notify = (notice) => session.notify(notice);
792
- const control = { signal: session.signal, aborted: () => session.signal.aborted };
793
- if (session.method === 'refresh') {
794
- await this.runRefresh(control, notify);
671
+ if (session.signal.aborted)
795
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');
796
686
  }
797
- if (session.method === 'device_code') {
798
- await this.runDevice(control, notify);
799
- return;
687
+ finally {
688
+ session.signal.removeEventListener('abort', abort);
800
689
  }
801
- throw new Error('未知的登录方式:' + session.method);
802
690
  },
803
691
  });
804
692
  this.registeredAuthorization = authorization;
@@ -807,24 +695,39 @@ class OpenAISubscriptionController extends TypertRemoteService {
807
695
  console.error('[openai-subscription] registerFlow failed: ' + errorMessage(error));
808
696
  }
809
697
  }
810
- shq(value) {
811
- return "'" + String(value).replace(/'/g, "'\\''") + "'";
812
- }
813
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() {
814
707
  this.ensureAuthorizationFlow();
815
708
  const modulePath = await this.locateAuthModule();
816
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
+ };
817
720
  if (credentials === undefined)
818
- return { configured: false, ready: !!modulePath };
721
+ return base;
819
722
  const [record, adapterRecord] = await Promise.all([
820
723
  credentials.readRecord(KEY),
821
724
  credentials.readRecord(PI_AI_RECORD),
822
725
  ]);
823
726
  const plugin = grantPayload(record);
824
727
  const adapter = grantPayload(adapterRecord);
825
- if (recordOf(record)?.kind !== 'grant' || typeof adapter.access !== 'string' || !adapter.access) {
826
- return { configured: false, ready: !!modulePath };
827
- }
728
+ base.cleanupAvailable = recordOf(record)?.kind === 'grant';
729
+ if (!base.cleanupAvailable || plugin.cleanupPending === true || typeof adapter.access !== 'string' || !adapter.access.trim())
730
+ return base;
828
731
  const managedIds = modelIds(plugin.managedModels);
829
732
  const suppressed = new Set(Array.isArray(plugin.suppressedModelIds)
830
733
  ? plugin.suppressedModelIds.filter((id) => typeof id === 'string')
@@ -841,23 +744,29 @@ class OpenAISubscriptionController extends TypertRemoteService {
841
744
  }
842
745
  const modelsSynced = managedIds.length > 0 && expectedIds.every((id) => configuredIds.includes(id));
843
746
  return {
747
+ ...base,
844
748
  configured: true,
845
- ready: !!modulePath,
846
- 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,
847
752
  modelsSynced,
848
753
  modelCount: modelsSynced ? configuredIds.length : 0,
849
754
  };
850
755
  }
851
- async authorize(method) {
852
- return this.beginLogin(typeof method === 'string' ? method : 'device_code');
756
+ async authorize(method = 'device_code') {
757
+ return this.beginLogin(typeof method === 'string' ? method : '');
853
758
  }
854
759
  async poll() {
855
- if (this.pendingBridge === null)
856
- return { status: 'idle', notices: [] };
857
760
  const bridge = this.pendingBridge;
858
- 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 }));
859
768
  if (bridge.done)
860
- 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 } : {}) };
861
770
  return { status: 'pending', notices };
862
771
  }
863
772
  async cancel() {
@@ -872,13 +781,34 @@ class OpenAISubscriptionController extends TypertRemoteService {
872
781
  }
873
782
  return { ok: true };
874
783
  }
875
- async syncModels() {
876
- // This explicit UI action is the opt-in for treating an existing allow-list
877
- // as locally owned additions on top of the live and installed catalogs.
878
- 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
+ }
793
+ }
794
+ /** Serialize disconnect with authorization and sync, including other tabs. */
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
+ }
879
809
  }
880
810
  /** Cancel active authorization and remove plugin-managed credentials and settings. */
881
- async logout() {
811
+ async performLogout() {
882
812
  const pending = this.pendingBridge;
883
813
  if (pending !== null) {
884
814
  pending.controller.abort();
@@ -900,10 +830,10 @@ class OpenAISubscriptionController extends TypertRemoteService {
900
830
  await activeSync.catch(() => { });
901
831
  const credentials = this.credentials();
902
832
  if (credentials === undefined)
903
- throw new Error('DSH credential service unavailable');
833
+ throw new SubscriptionError('credentials-unavailable');
904
834
  const record = await credentials.readRecord(KEY);
905
835
  if (recordOf(record)?.kind !== 'grant')
906
- throw new Error('ChatGPT 连接不属于此插件');
836
+ throw new SubscriptionError('not-connected');
907
837
  const payload = grantPayload(record);
908
838
  const managedPiRoute = payload.managedPiRoute === true;
909
839
  const managedModels = payload.managedModels;
@@ -919,16 +849,16 @@ class OpenAISubscriptionController extends TypertRemoteService {
919
849
  let staged = false;
920
850
  await credentials.modifyRecord(KEY, async (latest) => {
921
851
  if (latest === undefined || latest.kind !== 'grant')
922
- throw new Error('ChatGPT 连接在断开期间发生变化');
852
+ throw new SubscriptionError('credential-changed');
923
853
  const latestPayload = grantPayload(latest);
924
854
  if (latestPayload.access !== payload.access || latestPayload.refresh !== payload.refresh) {
925
- throw new Error('ChatGPT 连接在断开期间发生变化');
855
+ throw new SubscriptionError('credential-changed');
926
856
  }
927
857
  staged = true;
928
858
  return { kind: 'grant', payload: { provider: 'openai', cleanupPending: true } };
929
859
  });
930
860
  if (!staged)
931
- throw new Error('ChatGPT 连接在断开期间发生变化');
861
+ throw new SubscriptionError('credential-changed');
932
862
  }
933
863
  // The adapter credential is the authorization actually used for requests.
934
864
  await credentials.deleteRecord(PI_AI_RECORD);