dsh-openai-subscription 0.1.5 → 0.1.6
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 +16 -1
- package/README.zh.md +16 -1
- package/dist/client.d.ts +12 -0
- package/dist/client.js +185 -21
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +2 -1
- package/dist/host.d.ts +16 -0
- package/dist/host.js +95 -5
- package/dist/models.d.ts +33 -0
- package/dist/models.js +73 -2
- package/docs/DSH_SETTINGS_ICONS.md +40 -15
- package/package.json +5 -3
- package/scripts/patch-dsh-settings-icons.mjs +17 -5
package/README.md
CHANGED
|
@@ -53,7 +53,22 @@ Restart `dsh web`, then refresh the existing page. DSH registers the plugin auto
|
|
|
53
53
|
4. Return to DSH after authorization completes; the plugin fetches and syncs models automatically.
|
|
54
54
|
5. Select an available model from the DSH model picker.
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
The main area keeps **Sync models / Update models**: fetch the model list while preserving local configuration, without switching accounts. Expand **Connection management** for infrequent operations:
|
|
57
|
+
|
|
58
|
+
- **Refresh authorization / Reconnect**: recover from authorization problems. `llm-pi-ai` automatically renews refreshable credentials during requests; routine manual refresh is unnecessary. Recovery is also shown directly for expired or unknown authorization.
|
|
59
|
+
- **Reload status**: read local state only; does not renew authorization or sync models.
|
|
60
|
+
- **Disconnect**: remove the local connection after confirmation; does not delete your ChatGPT account.
|
|
61
|
+
|
|
62
|
+
### 1M and custom context windows
|
|
63
|
+
|
|
64
|
+
Under **Model context**, select a model, choose **Use model default / 1M / Custom**, then **Save context**. 1M means `1,000,000` tokens for input and output combined, not a fixed request size or a higher output-token limit.
|
|
65
|
+
|
|
66
|
+
- Only the selected model’s `llm-pi-ai.providers.openai-codex.models[].contextWindow` changes. Other models are not forced to 1M.
|
|
67
|
+
- Catalog defaults and maximum windows are separate. The 1M option is disabled below a known 1M maximum, and custom values cannot exceed known maxima.
|
|
68
|
+
- When no maximum is declared, an explicit override is allowed with a warning. Local configuration does not guarantee model/account capacity or unlock upstream access.
|
|
69
|
+
- Later model syncs preserve this local edit. Choose **Use model default** and save to restore the latest synchronized default field.
|
|
70
|
+
- Saving changes local settings only, without login or discovery. Concurrent edits produce a settings conflict; reload before saving again.
|
|
71
|
+
- Older snapshots may lack default/maximum metadata; **Update models** fills in facts the catalog supplies. After upgrading, load both the new host and client plugin, restart DSH, and refresh the page.
|
|
57
72
|
|
|
58
73
|
### Status and recovery
|
|
59
74
|
|
package/README.zh.md
CHANGED
|
@@ -53,7 +53,22 @@ dsh plugin --profile web add dsh-openai-subscription
|
|
|
53
53
|
4. 授权完成后返回 DSH;插件会自动获取并同步模型。
|
|
54
54
|
5. 在 DSH 的模型选择器中选择可用模型。
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
常用区域只保留 **同步模型 / 更新模型**:重新获取模型列表,保留本地配置,不切换账号。展开 **连接管理** 才会看到低频操作:
|
|
57
|
+
|
|
58
|
+
- **刷新授权 / 重新连接**:用于授权异常恢复。`llm-pi-ai` 会在模型请求期间自动续期可刷新的凭证,无需日常手动刷新;过期或有效性未知时会直接显示恢复按钮。
|
|
59
|
+
- **重新读取状态**:只读取本机状态,不刷新授权、不同步模型。
|
|
60
|
+
- **断开连接**:清理本机连接,操作前需确认,不会删除 ChatGPT 账号。
|
|
61
|
+
|
|
62
|
+
### 1M 与自定义上下文
|
|
63
|
+
|
|
64
|
+
在 **模型上下文** 中选择模型,再选择 **跟随模型默认 / 1M / 自定义**,点击 **保存上下文**。1M 表示 `1,000,000` tokens,是输入与输出合计窗口;不是每次固定发送 1M tokens,也不会提高单次输出上限。
|
|
65
|
+
|
|
66
|
+
- 仅修改选中模型的 `llm-pi-ai.providers.openai-codex.models[].contextWindow`,不把所有模型强制提升到 1M。
|
|
67
|
+
- 目录的默认窗口与最大窗口分开处理;已知上限低于 1M 时禁用 1M 选项,自定义也不能超过已知上限。
|
|
68
|
+
- 未知上限时允许明确设置,但本机配置不代表模型或账号支持该容量,不会解锁上游权限。
|
|
69
|
+
- 后续模型同步会保留本地上下文编辑;选择 **跟随模型默认** 并保存可恢复最近一次同步的默认字段。
|
|
70
|
+
- 保存仅操作本机设置,不触发登录或模型发现;其他页面同时改动时会报设置冲突,请重新读取后再保存。
|
|
71
|
+
- 旧版记录可能缺少默认/上限信息;点击 **更新模型** 可补齐目录提供的元数据。升级后需同时加载新版主机端与客户端,并重启 DSH、刷新页面。
|
|
57
72
|
|
|
58
73
|
### 状态与恢复
|
|
59
74
|
|
package/dist/client.d.ts
CHANGED
|
@@ -30,6 +30,18 @@ interface ModelSyncInfo {
|
|
|
30
30
|
count: number;
|
|
31
31
|
warningCode?: string;
|
|
32
32
|
}
|
|
33
|
+
interface ModelContextInfo {
|
|
34
|
+
id: string;
|
|
35
|
+
name?: string;
|
|
36
|
+
contextWindow?: number;
|
|
37
|
+
defaultContextWindow?: number;
|
|
38
|
+
maxContextWindow?: number;
|
|
39
|
+
customized: boolean;
|
|
40
|
+
}
|
|
41
|
+
interface ModelContextsInfo {
|
|
42
|
+
revision: number;
|
|
43
|
+
models: ModelContextInfo[];
|
|
44
|
+
}
|
|
33
45
|
interface RemoteResult<T> {
|
|
34
46
|
ok?: boolean;
|
|
35
47
|
error?: {
|
package/dist/client.js
CHANGED
|
@@ -17,7 +17,35 @@ const ZH = {
|
|
|
17
17
|
'model.synced': '模型已同步',
|
|
18
18
|
'model.attention': '模型需要同步',
|
|
19
19
|
'model.count': '{count} 个可用模型',
|
|
20
|
-
'model.synced.detail': '
|
|
20
|
+
'model.synced.detail': '已合并账号模型与 DSH 内置模型,保留你的本地编辑。',
|
|
21
|
+
'model.sync.help': '重新获取可用模型列表,不会更换账号或重置本地配置。',
|
|
22
|
+
'context.title': '模型上下文',
|
|
23
|
+
'context.help': '按模型设置总上下文窗口(输入 + 输出)。同步模型会保留此设置。',
|
|
24
|
+
'context.model': '模型',
|
|
25
|
+
'context.window': '上下文窗口',
|
|
26
|
+
'context.default': '跟随模型默认',
|
|
27
|
+
'context.million': '1M · 1,000,000 tokens',
|
|
28
|
+
'context.custom': '自定义',
|
|
29
|
+
'context.tokens': '自定义 tokens 数',
|
|
30
|
+
'context.current': '当前:{count} tokens',
|
|
31
|
+
'context.maximum': '目录声明上限:{count} tokens',
|
|
32
|
+
'context.unknown': '目录未声明最大窗口;本机配置不会解锁上游权限,请确认模型和账号支持。',
|
|
33
|
+
'context.limit.help': '超过目录声明上限的配置不可保存;实际可用性仍取决于账号和上游。',
|
|
34
|
+
'context.empty': '同步模型后即可配置上下文。',
|
|
35
|
+
'context.loading': '正在读取模型配置…',
|
|
36
|
+
'context.save': '保存上下文',
|
|
37
|
+
'context.saving': '正在保存…',
|
|
38
|
+
'context.saved': '上下文配置已保存,后续模型请求会使用新设置。',
|
|
39
|
+
'context.error': '无法读取上下文配置。请确认插件主机端已更新并重启 DSH 后重试。',
|
|
40
|
+
'error.invalid-context-window': '请输入正整数 tokens 数。',
|
|
41
|
+
'error.context-window-exceeded': '此设置超过了模型目录声明的最大上下文窗口。',
|
|
42
|
+
'error.model-not-found': '模型已不在当前列表中,请重新读取配置。',
|
|
43
|
+
'action.more': '连接管理',
|
|
44
|
+
'action.less': '收起连接管理',
|
|
45
|
+
'manage.refresh.help': '请求时会自动续期;仅在授权异常时手动刷新,无需重新登录。',
|
|
46
|
+
'manage.reconnect.help': '重新进行设备验证,以恢复此账号的授权。',
|
|
47
|
+
'manage.reload.help': '只重新读取本机状态,不刷新授权,也不同步模型。',
|
|
48
|
+
'manage.disconnect.help': '删除本机授权和未修改的插件模型项,不影响 ChatGPT 账号。',
|
|
21
49
|
'model.attention.detail': '确认同步后会保留本地新增项与已编辑字段。',
|
|
22
50
|
'action.connect': '连接 ChatGPT',
|
|
23
51
|
'action.connecting': '正在连接…',
|
|
@@ -120,7 +148,35 @@ const EN = {
|
|
|
120
148
|
'model.synced': 'Models synced',
|
|
121
149
|
'model.attention': 'Models need syncing',
|
|
122
150
|
'model.count': '{count} models available',
|
|
123
|
-
'model.synced.detail': 'Account
|
|
151
|
+
'model.synced.detail': 'Account and DSH models are merged, keeping your local edits.',
|
|
152
|
+
'model.sync.help': 'Fetch the available model list without switching accounts or resetting local settings.',
|
|
153
|
+
'context.title': 'Model context',
|
|
154
|
+
'context.help': 'Set each model’s total context window (input + output). Model sync preserves this setting.',
|
|
155
|
+
'context.model': 'Model',
|
|
156
|
+
'context.window': 'Context window',
|
|
157
|
+
'context.default': 'Use model default',
|
|
158
|
+
'context.million': '1M · 1,000,000 tokens',
|
|
159
|
+
'context.custom': 'Custom',
|
|
160
|
+
'context.tokens': 'Custom token count',
|
|
161
|
+
'context.current': 'Current: {count} tokens',
|
|
162
|
+
'context.maximum': 'Catalog limit: {count} tokens',
|
|
163
|
+
'context.unknown': 'The catalog does not declare a maximum. Local settings do not unlock upstream access; confirm model and account support.',
|
|
164
|
+
'context.limit.help': 'Values above the catalog limit cannot be saved. Availability still depends on your account and upstream service.',
|
|
165
|
+
'context.empty': 'Sync models to configure their context windows.',
|
|
166
|
+
'context.loading': 'Reading model settings…',
|
|
167
|
+
'context.save': 'Save context',
|
|
168
|
+
'context.saving': 'Saving…',
|
|
169
|
+
'context.saved': 'Context settings saved. Subsequent model requests will use the new setting.',
|
|
170
|
+
'context.error': 'Could not read context settings. Ensure the host plugin is updated and restart DSH before retrying.',
|
|
171
|
+
'error.invalid-context-window': 'Enter a positive whole number of tokens.',
|
|
172
|
+
'error.context-window-exceeded': 'This exceeds the maximum context window declared by the model catalog.',
|
|
173
|
+
'error.model-not-found': 'The model is no longer in the current list. Reload settings.',
|
|
174
|
+
'action.more': 'Connection management',
|
|
175
|
+
'action.less': 'Hide connection management',
|
|
176
|
+
'manage.refresh.help': 'Authorization renews automatically during requests. Refresh manually only to recover from authorization errors.',
|
|
177
|
+
'manage.reconnect.help': 'Repeat device verification to restore authorization for this account.',
|
|
178
|
+
'manage.reload.help': 'Read local status only. Does not renew authorization or sync models.',
|
|
179
|
+
'manage.disconnect.help': 'Remove local authorization and unchanged plugin models, not your ChatGPT account.',
|
|
124
180
|
'model.attention.detail': 'Syncing preserves local additions and fields you edited.',
|
|
125
181
|
'action.connect': 'Connect ChatGPT',
|
|
126
182
|
'action.connecting': 'Connecting…',
|
|
@@ -250,8 +306,8 @@ window.__ModuleLoader__.load({
|
|
|
250
306
|
color: var(--dsw-alias-label-primary, #0f1115);
|
|
251
307
|
}
|
|
252
308
|
.oasub-wrap, .oasub-wrap * { box-sizing: border-box; }
|
|
253
|
-
/*
|
|
254
|
-
.oasub-header
|
|
309
|
+
/* The header stays flush with the card's outer frame. */
|
|
310
|
+
.oasub-header { padding-inline: 0; }
|
|
255
311
|
.oasub-header { display: flex; align-items: center; gap: 12px; }
|
|
256
312
|
.oasub-mark {
|
|
257
313
|
display: grid;
|
|
@@ -307,6 +363,16 @@ window.__ModuleLoader__.load({
|
|
|
307
363
|
.oasub-model-title { font-size: 13px; font-weight: 500; line-height: 19px; }
|
|
308
364
|
.oasub-model-detail { color: var(--dsw-alias-label-tertiary, #81858c); font-size: 11px; line-height: 17px; }
|
|
309
365
|
.oasub-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
366
|
+
.oasub-management { display: flex; flex-direction: column; gap: 16px; border-top: 1px solid var(--dsw-alias-border-l2, rgba(15, 17, 21, .14)); padding-top: 16px; }
|
|
367
|
+
.oasub-manage-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
|
368
|
+
.oasub-manage-row > .oasub-button { flex: 0 0 auto; }
|
|
369
|
+
.oasub-button.disclosure { border-color: transparent; border-radius: 8px; color: var(--dsw-alias-label-secondary, #61666b); padding: 4px 0; min-height: 32px; align-self: flex-start; }
|
|
370
|
+
.oasub-context { display: flex; flex-direction: column; gap: 10px; }
|
|
371
|
+
.oasub-field { display: flex; flex-direction: column; gap: 6px; flex: 1 1 180px; min-width: 0; font-size: 12px; }
|
|
372
|
+
.oasub-context > .oasub-field { flex: none; }
|
|
373
|
+
.oasub-select { width: 100%; min-height: 36px; border: 1px solid var(--oasub-control-border); border-radius: 8px; padding: 7px 10px; font: inherit; color: inherit; background: var(--dsw-alias-bg-layer-1, #fff); }
|
|
374
|
+
.oasub-select:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #4176e6); outline-offset: 2px; }
|
|
375
|
+
.oasub-select:disabled { opacity: .55; }
|
|
310
376
|
.oasub-button {
|
|
311
377
|
appearance: none;
|
|
312
378
|
min-height: 36px;
|
|
@@ -379,6 +445,7 @@ window.__ModuleLoader__.load({
|
|
|
379
445
|
.oasub-status-line { flex-direction: column; }
|
|
380
446
|
.oasub-button { flex: 1 1 auto; }
|
|
381
447
|
.oasub-button.quiet { margin-left: 0; }
|
|
448
|
+
.oasub-manage-row { align-items: flex-start; flex-direction: column; gap: 8px; }
|
|
382
449
|
}
|
|
383
450
|
@media (forced-colors: active) {
|
|
384
451
|
.oasub-button { border-color: ButtonText; }
|
|
@@ -414,6 +481,7 @@ window.__ModuleLoader__.load({
|
|
|
414
481
|
'timeout', 'invalid-response', 'process-exited', 'credential-write-failed', 'credential-changed',
|
|
415
482
|
'settings-unavailable', 'models-unavailable', 'models-empty', 'models-confirmation-required',
|
|
416
483
|
'settings-conflict', 'settings-write-failed', 'ownership-save-failed', 'cancelled', 'unknown',
|
|
484
|
+
'invalid-context-window', 'context-window-exceeded', 'model-not-found',
|
|
417
485
|
]);
|
|
418
486
|
function allowedCode(value) {
|
|
419
487
|
return typeof value === 'string' && ERROR_CODES.has(value) ? value : undefined;
|
|
@@ -560,6 +628,25 @@ window.__ModuleLoader__.load({
|
|
|
560
628
|
}
|
|
561
629
|
return { synced: true, count: raw.count, warningCode: raw.warningCode == null ? undefined : allowedCode(raw.warningCode) ?? 'unknown' };
|
|
562
630
|
}
|
|
631
|
+
function positiveInteger(value) {
|
|
632
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
633
|
+
}
|
|
634
|
+
function parseModelContexts(value) {
|
|
635
|
+
const raw = recordOf(value);
|
|
636
|
+
if (raw === null || typeof raw.revision !== 'number' || !Number.isSafeInteger(raw.revision) || raw.revision < 0 || !Array.isArray(raw.models)) {
|
|
637
|
+
throw new ClientFailure('invalid-response');
|
|
638
|
+
}
|
|
639
|
+
const models = [];
|
|
640
|
+
for (const item of raw.models) {
|
|
641
|
+
const row = recordOf(item);
|
|
642
|
+
if (!row || typeof row.id !== 'string' || !row.id || models.some((model) => model.id === row.id))
|
|
643
|
+
throw new ClientFailure('invalid-response');
|
|
644
|
+
models.push({ id: row.id, name: typeof row.name === 'string' ? row.name : undefined,
|
|
645
|
+
contextWindow: positiveInteger(row.contextWindow), defaultContextWindow: positiveInteger(row.defaultContextWindow),
|
|
646
|
+
maxContextWindow: positiveInteger(row.maxContextWindow), customized: row.customized === true });
|
|
647
|
+
}
|
|
648
|
+
return { revision: raw.revision, models };
|
|
649
|
+
}
|
|
563
650
|
function safeVerificationUrl(value) {
|
|
564
651
|
if (typeof value !== 'string' || value !== value.trim())
|
|
565
652
|
return null;
|
|
@@ -591,6 +678,14 @@ window.__ModuleLoader__.load({
|
|
|
591
678
|
const [cancelPending, setCancelPending] = React.useState(false);
|
|
592
679
|
const [confirm, setConfirm] = React.useState(null);
|
|
593
680
|
const [statusRevision, setStatusRevision] = React.useState(0);
|
|
681
|
+
const [managementOpen, setManagementOpen] = React.useState(false);
|
|
682
|
+
const [contexts, setContexts] = React.useState(null);
|
|
683
|
+
const [contextLoading, setContextLoading] = React.useState(false);
|
|
684
|
+
const [contextError, setContextError] = React.useState(null);
|
|
685
|
+
const [modelId, setModelId] = React.useState('');
|
|
686
|
+
// null means no pending edit; '' selects the synchronized model default.
|
|
687
|
+
const [contextDraft, setContextDraft] = React.useState(null);
|
|
688
|
+
const [contextChoice, setContextChoice] = React.useState(null);
|
|
594
689
|
const actionLock = React.useRef(false);
|
|
595
690
|
const cancelAcknowledged = React.useRef(false);
|
|
596
691
|
const flowObserved = React.useRef(false);
|
|
@@ -649,6 +744,32 @@ window.__ModuleLoader__.load({
|
|
|
649
744
|
controller.abort();
|
|
650
745
|
};
|
|
651
746
|
}, [connection, statusRevision]);
|
|
747
|
+
React.useEffect(() => {
|
|
748
|
+
if (!configured) {
|
|
749
|
+
setContexts(null);
|
|
750
|
+
setContextDraft(null);
|
|
751
|
+
setContextChoice(null);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const controller = new AbortController();
|
|
755
|
+
setContextLoading(true);
|
|
756
|
+
setContextError(null);
|
|
757
|
+
remoteCall(connection, 'getModelContexts', {}, controller.signal).then(parseModelContexts).then((result) => {
|
|
758
|
+
if (controller.signal.aborted)
|
|
759
|
+
return;
|
|
760
|
+
setContexts(result);
|
|
761
|
+
setModelId((id) => result.models.some((model) => model.id === id) ? id : result.models[0]?.id ?? '');
|
|
762
|
+
setContextDraft(null);
|
|
763
|
+
setContextChoice(null);
|
|
764
|
+
}).catch((error) => {
|
|
765
|
+
if (!controller.signal.aborted) {
|
|
766
|
+
setContexts(null);
|
|
767
|
+
setContextError(errorKey(error, 'context.error'));
|
|
768
|
+
}
|
|
769
|
+
}).finally(() => { if (!controller.signal.aborted)
|
|
770
|
+
setContextLoading(false); });
|
|
771
|
+
return () => controller.abort();
|
|
772
|
+
}, [connection, configured, statusRevision]);
|
|
652
773
|
React.useEffect(() => {
|
|
653
774
|
if (props.subscribeReset === undefined)
|
|
654
775
|
return;
|
|
@@ -967,6 +1088,41 @@ window.__ModuleLoader__.load({
|
|
|
967
1088
|
}
|
|
968
1089
|
});
|
|
969
1090
|
};
|
|
1091
|
+
const selectedModel = contexts?.models.find((model) => model.id === modelId);
|
|
1092
|
+
const savedContext = selectedModel?.customized && selectedModel.contextWindow ? String(selectedModel.contextWindow) : '';
|
|
1093
|
+
const draft = contextDraft ?? savedContext;
|
|
1094
|
+
const contextMode = contextChoice ?? (draft === '' ? 'default' : draft === '1000000' ? 'million' : 'custom');
|
|
1095
|
+
const requestedContext = draft === '' ? null : /^\d+$/.test(draft) ? positiveInteger(Number(draft)) : undefined;
|
|
1096
|
+
const contextValidation = requestedContext === undefined ? 'error.invalid-context-window'
|
|
1097
|
+
: requestedContext !== null && selectedModel?.maxContextWindow !== undefined && requestedContext > selectedModel.maxContextWindow
|
|
1098
|
+
? 'error.context-window-exceeded' : null;
|
|
1099
|
+
const contextDirty = contextDraft !== null && (draft !== savedContext || (draft === '' && selectedModel?.customized === true));
|
|
1100
|
+
const saveContext = () => {
|
|
1101
|
+
const signal = lifetime.current?.signal;
|
|
1102
|
+
if (phaseRef.current !== 'idle' || actionLock.current || !signal || signal.aborted || !contexts || !selectedModel || !contextDirty || contextValidation || contextLoading || statusLoading)
|
|
1103
|
+
return;
|
|
1104
|
+
actionLock.current = true;
|
|
1105
|
+
changePhase('saving-context');
|
|
1106
|
+
setNotice(null);
|
|
1107
|
+
remoteCall(connection, 'setModelContext', {
|
|
1108
|
+
modelId: selectedModel.id, contextWindow: requestedContext, revision: contexts.revision,
|
|
1109
|
+
}, signal, MUTATION_TIMEOUT_MS).then((result) => {
|
|
1110
|
+
if (signal.aborted)
|
|
1111
|
+
return;
|
|
1112
|
+
if (result?.saved !== true)
|
|
1113
|
+
throw new ClientFailure('invalid-response');
|
|
1114
|
+
setNotice({ tone: 'success', key: 'context.saved' });
|
|
1115
|
+
}).catch((error) => {
|
|
1116
|
+
if (!signal.aborted)
|
|
1117
|
+
setNotice({ tone: 'error', key: failureCode(error) === 'timeout' ? 'error.long-action' : errorKey(error, 'error.settings-write-failed') });
|
|
1118
|
+
}).finally(() => {
|
|
1119
|
+
if (!signal.aborted) {
|
|
1120
|
+
actionLock.current = false;
|
|
1121
|
+
changePhase('idle');
|
|
1122
|
+
reloadStatus();
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
};
|
|
970
1126
|
const selectCode = () => {
|
|
971
1127
|
codeRef.current?.focus();
|
|
972
1128
|
codeRef.current?.select();
|
|
@@ -1014,6 +1170,7 @@ window.__ModuleLoader__.load({
|
|
|
1014
1170
|
type: 'button',
|
|
1015
1171
|
className: 'oasub-button ' + (info.modelsSynced ? '' : 'primary'),
|
|
1016
1172
|
disabled: busy,
|
|
1173
|
+
'aria-describedby': 'oasub-sync-help',
|
|
1017
1174
|
onClick: requestModelSync,
|
|
1018
1175
|
}, phase === 'syncing' ? t('action.syncing') : t(info.modelsSynced ? 'action.updateModels' : 'action.sync'))
|
|
1019
1176
|
: el('button', {
|
|
@@ -1021,25 +1178,32 @@ window.__ModuleLoader__.load({
|
|
|
1021
1178
|
className: 'oasub-button primary',
|
|
1022
1179
|
disabled: busy || !ready,
|
|
1023
1180
|
onClick: () => startAuthorization('device_code'),
|
|
1024
|
-
}, phase === 'starting' || phase === 'authorizing' ? t('action.connecting') : t('action.connect')), configured
|
|
1181
|
+
}, phase === 'starting' || phase === 'authorizing' ? t('action.connecting') : t('action.connect')), configured && info.credentialState !== 'valid'
|
|
1025
1182
|
? el('button', {
|
|
1026
|
-
type: 'button',
|
|
1027
|
-
|
|
1028
|
-
disabled: busy || !ready,
|
|
1183
|
+
type: 'button', className: 'oasub-button primary', disabled: busy || !ready,
|
|
1184
|
+
title: t(info.refreshable ? 'manage.refresh.help' : 'manage.reconnect.help'),
|
|
1029
1185
|
onClick: () => startAuthorization(info.refreshable ? 'refresh' : 'device_code'),
|
|
1030
|
-
}, phase === 'starting' || phase === 'authorizing'
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
? el('div', { className: 'oasub-
|
|
1186
|
+
}, phase === 'starting' || phase === 'authorizing' ? t('action.refreshing') : t(info.refreshable ? 'action.refresh' : 'action.reconnect'))
|
|
1187
|
+
: null), configured ? el('div', { id: 'oasub-sync-help', className: 'oasub-status-detail' }, t('model.sync.help')) : null);
|
|
1188
|
+
return el('div', { className: 'oasub-wrap' }, el('header', { className: 'oasub-header' }, el('div', { className: 'oasub-mark', 'aria-hidden': true }, el(OpenAIIcon, { size: 24 })), el('div', { className: 'oasub-heading' }, el('h2', { className: 'oasub-title' }, t('title')), el('p', { className: 'oasub-subtitle' }, t('subtitle')))), statusCard, configured ? el('section', { className: 'oasub-card oasub-context', 'aria-labelledby': 'oasub-context-title' }, el('div', { id: 'oasub-context-title', className: 'oasub-status-title' }, t('context.title')), el('div', { className: 'oasub-status-detail' }, t('context.help')), contextLoading ? el('div', { role: 'status', className: 'oasub-status-detail' }, t('context.loading')) : null, contextError ? el('div', { role: 'alert', className: 'oasub-notice error' }, t(contextError), el('button', { type: 'button', className: 'oasub-button quiet', disabled: contextLoading, onClick: reloadStatus }, t('action.retry'))) : null, !contextLoading && contexts?.models.length === 0 ? el('div', { className: 'oasub-status-detail' }, t('context.empty')) : null, selectedModel ? el('div', { className: 'oasub-context' }, el('div', { className: 'oasub-actions' }, el('label', { className: 'oasub-field' }, t('context.model'), el('select', { className: 'oasub-select', 'aria-label': t('context.model'), value: modelId, disabled: busy || contextLoading || statusLoading,
|
|
1189
|
+
onChange: (event) => { setModelId(event.currentTarget.value); setContextDraft(null); setContextChoice(null); } }, contexts?.models.map((model) => el('option', { key: model.id, value: model.id }, model.name && model.name !== model.id ? model.name + ' (' + model.id + ')' : model.id)))), el('label', { className: 'oasub-field' }, t('context.window'), el('select', { className: 'oasub-select', 'aria-label': t('context.window'), value: contextMode, disabled: busy || contextLoading || statusLoading,
|
|
1190
|
+
'aria-describedby': 'oasub-context-limit',
|
|
1191
|
+
onChange: (event) => {
|
|
1192
|
+
const choice = event.currentTarget.value === 'default' ? 'default' : event.currentTarget.value === 'million' ? 'million' : 'custom';
|
|
1193
|
+
setContextChoice(choice);
|
|
1194
|
+
setContextDraft(choice === 'default' ? '' : choice === 'million' ? '1000000' : '-');
|
|
1195
|
+
} }, el('option', { value: 'default' }, t('context.default') + (selectedModel.defaultContextWindow ? ' · ' + selectedModel.defaultContextWindow.toLocaleString() : '')), el('option', { value: 'million', disabled: selectedModel.maxContextWindow !== undefined && selectedModel.maxContextWindow < 1_000_000 }, t('context.million')), el('option', { value: 'custom' }, t('context.custom'))))), contextMode === 'custom' ? el('label', { className: 'oasub-field' }, t('context.tokens'), el('input', { className: 'oasub-select', type: 'text', inputMode: 'numeric', 'aria-label': t('context.tokens'),
|
|
1196
|
+
value: draft === '-' ? '' : draft, disabled: busy || contextLoading || statusLoading,
|
|
1197
|
+
'aria-invalid': contextValidation !== null, 'aria-describedby': 'oasub-context-limit',
|
|
1198
|
+
onChange: (event) => setContextDraft(event.currentTarget.value || '-') })) : null, selectedModel.contextWindow ? el('div', { className: 'oasub-status-detail' }, t('context.current', { count: selectedModel.contextWindow.toLocaleString() })) : null, el('div', { id: 'oasub-context-limit', className: 'oasub-status-detail' }, selectedModel.maxContextWindow ? t('context.maximum', { count: selectedModel.maxContextWindow.toLocaleString() }) + ' · ' + t('context.limit.help') : t('context.unknown')), contextDirty && contextValidation ? el('div', { role: 'alert', className: 'oasub-status-detail' }, t(contextValidation)) : null, contextDirty ? el('div', { className: 'oasub-actions' }, el('button', { type: 'button', className: 'oasub-button primary', disabled: busy || contextLoading || statusLoading || contextValidation !== null,
|
|
1199
|
+
onClick: saveContext }, t(phase === 'saving-context' ? 'context.saving' : 'context.save'))) : null) : null) : null, statusError !== null
|
|
1200
|
+
? el('div', { className: 'oasub-notice error', role: 'alert' }, t(statusError), info !== null ? ' ' + t('status.stale') : null, el('button', { type: 'button', className: 'oasub-button quiet', disabled: statusLoading, onClick: reloadStatus }, t('action.retry'))) : null, el('button', { ref: reloadRef, type: 'button', className: 'oasub-button disclosure',
|
|
1201
|
+
'aria-expanded': managementOpen, 'aria-controls': managementOpen ? 'oasub-management' : undefined,
|
|
1202
|
+
onClick: () => setManagementOpen((open) => !open) }, t(managementOpen ? 'action.less' : 'action.more')), managementOpen ? el('section', { id: 'oasub-management', className: 'oasub-management', 'aria-label': t('action.more') }, configured && info?.credentialState === 'valid' ? el('div', { className: 'oasub-manage-row' }, el('div', { className: 'oasub-status-detail', id: 'oasub-refresh-help' }, t(info.refreshable ? 'manage.refresh.help' : 'manage.reconnect.help')), el('button', { type: 'button', className: 'oasub-button', disabled: busy || !ready,
|
|
1203
|
+
'aria-describedby': 'oasub-refresh-help',
|
|
1204
|
+
onClick: () => startAuthorization(info.refreshable ? 'refresh' : 'device_code') }, phase === 'starting' || phase === 'authorizing' ? t('action.refreshing') : t(info.refreshable ? 'action.refresh' : 'action.reconnect'))) : null, el('div', { className: 'oasub-manage-row' }, el('div', { className: 'oasub-status-detail', id: 'oasub-reload-help' }, t('manage.reload.help')), el('button', { type: 'button', className: 'oasub-button', disabled: statusLoading,
|
|
1205
|
+
'aria-describedby': 'oasub-reload-help', onClick: reloadStatus }, t(statusLoading ? 'status.reloading' : 'action.reload'))), info?.cleanupAvailable ? el('div', { className: 'oasub-manage-row' }, el('div', { className: 'oasub-status-detail', id: 'oasub-disconnect-help' }, t('manage.disconnect.help')), el('button', { type: 'button', className: 'oasub-button', disabled: busy,
|
|
1206
|
+
'aria-describedby': 'oasub-disconnect-help', onClick: () => openConfirm('disconnect') }, t(phase === 'disconnecting' ? 'action.disconnecting' : 'action.disconnect'))) : null) : null, device !== null
|
|
1043
1207
|
? el('section', { className: 'oasub-card oasub-device', 'aria-label': t('device.title') }, el('div', { className: 'oasub-device-head' }, el('div', { className: 'oasub-status-title' }, t('device.title')), el('div', { className: 'oasub-status-detail' }, t('device.detail'))), el('input', { ref: codeRef, className: 'oasub-code', type: 'text', readOnly: true, value: device.code,
|
|
1044
1208
|
'aria-label': t('device.code'), autoComplete: 'off', spellCheck: false, onFocus: (event) => event.currentTarget.select() }), el('div', { className: 'oasub-actions' }, device.url !== null
|
|
1045
1209
|
? el('a', { className: 'oasub-button primary', href: device.url, target: '_blank', rel: 'noopener noreferrer',
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Stable, non-sensitive failures shared by the host's public boundaries. */
|
|
2
|
-
export declare const FAILURE_CODES: readonly ['credentials-unavailable', 'shell-unavailable', 'timer-unavailable', 'component-unavailable', 'runtime-unsupported', 'busy', 'invalid-method', 'not-connected', 'not-refreshable', 'device-auth-disabled', 'access-denied', 'authorization-expired', 'rate-limited', 'network', 'timeout', 'invalid-response', 'process-exited', 'credential-write-failed', 'credential-changed', 'settings-unavailable', 'models-unavailable', 'models-empty', 'models-confirmation-required', 'settings-conflict', 'settings-write-failed', 'ownership-save-failed', 'cancelled', 'unknown'];
|
|
2
|
+
export declare const FAILURE_CODES: readonly ['credentials-unavailable', 'shell-unavailable', 'timer-unavailable', 'component-unavailable', 'runtime-unsupported', 'busy', 'invalid-method', 'not-connected', 'not-refreshable', 'device-auth-disabled', 'access-denied', 'authorization-expired', 'rate-limited', 'network', 'timeout', 'invalid-response', 'process-exited', 'credential-write-failed', 'credential-changed', 'settings-unavailable', 'models-unavailable', 'models-empty', 'models-confirmation-required', 'settings-conflict', 'settings-write-failed', 'ownership-save-failed', 'invalid-context-window', 'context-window-exceeded', 'model-not-found', 'cancelled', 'unknown'];
|
|
3
3
|
export type FailureCode = typeof FAILURE_CODES[number];
|
|
4
4
|
/** The marker survives RPC implementations which serialize only Error.message. */
|
|
5
5
|
export declare class SubscriptionError extends Error {
|
package/dist/errors.js
CHANGED
|
@@ -6,7 +6,8 @@ export const FAILURE_CODES = [
|
|
|
6
6
|
'network', 'timeout', 'invalid-response', 'process-exited', 'credential-write-failed',
|
|
7
7
|
'credential-changed', 'settings-unavailable', 'models-unavailable', 'models-empty',
|
|
8
8
|
'models-confirmation-required', 'settings-conflict', 'settings-write-failed',
|
|
9
|
-
'ownership-save-failed', '
|
|
9
|
+
'ownership-save-failed', 'invalid-context-window', 'context-window-exceeded', 'model-not-found',
|
|
10
|
+
'cancelled', 'unknown',
|
|
10
11
|
];
|
|
11
12
|
/** The marker survives RPC implementations which serialize only Error.message. */
|
|
12
13
|
export class SubscriptionError extends Error {
|
package/dist/host.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export { authModuleCandidates } from './platform.js';
|
|
|
2
2
|
import { type FailureCode } from './errors.js';
|
|
3
3
|
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
4
4
|
import type { Context } from '@deepseek-ai/cordis';
|
|
5
|
+
import { type ModelContextInfo } from './models.js';
|
|
5
6
|
/** Stable progress keys let the browser localize notices without exposing diagnostics. */
|
|
6
7
|
type FlowNoticeKind = 'requesting-code' | 'enter-code' | 'refreshing' | 'models-synced' | 'models-sync-failed';
|
|
7
8
|
/** Host-side notice queued for the polling client. Mirrors `AuthorizationNotice`. */
|
|
@@ -49,6 +50,11 @@ interface ModelSyncResult {
|
|
|
49
50
|
count: number;
|
|
50
51
|
warningCode?: 'ownership-save-failed';
|
|
51
52
|
}
|
|
53
|
+
/** Local-only context settings, guarded by the DSH settings revision. */
|
|
54
|
+
interface ModelContextsResult {
|
|
55
|
+
revision: number;
|
|
56
|
+
models: ModelContextInfo[];
|
|
57
|
+
}
|
|
52
58
|
/** `openaiSubscription/poll` reply. */
|
|
53
59
|
type PollResult = {
|
|
54
60
|
status: 'idle';
|
|
@@ -71,6 +77,7 @@ declare class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
71
77
|
private syncingModels;
|
|
72
78
|
private modelSyncController;
|
|
73
79
|
private modelDiscovery;
|
|
80
|
+
private configuringModel;
|
|
74
81
|
private disconnecting;
|
|
75
82
|
constructor(ctx: Context);
|
|
76
83
|
private credentials;
|
|
@@ -106,6 +113,15 @@ declare class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
106
113
|
ok: true;
|
|
107
114
|
}>;
|
|
108
115
|
syncModels(confirmed?: unknown): Promise<ModelSyncResult>;
|
|
116
|
+
private assertContextIdle;
|
|
117
|
+
/** Never discovers models or resolves authorization: this is a local settings read. */
|
|
118
|
+
private readContextSettings;
|
|
119
|
+
getModelContexts(): Promise<ModelContextsResult>;
|
|
120
|
+
/** Only the named row changes; stale tabs must reload rather than overwrite. */
|
|
121
|
+
setModelContext(modelId: unknown, contextWindow: unknown, revision: unknown): Promise<{
|
|
122
|
+
saved: true;
|
|
123
|
+
}>;
|
|
124
|
+
private performContextUpdate;
|
|
109
125
|
/** Serialize disconnect with authorization and sync, including other tabs. */
|
|
110
126
|
logout(): Promise<{
|
|
111
127
|
ok: true;
|
package/dist/host.js
CHANGED
|
@@ -6,7 +6,7 @@ import { DRIVER_REFRESH, parseDriverMessage, runDeviceDriver } from './driver.js
|
|
|
6
6
|
import { SubscriptionError, failureCode, oauthFailureCode, logFailure } from './errors.js';
|
|
7
7
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
8
8
|
import { normalizeOAuthCredential } from './oauth.js';
|
|
9
|
-
import { discoverOpenAIModels, mergeModelCatalog, removeManagedModels, } from './models.js';
|
|
9
|
+
import { discoverOpenAIModels, describeModelContexts, configureModelContext, validateContextWindow, mergeModelCatalog, removeManagedModels, } from './models.js';
|
|
10
10
|
/** Plugin-owned authorization metadata. */
|
|
11
11
|
const KEY = 'dsh-openai-subscription/chatgpt';
|
|
12
12
|
/** Credential consumed by the `openai-codex` model provider. */
|
|
@@ -66,7 +66,7 @@ function settingsConflict(error) {
|
|
|
66
66
|
}
|
|
67
67
|
//#endregion
|
|
68
68
|
/** Register source-mode remote methods without decorator syntax. */
|
|
69
|
-
const REMOTE_METHODS = ['status', 'authorize', 'poll', 'cancel', 'syncModels', 'logout'];
|
|
69
|
+
const REMOTE_METHODS = ['status', 'authorize', 'poll', 'cancel', 'syncModels', 'getModelContexts', 'setModelContext', 'logout'];
|
|
70
70
|
function decorateRemoteMethods(klass, methods) {
|
|
71
71
|
const initializers = [];
|
|
72
72
|
for (const name of methods) {
|
|
@@ -93,6 +93,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
93
93
|
syncingModels = null;
|
|
94
94
|
modelSyncController = null;
|
|
95
95
|
modelDiscovery = discoverOpenAIModels;
|
|
96
|
+
configuringModel = null;
|
|
96
97
|
disconnecting = null;
|
|
97
98
|
constructor(ctx) {
|
|
98
99
|
super(ctx, 'openaiSubscription', { namespace: 'openaiSubscription' });
|
|
@@ -100,7 +101,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
100
101
|
ctx.effect(() => async () => {
|
|
101
102
|
this.pendingBridge?.controller.abort();
|
|
102
103
|
this.modelSyncController?.abort();
|
|
103
|
-
await Promise.allSettled([this.pendingBridge?.task, this.syncingModels, this.disconnecting]);
|
|
104
|
+
await Promise.allSettled([this.pendingBridge?.task, this.syncingModels, this.configuringModel, this.disconnecting]);
|
|
104
105
|
this.pendingBridge = null;
|
|
105
106
|
});
|
|
106
107
|
}
|
|
@@ -474,6 +475,19 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
474
475
|
throw new SubscriptionError(failureCode(error, 'models-unavailable'));
|
|
475
476
|
}
|
|
476
477
|
const discovered = unionModelCatalog(remoteModels, builtinModels);
|
|
478
|
+
const builtinById = new Map(builtinModels.map((model) => [model.id, model]));
|
|
479
|
+
const limitsById = new Map((remoteModels.contextLimits ?? []).map((limit) => [limit.id, limit.maxContextWindow]));
|
|
480
|
+
const contextMetadata = discovered.map(({ id }) => {
|
|
481
|
+
const catalogContextWindow = builtinById.get(id)?.contextWindow;
|
|
482
|
+
const maxContextWindow = limitsById.get(id);
|
|
483
|
+
return {
|
|
484
|
+
id,
|
|
485
|
+
...(typeof catalogContextWindow === 'number' && Number.isSafeInteger(catalogContextWindow) && catalogContextWindow > 0
|
|
486
|
+
? { catalogContextWindow } : {}),
|
|
487
|
+
...(typeof maxContextWindow === 'number' && Number.isSafeInteger(maxContextWindow) && maxContextWindow > 0
|
|
488
|
+
? { maxContextWindow } : {}),
|
|
489
|
+
};
|
|
490
|
+
});
|
|
477
491
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
478
492
|
if (signal.aborted)
|
|
479
493
|
throw new SubscriptionError('cancelled');
|
|
@@ -528,6 +542,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
528
542
|
...payload,
|
|
529
543
|
managedPiRoute: payload.managedPiRoute === true || createdRoute,
|
|
530
544
|
managedModels: merged.managed,
|
|
545
|
+
modelContextMetadata: contextMetadata,
|
|
531
546
|
suppressedModelIds: merged.suppressed,
|
|
532
547
|
modelsSyncedAt: Date.now(),
|
|
533
548
|
},
|
|
@@ -603,7 +618,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
603
618
|
if (method !== 'device_code' && method !== 'refresh') {
|
|
604
619
|
return { started: false, error: '[openai-subscription:invalid-method]', errorCode: 'invalid-method' };
|
|
605
620
|
}
|
|
606
|
-
if (this.disconnecting != null || this.syncingModels !== null || (this.pendingBridge !== null && !this.pendingBridge.done)) {
|
|
621
|
+
if (this.disconnecting != null || this.configuringModel != null || this.syncingModels !== null || (this.pendingBridge !== null && !this.pendingBridge.done)) {
|
|
607
622
|
return { started: false, error: '[openai-subscription:busy]', errorCode: 'busy' };
|
|
608
623
|
}
|
|
609
624
|
this.pendingBridge = null;
|
|
@@ -785,7 +800,7 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
785
800
|
return { ok: true };
|
|
786
801
|
}
|
|
787
802
|
async syncModels(confirmed) {
|
|
788
|
-
if (this.disconnecting != null || (this.pendingBridge !== null && !this.pendingBridge.done))
|
|
803
|
+
if (this.disconnecting != null || this.configuringModel != null || (this.pendingBridge !== null && !this.pendingBridge.done))
|
|
789
804
|
throw new SubscriptionError('busy');
|
|
790
805
|
try {
|
|
791
806
|
return await this.synchronizeModels(undefined, confirmed === true);
|
|
@@ -794,6 +809,78 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
794
809
|
throw new SubscriptionError(failureCode(error, 'settings-write-failed'));
|
|
795
810
|
}
|
|
796
811
|
}
|
|
812
|
+
assertContextIdle() {
|
|
813
|
+
if (this.disconnecting != null || this.syncingModels !== null || this.configuringModel != null
|
|
814
|
+
|| (this.pendingBridge !== null && !this.pendingBridge.done))
|
|
815
|
+
throw new SubscriptionError('busy');
|
|
816
|
+
}
|
|
817
|
+
/** Never discovers models or resolves authorization: this is a local settings read. */
|
|
818
|
+
async readContextSettings() {
|
|
819
|
+
const settings = this.ctx.get('settings');
|
|
820
|
+
if (settings === undefined || typeof settings.describe !== 'function')
|
|
821
|
+
throw new SubscriptionError('settings-unavailable');
|
|
822
|
+
const credentials = this.credentials();
|
|
823
|
+
if (credentials === undefined)
|
|
824
|
+
throw new SubscriptionError('credentials-unavailable');
|
|
825
|
+
let owner;
|
|
826
|
+
try {
|
|
827
|
+
owner = await credentials.readRecord(KEY);
|
|
828
|
+
}
|
|
829
|
+
catch (error) {
|
|
830
|
+
throw new SubscriptionError(failureCode(error, 'credentials-unavailable'));
|
|
831
|
+
}
|
|
832
|
+
const descriptor = settings.describe({ redactSecrets: true }).find((entry) => entry.ns === 'llm-pi-ai');
|
|
833
|
+
if (descriptor === undefined || !Number.isSafeInteger(descriptor.revision) || descriptor.revision < 0) {
|
|
834
|
+
throw new SubscriptionError('settings-unavailable');
|
|
835
|
+
}
|
|
836
|
+
return { settings, descriptor, plugin: grantPayload(owner) };
|
|
837
|
+
}
|
|
838
|
+
async getModelContexts() {
|
|
839
|
+
this.assertContextIdle();
|
|
840
|
+
try {
|
|
841
|
+
const { descriptor, plugin } = await this.readContextSettings();
|
|
842
|
+
// A sync/logout may have started while the local record was being read.
|
|
843
|
+
this.assertContextIdle();
|
|
844
|
+
return {
|
|
845
|
+
revision: descriptor.revision,
|
|
846
|
+
models: describeModelContexts(explicitRouteModels(descriptor.user, descriptor.base), plugin.managedModels, plugin.modelContextMetadata, piRoute(descriptor.value)?.defaultContextWindow),
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
throw new SubscriptionError(failureCode(error, 'settings-unavailable'));
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
/** Only the named row changes; stale tabs must reload rather than overwrite. */
|
|
854
|
+
async setModelContext(modelId, contextWindow, revision) {
|
|
855
|
+
validateContextWindow(contextWindow);
|
|
856
|
+
if (typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 0) {
|
|
857
|
+
throw new SubscriptionError('settings-conflict');
|
|
858
|
+
}
|
|
859
|
+
this.assertContextIdle();
|
|
860
|
+
const pending = this.performContextUpdate(modelId, contextWindow, revision).catch((error) => {
|
|
861
|
+
throw new SubscriptionError(failureCode(error, 'settings-write-failed'));
|
|
862
|
+
});
|
|
863
|
+
this.configuringModel = pending;
|
|
864
|
+
try {
|
|
865
|
+
return await pending;
|
|
866
|
+
}
|
|
867
|
+
finally {
|
|
868
|
+
if (this.configuringModel === pending)
|
|
869
|
+
this.configuringModel = null;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
async performContextUpdate(modelId, contextWindow, revision) {
|
|
873
|
+
const { settings, descriptor, plugin } = await this.readContextSettings();
|
|
874
|
+
if (typeof settings.mutate !== 'function')
|
|
875
|
+
throw new SubscriptionError('settings-unavailable');
|
|
876
|
+
if (descriptor.revision !== revision)
|
|
877
|
+
throw new SubscriptionError('settings-conflict');
|
|
878
|
+
const models = configureModelContext(explicitRouteModels(descriptor.user, descriptor.base), plugin.managedModels, plugin.modelContextMetadata, modelId, contextWindow);
|
|
879
|
+
// Settings' optimistic revision check is the final guard against file edits
|
|
880
|
+
// and other processes. Never retry the same UI intent against a newer state.
|
|
881
|
+
await settings.mutate('llm-pi-ai', [{ op: 'set', path: ['providers', 'openai-codex', 'models'], value: models }], revision);
|
|
882
|
+
return { saved: true };
|
|
883
|
+
}
|
|
797
884
|
/** Serialize disconnect with authorization and sync, including other tabs. */
|
|
798
885
|
async logout() {
|
|
799
886
|
if (this.disconnecting != null)
|
|
@@ -831,6 +918,9 @@ class OpenAISubscriptionController extends TypertRemoteService {
|
|
|
831
918
|
const activeSync = this.syncingModels;
|
|
832
919
|
if (activeSync !== null)
|
|
833
920
|
await activeSync.catch(() => { });
|
|
921
|
+
const activeContextUpdate = this.configuringModel;
|
|
922
|
+
if (activeContextUpdate != null)
|
|
923
|
+
await activeContextUpdate.catch(() => { });
|
|
834
924
|
const credentials = this.credentials();
|
|
835
925
|
if (credentials === undefined)
|
|
836
926
|
throw new SubscriptionError('credentials-unavailable');
|
package/dist/models.d.ts
CHANGED
|
@@ -21,11 +21,34 @@ export interface ModelDiscoveryOptions {
|
|
|
21
21
|
signal?: AbortSignal;
|
|
22
22
|
timeoutMs?: number;
|
|
23
23
|
}
|
|
24
|
+
export interface ModelContextLimit {
|
|
25
|
+
id: string;
|
|
26
|
+
/** A disclosed upper bound, not the model's default context window. */
|
|
27
|
+
maxContextWindow: number;
|
|
28
|
+
}
|
|
24
29
|
export interface DiscoveredModelCatalog {
|
|
25
30
|
/** Models the account-scoped picker exposes. */
|
|
26
31
|
models: ModelProfile[];
|
|
27
32
|
/** Every valid slug mentioned upstream, including explicitly hidden rows. */
|
|
28
33
|
seenIds: string[];
|
|
34
|
+
/** Kept outside DSH model profiles: its schema only accepts contextWindow. */
|
|
35
|
+
contextLimits?: ModelContextLimit[];
|
|
36
|
+
}
|
|
37
|
+
/** Non-secret catalog facts kept alongside the ownership snapshot. */
|
|
38
|
+
export interface ModelContextMetadata {
|
|
39
|
+
id: string;
|
|
40
|
+
maxContextWindow?: number;
|
|
41
|
+
/** The installed catalog fallback used when an explicit field is removed. */
|
|
42
|
+
catalogContextWindow?: number;
|
|
43
|
+
}
|
|
44
|
+
/** Safe, deliberately whitelisted facts for the context settings UI. */
|
|
45
|
+
export interface ModelContextInfo {
|
|
46
|
+
id: string;
|
|
47
|
+
name?: string;
|
|
48
|
+
contextWindow?: number;
|
|
49
|
+
defaultContextWindow?: number;
|
|
50
|
+
maxContextWindow?: number;
|
|
51
|
+
customized: boolean;
|
|
29
52
|
}
|
|
30
53
|
export interface MergedModelCatalog {
|
|
31
54
|
/** Complete explicit settings list: live remote entries plus local custom entries. */
|
|
@@ -42,6 +65,16 @@ export interface MergedModelCatalog {
|
|
|
42
65
|
export declare function parseOpenAIModelCatalog(value: unknown): DiscoveredModelCatalog;
|
|
43
66
|
/** Fetch the live model catalog available to this exact ChatGPT account. */
|
|
44
67
|
export declare function discoverOpenAIModels(credential: ModelDiscoveryCredential, options?: ModelDiscoveryOptions): Promise<DiscoveredModelCatalog>;
|
|
68
|
+
/** Read only context metadata; never pass unknown settings or owner fields to Web. */
|
|
69
|
+
export declare function describeModelContexts(existing: unknown, previousManaged: unknown, contextMetadata: unknown, defaultContextWindow?: unknown): ModelContextInfo[];
|
|
70
|
+
/** Validate at the public boundary before reading or modifying any services. */
|
|
71
|
+
export declare function validateContextWindow(value: unknown): asserts value is number | null;
|
|
72
|
+
/**
|
|
73
|
+
* Update exactly one explicit row as a local edit. The provider snapshot stays
|
|
74
|
+
* untouched, so subsequent three-way merges retain this choice. Null restores
|
|
75
|
+
* that snapshot's field (or removes it to inherit the installed catalog).
|
|
76
|
+
*/
|
|
77
|
+
export declare function configureModelContext(existing: unknown, previousManaged: unknown, contextMetadata: unknown, modelId: unknown, contextWindow: unknown): ModelProfile[];
|
|
45
78
|
/** Return local model entries not still equal to the plugin's previous provider snapshot. */
|
|
46
79
|
export declare function removeManagedModels(existing: unknown, previousManaged: unknown): ModelProfile[];
|
|
47
80
|
/**
|
package/dist/models.js
CHANGED
|
@@ -74,6 +74,7 @@ export function parseOpenAIModelCatalog(value) {
|
|
|
74
74
|
if (root === null || !Array.isArray(root.models))
|
|
75
75
|
throw new SubscriptionError('invalid-response');
|
|
76
76
|
const candidates = [];
|
|
77
|
+
const contextLimits = [];
|
|
77
78
|
const seenIds = new Set();
|
|
78
79
|
const adoptedIds = new Set();
|
|
79
80
|
for (let index = 0; index < root.models.length; index++) {
|
|
@@ -88,7 +89,15 @@ export function parseOpenAIModelCatalog(value) {
|
|
|
88
89
|
continue;
|
|
89
90
|
const model = { id };
|
|
90
91
|
const name = nonEmptyString(raw.display_name);
|
|
91
|
-
const
|
|
92
|
+
const defaultContextWindow = positiveInteger(raw.context_window);
|
|
93
|
+
const maxContextWindow = positiveInteger(raw.max_context_window);
|
|
94
|
+
// A larger advertised maximum is opt-in, not permission to enlarge every
|
|
95
|
+
// model automatically. Inconsistent defaults must not exceed a known cap.
|
|
96
|
+
const contextWindow = defaultContextWindow === undefined ? maxContextWindow
|
|
97
|
+
: maxContextWindow === undefined ? defaultContextWindow
|
|
98
|
+
: Math.min(defaultContextWindow, maxContextWindow);
|
|
99
|
+
if (maxContextWindow !== undefined)
|
|
100
|
+
contextLimits.push({ id, maxContextWindow });
|
|
92
101
|
const input = normalizeInputs(raw.input_modalities);
|
|
93
102
|
const reasoningEfforts = normalizeReasoning(raw.supported_reasoning_levels);
|
|
94
103
|
if (name !== undefined)
|
|
@@ -110,7 +119,7 @@ export function parseOpenAIModelCatalog(value) {
|
|
|
110
119
|
const models = candidates.map((candidate) => candidate.model);
|
|
111
120
|
if (models.length === 0)
|
|
112
121
|
throw new SubscriptionError('models-empty');
|
|
113
|
-
return { models, seenIds: [...seenIds] };
|
|
122
|
+
return { models, seenIds: [...seenIds], ...(contextLimits.length > 0 ? { contextLimits } : {}) };
|
|
114
123
|
}
|
|
115
124
|
function abortableSignal(parent, timeoutMs) {
|
|
116
125
|
const controller = new AbortController();
|
|
@@ -290,6 +299,68 @@ function modelProfiles(value) {
|
|
|
290
299
|
}
|
|
291
300
|
return result;
|
|
292
301
|
}
|
|
302
|
+
/** Read only context metadata; never pass unknown settings or owner fields to Web. */
|
|
303
|
+
export function describeModelContexts(existing, previousManaged, contextMetadata, defaultContextWindow) {
|
|
304
|
+
const managed = new Map(modelProfiles(previousManaged).map((model) => [model.id, model]));
|
|
305
|
+
const metadata = new Map(modelProfiles(contextMetadata).map((model) => [model.id, model]));
|
|
306
|
+
return modelProfiles(existing).map((model) => {
|
|
307
|
+
const previous = managed.get(model.id);
|
|
308
|
+
const facts = metadata.get(model.id);
|
|
309
|
+
// Metadata presence proves this id was compared with the installed catalog.
|
|
310
|
+
// Without it (pre-upgrade snapshots), an absent field is unknown rather than
|
|
311
|
+
// an invented fallback that could misrepresent DSH's actual model capacity.
|
|
312
|
+
const inherited = facts === undefined ? undefined
|
|
313
|
+
: positiveInteger(facts.catalogContextWindow) ?? positiveInteger(defaultContextWindow);
|
|
314
|
+
const current = positiveInteger(model.contextWindow) ?? inherited;
|
|
315
|
+
const baseline = positiveInteger(previous?.contextWindow) ?? inherited;
|
|
316
|
+
const max = positiveInteger(facts?.maxContextWindow);
|
|
317
|
+
const name = nonEmptyString(model.name);
|
|
318
|
+
return {
|
|
319
|
+
id: model.id,
|
|
320
|
+
...(name === undefined ? {} : { name }),
|
|
321
|
+
...(current === undefined ? {} : { contextWindow: current }),
|
|
322
|
+
...(baseline === undefined ? {} : { defaultContextWindow: baseline }),
|
|
323
|
+
...(max === undefined ? {} : { maxContextWindow: max }),
|
|
324
|
+
customized: !Object.is(model.contextWindow, previous?.contextWindow),
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
/** Validate at the public boundary before reading or modifying any services. */
|
|
329
|
+
export function validateContextWindow(value) {
|
|
330
|
+
if (value !== null && positiveInteger(value) === undefined)
|
|
331
|
+
throw new SubscriptionError('invalid-context-window');
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Update exactly one explicit row as a local edit. The provider snapshot stays
|
|
335
|
+
* untouched, so subsequent three-way merges retain this choice. Null restores
|
|
336
|
+
* that snapshot's field (or removes it to inherit the installed catalog).
|
|
337
|
+
*/
|
|
338
|
+
export function configureModelContext(existing, previousManaged, contextMetadata, modelId, contextWindow) {
|
|
339
|
+
validateContextWindow(contextWindow);
|
|
340
|
+
if (typeof modelId !== 'string' || modelId.length === 0 || !Array.isArray(existing)) {
|
|
341
|
+
throw new SubscriptionError('model-not-found');
|
|
342
|
+
}
|
|
343
|
+
const matches = existing.filter((model) => recordOf(model)?.id === modelId);
|
|
344
|
+
if (matches.length !== 1)
|
|
345
|
+
throw new SubscriptionError('model-not-found');
|
|
346
|
+
const previous = modelProfiles(previousManaged).find((model) => model.id === modelId);
|
|
347
|
+
const facts = modelProfiles(contextMetadata).find((model) => model.id === modelId);
|
|
348
|
+
const next = contextWindow === null ? positiveInteger(previous?.contextWindow) : contextWindow;
|
|
349
|
+
const max = positiveInteger(facts?.maxContextWindow);
|
|
350
|
+
if (next !== undefined && max !== undefined && next > max)
|
|
351
|
+
throw new SubscriptionError('context-window-exceeded');
|
|
352
|
+
// Preserve every other row/field, including fields this plugin does not own.
|
|
353
|
+
return existing.map((candidate) => {
|
|
354
|
+
if (recordOf(candidate)?.id !== modelId)
|
|
355
|
+
return candidate;
|
|
356
|
+
const model = { ...candidate };
|
|
357
|
+
if (next === undefined)
|
|
358
|
+
delete model.contextWindow;
|
|
359
|
+
else
|
|
360
|
+
model.contextWindow = next;
|
|
361
|
+
return model;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
293
364
|
function jsonEqual(left, right) {
|
|
294
365
|
if (Object.is(left, right))
|
|
295
366
|
return true;
|
|
@@ -43,13 +43,29 @@ For a Bun global installation, that root is typically `~/.bun/install/global`.
|
|
|
43
43
|
Confirm the actual installation location before applying.
|
|
44
44
|
|
|
45
45
|
`--check` validates without writing. `--apply` is idempotent for the same patch.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
For each of `@babel/parser` and `esbuild`, the script resolves `ROOT` first, then
|
|
47
|
+
its own package directory (not the invoking working directory). Only a missing
|
|
48
|
+
module falls back; a broken installed tool is reported rather than hidden.
|
|
49
|
+
These tools are development dependencies of this repository. If `--check`
|
|
50
|
+
reports a missing tool, install them in a source checkout, **not the DSH host**:
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
```sh
|
|
53
|
+
# In a source checkout of dsh-openai-subscription:
|
|
54
|
+
pnpm install --frozen-lockfile
|
|
55
|
+
node scripts/patch-dsh-settings-icons.mjs --root /path/to/dsh --check
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A production-only/plugin installation may omit development dependencies; run
|
|
59
|
+
the script from the prepared source checkout instead. Dependency installation
|
|
60
|
+
is a separate explicit action and can write to pnpm's store. The patch script
|
|
61
|
+
itself never installs tools, accesses credentials, changes account settings,
|
|
62
|
+
restarts DSH, or launches a server. Plugin installation never invokes it.
|
|
63
|
+
|
|
64
|
+
`--check` 只检查,`--apply` 才修改,重复执行不会叠加补丁。构建工具逐个先从
|
|
65
|
+
DSH 安装目录解析,缺少时再从脚本所属插件目录解析,不依赖当前工作目录;已存在
|
|
66
|
+
但损坏的工具会报错。工具作为本仓库开发依赖安装,无需修改宿主的依赖清单。
|
|
67
|
+
生产安装可能省略开发依赖,此时先在源码仓库显式运行 `pnpm install --frozen-lockfile`,
|
|
68
|
+
再从该仓库执行脚本。脚本本身不会联网安装、读取凭证、修改账号设置或重启服务。
|
|
53
69
|
|
|
54
70
|
Supported package versions (also guarded by exact SHA-256 file hashes):
|
|
55
71
|
|
|
@@ -80,15 +96,24 @@ minifier symbol names blindly or substitute a different Web server.
|
|
|
80
96
|
渲染器外,还通过 AST 定位静态注册表并用 esbuild 重新生成 Web 产物。新文件
|
|
81
97
|
采用内容哈希命名,保留原文件,最后切换 HTML 入口。
|
|
82
98
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
99
|
+
The installed plugin client must also contain its `icon: OpenAIIcon` registration;
|
|
100
|
+
if it already does, no plugin rebuild is needed for this host-only patch.
|
|
101
|
+
Otherwise rebuild/reinstall this plugin's client bundle as well. The tested DSH
|
|
102
|
+
client-HMR file poller notices modified built plugin bundles, but the **shell
|
|
103
|
+
change always requires refreshing the existing DSH page**. If that poller is
|
|
104
|
+
disabled, arrange a DSH restart yourself before refreshing. Editing TypeScript
|
|
105
|
+
alone does not rebuild installed plugin bundles. A DSH source checkout's
|
|
106
|
+
`pnpm run dev:web` watcher and the host's client-HMR receiver are different:
|
|
107
|
+
confirm the watcher before expecting source edits to rebuild automatically.
|
|
108
|
+
This compiled global installation does not provide that source-checkout script;
|
|
109
|
+
do not start a replacement Vite server to update an existing DSH GUI.
|
|
110
|
+
|
|
111
|
+
已安装的插件客户端也必须包含 `icon: OpenAIIcon` 注册;如果已有,修补宿主时
|
|
112
|
+
无需重复构建插件,否则先构建并安装插件。当前测试环境的文件监听会拾取已构建
|
|
113
|
+
插件的变化,但 **Web 壳层修改必须刷新现有页面**;没有监听时,还需自行安排
|
|
114
|
+
重启 DSH。`pnpm run dev:web` 源码构建 watcher 不等于客户端 HMR 接收器,未确认
|
|
115
|
+
watcher 时不能宣称源码修改会自动生效。本编译包安装根不提供源码 watcher 脚本,
|
|
116
|
+
另起 Vite 服务也不会更新现有 GUI。脚本不会自动重启正在进行的会话。
|
|
92
117
|
|
|
93
118
|
## Backup and restore / 备份与还原
|
|
94
119
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-openai-subscription",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "ChatGPT subscription sign-in for DeepSeek Harness via OpenAI device authorization.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.6.3",
|
|
@@ -75,10 +75,11 @@
|
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
78
|
-
"@deepseek-ai/dsh-
|
|
79
|
-
"@deepseek-ai/dsh-
|
|
78
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.2-rc.1",
|
|
79
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.2-rc.1"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
+
"@babel/parser": "^8.0.4",
|
|
82
83
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
83
84
|
"@deepseek-ai/cordis-plugin-timer": "^1.1.4",
|
|
84
85
|
"@deepseek-ai/dsh-authorization": "^0.1.2-rc.1",
|
|
@@ -87,6 +88,7 @@
|
|
|
87
88
|
"@deepseek-ai/dsh-shell": "^0.1.2-rc.1",
|
|
88
89
|
"@deepseek-ai/dsh-typert-protocol": "^0.1.2-rc.1",
|
|
89
90
|
"@types/node": "^25.6.1",
|
|
91
|
+
"esbuild": "^0.28.2",
|
|
90
92
|
"typescript": "^7.0.2"
|
|
91
93
|
}
|
|
92
94
|
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* node scripts/patch-dsh-settings-icons.mjs --root /path/to/dsh --apply
|
|
8
8
|
* node scripts/patch-dsh-settings-icons.mjs --root /path/to/dsh --restore
|
|
9
9
|
*
|
|
10
|
-
* Requires @babel/parser and esbuild
|
|
11
|
-
*
|
|
10
|
+
* Requires @babel/parser and esbuild: resolve each from ROOT first, then this
|
|
11
|
+
* patch package's own dependencies. This is an exact-version AND SHA-256 guarded patch,
|
|
12
12
|
* NOT a general updater: an upstream update must be reviewed before extending
|
|
13
13
|
* the allowlist. No network, credential files, process discovery, or restarts.
|
|
14
14
|
*
|
|
@@ -156,9 +156,21 @@ function imports(source, parse) {
|
|
|
156
156
|
return values.sort()
|
|
157
157
|
}
|
|
158
158
|
export function loadToolchain(root) {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
const resolvers = [createRequire(join(root, 'package.json')), createRequire(new URL('../package.json', import.meta.url))]
|
|
160
|
+
const load = (name) => {
|
|
161
|
+
for (const require of resolvers) {
|
|
162
|
+
let path
|
|
163
|
+
try { path = require.resolve(name) }
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (error.code !== 'MODULE_NOT_FOUND') throw error
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
// Only missing resolution falls back: do not hide broken installed tools.
|
|
169
|
+
return require(path)
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`Build requires ${name} resolvable from --root or the patch package; install this package's development dependencies with pnpm install; no files changed`)
|
|
172
|
+
}
|
|
173
|
+
return { parse: load('@babel/parser').parse, transform: load('esbuild').transform }
|
|
162
174
|
}
|
|
163
175
|
function checkVersions(root) {
|
|
164
176
|
for (const [name, version] of Object.entries(VERSIONS)) {
|