dsh-openai-subscription 0.1.0 → 0.1.2

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.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
2
2
  import type { Context } from '@deepseek-ai/cordis';
3
+ /** Stable progress keys let the browser localize notices without exposing diagnostics. */
4
+ type FlowNoticeKind = 'requesting-code' | 'enter-code' | 'refreshing' | 'models-synced' | 'models-sync-failed';
3
5
  /** Host-side notice queued for the polling client. Mirrors `AuthorizationNotice`. */
4
6
  interface FlowNotice {
7
+ kind?: FlowNoticeKind;
5
8
  message: string;
6
9
  url?: string;
7
10
  code?: string;
@@ -15,19 +18,16 @@ interface FlowState {
15
18
  controller: AbortController;
16
19
  task: Promise<void> | null;
17
20
  }
18
- /** `openaiSubscription/status` reply. */
21
+ /** `openaiSubscription/status` reply: semantic UI facts, never raw account metadata. */
19
22
  type StatusResult = {
20
23
  configured: false;
21
24
  ready: boolean;
22
25
  } | {
23
26
  configured: true;
24
27
  ready: boolean;
25
- accountId: string | null;
26
- expires: number | null;
27
- loginMethod: string | null;
28
- obtainedAt: number | null;
29
- refreshedAt: number | null;
30
- hasRefresh: boolean;
28
+ refreshable: boolean;
29
+ modelsSynced: boolean;
30
+ modelCount: number;
31
31
  };
32
32
  /** `openaiSubscription/authorize` reply. */
33
33
  type AuthorizeResult = {
@@ -36,6 +36,11 @@ type AuthorizeResult = {
36
36
  } | {
37
37
  started: true;
38
38
  };
39
+ /** `openaiSubscription/syncModels` reply. */
40
+ interface ModelSyncResult {
41
+ synced: true;
42
+ count: number;
43
+ }
39
44
  /** `openaiSubscription/poll` reply. */
40
45
  type PollResult = {
41
46
  status: 'idle';
@@ -54,23 +59,27 @@ declare class OpenAISubscriptionController extends TypertRemoteService {
54
59
  private cachedModule;
55
60
  private locatingModule;
56
61
  private pendingBridge;
62
+ private syncingModels;
63
+ private modelSyncController;
64
+ private modelDiscovery;
57
65
  constructor(ctx: Context);
58
66
  private credentials;
59
67
  private shell;
60
68
  private timer;
61
69
  private authorization;
70
+ private llm;
62
71
  private ensureAuthorizationFlow;
63
72
  private locateAuthModule;
64
73
  private runDevice;
65
74
  private runRefresh;
66
75
  /** Store the credential used by the `openai-codex` provider. */
67
76
  private mirrorToPiAi;
68
- /** Enable the default provider without replacing user configuration. */
69
- private ensurePiRoute;
70
- /** Mark the default provider as plugin-managed. */
71
- private markPiRouteManaged;
72
- /** Remove only the unchanged default provider created by this plugin. */
73
- private removePiRouteIfBare;
77
+ /** Keep one cancellable model sync in flight so login, refresh, and UI actions cannot race. */
78
+ private synchronizeModels;
79
+ /** Fetch the account-scoped catalog and replace only this plugin's previous rows. */
80
+ private performModelSync;
81
+ /** Remove only unchanged dynamic rows, preserving every local model and profile field. */
82
+ private removeManagedPiRoute;
74
83
  private beginLogin;
75
84
  private registerAuthorizationFlow;
76
85
  private shq;
@@ -80,6 +89,7 @@ declare class OpenAISubscriptionController extends TypertRemoteService {
80
89
  cancel(): Promise<{
81
90
  ok: true;
82
91
  }>;
92
+ syncModels(): Promise<ModelSyncResult>;
83
93
  /** Cancel active authorization and remove plugin-managed credentials and settings. */
84
94
  logout(): Promise<{
85
95
  ok: true;
package/dist/host.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // OAuth is delegated to the OpenAI Codex integration bundled with DSH.
3
3
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
4
4
  import { normalizeOAuthCredential } from './oauth.js';
5
+ import { discoverOpenAIModels, mergeModelCatalog, removeManagedModels, } from './models.js';
5
6
  /** Plugin-owned authorization metadata. */
6
7
  const KEY = 'dsh-openai-subscription/chatgpt';
7
8
  /** Credential consumed by the `openai-codex` model provider. */
@@ -64,9 +65,58 @@ function errorMessage(error) {
64
65
  const message = error?.message;
65
66
  return message ? String(message) : String(error);
66
67
  }
68
+ function recordOf(value) {
69
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
70
+ ? value
71
+ : null;
72
+ }
73
+ function grantPayload(record) {
74
+ const typed = recordOf(record);
75
+ return typed?.kind === 'grant' ? recordOf(typed.payload) ?? {} : {};
76
+ }
77
+ function modelIds(value) {
78
+ if (!Array.isArray(value))
79
+ return [];
80
+ const ids = [];
81
+ for (const candidate of value) {
82
+ const id = recordOf(candidate)?.id;
83
+ if (typeof id === 'string' && id.length > 0 && !ids.includes(id))
84
+ ids.push(id);
85
+ }
86
+ return ids;
87
+ }
88
+ function piRoute(section) {
89
+ const providers = recordOf(recordOf(section)?.providers);
90
+ return providers === null ? null : recordOf(providers['openai-codex']);
91
+ }
92
+ function explicitRouteModels(user, base) {
93
+ const userRoute = piRoute(user);
94
+ if (userRoute !== null && Object.prototype.hasOwnProperty.call(userRoute, 'models'))
95
+ return userRoute.models;
96
+ const baseRoute = piRoute(base);
97
+ if (baseRoute !== null && Object.prototype.hasOwnProperty.call(baseRoute, 'models'))
98
+ return baseRoute.models;
99
+ return undefined;
100
+ }
101
+ function unionModelCatalog(remote, builtin) {
102
+ const models = remote.models.map((model) => ({ ...model }));
103
+ const mentionedUpstream = new Set(remote.seenIds);
104
+ for (const candidate of builtin) {
105
+ if (typeof candidate.id !== 'string' || candidate.id.length === 0 || mentionedUpstream.has(candidate.id))
106
+ continue;
107
+ mentionedUpstream.add(candidate.id);
108
+ // Keep unmentioned catalog rows minimal so llm-pi-ai supplies complete
109
+ // metadata, but never resurrect an id the account endpoint explicitly hid.
110
+ models.push({ id: candidate.id });
111
+ }
112
+ return models;
113
+ }
114
+ function settingsConflict(error) {
115
+ return recordOf(error)?.code === 'SETTINGS_CONFLICT';
116
+ }
67
117
  //#endregion
68
118
  /** Register source-mode remote methods without decorator syntax. */
69
- const REMOTE_METHODS = ['status', 'authorize', 'poll', 'cancel', 'logout'];
119
+ const REMOTE_METHODS = ['status', 'authorize', 'poll', 'cancel', 'syncModels', 'logout'];
70
120
  function decorateRemoteMethods(klass, methods) {
71
121
  const initializers = [];
72
122
  for (const name of methods) {
@@ -90,6 +140,9 @@ class OpenAISubscriptionController extends TypertRemoteService {
90
140
  cachedModule = null;
91
141
  locatingModule = null;
92
142
  pendingBridge = null;
143
+ syncingModels = null;
144
+ modelSyncController = null;
145
+ modelDiscovery = discoverOpenAIModels;
93
146
  constructor(ctx) {
94
147
  super(ctx, 'openaiSubscription', { namespace: 'openaiSubscription' });
95
148
  // Resolve optional services per operation so late mounts and reloads work.
@@ -106,6 +159,9 @@ class OpenAISubscriptionController extends TypertRemoteService {
106
159
  authorization() {
107
160
  return this.ctx.get('authorization');
108
161
  }
162
+ llm() {
163
+ return this.ctx.get('llm');
164
+ }
109
165
  ensureAuthorizationFlow() {
110
166
  const authorization = this.authorization();
111
167
  if (authorization === undefined) {
@@ -162,7 +218,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
162
218
  notify({ message: '当前 DSH 环境缺少 OpenAI 登录组件,请更新 DSH 后重试。' });
163
219
  throw new Error('OpenAI login component unavailable');
164
220
  }
165
- notify({ message: '正在向 OpenAI 请求设备登录码…' });
221
+ notify({ kind: 'requesting-code', message: '正在向 OpenAI 请求设备登录码…' });
166
222
  const spec = shell.resolve({
167
223
  command: 'node --input-type=module --eval ' + this.shq(DRIVER_DEVICE) + ' ' + this.shq(modulePath),
168
224
  timeoutMs: 16 * 60 * 1000,
@@ -206,6 +262,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
206
262
  }
207
263
  if (typeof msg.userCode === 'string' && msg.userCode) {
208
264
  notify({
265
+ kind: 'enter-code',
209
266
  message: '请打开链接,使用有 Codex 权限的 ChatGPT 账号登录并输入设备码。',
210
267
  url: typeof msg.verificationUri === 'string' ? msg.verificationUri : 'https://auth.openai.com/codex/device',
211
268
  code: msg.userCode,
@@ -278,6 +335,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
278
335
  return {
279
336
  kind: 'grant',
280
337
  payload: {
338
+ ...currentPayload,
281
339
  provider: 'openai',
282
340
  loginMethod: 'device_code',
283
341
  accountId: granted.accountId ?? null,
@@ -299,8 +357,16 @@ class OpenAISubscriptionController extends TypertRemoteService {
299
357
  }
300
358
  if (control.aborted())
301
359
  return null;
302
- if (await this.ensurePiRoute())
303
- await this.markPiRouteManaged();
360
+ try {
361
+ await this.synchronizeModels(control.signal);
362
+ notify({ kind: 'models-synced', message: '可用模型已同步。' });
363
+ }
364
+ catch (error) {
365
+ if (control.aborted())
366
+ return null;
367
+ console.error('[openai-subscription] initial model sync failed: ' + errorMessage(error));
368
+ notify({ kind: 'models-sync-failed', message: '授权已保存,模型目录可稍后重试同步。' });
369
+ }
304
370
  return granted;
305
371
  }
306
372
  async runRefresh(control, notify) {
@@ -345,7 +411,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
345
411
  previous.expires = secretPayload.expires;
346
412
  if (typeof secretPayload.accountId === 'string' && secretPayload.accountId)
347
413
  previous.accountId = secretPayload.accountId;
348
- notify({ message: '正在刷新 ChatGPT 订阅授权…' });
414
+ notify({ kind: 'refreshing', message: '正在刷新 ChatGPT 订阅授权…' });
349
415
  const spec = shell.resolve({
350
416
  command: 'node --input-type=module --eval ' + this.shq(DRIVER_REFRESH) + ' ' + this.shq(modulePath),
351
417
  timeoutMs: 120000,
@@ -400,6 +466,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
400
466
  return {
401
467
  kind: 'grant',
402
468
  payload: {
469
+ ...latestPayload,
403
470
  provider: 'openai',
404
471
  loginMethod: typeof latestPayload.loginMethod === 'string' ? latestPayload.loginMethod : 'refresh',
405
472
  accountId: next.accountId ?? null,
@@ -414,8 +481,16 @@ class OpenAISubscriptionController extends TypertRemoteService {
414
481
  });
415
482
  if (control.aborted())
416
483
  return null;
417
- if (await this.ensurePiRoute())
418
- await this.markPiRouteManaged();
484
+ try {
485
+ await this.synchronizeModels(control.signal);
486
+ notify({ kind: 'models-synced', message: '授权与可用模型已更新。' });
487
+ }
488
+ catch (error) {
489
+ if (control.aborted())
490
+ return null;
491
+ console.error('[openai-subscription] model sync after refresh failed: ' + errorMessage(error));
492
+ notify({ kind: 'models-sync-failed', message: '授权已更新,模型目录可稍后重试同步。' });
493
+ }
419
494
  return next;
420
495
  }
421
496
  /** Store the credential used by the `openai-codex` provider. */
@@ -453,64 +528,201 @@ class OpenAISubscriptionController extends TypertRemoteService {
453
528
  return false;
454
529
  }
455
530
  }
456
- /** Enable the default provider without replacing user configuration. */
457
- async ensurePiRoute() {
458
- const settings = this.ctx.get('settings');
459
- if (settings === undefined || typeof settings.mutate !== 'function')
460
- return false;
461
- try {
462
- const descriptor = settings.describe({ redactSecrets: true }).find((entry) => entry.ns === 'llm-pi-ai');
463
- if (descriptor === undefined)
464
- return false;
465
- const section = (descriptor.value && typeof descriptor.value === 'object') ? descriptor.value : {};
466
- const providers = (section.providers && typeof section.providers === 'object') ? section.providers : {};
467
- if (providers['openai-codex'] !== undefined)
468
- return false;
469
- await settings.mutate('llm-pi-ai', [{ op: 'set', path: ['providers', 'openai-codex'], value: {} }], descriptor.revision);
470
- return true;
471
- }
472
- catch (error) {
473
- console.error('[openai-subscription] enable openai-codex route failed: ' + errorMessage(error));
474
- return false;
475
- }
531
+ /** Keep one cancellable model sync in flight so login, refresh, and UI actions cannot race. */
532
+ synchronizeModels(signal, adoptExistingModels = false) {
533
+ if (this.syncingModels !== null)
534
+ return this.syncingModels;
535
+ const controller = new AbortController();
536
+ const relayAbort = () => controller.abort(signal?.reason);
537
+ if (signal?.aborted)
538
+ relayAbort();
539
+ else
540
+ signal?.addEventListener('abort', relayAbort, { once: true });
541
+ const pending = this.performModelSync(controller.signal, adoptExistingModels);
542
+ this.syncingModels = pending;
543
+ this.modelSyncController = controller;
544
+ void pending.finally(() => {
545
+ signal?.removeEventListener('abort', relayAbort);
546
+ if (this.syncingModels === pending)
547
+ this.syncingModels = null;
548
+ if (this.modelSyncController === controller)
549
+ this.modelSyncController = null;
550
+ }).catch(() => {
551
+ // The original caller observes the failure; this branch only settles finally().
552
+ });
553
+ return pending;
476
554
  }
477
- /** Mark the default provider as plugin-managed. */
478
- async markPiRouteManaged() {
555
+ /** Fetch the account-scoped catalog and replace only this plugin's previous rows. */
556
+ async performModelSync(signal, adoptExistingModels) {
479
557
  const credentials = this.credentials();
480
558
  if (credentials === undefined)
481
- return;
559
+ throw new Error('DSH 凭证服务不可用,请重启后重试');
560
+ const settings = this.ctx.get('settings');
561
+ if (settings === undefined || typeof settings.mutate !== 'function') {
562
+ throw new Error('DSH 模型设置服务不可用,请重启后重试');
563
+ }
564
+ const [ownerRecord, adapterRecord] = await Promise.all([
565
+ credentials.readRecord(KEY),
566
+ credentials.readRecord(PI_AI_RECORD),
567
+ ]);
568
+ if (recordOf(ownerRecord)?.kind !== 'grant')
569
+ throw new Error('尚未通过此插件连接 ChatGPT,请先完成登录');
570
+ const adapter = grantPayload(adapterRecord);
571
+ const access = typeof adapter.access === 'string' ? adapter.access : '';
572
+ if (!access)
573
+ throw new Error('尚未连接 ChatGPT,请先完成登录');
574
+ const accountId = typeof adapter.accountId === 'string' && adapter.accountId.length > 0
575
+ ? adapter.accountId
576
+ : undefined;
577
+ const discoveryCredential = { access };
578
+ if (accountId !== undefined)
579
+ discoveryCredential.accountId = accountId;
580
+ // Refuse automatic adoption before any network request when an existing
581
+ // user/profile allow-list has no prior plugin ownership snapshot.
582
+ const initialDescriptor = settings.describe({ redactSecrets: true }).find((entry) => entry.ns === 'llm-pi-ai');
583
+ if (initialDescriptor === undefined)
584
+ throw new Error('DSH 模型适配器未启用,请更新 DSH 后重试');
585
+ const initialPlugin = grantPayload(ownerRecord);
586
+ if (!adoptExistingModels
587
+ && modelIds(initialPlugin.managedModels).length === 0
588
+ && explicitRouteModels(initialDescriptor.user, initialDescriptor.base) !== undefined) {
589
+ throw new Error('现有模型列表由配置管理,请在 ChatGPT 设置页确认同步');
590
+ }
591
+ 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
+ let builtinModels;
482
597
  try {
483
- await credentials.modifyRecord(KEY, async (current) => {
484
- if (current === undefined || current.kind !== 'grant')
485
- return undefined;
486
- const payload = (current.payload && typeof current.payload === 'object') ? current.payload : {};
487
- return { ...current, payload: { ...payload, managedPiRoute: true } };
488
- });
598
+ builtinModels = await llm.discoverModels('llm-pi-ai', { provider: 'openai-codex' }, signal);
489
599
  }
490
600
  catch (error) {
491
- console.error('[openai-subscription] remember managed openai-codex route failed: ' + errorMessage(error));
601
+ console.error('[openai-subscription] read installed openai-codex catalog failed: ' + errorMessage(error));
602
+ throw new Error('无法读取 DSH 内置模型目录,现有设置未更改');
603
+ }
604
+ const discovered = unionModelCatalog(remoteModels, builtinModels);
605
+ for (let attempt = 0; attempt < 3; attempt++) {
606
+ if (signal.aborted)
607
+ throw new Error('模型同步已取消');
608
+ const [currentOwner, currentAdapter] = await Promise.all([
609
+ credentials.readRecord(KEY),
610
+ credentials.readRecord(PI_AI_RECORD),
611
+ ]);
612
+ if (recordOf(currentOwner)?.kind !== 'grant')
613
+ throw new Error('ChatGPT 连接已被移除');
614
+ const liveAdapter = grantPayload(currentAdapter);
615
+ const liveAccess = typeof liveAdapter.access === 'string' ? liveAdapter.access : '';
616
+ const liveAccountId = typeof liveAdapter.accountId === 'string' ? liveAdapter.accountId : undefined;
617
+ if (!liveAccess || (accountId === undefined ? liveAccess !== access : liveAccountId !== accountId)) {
618
+ throw new Error('ChatGPT 连接在同步期间发生变化,请重试');
619
+ }
620
+ const descriptor = settings.describe({ redactSecrets: true }).find((entry) => entry.ns === 'llm-pi-ai');
621
+ if (descriptor === undefined)
622
+ throw new Error('DSH 模型适配器未启用,请更新 DSH 后重试');
623
+ const plugin = grantPayload(currentOwner);
624
+ const explicitModels = explicitRouteModels(descriptor.user, descriptor.base);
625
+ if (!adoptExistingModels && modelIds(plugin.managedModels).length === 0 && explicitModels !== undefined) {
626
+ throw new Error('现有模型列表由配置管理,请在 ChatGPT 设置页确认同步');
627
+ }
628
+ const merged = mergeModelCatalog(explicitModels, plugin.managedModels, discovered, plugin.suppressedModelIds);
629
+ const createdRoute = piRoute(descriptor.value) === null;
630
+ const operation = createdRoute
631
+ ? { op: 'set', path: ['providers', 'openai-codex'], value: { models: merged.models } }
632
+ : { op: 'set', path: ['providers', 'openai-codex', 'models'], value: merged.models };
633
+ try {
634
+ if (signal.aborted)
635
+ throw new Error('模型同步已取消');
636
+ await settings.mutate('llm-pi-ai', [operation], descriptor.revision);
637
+ }
638
+ catch (error) {
639
+ if (attempt < 2 && settingsConflict(error))
640
+ continue;
641
+ if (settingsConflict(error))
642
+ throw new Error('模型设置刚刚发生变化,请重试同步');
643
+ throw error;
644
+ }
645
+ try {
646
+ await credentials.modifyRecord(KEY, async (latest) => {
647
+ if (latest === undefined || latest.kind !== 'grant')
648
+ throw new Error('ChatGPT 连接在同步期间被移除');
649
+ const payload = recordOf(latest.payload) ?? {};
650
+ if (accountId !== undefined && typeof payload.accountId === 'string' && payload.accountId !== accountId) {
651
+ throw new Error('ChatGPT 账号在同步期间发生变化');
652
+ }
653
+ return {
654
+ ...latest,
655
+ payload: {
656
+ ...payload,
657
+ managedPiRoute: payload.managedPiRoute === true || createdRoute,
658
+ managedModels: merged.managed,
659
+ suppressedModelIds: merged.suppressed,
660
+ modelsSyncedAt: Date.now(),
661
+ },
662
+ };
663
+ });
664
+ }
665
+ catch (error) {
666
+ // Settings already contain the safe complete snapshot. A later sync will
667
+ // conservatively treat it as local rather than risk overwriting it.
668
+ console.error('[openai-subscription] remember managed model catalog failed: ' + errorMessage(error));
669
+ }
670
+ return { synced: true, count: modelIds(merged.models).length };
492
671
  }
672
+ throw new Error('模型设置刚刚发生变化,请重试同步');
493
673
  }
494
- /** Remove only the unchanged default provider created by this plugin. */
495
- async removePiRouteIfBare() {
496
- const settings = this.ctx.get('settings');
497
- if (settings === undefined || typeof settings.mutate !== 'function')
674
+ /** Remove only unchanged dynamic rows, preserving every local model and profile field. */
675
+ async removeManagedPiRoute(managedRoute, managedModels) {
676
+ const managedIds = modelIds(managedModels);
677
+ if (!managedRoute && managedIds.length === 0)
498
678
  return;
499
- try {
679
+ const settings = this.ctx.get('settings');
680
+ if (settings === undefined || typeof settings.mutate !== 'function') {
681
+ throw new Error('DSH 模型设置服务不可用,尚未断开连接');
682
+ }
683
+ for (let attempt = 0; attempt < 3; attempt++) {
500
684
  const descriptor = settings.describe({ redactSecrets: true }).find((entry) => entry.ns === 'llm-pi-ai');
501
685
  if (descriptor === undefined)
686
+ throw new Error('DSH 模型适配器未启用,尚未断开连接');
687
+ const route = piRoute(descriptor.user);
688
+ if (route === null)
502
689
  return;
503
- const user = (descriptor.user && typeof descriptor.user === 'object') ? descriptor.user : {};
504
- const providers = (user.providers && typeof user.providers === 'object') ? user.providers : {};
505
- const entry = providers['openai-codex'];
506
- if (entry === undefined)
690
+ const profileFields = Object.keys(route).filter((key) => key !== 'models');
691
+ const existingIds = modelIds(route.models);
692
+ let operation = null;
693
+ if (managedIds.length === 0) {
694
+ if (managedRoute && existingIds.length === 0 && profileFields.length === 0) {
695
+ operation = { op: 'unset', path: ['providers', 'openai-codex'] };
696
+ }
697
+ }
698
+ else if (existingIds.length === 0) {
699
+ if (managedRoute && profileFields.length === 0) {
700
+ operation = { op: 'unset', path: ['providers', 'openai-codex'] };
701
+ }
702
+ }
703
+ else {
704
+ const remaining = removeManagedModels(route.models, managedModels);
705
+ if (remaining.length < existingIds.length) {
706
+ operation = managedRoute && remaining.length === 0 && profileFields.length === 0
707
+ ? { op: 'unset', path: ['providers', 'openai-codex'] }
708
+ : remaining.length === 0
709
+ ? { op: 'unset', path: ['providers', 'openai-codex', 'models'] }
710
+ : { op: 'set', path: ['providers', 'openai-codex', 'models'], value: remaining };
711
+ }
712
+ }
713
+ if (operation === null)
507
714
  return;
508
- if (!(typeof entry === 'object' && entry !== null && Object.keys(entry).length === 0))
715
+ try {
716
+ await settings.mutate('llm-pi-ai', [operation], descriptor.revision);
509
717
  return;
510
- await settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', 'openai-codex'] }], descriptor.revision);
511
- }
512
- catch (error) {
513
- console.error('[openai-subscription] disable openai-codex route failed: ' + errorMessage(error));
718
+ }
719
+ catch (error) {
720
+ if (attempt < 2 && settingsConflict(error))
721
+ continue;
722
+ if (settingsConflict(error))
723
+ throw new Error('模型设置持续发生变化,尚未断开连接');
724
+ throw error;
725
+ }
514
726
  }
515
727
  }
516
728
  beginLogin(method) {
@@ -525,7 +737,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
525
737
  const state = { notices: [], done: false, outcome: null, error: null, controller, task: null };
526
738
  this.pendingBridge = state;
527
739
  const notify = (notice) => {
528
- state.notices.push({ message: notice.message, url: notice.url, code: notice.code });
740
+ state.notices.push({ kind: notice.kind, message: notice.message, url: notice.url, code: notice.code });
529
741
  if (state.notices.length > 50)
530
742
  state.notices.shift();
531
743
  };
@@ -608,23 +820,32 @@ class OpenAISubscriptionController extends TypertRemoteService {
608
820
  credentials.readRecord(KEY),
609
821
  credentials.readRecord(PI_AI_RECORD),
610
822
  ]);
611
- const p = record?.kind === 'grant' && record.payload && typeof record.payload === 'object'
612
- ? record.payload
613
- : {};
614
- const adapter = adapterRecord?.kind === 'grant' && adapterRecord.payload && typeof adapterRecord.payload === 'object'
615
- ? adapterRecord.payload
616
- : {};
617
- if (typeof adapter.access !== 'string' || !adapter.access)
823
+ const plugin = grantPayload(record);
824
+ const adapter = grantPayload(adapterRecord);
825
+ if (recordOf(record)?.kind !== 'grant' || typeof adapter.access !== 'string' || !adapter.access) {
618
826
  return { configured: false, ready: !!modulePath };
827
+ }
828
+ const managedIds = modelIds(plugin.managedModels);
829
+ const suppressed = new Set(Array.isArray(plugin.suppressedModelIds)
830
+ ? plugin.suppressedModelIds.filter((id) => typeof id === 'string')
831
+ : []);
832
+ const expectedIds = managedIds.filter((id) => !suppressed.has(id));
833
+ let configuredIds = [];
834
+ try {
835
+ const settings = this.ctx.get('settings');
836
+ const descriptor = settings?.describe({ redactSecrets: true }).find((entry) => entry.ns === 'llm-pi-ai');
837
+ configuredIds = modelIds(explicitRouteModels(descriptor?.user, descriptor?.base));
838
+ }
839
+ catch (error) {
840
+ console.error('[openai-subscription] read model sync status failed: ' + errorMessage(error));
841
+ }
842
+ const modelsSynced = managedIds.length > 0 && expectedIds.every((id) => configuredIds.includes(id));
619
843
  return {
620
844
  configured: true,
621
845
  ready: !!modulePath,
622
- accountId: typeof adapter.accountId === 'string' ? adapter.accountId : (typeof p.accountId === 'string' ? p.accountId : null),
623
- expires: typeof adapter.expires === 'number' && Number.isFinite(adapter.expires) ? adapter.expires : null,
624
- loginMethod: typeof p.loginMethod === 'string' ? p.loginMethod : null,
625
- obtainedAt: typeof p.obtainedAt === 'number' ? p.obtainedAt : null,
626
- refreshedAt: typeof p.refreshedAt === 'number' ? p.refreshedAt : null,
627
- hasRefresh: typeof adapter.refresh === 'string' && adapter.refresh.length > 0,
846
+ refreshable: typeof adapter.refresh === 'string' && adapter.refresh.length > 0,
847
+ modelsSynced,
848
+ modelCount: modelsSynced ? configuredIds.length : 0,
628
849
  };
629
850
  }
630
851
  async authorize(method) {
@@ -651,6 +872,11 @@ class OpenAISubscriptionController extends TypertRemoteService {
651
872
  }
652
873
  return { ok: true };
653
874
  }
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);
879
+ }
654
880
  /** Cancel active authorization and remove plugin-managed credentials and settings. */
655
881
  async logout() {
656
882
  const pending = this.pendingBridge;
@@ -668,19 +894,52 @@ class OpenAISubscriptionController extends TypertRemoteService {
668
894
  }
669
895
  catch { }
670
896
  }
897
+ this.modelSyncController?.abort();
898
+ const activeSync = this.syncingModels;
899
+ if (activeSync !== null)
900
+ await activeSync.catch(() => { });
671
901
  const credentials = this.credentials();
672
902
  if (credentials === undefined)
673
903
  throw new Error('DSH credential service unavailable');
674
904
  const record = await credentials.readRecord(KEY);
675
- const payload = record?.kind === 'grant' && record.payload && typeof record.payload === 'object'
676
- ? record.payload
677
- : {};
905
+ if (recordOf(record)?.kind !== 'grant')
906
+ throw new Error('ChatGPT 连接不属于此插件');
907
+ const payload = grantPayload(record);
678
908
  const managedPiRoute = payload.managedPiRoute === true;
679
- // Delete the provider credential before reporting a successful logout.
909
+ const managedModels = payload.managedModels;
910
+ // Keep the ownership snapshot until settings cleanup succeeds, so a
911
+ // revision conflict can never turn into an unrecoverable orphaned catalog.
912
+ await this.removeManagedPiRoute(managedPiRoute, managedModels);
913
+ // Remove duplicate secrets from the plugin record before deleting the
914
+ // provider credential. If provider deletion fails, retry remains possible
915
+ // without retaining a second access/refresh-token copy.
916
+ const containsSecret = (typeof payload.access === 'string' && payload.access.length > 0)
917
+ || (typeof payload.refresh === 'string' && payload.refresh.length > 0);
918
+ if (containsSecret) {
919
+ let staged = false;
920
+ await credentials.modifyRecord(KEY, async (latest) => {
921
+ if (latest === undefined || latest.kind !== 'grant')
922
+ throw new Error('ChatGPT 连接在断开期间发生变化');
923
+ const latestPayload = grantPayload(latest);
924
+ if (latestPayload.access !== payload.access || latestPayload.refresh !== payload.refresh) {
925
+ throw new Error('ChatGPT 连接在断开期间发生变化');
926
+ }
927
+ staged = true;
928
+ return { kind: 'grant', payload: { provider: 'openai', cleanupPending: true } };
929
+ });
930
+ if (!staged)
931
+ throw new Error('ChatGPT 连接在断开期间发生变化');
932
+ }
933
+ // The adapter credential is the authorization actually used for requests.
680
934
  await credentials.deleteRecord(PI_AI_RECORD);
681
- await credentials.deleteRecord(KEY);
682
- if (managedPiRoute)
683
- await this.removePiRouteIfBare();
935
+ try {
936
+ await credentials.deleteRecord(KEY);
937
+ }
938
+ catch (error) {
939
+ // At this point settings are clean and both token copies are gone; a
940
+ // harmless cleanup marker may remain but must not make disconnect fail.
941
+ console.error('[openai-subscription] remove cleanup marker failed: ' + errorMessage(error));
942
+ }
684
943
  return { ok: true };
685
944
  }
686
945
  }