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/README.md +44 -15
- package/README.zh.md +45 -16
- package/dist/client.d.ts +40 -29
- package/dist/client.js +565 -167
- package/dist/host.d.ts +23 -13
- package/dist/host.js +331 -72
- package/dist/models.d.ts +51 -0
- package/dist/models.js +370 -0
- package/package.json +9 -6
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
/**
|
|
69
|
-
private
|
|
70
|
-
/**
|
|
71
|
-
private
|
|
72
|
-
/** Remove only
|
|
73
|
-
private
|
|
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
|
-
|
|
303
|
-
await this.
|
|
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
|
-
|
|
418
|
-
await this.
|
|
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
|
-
/**
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
/**
|
|
478
|
-
async
|
|
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
|
-
|
|
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
|
|
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]
|
|
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
|
|
495
|
-
async
|
|
496
|
-
const
|
|
497
|
-
if (
|
|
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
|
-
|
|
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
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
if (
|
|
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
|
-
|
|
715
|
+
try {
|
|
716
|
+
await settings.mutate('llm-pi-ai', [operation], descriptor.revision);
|
|
509
717
|
return;
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
612
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
676
|
-
|
|
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
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
}
|