dsh-openai-subscription 0.1.5 → 0.1.7

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 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
- `llm-pi-ai` automatically renews refreshable OAuth credentials during model requests. **Refresh authorization** in this settings page is primarily a manual recovery action.
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
- `llm-pi-ai` 会在模型请求期间自动续期可刷新的 OAuth 凭证。设置页中的 **刷新授权** 主要用于授权异常时的手动恢复。
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,40 @@ 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 大上下文',
28
+ 'context.default.detail': '使用同步的模型配置',
29
+ 'context.custom.detail': '手动设置 tokens 数',
30
+ 'context.pending': '有未保存的更改',
31
+ 'context.applied': '已保存',
32
+ 'manage.help': '授权维护与本机连接',
33
+ 'context.custom': '自定义',
34
+ 'context.tokens': '自定义 tokens 数',
35
+ 'context.current': '当前:{count} tokens',
36
+ 'context.maximum': '目录声明上限:{count} tokens',
37
+ 'context.unknown': '目录未声明最大窗口;本机配置不会解锁上游权限,请确认模型和账号支持。',
38
+ 'context.limit.help': '超过目录声明上限的配置不可保存;实际可用性仍取决于账号和上游。',
39
+ 'context.empty': '同步模型后即可配置上下文。',
40
+ 'context.loading': '正在读取模型配置…',
41
+ 'context.save': '保存上下文',
42
+ 'context.saving': '正在保存…',
43
+ 'context.saved': '上下文配置已保存,后续模型请求会使用新设置。',
44
+ 'context.error': '无法读取上下文配置。请确认插件主机端已更新并重启 DSH 后重试。',
45
+ 'error.invalid-context-window': '请输入正整数 tokens 数。',
46
+ 'error.context-window-exceeded': '此设置超过了模型目录声明的最大上下文窗口。',
47
+ 'error.model-not-found': '模型已不在当前列表中,请重新读取配置。',
48
+ 'action.more': '连接管理',
49
+ 'action.less': '收起连接管理',
50
+ 'manage.refresh.help': '请求时会自动续期;仅在授权异常时手动刷新,无需重新登录。',
51
+ 'manage.reconnect.help': '重新进行设备验证,以恢复此账号的授权。',
52
+ 'manage.reload.help': '只重新读取本机状态,不刷新授权,也不同步模型。',
53
+ 'manage.disconnect.help': '删除本机授权和未修改的插件模型项,不影响 ChatGPT 账号。',
21
54
  'model.attention.detail': '确认同步后会保留本地新增项与已编辑字段。',
22
55
  'action.connect': '连接 ChatGPT',
23
56
  'action.connecting': '正在连接…',
@@ -120,7 +153,40 @@ const EN = {
120
153
  'model.synced': 'Models synced',
121
154
  'model.attention': 'Models need syncing',
122
155
  'model.count': '{count} models available',
123
- 'model.synced.detail': 'Account, built-in, and locally edited catalogs are safely merged.',
156
+ 'model.synced.detail': 'Account and DSH models are merged, keeping your local edits.',
157
+ 'model.sync.help': 'Fetch the available model list without switching accounts or resetting local settings.',
158
+ 'context.title': 'Model context',
159
+ 'context.help': 'Set each model’s total context window (input + output). Model sync preserves this setting.',
160
+ 'context.model': 'Model',
161
+ 'context.window': 'Context window',
162
+ 'context.default': 'Use model default',
163
+ 'context.million': '1M context',
164
+ 'context.default.detail': 'Use the synced model setting',
165
+ 'context.custom.detail': 'Set a token count manually',
166
+ 'context.pending': 'Unsaved changes',
167
+ 'context.applied': 'Saved',
168
+ 'manage.help': 'Authorization and local connection',
169
+ 'context.custom': 'Custom',
170
+ 'context.tokens': 'Custom token count',
171
+ 'context.current': 'Current: {count} tokens',
172
+ 'context.maximum': 'Catalog limit: {count} tokens',
173
+ 'context.unknown': 'The catalog does not declare a maximum. Local settings do not unlock upstream access; confirm model and account support.',
174
+ 'context.limit.help': 'Values above the catalog limit cannot be saved. Availability still depends on your account and upstream service.',
175
+ 'context.empty': 'Sync models to configure their context windows.',
176
+ 'context.loading': 'Reading model settings…',
177
+ 'context.save': 'Save context',
178
+ 'context.saving': 'Saving…',
179
+ 'context.saved': 'Context settings saved. Subsequent model requests will use the new setting.',
180
+ 'context.error': 'Could not read context settings. Ensure the host plugin is updated and restart DSH before retrying.',
181
+ 'error.invalid-context-window': 'Enter a positive whole number of tokens.',
182
+ 'error.context-window-exceeded': 'This exceeds the maximum context window declared by the model catalog.',
183
+ 'error.model-not-found': 'The model is no longer in the current list. Reload settings.',
184
+ 'action.more': 'Connection management',
185
+ 'action.less': 'Hide connection management',
186
+ 'manage.refresh.help': 'Authorization renews automatically during requests. Refresh manually only to recover from authorization errors.',
187
+ 'manage.reconnect.help': 'Repeat device verification to restore authorization for this account.',
188
+ 'manage.reload.help': 'Read local status only. Does not renew authorization or sync models.',
189
+ 'manage.disconnect.help': 'Remove local authorization and unchanged plugin models, not your ChatGPT account.',
124
190
  'model.attention.detail': 'Syncing preserves local additions and fields you edited.',
125
191
  'action.connect': 'Connect ChatGPT',
126
192
  'action.connecting': 'Connecting…',
@@ -250,8 +316,8 @@ window.__ModuleLoader__.load({
250
316
  color: var(--dsw-alias-label-primary, #0f1115);
251
317
  }
252
318
  .oasub-wrap, .oasub-wrap * { box-sizing: border-box; }
253
- /* Align the header icon and footer button with the card's outer frame. Only the card is inset. */
254
- .oasub-header, .oasub-footer { padding-inline: 0; }
319
+ /* The header stays flush with the card's outer frame. */
320
+ .oasub-header { padding-inline: 0; }
255
321
  .oasub-header { display: flex; align-items: center; gap: 12px; }
256
322
  .oasub-mark {
257
323
  display: grid;
@@ -307,6 +373,64 @@ window.__ModuleLoader__.load({
307
373
  .oasub-model-title { font-size: 13px; font-weight: 500; line-height: 19px; }
308
374
  .oasub-model-detail { color: var(--dsw-alias-label-tertiary, #81858c); font-size: 11px; line-height: 17px; }
309
375
  .oasub-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
376
+ .oasub-wrap { container-type: inline-size; }
377
+ .oasub-ui-icon { display: block; flex: none; }
378
+ .oasub-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
379
+ .oasub-section-copy { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
380
+ .oasub-context { display: flex; flex-direction: column; gap: 16px; }
381
+ .oasub-context-grid { display: grid; grid-template-columns: 1.1fr 1fr; align-items: start; gap: 12px; }
382
+ .oasub-field { display: flex; flex-direction: column; gap: 8px; min-width: 0; font-size: 12px; font-weight: 500; }
383
+ .oasub-context > .oasub-field { flex: none; }
384
+ .oasub-picker { position: relative; min-width: 0; }
385
+ .oasub-picker-trigger, .oasub-input-shell {
386
+ width: 100%; min-height: 56px; padding: 10px 12px;
387
+ border: 1px solid var(--dsw-alias-border-l1, rgba(15, 17, 21, .2)); border-radius: 12px;
388
+ color: var(--dsw-alias-label-primary, #0f1115); background: var(--dsw-alias-bg-layer-2, #f5f6f7);
389
+ }
390
+ .oasub-picker-trigger { appearance: none; display: flex; align-items: center; justify-content: space-between; gap: 12px; font: inherit; text-align: start; cursor: pointer; transition: border-color .16s, background .16s; }
391
+ .oasub-picker-trigger:hover:not(:disabled), .oasub-picker-trigger[aria-expanded="true"] { border-color: var(--oasub-control-border); background: var(--dsw-alias-bg-layer-1, #fff); }
392
+ .oasub-picker-trigger:focus-visible, .oasub-input-shell:focus-within, .oasub-management-toggle:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, #4176e6); outline-offset: 3px; }
393
+ .oasub-picker-trigger:disabled { opacity: .5; cursor: default; }
394
+ .oasub-picker-copy { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
395
+ .oasub-picker-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 19px; }
396
+ .oasub-picker-detail { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-secondary, #61666b); font-size: 11px; font-weight: 400; line-height: 16px; font-variant-numeric: tabular-nums; }
397
+ .oasub-chevron { color: var(--dsw-alias-label-secondary, #61666b); transition: transform .16s; }
398
+ [aria-expanded="true"] > .oasub-chevron { transform: rotate(180deg); }
399
+ .oasub-picker-menu { position: absolute; inset: calc(100% + 6px) 0 auto; z-index: 20; margin: 0; padding: 5px; max-height: 248px; overflow-y: auto; overscroll-behavior: contain; list-style: none; border: 1px solid var(--dsw-alias-border-l1, rgba(15, 17, 21, .2)); border-radius: 14px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 8px 28px rgba(0, 0, 0, .14); }
400
+ .oasub-picker-option { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 48px; padding: 8px 10px; border-radius: 9px; cursor: pointer; }
401
+ .oasub-picker-option[data-active="true"] { background: var(--dsw-alias-interactive-bg-hover, rgba(15, 17, 21, .06)); }
402
+ .oasub-picker-option[aria-selected="true"] .oasub-picker-label { font-weight: 600; }
403
+ .oasub-picker-option[aria-disabled="true"] { opacity: .4; cursor: not-allowed; }
404
+ .oasub-input-shell { display: flex; align-items: center; gap: 12px; min-height: 44px; }
405
+ .oasub-input-shell:has(input:disabled) { opacity: .5; }
406
+ .oasub-input-shell:has([aria-invalid="true"]) { border-color: var(--dsw-alias-state-error-primary, #dc2626); }
407
+ .oasub-token-input { appearance: none; width: 100%; min-width: 0; padding: 0; border: 0; outline: none; background: transparent; color: inherit; font: inherit; font-size: 14px; line-height: 22px; font-variant-numeric: tabular-nums; }
408
+ .oasub-token-unit { color: var(--dsw-alias-label-tertiary, #81858c); font-size: 12px; font-weight: 400; }
409
+ .oasub-context-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; color: var(--dsw-alias-label-secondary, #61666b); font-size: 12px; line-height: 18px; font-variant-numeric: tabular-nums; }
410
+ .oasub-context-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; border-radius: 6px; background: var(--dsw-alias-bg-layer-2, #f5f6f7); font-size: 10px; }
411
+ .oasub-context-note { display: flex; align-items: flex-start; gap: 7px; color: var(--dsw-alias-label-tertiary, #81858c); font-size: 11px; line-height: 17px; }
412
+ .oasub-context-note > svg { margin-top: 2px; }
413
+ .oasub-context-error { color: var(--dsw-alias-state-error-primary, #b91c1c); font-size: 12px; line-height: 18px; }
414
+ .oasub-context-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding-top: 14px; border-top: 1px solid var(--dsw-alias-border-l2, rgba(15, 17, 21, .14)); }
415
+ .oasub-management-card { padding: 0; gap: 0; }
416
+ .oasub-management-heading { margin: 0; font: inherit; }
417
+ .oasub-management-toggle { appearance: none; display: flex; align-items: center; gap: 12px; width: 100%; padding: var(--oasub-section-inset); border: 0; border-radius: 16px; color: inherit; background: transparent; font: inherit; text-align: start; cursor: pointer; transition: background .16s; }
418
+ .oasub-management-toggle:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(15, 17, 21, .04)); }
419
+ .oasub-management-toggle > .oasub-section-copy { flex: 1; }
420
+ .oasub-management-symbol { display: grid; place-items: center; width: 34px; height: 34px; flex: none; border-radius: 10px; color: var(--dsw-alias-label-secondary, #61666b); background: var(--dsw-alias-bg-layer-2, #f5f6f7); }
421
+ .oasub-management { display: flex; flex-direction: column; margin-inline: var(--oasub-section-inset); }
422
+ .oasub-management[hidden] { display: none; }
423
+ .oasub-manage-row { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding-block: 16px; border-top: 1px solid var(--dsw-alias-border-l2, rgba(15, 17, 21, .14)); }
424
+ .oasub-manage-row .oasub-status-detail { font-size: 11px; line-height: 17px; }
425
+ .oasub-manage-title { font-size: 13px; font-weight: 500; line-height: 20px; }
426
+ .oasub-manage-row > .oasub-button { flex: 0 0 auto; min-height: 32px; padding: 5px 12px; border-color: var(--dsw-alias-border-l1, rgba(15, 17, 21, .2)); font-size: 12px; }
427
+ .oasub-manage-row > .oasub-button.danger-quiet { color: var(--dsw-alias-state-error-primary, #b91c1c); border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary, #dc2626) 25%, transparent); }
428
+ .oasub-manage-row > .oasub-button.danger-quiet:hover:not(:disabled) { background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #dc2626) 7%, transparent); border-color: var(--dsw-alias-state-error-primary, #dc2626); }
429
+ @container (max-width: 460px) {
430
+ .oasub-context-grid { grid-template-columns: minmax(0, 1fr); }
431
+ .oasub-manage-row { align-items: flex-start; flex-direction: column; gap: 10px; }
432
+ .oasub-manage-row > .oasub-button { align-self: flex-start; }
433
+ }
310
434
  .oasub-button {
311
435
  appearance: none;
312
436
  min-height: 36px;
@@ -379,8 +503,12 @@ window.__ModuleLoader__.load({
379
503
  .oasub-status-line { flex-direction: column; }
380
504
  .oasub-button { flex: 1 1 auto; }
381
505
  .oasub-button.quiet { margin-left: 0; }
506
+ .oasub-manage-row { align-items: flex-start; flex-direction: column; gap: 8px; }
382
507
  }
383
508
  @media (forced-colors: active) {
509
+ .oasub-picker-trigger, .oasub-input-shell, .oasub-picker-menu { border-color: ButtonText; }
510
+ .oasub-picker-option[data-active="true"] { outline: 1px solid Highlight; outline-offset: -1px; }
511
+ .oasub-picker-option[aria-disabled="true"], .oasub-picker-trigger:disabled { color: GrayText; opacity: 1; }
384
512
  .oasub-button { border-color: ButtonText; }
385
513
  .oasub-button.primary, .oasub-button.danger { border-color: ButtonText; color: ButtonText; background: ButtonFace; }
386
514
  .oasub-button:not(.primary):not(.danger):hover:not(:disabled),
@@ -389,7 +517,7 @@ window.__ModuleLoader__.load({
389
517
  .oasub-button:disabled { border-color: GrayText; color: GrayText; opacity: 1; }
390
518
  }
391
519
  @media (prefers-reduced-motion: reduce) {
392
- .oasub-button { transition: none; }
520
+ .oasub-button, .oasub-picker-trigger, .oasub-chevron, .oasub-management-toggle { transition: none; }
393
521
  .oasub-skeleton::after { animation: none; display: none; }
394
522
  }
395
523
  `;
@@ -414,6 +542,7 @@ window.__ModuleLoader__.load({
414
542
  'timeout', 'invalid-response', 'process-exited', 'credential-write-failed', 'credential-changed',
415
543
  'settings-unavailable', 'models-unavailable', 'models-empty', 'models-confirmation-required',
416
544
  'settings-conflict', 'settings-write-failed', 'ownership-save-failed', 'cancelled', 'unknown',
545
+ 'invalid-context-window', 'context-window-exceeded', 'model-not-found',
417
546
  ]);
418
547
  function allowedCode(value) {
419
548
  return typeof value === 'string' && ERROR_CODES.has(value) ? value : undefined;
@@ -560,6 +689,25 @@ window.__ModuleLoader__.load({
560
689
  }
561
690
  return { synced: true, count: raw.count, warningCode: raw.warningCode == null ? undefined : allowedCode(raw.warningCode) ?? 'unknown' };
562
691
  }
692
+ function positiveInteger(value) {
693
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
694
+ }
695
+ function parseModelContexts(value) {
696
+ const raw = recordOf(value);
697
+ if (raw === null || typeof raw.revision !== 'number' || !Number.isSafeInteger(raw.revision) || raw.revision < 0 || !Array.isArray(raw.models)) {
698
+ throw new ClientFailure('invalid-response');
699
+ }
700
+ const models = [];
701
+ for (const item of raw.models) {
702
+ const row = recordOf(item);
703
+ if (!row || typeof row.id !== 'string' || !row.id || models.some((model) => model.id === row.id))
704
+ throw new ClientFailure('invalid-response');
705
+ models.push({ id: row.id, name: typeof row.name === 'string' ? row.name : undefined,
706
+ contextWindow: positiveInteger(row.contextWindow), defaultContextWindow: positiveInteger(row.defaultContextWindow),
707
+ maxContextWindow: positiveInteger(row.maxContextWindow), customized: row.customized === true });
708
+ }
709
+ return { revision: raw.revision, models };
710
+ }
563
711
  function safeVerificationUrl(value) {
564
712
  if (typeof value !== 'string' || value !== value.trim())
565
713
  return null;
@@ -574,6 +722,17 @@ window.__ModuleLoader__.load({
574
722
  return null;
575
723
  }
576
724
  }
725
+ function uiIcon(name, size = 16) {
726
+ const paths = {
727
+ chevron: 'm6 9 6 6 6-6', check: 'm5 12 4 4L19 6',
728
+ info: 'M12 11v6m0-10v.01M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',
729
+ settings: 'M4 7h9m4 0h3M4 17h3m4 0h9M13 4v6M7 14v6',
730
+ };
731
+ return React.createElement('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
732
+ stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
733
+ className: 'oasub-ui-icon' + (name === 'chevron' ? ' oasub-chevron' : ''), 'aria-hidden': true, focusable: false,
734
+ }, React.createElement('path', { d: paths[name] }));
735
+ }
577
736
  function Section(props) {
578
737
  const el = React.createElement;
579
738
  const { connection, timer, t } = props;
@@ -591,6 +750,18 @@ window.__ModuleLoader__.load({
591
750
  const [cancelPending, setCancelPending] = React.useState(false);
592
751
  const [confirm, setConfirm] = React.useState(null);
593
752
  const [statusRevision, setStatusRevision] = React.useState(0);
753
+ const [managementOpen, setManagementOpen] = React.useState(false);
754
+ const [contexts, setContexts] = React.useState(null);
755
+ const [contextLoading, setContextLoading] = React.useState(false);
756
+ const [contextError, setContextError] = React.useState(null);
757
+ const [modelId, setModelId] = React.useState('');
758
+ // null means no pending edit; '' selects the synchronized model default.
759
+ const [contextDraft, setContextDraft] = React.useState(null);
760
+ const [contextChoice, setContextChoice] = React.useState(null);
761
+ const [picker, setPicker] = React.useState(null);
762
+ const pickerRoot = React.useRef(null);
763
+ const activeOption = React.useRef(null);
764
+ const pickerSearch = React.useRef({ text: '', at: 0 });
594
765
  const actionLock = React.useRef(false);
595
766
  const cancelAcknowledged = React.useRef(false);
596
767
  const flowObserved = React.useRef(false);
@@ -649,6 +820,54 @@ window.__ModuleLoader__.load({
649
820
  controller.abort();
650
821
  };
651
822
  }, [connection, statusRevision]);
823
+ React.useEffect(() => {
824
+ if (!configured) {
825
+ setContexts(null);
826
+ setContextDraft(null);
827
+ setContextChoice(null);
828
+ return;
829
+ }
830
+ const controller = new AbortController();
831
+ setContextLoading(true);
832
+ setContextError(null);
833
+ remoteCall(connection, 'getModelContexts', {}, controller.signal).then(parseModelContexts).then((result) => {
834
+ if (controller.signal.aborted)
835
+ return;
836
+ setContexts(result);
837
+ setModelId((id) => result.models.some((model) => model.id === id) ? id : result.models[0]?.id ?? '');
838
+ setContextDraft(null);
839
+ setContextChoice(null);
840
+ }).catch((error) => {
841
+ if (!controller.signal.aborted) {
842
+ setContexts(null);
843
+ setContextError(errorKey(error, 'context.error'));
844
+ }
845
+ }).finally(() => { if (!controller.signal.aborted)
846
+ setContextLoading(false); });
847
+ return () => controller.abort();
848
+ }, [connection, configured, statusRevision]);
849
+ const contextDisabled = busy || contextLoading || statusLoading;
850
+ React.useEffect(() => {
851
+ if (contextDisabled || !configured)
852
+ setPicker(null);
853
+ }, [contextDisabled, configured, contexts]);
854
+ React.useEffect(() => {
855
+ if (!picker || typeof document === 'undefined')
856
+ return;
857
+ const dismissOutside = (event) => {
858
+ if (!pickerRoot.current?.contains(event.target))
859
+ setPicker(null);
860
+ };
861
+ document.addEventListener('pointerdown', dismissOutside);
862
+ document.addEventListener('focusin', dismissOutside);
863
+ return () => {
864
+ document.removeEventListener('pointerdown', dismissOutside);
865
+ document.removeEventListener('focusin', dismissOutside);
866
+ };
867
+ }, [picker?.id]);
868
+ React.useEffect(() => {
869
+ activeOption.current?.scrollIntoView?.({ block: 'nearest' });
870
+ }, [picker?.id, picker?.active]);
652
871
  React.useEffect(() => {
653
872
  if (props.subscribeReset === undefined)
654
873
  return;
@@ -967,6 +1186,116 @@ window.__ModuleLoader__.load({
967
1186
  }
968
1187
  });
969
1188
  };
1189
+ const selectedModel = contexts?.models.find((model) => model.id === modelId);
1190
+ const savedContext = selectedModel?.customized && selectedModel.contextWindow ? String(selectedModel.contextWindow) : '';
1191
+ const draft = contextDraft ?? savedContext;
1192
+ const contextMode = contextChoice ?? (draft === '' ? 'default' : draft === '1000000' ? 'million' : 'custom');
1193
+ const requestedContext = draft === '' ? null : /^\d+$/.test(draft) ? positiveInteger(Number(draft)) : undefined;
1194
+ const contextValidation = requestedContext === undefined ? 'error.invalid-context-window'
1195
+ : requestedContext !== null && selectedModel?.maxContextWindow !== undefined && requestedContext > selectedModel.maxContextWindow
1196
+ ? 'error.context-window-exceeded' : null;
1197
+ const contextDirty = contextDraft !== null && (draft !== savedContext || (draft === '' && selectedModel?.customized === true));
1198
+ const saveContext = () => {
1199
+ const signal = lifetime.current?.signal;
1200
+ if (phaseRef.current !== 'idle' || actionLock.current || !signal || signal.aborted || !contexts || !selectedModel || !contextDirty || contextValidation || contextLoading || statusLoading)
1201
+ return;
1202
+ actionLock.current = true;
1203
+ changePhase('saving-context');
1204
+ setNotice(null);
1205
+ remoteCall(connection, 'setModelContext', {
1206
+ modelId: selectedModel.id, contextWindow: requestedContext, revision: contexts.revision,
1207
+ }, signal, MUTATION_TIMEOUT_MS).then((result) => {
1208
+ if (signal.aborted)
1209
+ return;
1210
+ if (result?.saved !== true)
1211
+ throw new ClientFailure('invalid-response');
1212
+ setNotice({ tone: 'success', key: 'context.saved' });
1213
+ }).catch((error) => {
1214
+ if (!signal.aborted)
1215
+ setNotice({ tone: 'error', key: failureCode(error) === 'timeout' ? 'error.long-action' : errorKey(error, 'error.settings-write-failed') });
1216
+ }).finally(() => {
1217
+ if (!signal.aborted) {
1218
+ actionLock.current = false;
1219
+ changePhase('idle');
1220
+ reloadStatus();
1221
+ }
1222
+ });
1223
+ };
1224
+ // Select-only combobox: focus stays on the trigger, and navigation never saves a value.
1225
+ const renderPicker = (id, label, value, options, choose, describedBy) => {
1226
+ const open = picker?.id === id && !contextDisabled;
1227
+ const selected = options.find((option) => option.value === value);
1228
+ const enabled = options.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0);
1229
+ const selectedIndex = Math.max(0, options.findIndex((option) => option.value === value && !option.disabled));
1230
+ const active = open ? picker.active : selectedIndex;
1231
+ const optionId = (index) => id + '-option-' + index;
1232
+ const commit = (index) => {
1233
+ const option = options[index];
1234
+ if (!option || option.disabled || contextDisabled)
1235
+ return;
1236
+ choose(option.value);
1237
+ setPicker(null);
1238
+ };
1239
+ const onKeyDown = (event) => {
1240
+ if (contextDisabled)
1241
+ return;
1242
+ if (event.key === 'Escape' && open) {
1243
+ event.preventDefault();
1244
+ event.stopPropagation();
1245
+ setPicker(null);
1246
+ return;
1247
+ }
1248
+ if (event.key === 'Tab') {
1249
+ setPicker(null);
1250
+ return;
1251
+ }
1252
+ if (event.key === 'Enter' || event.key === ' ') {
1253
+ event.preventDefault();
1254
+ if (open)
1255
+ commit(active);
1256
+ else {
1257
+ pickerSearch.current.text = '';
1258
+ setPicker({ id, active: selectedIndex });
1259
+ }
1260
+ return;
1261
+ }
1262
+ const direction = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
1263
+ if (direction || event.key === 'Home' || event.key === 'End') {
1264
+ event.preventDefault();
1265
+ const position = enabled.indexOf(active);
1266
+ const next = event.key === 'Home' ? enabled[0] : event.key === 'End' ? enabled.at(-1)
1267
+ : !open ? selectedIndex : enabled[(position + direction + enabled.length) % enabled.length];
1268
+ setPicker({ id, active: next ?? 0 });
1269
+ return;
1270
+ }
1271
+ if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
1272
+ event.preventDefault();
1273
+ const search = pickerSearch.current;
1274
+ search.text = (Date.now() - search.at > 700 ? '' : search.text) + event.key.toLocaleLowerCase();
1275
+ search.at = Date.now();
1276
+ const text = [...search.text].every((char) => char === search.text[0]) ? search.text[0] : search.text;
1277
+ const order = [...enabled.filter((index) => index > active), ...enabled.filter((index) => index <= active)];
1278
+ const match = order.find((index) => options[index].label.toLocaleLowerCase().startsWith(text) || options[index].value.toLocaleLowerCase().startsWith(text));
1279
+ if (match !== undefined)
1280
+ setPicker({ id, active: match });
1281
+ }
1282
+ };
1283
+ const copy = (option) => el('span', { className: 'oasub-picker-copy' }, el('span', { className: 'oasub-picker-label' }, option?.label ?? value), option?.detail ? el('span', { className: 'oasub-picker-detail' }, option.detail) : null);
1284
+ return el('div', { className: 'oasub-field' }, el('label', { id: id + '-label', htmlFor: id }, label), el('div', { className: 'oasub-picker', ref: open ? pickerRoot : undefined }, el('button', { id, type: 'button', className: 'oasub-picker-trigger', role: 'combobox',
1285
+ 'aria-label': label, 'aria-expanded': open, 'aria-haspopup': 'listbox',
1286
+ 'aria-controls': open ? id + '-list' : undefined, 'aria-activedescendant': open ? optionId(active) : undefined,
1287
+ 'aria-describedby': describedBy, disabled: contextDisabled, 'data-value': value,
1288
+ onKeyDown, onClick: () => { pickerSearch.current.text = ''; setPicker(open ? null : { id, active: selectedIndex }); },
1289
+ }, copy(selected), uiIcon('chevron')), open ? el('ul', { id: id + '-list', className: 'oasub-picker-menu', role: 'listbox', 'aria-labelledby': id + '-label',
1290
+ onMouseDown: (event) => event.preventDefault(),
1291
+ }, options.map((option, index) => el('li', { key: option.value, id: optionId(index), role: 'option',
1292
+ className: 'oasub-picker-option', 'aria-selected': option.value === value, 'aria-disabled': !!option.disabled,
1293
+ 'data-value': option.value, 'data-active': index === active, ref: index === active ? activeOption : undefined,
1294
+ onMouseMove: () => { if (!option.disabled && index !== active)
1295
+ setPicker({ id, active: index }); },
1296
+ onClick: () => commit(index),
1297
+ }, copy(option), option.value === value ? uiIcon('check', 15) : null))) : null));
1298
+ };
970
1299
  const selectCode = () => {
971
1300
  codeRef.current?.focus();
972
1301
  codeRef.current?.select();
@@ -1014,6 +1343,7 @@ window.__ModuleLoader__.load({
1014
1343
  type: 'button',
1015
1344
  className: 'oasub-button ' + (info.modelsSynced ? '' : 'primary'),
1016
1345
  disabled: busy,
1346
+ 'aria-describedby': 'oasub-sync-help',
1017
1347
  onClick: requestModelSync,
1018
1348
  }, phase === 'syncing' ? t('action.syncing') : t(info.modelsSynced ? 'action.updateModels' : 'action.sync'))
1019
1349
  : el('button', {
@@ -1021,25 +1351,42 @@ window.__ModuleLoader__.load({
1021
1351
  className: 'oasub-button primary',
1022
1352
  disabled: busy || !ready,
1023
1353
  onClick: () => startAuthorization('device_code'),
1024
- }, phase === 'starting' || phase === 'authorizing' ? t('action.connecting') : t('action.connect')), configured
1354
+ }, phase === 'starting' || phase === 'authorizing' ? t('action.connecting') : t('action.connect')), configured && info.credentialState !== 'valid'
1025
1355
  ? el('button', {
1026
- type: 'button',
1027
- className: 'oasub-button',
1028
- disabled: busy || !ready,
1356
+ type: 'button', className: 'oasub-button primary', disabled: busy || !ready,
1357
+ title: t(info.refreshable ? 'manage.refresh.help' : 'manage.reconnect.help'),
1029
1358
  onClick: () => startAuthorization(info.refreshable ? 'refresh' : 'device_code'),
1030
- }, phase === 'starting' || phase === 'authorizing'
1031
- ? t('action.refreshing')
1032
- : t(info.refreshable ? 'action.refresh' : 'action.reconnect'))
1033
- : null, info.cleanupAvailable
1034
- ? el('button', {
1035
- type: 'button',
1036
- className: 'oasub-button danger quiet',
1037
- disabled: busy,
1038
- onClick: () => openConfirm('disconnect'),
1039
- }, phase === 'disconnecting' ? t('action.disconnecting') : t('action.disconnect'))
1040
- : null));
1041
- 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, statusError !== null
1042
- ? 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('div', { className: 'oasub-actions oasub-footer' }, el('button', { ref: reloadRef, type: 'button', className: 'oasub-button', disabled: statusLoading, onClick: reloadStatus }, t(statusLoading ? 'status.reloading' : 'action.reload'))), device !== null
1359
+ }, phase === 'starting' || phase === 'authorizing' ? t('action.refreshing') : t(info.refreshable ? 'action.refresh' : 'action.reconnect'))
1360
+ : null), configured ? el('div', { id: 'oasub-sync-help', className: 'oasub-status-detail' }, t('model.sync.help')) : null);
1361
+ 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', { className: 'oasub-section-heading' }, el('div', { className: 'oasub-section-copy' }, 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-context-grid' }, renderPicker('oasub-model', t('context.model'), modelId, (contexts?.models ?? []).map((model) => ({
1362
+ value: model.id, label: model.name || model.id,
1363
+ detail: model.name && model.name !== model.id ? model.id : undefined,
1364
+ })), (value) => { setModelId(value); setContextDraft(null); setContextChoice(null); }), renderPicker('oasub-window', t('context.window'), contextMode, [
1365
+ { value: 'default', label: t('context.default'), detail: selectedModel.defaultContextWindow ? selectedModel.defaultContextWindow.toLocaleString() + ' tokens' : t('context.default.detail') },
1366
+ { value: 'million', label: t('context.million'), detail: '1,000,000 tokens', disabled: selectedModel.maxContextWindow !== undefined && selectedModel.maxContextWindow < 1_000_000 },
1367
+ { value: 'custom', label: t('context.custom'), detail: t('context.custom.detail') },
1368
+ ], (value) => {
1369
+ const choice = value === 'default' ? 'default' : value === 'million' ? 'million' : 'custom';
1370
+ setContextChoice(choice);
1371
+ setContextDraft(choice === 'default' ? '' : choice === 'million' ? '1000000' : '-');
1372
+ }, 'oasub-context-limit')), contextMode === 'custom' ? el('label', { className: 'oasub-field' }, t('context.tokens'), el('span', { className: 'oasub-input-shell' }, el('input', { className: 'oasub-token-input', type: 'text', inputMode: 'numeric', 'aria-label': t('context.tokens'),
1373
+ value: draft === '-' ? '' : draft, disabled: contextDisabled, placeholder: '1000000',
1374
+ 'aria-invalid': contextValidation !== null, 'aria-describedby': 'oasub-context-limit' + (contextDirty && contextValidation ? ' oasub-context-error' : ''),
1375
+ onChange: (event) => setContextDraft(event.currentTarget.value || '-') }), el('span', { className: 'oasub-token-unit', 'aria-hidden': true }, 'tokens'))) : null, el('div', { id: 'oasub-context-limit', className: 'oasub-context-note' }, uiIcon('info', 13), el('span', null, selectedModel.maxContextWindow ? t('context.maximum', { count: selectedModel.maxContextWindow.toLocaleString() }) + ' · ' + t('context.limit.help') : t('context.unknown'))), contextDirty && contextValidation ? el('div', { id: 'oasub-context-error', role: 'alert', className: 'oasub-context-error' }, t(contextValidation)) : null, el('div', { className: 'oasub-context-footer' }, el('div', { className: 'oasub-context-meta' }, selectedModel.contextWindow ? el('span', null, t('context.current', { count: selectedModel.contextWindow.toLocaleString() })) : null, contextDirty ? el('span', { className: 'oasub-context-badge' }, t('context.pending'))
1376
+ : selectedModel.customized ? el('span', { className: 'oasub-context-badge' }, uiIcon('check', 12), t('context.applied')) : null), contextDirty ? el('button', { type: 'button', className: 'oasub-button primary', disabled: contextDisabled || contextValidation !== null,
1377
+ onClick: saveContext }, t(phase === 'saving-context' ? 'context.saving' : 'context.save')) : null)) : null) : null, statusError !== null
1378
+ ? 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('section', { className: 'oasub-card oasub-management-card', 'aria-label': t('action.more') }, el('h3', { className: 'oasub-management-heading' }, el('button', { ref: reloadRef, type: 'button', className: 'oasub-management-toggle',
1379
+ 'aria-label': t(managementOpen ? 'action.less' : 'action.more'),
1380
+ 'aria-expanded': managementOpen, 'aria-controls': 'oasub-management',
1381
+ onClick: () => setManagementOpen((open) => !open) }, el('span', { className: 'oasub-management-symbol' }, uiIcon('settings', 18)), el('span', { className: 'oasub-section-copy' }, el('span', { className: 'oasub-status-title' }, t('action.more')), el('span', { className: 'oasub-status-detail' }, t('manage.help'))), uiIcon('chevron'))), el('div', { id: 'oasub-management', className: 'oasub-management', hidden: !managementOpen }, managementOpen ? [
1382
+ configured && info?.credentialState === 'valid' ? el('div', { key: 'refresh', className: 'oasub-manage-row' }, el('div', { className: 'oasub-section-copy' }, el('div', { className: 'oasub-manage-title' }, t(info.refreshable ? 'action.refresh' : 'action.reconnect')), 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,
1383
+ 'aria-describedby': 'oasub-refresh-help',
1384
+ onClick: () => startAuthorization(info.refreshable ? 'refresh' : 'device_code') }, phase === 'starting' || phase === 'authorizing' ? t('action.refreshing') : t(info.refreshable ? 'action.refresh' : 'action.reconnect'))) : null,
1385
+ el('div', { key: 'reload', className: 'oasub-manage-row' }, el('div', { className: 'oasub-section-copy' }, el('div', { className: 'oasub-manage-title' }, t('action.reload')), el('div', { className: 'oasub-status-detail', id: 'oasub-reload-help' }, t('manage.reload.help'))), el('button', { type: 'button', className: 'oasub-button', disabled: statusLoading,
1386
+ 'aria-describedby': 'oasub-reload-help', onClick: reloadStatus }, t(statusLoading ? 'status.reloading' : 'action.reload'))),
1387
+ info?.cleanupAvailable ? el('div', { key: 'disconnect', className: 'oasub-manage-row' }, el('div', { className: 'oasub-section-copy' }, el('div', { className: 'oasub-manage-title' }, t('action.disconnect')), el('div', { className: 'oasub-status-detail', id: 'oasub-disconnect-help' }, t('manage.disconnect.help'))), el('button', { type: 'button', className: 'oasub-button danger-quiet', disabled: busy,
1388
+ 'aria-describedby': 'oasub-disconnect-help', onClick: () => openConfirm('disconnect') }, t(phase === 'disconnecting' ? 'action.disconnecting' : 'action.disconnect'))) : null,
1389
+ ] : null)), device !== null
1043
1390
  ? 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
1391
  'aria-label': t('device.code'), autoComplete: 'off', spellCheck: false, onFocus: (event) => event.currentTarget.select() }), el('div', { className: 'oasub-actions' }, device.url !== null
1045
1392
  ? 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', 'cancelled', 'unknown',
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 contextWindow = positiveInteger(raw.context_window) ?? positiveInteger(raw.max_context_window);
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
- The script requires `@babel/parser` and `esbuild` resolvable from `ROOT`; both
47
- were already installed in the tested environment. It does not install tools,
48
- access credentials, change account settings, restart DSH, or launch a server.
49
- It is never invoked automatically by plugin installation.
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
- `--check` 只检查,`--apply` 才修改,重复执行不会叠加补丁。构建工具必须已存在于
52
- DSH 安装目录,脚本不会联网安装。它不读取凭证、不修改账号设置,也不重启服务。
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
- Rebuild/reinstall this plugin's client bundle as well. The tested DSH client-HMR
84
- file poller notices modified built plugin bundles, but the **shell change always
85
- requires refreshing the existing DSH page**. If that poller is disabled, arrange
86
- a DSH restart yourself before refreshing. Editing TypeScript alone does not
87
- rebuild installed plugin bundles; no automatic source-watcher behavior is assumed.
88
-
89
- 插件自身也要重新构建并安装。当前测试环境的文件监听会拾取已构建插件的变化,
90
- **Web 壳层修改必须刷新现有页面**;没有监听时,还需自行安排重启 DSH。
91
- 仅修改 TypeScript 源码不会更新已安装的 GUI,脚本也不会自动重启正在进行的会话。
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.5",
3
+ "version": "0.1.7",
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-typert-protocol": "^0.1.2-rc.1",
79
- "@deepseek-ai/dsh-client-connection": "^0.1.2-rc.1"
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 resolvable from ROOT (already present in
11
- * the tested installation). This is an exact-version AND SHA-256 guarded patch,
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 require = createRequire(join(root, 'package.json'))
160
- try { return { parse: require('@babel/parser').parse, transform: require('esbuild').transform } }
161
- catch (error) { throw new Error('Build requires @babel/parser and esbuild resolvable from --root; no files changed', { cause: error }) }
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)) {