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/README.md +36 -12
- package/README.zh.md +36 -12
- package/dist/client.d.ts +8 -0
- package/dist/client.js +507 -170
- package/dist/driver.d.ts +12 -0
- package/dist/driver.js +136 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +58 -0
- package/dist/host.d.ts +32 -14
- package/dist/host.js +242 -312
- package/dist/models.js +91 -55
- package/dist/oauth.js +2 -2
- package/dist/platform.d.ts +17 -0
- package/dist/platform.js +279 -0
- package/package.json +2 -2
package/dist/host.js
CHANGED
|
@@ -1,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
161
|
+
throw new SubscriptionError('credentials-unavailable');
|
|
214
162
|
}
|
|
215
163
|
const modulePath = await this.locateAuthModule();
|
|
216
164
|
const shell = this.shell();
|
|
217
|
-
if (
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
223
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
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 (
|
|
402
|
-
|
|
403
|
-
|
|
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:
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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 (
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
|
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
|
|
459
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
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
|
-
|
|
798
|
-
|
|
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
|
|
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
|
-
|
|
826
|
-
|
|
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
|
-
|
|
846
|
-
|
|
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 : '
|
|
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
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
|
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
|
|
833
|
+
throw new SubscriptionError('credentials-unavailable');
|
|
904
834
|
const record = await credentials.readRecord(KEY);
|
|
905
835
|
if (recordOf(record)?.kind !== 'grant')
|
|
906
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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);
|