@goodandready/dsh-opencode-provider 0.5.31 → 0.5.32

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.32 — release 9: proxy auth and route switches
4
+
5
+ - **#71 — authenticated proxy**: `proxyUsername` plus a password stored in the credential service enable Basic auth for HTTP/HTTPS and SOCKS5 proxies. The authenticated URL exists only in memory; `/state` returns the credential-free address and a `configured` flag, never the password.
6
+ - **#80 — route switches**: `goEnabled` and `consoleEnabled` are exposed in Settings. Disabling a route removes it from the provider directory, the picker and request handling at runtime; at least one route stays enabled.
7
+ - 144 tests.
8
+
3
9
  ## 0.5.31 — release 8: command localization
4
10
 
5
11
  - **#48 — `/opencode` localization**: new `commandLocale` setting (`en` default, `zh`) localizes status, usage, accounts, models, rules and errors. Exposed in Settings → OpenCode.
package/README.md CHANGED
@@ -99,7 +99,8 @@ graph LR
99
99
  - **Request heatmap** in the quota panel: a Monday-first weekday × hour grid in local time, plus a 24-hour aggregate strip underneath. Selectors switch between all requests, any single model, and any single account. A per-account table shows requests, errors and average latency/TTFT. The adapter appends `{at, provider, model, purpose?, account, reason, ok, latencyMs, ttftMs?, error?}` to `~/.dsh/storages/dsh-opencode/requests.jsonl`; no prompt, response or key is stored. Auxiliary `session-title`/`compaction` calls are excluded from the heatmap and exact duplicate records collapse. The log compacts at 2 MB to the newest 5000 lines. Collection starts with this build — the projection cache only has `lastPromptAt`, not per-request timestamps.
100
100
  - **Stable `x-opencode-session`** derived from the durable session id, so retries and every turn of a conversation reuse the same routing key.
101
101
  - **Live catalog** from `/v1/models` with a generated `models.dev` fallback.
102
- - **Proxy support**: one live `proxyUrl` setting routes inference, model discovery and usage checks through HTTP, HTTPS or SOCKS5; the dispatcher is reused until the address changes.
102
+ - **Proxy support**: one live `proxyUrl` setting routes inference, model discovery and usage checks through HTTP, HTTPS or SOCKS5; the dispatcher is reused until the address changes. `proxyUsername` and a password stored in the credential service add Basic auth for both HTTP and SOCKS5. Passwords are never returned to the browser.
103
+ - **Route switches**: enable or disable the Console/Zen and Go/Go Plus routes independently; a disabled route is removed from the picker and refuses requests. At least one route stays enabled.
103
104
  - **Per-model subscription limits** from the OpenCode Go pricing table: monthly dollar allowance plus estimated requests for the 5-hour, weekly and monthly windows, for both Go and Go Plus. Shown in the model picker and the settings page; estimates only — the gateway remains the authority.
104
105
  - **`/opencode` command** for status, usage, accounts, models, rules, and `test [model]`.
105
106
  - **TUI settings section** for the terminal profile (`/settings → OpenCode`) with the key, endpoints, proxy, allowlist, active account, fallback model, sticky routing and timeouts.
@@ -132,12 +133,12 @@ npm pack
132
133
  Install the **packed tarball** into a profile:
133
134
 
134
135
  ```bash
135
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.31.tgz
136
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.32.tgz
136
137
  # DSH Desktop:
137
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.31.tgz
138
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.32.tgz
138
139
 
139
140
  # Or install the published package:
140
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.31
141
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.32
141
142
  ```
142
143
 
143
144
  Restart dsh (or DSH Desktop), then pick **OpenCode (plugin)** or **OpenCode Go (plugin)** in the model picker.
package/README.ru.md CHANGED
@@ -108,7 +108,8 @@ graph LR
108
108
  - **Почасовой Heatmap запросов**: сетка «день недели × час» в локальном времени плюс суточная полоса агрегации. Фильтры переключают отображение между всеми запросами, конкретной моделью и аккаунтом. Лог `~/.dsh/storages/dsh-opencode/requests.jsonl` хранит только метаданные `{at, provider, model, purpose, account, reason, ok, latencyMs, ttftMs, error}` (промпты и ответы не сохраняются).
109
109
  - **Стабильный заголовок `x-opencode-session`**: вычисляется из устойчивого ID сессии DSH, обеспечивая одинаковый ключ маршрутизации при повторах и диалоге.
110
110
  - **Живой каталог моделей**: автоматическое чтение `/v1/models` с резервным fallback из `models.dev`.
111
- - **Proxy**: live-параметр `proxyUrl` направляет инференс, каталог и проверку квот через HTTP, HTTPS или SOCKS5; dispatcher переиспользуется до смены адреса.
111
+ - **Proxy**: live-параметр `proxyUrl` направляет инференс, каталог и проверку квот через HTTP, HTTPS или SOCKS5; dispatcher переиспользуется до смены адреса. `proxyUsername` и пароль из credential-сервиса включают Basic auth для HTTP и SOCKS5. Пароль не возвращается в браузер.
112
+ - **Переключатели маршрутов**: Console/Zen и Go/Go Plus включаются и отключаются независимо; отключённый маршрут исчезает из выбора и отклоняет запросы. Хотя бы один маршрут остаётся включённым.
112
113
  - **Оценки лимитов подписки OpenCode Go**: долларовый лимит и расчетное число запросов для окон 5h, недели и месяца для планов Go и Go Plus.
113
114
  - **Команда `/opencode`**: быстрый просмотр статуса, квот, аккаунтов, моделей, правил и запуск теста связи `test [model]`.
114
115
  - **Настройки в терминале (TUI)**: раздел `/settings → OpenCode` для профиля terminal с ключом, эндпоинтами, allowlist, активным аккаунтом, fallback-моделью, sticky-маршрутизацией и таймаутами.
@@ -131,12 +132,12 @@ npm pack
131
132
  Установка из **упакованного tarball**:
132
133
 
133
134
  ```bash
134
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.31.tgz
135
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.32.tgz
135
136
  # DSH Desktop:
136
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.31.tgz
137
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.32.tgz
137
138
 
138
139
  # Либо установка опубликованного пакета из npm:
139
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.31
140
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.32
140
141
  ```
141
142
 
142
143
  После установки перезапустите dsh (или DSH Desktop) и выберите **OpenCode (plugin)** или **OpenCode Go (plugin)** в списке моделей.
package/README.zh.md CHANGED
@@ -106,7 +106,8 @@ graph LR
106
106
  - **本地时间请求热力图**:星期×小时交互式热力图,排除系统心跳与重复记录,可按模型或账户过滤,仅保存 `{at, provider, model, purpose, account, reason, ok, latencyMs, ttftMs, error}` 元数据,不保存任何提示词与回复。
107
107
  - **稳定的 `x-opencode-session` 标头**:根据 DSH 会话 ID 派生,确保重试与连续对话复用相同的网关路由键。
108
108
  - **实时模型目录**:自动从 `/v1/models` 同步,并以 `models.dev` 生成文件作为回退。
109
- - **代理**:一个实时 `proxyUrl` 设置即可让推理、模型目录和额度检查走 HTTP、HTTPS 或 SOCKS5;地址变化前复用 dispatcher。
109
+ - **代理**:一个实时 `proxyUrl` 设置即可让推理、模型目录和额度检查走 HTTP、HTTPS 或 SOCKS5;地址变化前复用 dispatcher。`proxyUsername` 与凭据服务中的密码可为 HTTP 和 SOCKS5 启用 Basic 认证,密码不会返回浏览器。
110
+ - **路由开关**:可独立启用或禁用 Console/Zen 与 Go/Go Plus 路由;禁用的路由会从选择器移除并拒绝请求,至少保留一个路由。
110
111
  - **Go 订阅额度估算**:每月美元额度以及 5 小时、每周和每月的估算请求次数。
111
112
  - **`/opencode` 斜杠指令**:快速查询状态、配额、账户、模型及执行联通性测试。
112
113
  - **TUI 设置**:`/settings → OpenCode` 支持密钥、端点、allowlist、活动账户、回退模型、粘性路由和超时。
@@ -126,12 +127,12 @@ npm pack
126
127
  通过**打包文件 (.tgz)**安装:
127
128
 
128
129
  ```bash
129
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.31.tgz
130
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.32.tgz
130
131
  # DSH 桌面端:
131
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.31.tgz
132
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.32.tgz
132
133
 
133
134
  # 或从 npm 安装已发布版本:
134
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.31
135
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.32
135
136
  ```
136
137
 
137
138
  安装完成后重启 dsh 或 DSH 桌面端,在模型选择器中选择 **OpenCode (plugin)** 或 **OpenCode Go (plugin)** 即可。
package/lib/client.js CHANGED
@@ -190,7 +190,18 @@ window.__ModuleLoader__.load({
190
190
  commandLocale: "/opencode command language",
191
191
  commandLocaleHint: "Language for /opencode status, accounts, usage, models and rules.",
192
192
  proxyUrl: "Proxy URL",
193
- proxyUrlHint: "HTTP, HTTPS or SOCKS5 proxy for inference, catalog and usage. Empty disables it. Credentials are not supported.",
193
+ proxyUrlHint: "HTTP, HTTPS or SOCKS5 proxy for inference, catalog and usage. Empty disables it. Do not put credentials in this URL.",
194
+ proxyUsername: "Proxy username",
195
+ proxyPassword: "Proxy password",
196
+ proxyPasswordSet: "configured — enter a new one to replace it",
197
+ proxyPasswordUnset: "not configured",
198
+ proxyPasswordHint: "Stored in the credential service, never returned to the browser.",
199
+ saveProxyPassword: "Save proxy password",
200
+ clearProxyPassword: "Clear proxy password",
201
+ enabledRoutes: "Enabled routes",
202
+ routeConsole: "OpenCode Console / Zen",
203
+ routeGo: "OpenCode Go / Go Plus",
204
+ enabledRoutesHint: "Disabled routes are removed from the picker; at least one route stays enabled.",
194
205
  maxOutputTokens: "Output limit (empty = model default, 0 = omit)",
195
206
  contextOverrideShort: "Context",
196
207
  maxOutputOverrideShort: "Max output",
@@ -426,7 +437,18 @@ window.__ModuleLoader__.load({
426
437
  commandLocale: "/opencode 命令语言",
427
438
  commandLocaleHint: "用于 /opencode 的状态、账户、用量、模型和规则输出。",
428
439
  proxyUrl: "代理地址",
429
- proxyUrlHint: "用于推理、目录和用量的 HTTP、HTTPS 或 SOCKS5 代理。留空则禁用。不支持代理认证。",
440
+ proxyUrlHint: "用于推理、目录和用量的 HTTP、HTTPS 或 SOCKS5 代理。留空则禁用。请勿在此 URL 中填写凭据。",
441
+ proxyUsername: "代理用户名",
442
+ proxyPassword: "代理密码",
443
+ proxyPasswordSet: "已配置 — 输入新密码可替换",
444
+ proxyPasswordUnset: "未配置",
445
+ proxyPasswordHint: "保存在凭据服务中,不会返回给浏览器。",
446
+ saveProxyPassword: "保存代理密码",
447
+ clearProxyPassword: "清除代理密码",
448
+ enabledRoutes: "启用的路由",
449
+ routeConsole: "OpenCode Console / Zen",
450
+ routeGo: "OpenCode Go / Go Plus",
451
+ enabledRoutesHint: "禁用的路由会从选择器中移除;至少保留一个启用的路由。",
430
452
  maxOutputTokens: "输出上限(空 = 模型默认,0 = 省略)",
431
453
  contextOverrideShort: "上下文",
432
454
  maxOutputOverrideShort: "最大输出",
@@ -556,6 +578,18 @@ window.__ModuleLoader__.load({
556
578
  body: JSON.stringify({ ref })
557
579
  });
558
580
  }
581
+ function saveProxyPassword(password) {
582
+ return call("/dsh-opencode/proxy-password", {
583
+ method: "POST",
584
+ body: JSON.stringify({ password })
585
+ });
586
+ }
587
+ function clearProxyPassword() {
588
+ return call("/dsh-opencode/proxy-password/clear", {
589
+ method: "POST",
590
+ body: "{}"
591
+ });
592
+ }
559
593
  function importAuth(entry, ref) {
560
594
  return call("/dsh-opencode/auth-import", {
561
595
  method: "POST",
@@ -1718,6 +1752,9 @@ window.__ModuleLoader__.load({
1718
1752
  requestImagePixelBudget: String(state.config.requestImagePixelBudget),
1719
1753
  requestImageMaxBytes: String(state.config.requestImageMaxBytes),
1720
1754
  proxyUrl: state.config.proxyUrl,
1755
+ proxyUsername: state.config.proxyUsername,
1756
+ goEnabled: state.config.goEnabled,
1757
+ consoleEnabled: state.config.consoleEnabled,
1721
1758
  catalogRefreshMinutes: String(state.config.catalogRefreshMinutes),
1722
1759
  commandLocale: state.config.commandLocale,
1723
1760
  maxOutputTokens: state.config.maxOutputTokens === null ? "" : String(state.config.maxOutputTokens),
@@ -1772,6 +1809,9 @@ window.__ModuleLoader__.load({
1772
1809
  requestImagePixelBudget: numberOrNull(draft.requestImagePixelBudget) ?? 2458624,
1773
1810
  requestImageMaxBytes: numberOrNull(draft.requestImageMaxBytes) ?? 1048576,
1774
1811
  proxyUrl: draft.proxyUrl.trim(),
1812
+ proxyUsername: draft.proxyUsername.trim(),
1813
+ goEnabled: draft.goEnabled,
1814
+ consoleEnabled: draft.consoleEnabled,
1775
1815
  catalogRefreshMinutes: numberOrNull(draft.catalogRefreshMinutes) ?? 10,
1776
1816
  commandLocale: draft.commandLocale,
1777
1817
  maxOutputTokens: numberOrNull(draft.maxOutputTokens),
@@ -2014,6 +2054,8 @@ window.__ModuleLoader__.load({
2014
2054
  const [error, setError] = (0, react.useState)(null);
2015
2055
  const [notice, setNotice] = (0, react.useState)(null);
2016
2056
  const [keyDraft, setKeyDraft] = (0, react.useState)("");
2057
+ const [proxyPassword, setProxyPassword] = (0, react.useState)("");
2058
+ const [proxyBusy, setProxyBusy] = (0, react.useState)(false);
2017
2059
  const [keyDrafts, setKeyDrafts] = (0, react.useState)({});
2018
2060
  const [search, setSearch] = (0, react.useState)("");
2019
2061
  const [testModel, setTestModel] = (0, react.useState)("deepseek-v4.1-flash");
@@ -2077,6 +2119,34 @@ window.__ModuleLoader__.load({
2077
2119
  setSaving(false);
2078
2120
  }
2079
2121
  };
2122
+ const storeProxyPassword = async () => {
2123
+ if (proxyPassword === "") return;
2124
+ setProxyBusy(true);
2125
+ setError(null);
2126
+ try {
2127
+ await saveProxyPassword(proxyPassword);
2128
+ setProxyPassword("");
2129
+ setNotice(t("saved"));
2130
+ await load();
2131
+ } catch (caught) {
2132
+ setError(caught instanceof Error ? caught.message : t("failed"));
2133
+ } finally {
2134
+ setProxyBusy(false);
2135
+ }
2136
+ };
2137
+ const removeProxyPassword = async () => {
2138
+ setProxyBusy(true);
2139
+ setError(null);
2140
+ try {
2141
+ await clearProxyPassword();
2142
+ setNotice(t("saved"));
2143
+ await load();
2144
+ } catch (caught) {
2145
+ setError(caught instanceof Error ? caught.message : t("failed"));
2146
+ } finally {
2147
+ setProxyBusy(false);
2148
+ }
2149
+ };
2080
2150
  const checkKey = async (label) => {
2081
2151
  setChecking(label);
2082
2152
  setError(null);
@@ -3245,6 +3315,84 @@ window.__ModuleLoader__.load({
3245
3315
  value: draft.proxyUrl,
3246
3316
  onChange: (value) => update({ proxyUrl: value })
3247
3317
  }),
3318
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
3319
+ label: t("proxyUsername"),
3320
+ value: draft.proxyUsername,
3321
+ onChange: (value) => update({ proxyUsername: value })
3322
+ }),
3323
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3324
+ className: "oc-field",
3325
+ children: [
3326
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3327
+ className: "oc-label",
3328
+ children: t("proxyPassword")
3329
+ }),
3330
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3331
+ className: "oc-input",
3332
+ type: "password",
3333
+ value: proxyPassword,
3334
+ placeholder: state.proxy.configured ? t("proxyPasswordSet") : t("proxyPasswordUnset"),
3335
+ onChange: (event) => setProxyPassword(event.target.value)
3336
+ }),
3337
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3338
+ className: "oc-hint",
3339
+ children: t("proxyPasswordHint")
3340
+ }),
3341
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3342
+ className: "oc-actions",
3343
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3344
+ type: "button",
3345
+ className: "oc-button",
3346
+ disabled: proxyBusy || proxyPassword === "",
3347
+ onClick: () => {
3348
+ storeProxyPassword();
3349
+ },
3350
+ children: t("saveProxyPassword")
3351
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3352
+ type: "button",
3353
+ className: "oc-button",
3354
+ disabled: proxyBusy || !state.proxy.configured,
3355
+ onClick: () => {
3356
+ removeProxyPassword();
3357
+ },
3358
+ children: t("clearProxyPassword")
3359
+ })]
3360
+ })
3361
+ ]
3362
+ }),
3363
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3364
+ className: "oc-field",
3365
+ children: [
3366
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3367
+ className: "oc-label",
3368
+ children: t("enabledRoutes")
3369
+ }),
3370
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3371
+ className: "oc-actions",
3372
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [
3373
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3374
+ type: "checkbox",
3375
+ checked: draft.consoleEnabled,
3376
+ onChange: (event) => update({ consoleEnabled: event.target.checked })
3377
+ }),
3378
+ " ",
3379
+ t("routeConsole")
3380
+ ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [
3381
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3382
+ type: "checkbox",
3383
+ checked: draft.goEnabled,
3384
+ onChange: (event) => update({ goEnabled: event.target.checked })
3385
+ }),
3386
+ " ",
3387
+ t("routeGo")
3388
+ ] })]
3389
+ }),
3390
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3391
+ className: "oc-hint",
3392
+ children: t("enabledRoutesHint")
3393
+ })
3394
+ ]
3395
+ }),
3248
3396
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
3249
3397
  label: t("maxOutputTokens"),
3250
3398
  type: "number",
package/lib/index.d.ts CHANGED
@@ -50,6 +50,14 @@ interface Config {
50
50
  requestImageMaxBytes?: number;
51
51
  /** HTTP/HTTPS/SOCKS5 proxy for inference, catalog and usage requests. */
52
52
  proxyUrl?: string;
53
+ /** Proxy username; the password is resolved from `proxyPasswordRef`. */
54
+ proxyUsername?: string;
55
+ /** Credential reference for the proxy password. */
56
+ proxyPasswordRef?: string;
57
+ /** Enable the Go / Go Plus subscription route. */
58
+ goEnabled?: boolean;
59
+ /** Enable the Console / Zen pay-as-you-go route. */
60
+ consoleEnabled?: boolean;
53
61
  /** Live catalog cache lifetime in minutes; defaults to 10. */
54
62
  catalogRefreshMinutes?: number;
55
63
  /** Language for `/opencode` output; defaults to English. */
@@ -106,6 +114,10 @@ interface ResolvedConfig {
106
114
  requestImagePixelBudget: number;
107
115
  requestImageMaxBytes: number;
108
116
  proxyUrl: string;
117
+ proxyUsername: string;
118
+ proxyPasswordRef: string;
119
+ goEnabled: boolean;
120
+ consoleEnabled: boolean;
109
121
  catalogRefreshMinutes: number;
110
122
  commandLocale: CommandLocale;
111
123
  visibleModels: readonly string[];
@@ -408,6 +420,7 @@ interface RouteDeps {
408
420
  readonly analytics?: (days: number) => Promise<AnalyticsReport>;
409
421
  readonly heatmap?: (days: number) => Promise<RequestHeatmap>;
410
422
  readonly refreshTui?: () => void;
423
+ readonly syncRoutes?: () => void;
411
424
  /** Test seam; defaults to the local managed-credentials registry. */
412
425
  readonly managedPath?: string;
413
426
  }
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
1
2
  import { createHash } from "node:crypto";
2
3
  import { existsSync, readFileSync, statSync } from "node:fs";
3
4
  import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
@@ -6,15 +7,15 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
6
7
  import { LlmAdapter, LlmError, ReasoningEffortId, ToolCallId, attributionHeaders, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
7
8
  import z from "@deepseek-ai/schemastery";
8
9
  import { ProxyAgent, Socks5ProxyAgent } from "undici";
9
- import { credentialRef } from "@deepseek-ai/dsh-credentials";
10
10
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
11
11
  import { spawn } from "node:child_process";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { isIP } from "node:net";
14
14
  // src/proxy.ts
15
15
  /**
16
- * Proxy addresses are deliberately credential-free: the settings API returns
17
- * the configured address to the browser, so embedding a password would leak it.
16
+ * Proxy addresses stay credential-free: the settings API returns the configured
17
+ * address to the browser. Authentication is supplied separately from the
18
+ * credential service and is only assembled in memory.
18
19
  */
19
20
  function normalizeProxyUrl(raw) {
20
21
  const value = raw?.trim() ?? "";
@@ -31,7 +32,24 @@ function normalizeProxyUrl(raw) {
31
32
  "socks5:"
32
33
  ].includes(url.protocol)) throw new Error("proxy address must use http://, https:// or socks5://");
33
34
  if (url.hostname === "" || url.port === "0" || url.pathname !== "" && url.pathname !== "/" || url.search !== "" || url.hash !== "") throw new Error("proxy address must contain a host and optional port, without a path, query or fragment");
34
- if (url.username !== "" || url.password !== "") throw new Error("proxy credentials are not supported; use a local proxy without authentication");
35
+ if (url.username !== "" || url.password !== "") throw new Error("proxy credentials must be set through the proxy username and password fields");
36
+ return url.href;
37
+ }
38
+ /** Validate a proxy username; the password lives in the credential service. */
39
+ function normalizeProxyUsername(raw) {
40
+ const value = raw?.trim() ?? "";
41
+ if (value === "") return "";
42
+ if (value.length > 256 || value.includes(":") || /[\u0000-\u001f\u007f]/.test(value)) throw new Error("proxy username must be 1–256 characters without a colon or control characters");
43
+ return value;
44
+ }
45
+ /** Assemble the authenticated proxy URL in memory; never persist or return it. */
46
+ function buildProxyUrl(address, username, password) {
47
+ const base = normalizeProxyUrl(address);
48
+ const user = normalizeProxyUsername(username);
49
+ if (base === "" || user === "") return base;
50
+ const url = new URL(base);
51
+ url.username = encodeURIComponent(user);
52
+ url.password = encodeURIComponent(password ?? "");
35
53
  return url.href;
36
54
  }
37
55
  /**
@@ -43,7 +61,7 @@ function createProxyFetch(address, baseFetch = fetch) {
43
61
  let disposed = false;
44
62
  const wrapped = (async (input, init) => {
45
63
  if (disposed) throw new Error("proxy transport is disposed");
46
- const proxy = normalizeProxyUrl(address());
64
+ const proxy = normalizeProxyUrl(await address());
47
65
  if (proxy === "") return baseFetch(input, init);
48
66
  let agent = agents.get(proxy);
49
67
  if (agent === void 0) {
@@ -81,7 +99,7 @@ const DEFAULT_GO_API_BASE = "https://opencode.ai/inference/go";
81
99
  const DEFAULT_GO_ZEN_BASE = "https://opencode.ai/zen/go/v1";
82
100
  const DEFAULT_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
83
101
  const DEFAULT_AUTH_ENTRY = "opencode-go";
84
- const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.31";
102
+ const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.32";
85
103
  const DEFAULT_CONTEXT_WINDOW = 262144;
86
104
  const DEFAULT_MAX_TOKENS = 32768;
87
105
  const DEFAULT_GENERATE_MAX_TOKENS = 65536;
@@ -89,6 +107,7 @@ const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 25165824;
89
107
  const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2458624;
90
108
  const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1048576;
91
109
  const DEFAULT_REQUEST_PREPARATION_TIMEOUT_MS = 6e4;
110
+ const DEFAULT_PROXY_PASSWORD_REF = "OPENCODE_PROXY_PASSWORD";
92
111
  function markVolatile(schema) {
93
112
  const mark = schema.volatile;
94
113
  return typeof mark === "function" ? mark.call(schema) : schema;
@@ -109,6 +128,10 @@ const Config = z.object({
109
128
  requestImagePixelBudget: markVolatile(z.number().min(1).max(1e8).step(1).default(DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET)),
110
129
  requestImageMaxBytes: markVolatile(z.number().min(1).max(1e8).step(1).default(DEFAULT_REQUEST_IMAGE_MAX_BYTES)),
111
130
  proxyUrl: markVolatile(z.string().default("")),
131
+ proxyUsername: markVolatile(z.string().default("")),
132
+ proxyPasswordRef: markVolatile(z.string().role("credential-ref").default(DEFAULT_PROXY_PASSWORD_REF)),
133
+ goEnabled: markVolatile(z.boolean().default(true)),
134
+ consoleEnabled: markVolatile(z.boolean().default(true)),
112
135
  catalogRefreshMinutes: markVolatile(z.number().min(1).max(10080).step(1).default(10)),
113
136
  commandLocale: markVolatile(z.union(["en", "zh"]).default("en")),
114
137
  visibleModels: markVolatile(z.array(z.string()).default([])),
@@ -229,6 +252,19 @@ function resolveConfig(config) {
229
252
  requestImagePixelBudget: positiveInt(config.requestImagePixelBudget, DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET),
230
253
  requestImageMaxBytes: positiveInt(config.requestImageMaxBytes, DEFAULT_REQUEST_IMAGE_MAX_BYTES),
231
254
  proxyUrl: normalizeProxyUrl(config.proxyUrl),
255
+ proxyUsername: normalizeProxyUsername(config.proxyUsername),
256
+ proxyPasswordRef: typeof config.proxyPasswordRef === "string" && config.proxyPasswordRef.trim() !== "" ? config.proxyPasswordRef.trim() : DEFAULT_PROXY_PASSWORD_REF,
257
+ ...(() => {
258
+ const goEnabled = config.goEnabled !== false;
259
+ const consoleEnabled = config.consoleEnabled !== false;
260
+ return goEnabled || consoleEnabled ? {
261
+ goEnabled,
262
+ consoleEnabled
263
+ } : {
264
+ goEnabled: true,
265
+ consoleEnabled: false
266
+ };
267
+ })(),
232
268
  catalogRefreshMinutes: positiveInt(config.catalogRefreshMinutes, 10),
233
269
  commandLocale: config.commandLocale === "zh" ? "zh" : "en",
234
270
  visibleModels: Array.isArray(config.visibleModels) ? config.visibleModels.filter((id) => typeof id === "string" && id !== "") : [],
@@ -3501,6 +3537,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3501
3537
  }
3502
3538
  async listModels(provider, opts) {
3503
3539
  const config = this.deps.config();
3540
+ if (isGoProvider(provider) ? !config.goEnabled : !config.consoleEnabled) return [];
3504
3541
  let apiKey;
3505
3542
  try {
3506
3543
  apiKey = (await this.deps.resolveKey(provider)).key;
@@ -3524,6 +3561,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3524
3561
  }
3525
3562
  async resolveModel(provider, model) {
3526
3563
  const config = this.deps.config();
3564
+ if (isGoProvider(provider) ? !config.goEnabled : !config.consoleEnabled) throw new Error(`OpenCode route "${provider}" is disabled`);
3527
3565
  const meta = effectiveModel(model, config);
3528
3566
  const maxTokens = effectiveMaxTokens(meta, config);
3529
3567
  return {
@@ -3546,6 +3584,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3546
3584
  async *run(options, fallback) {
3547
3585
  const record = this.deps.recordRequest ?? ((provider, model, purpose, account, reason, outcome) => recordRequest(provider, model, outcome?.at ?? Date.now(), purpose, account, reason, outcome));
3548
3586
  const config = this.deps.config();
3587
+ if (isGoProvider(options.provider) ? !config.goEnabled : !config.consoleEnabled) throw new Error(`OpenCode route "${options.provider}" is disabled`);
3549
3588
  const runStartedAt = Date.now();
3550
3589
  const model = effectiveModel(options.model, config);
3551
3590
  const sessionId = openCodeSessionId(options);
@@ -5035,6 +5074,10 @@ function publicConfig(config) {
5035
5074
  requestImagePixelBudget: config.requestImagePixelBudget,
5036
5075
  requestImageMaxBytes: config.requestImageMaxBytes,
5037
5076
  proxyUrl: config.proxyUrl,
5077
+ proxyUsername: config.proxyUsername,
5078
+ proxyPasswordRef: config.proxyPasswordRef,
5079
+ goEnabled: config.goEnabled,
5080
+ consoleEnabled: config.consoleEnabled,
5038
5081
  catalogRefreshMinutes: config.catalogRefreshMinutes,
5039
5082
  commandLocale: config.commandLocale,
5040
5083
  visibleModels: config.visibleModels,
@@ -5238,6 +5281,10 @@ function configOps(patch) {
5238
5281
  if ("requestImagePixelBudget" in patch) set("requestImagePixelBudget", patch.requestImagePixelBudget);
5239
5282
  if ("requestImageMaxBytes" in patch) set("requestImageMaxBytes", patch.requestImageMaxBytes);
5240
5283
  if ("proxyUrl" in patch) set("proxyUrl", normalizeProxyUrl(typeof patch.proxyUrl === "string" ? patch.proxyUrl : ""));
5284
+ if ("proxyUsername" in patch) set("proxyUsername", normalizeProxyUsername(typeof patch.proxyUsername === "string" ? patch.proxyUsername : ""));
5285
+ if ("proxyPasswordRef" in patch) set("proxyPasswordRef", patch.proxyPasswordRef);
5286
+ if ("goEnabled" in patch) set("goEnabled", patch.goEnabled === true);
5287
+ if ("consoleEnabled" in patch) set("consoleEnabled", patch.consoleEnabled === true);
5241
5288
  if ("catalogRefreshMinutes" in patch) set("catalogRefreshMinutes", patch.catalogRefreshMinutes);
5242
5289
  if ("commandLocale" in patch) set("commandLocale", patch.commandLocale === "zh" ? "zh" : "en");
5243
5290
  if ("visibleModels" in patch) set("visibleModels", cleanStringArray(patch.visibleModels));
@@ -5277,6 +5324,12 @@ async function buildState(ctx, deps) {
5277
5324
  ...info.source === void 0 ? {} : { source: info.source }
5278
5325
  };
5279
5326
  } catch {}
5327
+ let proxyConfigured = false;
5328
+ if (config.proxyUsername !== "" && credentials !== void 0) try {
5329
+ proxyConfigured = (await credentials.describe(credentialRef(config.proxyPasswordRef))).configured;
5330
+ } catch {
5331
+ proxyConfigured = false;
5332
+ }
5280
5333
  const resolved = await deps.pool.resolveAll(GO_PROVIDER).catch(() => []);
5281
5334
  if (!keyInfo.configured) keyInfo = {
5282
5335
  ...keyInfo,
@@ -5334,9 +5387,10 @@ async function buildState(ctx, deps) {
5334
5387
  managed: slot.ref !== "" && managedRefs.has(slot.ref)
5335
5388
  });
5336
5389
  }
5390
+ const listingProvider = config.goEnabled ? GO_PROVIDER : CONSOLE_PROVIDER;
5337
5391
  let models = [];
5338
5392
  try {
5339
- models = (await deps.adapter.listModels(GO_PROVIDER, { unfiltered: true })).map((model) => {
5393
+ models = (await deps.adapter.listModels(listingProvider, { unfiltered: true })).map((model) => {
5340
5394
  const meta = effectiveModel(model.id, config);
5341
5395
  return {
5342
5396
  id: model.id,
@@ -5351,8 +5405,8 @@ async function buildState(ctx, deps) {
5351
5405
  input: meta.input,
5352
5406
  reasoning: meta.reasoning,
5353
5407
  visible: modelVisible(model.id, config),
5354
- ...catalogReleaseDate("opencode-go-direct", model.id, config) === void 0 ? {} : { releaseDate: catalogReleaseDate(GO_PROVIDER, model.id, config) },
5355
- isNew: isNewRelease(catalogReleaseDate(GO_PROVIDER, model.id, config))
5408
+ ...catalogReleaseDate(listingProvider, model.id, config) === void 0 ? {} : { releaseDate: catalogReleaseDate(listingProvider, model.id, config) },
5409
+ isNew: isNewRelease(catalogReleaseDate(listingProvider, model.id, config))
5356
5410
  };
5357
5411
  });
5358
5412
  } catch {
@@ -5363,9 +5417,14 @@ async function buildState(ctx, deps) {
5363
5417
  return {
5364
5418
  config: publicConfig(config),
5365
5419
  key: keyInfo,
5420
+ proxy: {
5421
+ configured: proxyConfigured,
5422
+ username: config.proxyUsername,
5423
+ passwordRef: config.proxyPasswordRef
5424
+ },
5366
5425
  accounts,
5367
5426
  models,
5368
- catalog: catalogStatus(GO_PROVIDER, config, models.length),
5427
+ catalog: catalogStatus(listingProvider, config, models.length),
5369
5428
  usage: usage ?? null,
5370
5429
  service: {
5371
5430
  ...usage?.checkedAt === void 0 ? {} : { usageCheckedAt: usage.checkedAt },
@@ -5501,6 +5560,7 @@ function applyRoutes(ctx, web, deps) {
5501
5560
  await settings.mutate(NS, ops, revision);
5502
5561
  deps.usage.clear();
5503
5562
  deps.refreshTui?.();
5563
+ deps.syncRoutes?.();
5504
5564
  json(res, 200, { ok: true });
5505
5565
  } catch (error) {
5506
5566
  const message = error instanceof Error ? error.message : "config write failed";
@@ -5552,6 +5612,50 @@ function applyRoutes(ctx, web, deps) {
5552
5612
  json(res, 500, { error: error instanceof Error ? error.message : "key clear failed" });
5553
5613
  }
5554
5614
  });
5615
+ route("/dsh-opencode/proxy-password", async (req, res) => {
5616
+ if (!requirePost(req, res)) return;
5617
+ const credentials = getCredentials(ctx);
5618
+ if (credentials === void 0) {
5619
+ json(res, 503, { error: "credentials service unavailable" });
5620
+ return;
5621
+ }
5622
+ try {
5623
+ const body = await readJson(req);
5624
+ const password = typeof body.password === "string" ? body.password : "";
5625
+ if (password === "") {
5626
+ json(res, 400, { error: "proxy password is required" });
5627
+ return;
5628
+ }
5629
+ const refName = deps.config().proxyPasswordRef;
5630
+ await credentials.set(credentialRef(refName), password);
5631
+ await addManagedCredentialRef(refName, deps.managedPath);
5632
+ json(res, 200, {
5633
+ ok: true,
5634
+ ref: refName
5635
+ });
5636
+ } catch (error) {
5637
+ json(res, 500, { error: error instanceof Error ? error.message : "proxy password write failed" });
5638
+ }
5639
+ });
5640
+ route("/dsh-opencode/proxy-password/clear", async (req, res) => {
5641
+ if (!requirePost(req, res)) return;
5642
+ const credentials = getCredentials(ctx);
5643
+ if (credentials === void 0) {
5644
+ json(res, 503, { error: "credentials service unavailable" });
5645
+ return;
5646
+ }
5647
+ try {
5648
+ const refName = deps.config().proxyPasswordRef;
5649
+ await credentials.unset(credentialRef(refName));
5650
+ await removeManagedCredentialRef(refName, deps.managedPath);
5651
+ json(res, 200, {
5652
+ ok: true,
5653
+ ref: refName
5654
+ });
5655
+ } catch (error) {
5656
+ json(res, 500, { error: error instanceof Error ? error.message : "proxy password clear failed" });
5657
+ }
5658
+ });
5555
5659
  route("/dsh-opencode/auth-import", async (req, res) => {
5556
5660
  if (!requirePost(req, res)) return;
5557
5661
  const credentials = getCredentials(ctx);
@@ -6114,7 +6218,19 @@ const inject = ["llm"];
6114
6218
  function apply(ctx, config) {
6115
6219
  const current = () => resolveConfig(unwrapVolatileConfig(config));
6116
6220
  const keys = createKeyPool(ctx, current);
6117
- const proxy = createProxyFetch(() => current().proxyUrl);
6221
+ const proxy = createProxyFetch(async () => {
6222
+ const config = current();
6223
+ let password = "";
6224
+ if (config.proxyUsername !== "") {
6225
+ const credentials = ctx.get("credentials");
6226
+ if (credentials?.resolve !== void 0) try {
6227
+ password = (await credentials.resolve(credentialRef(config.proxyPasswordRef)))?.value ?? "";
6228
+ } catch {
6229
+ password = "";
6230
+ }
6231
+ }
6232
+ return buildProxyUrl(config.proxyUrl, config.proxyUsername, password);
6233
+ });
6118
6234
  const usage = createUsageReader(current, (provider) => keys.resolve(provider), proxy.fetch);
6119
6235
  const adapter = new OpenCodeAdapter({
6120
6236
  config: current,
@@ -6127,19 +6243,30 @@ function apply(ctx, config) {
6127
6243
  ctx.effect(() => () => {
6128
6244
  proxy.dispose();
6129
6245
  });
6130
- ctx.llm.registerConfigurableProviders([{
6246
+ const providersFor = () => {
6247
+ const config = current();
6248
+ return [...config.consoleEnabled ? [CONSOLE_PROVIDER] : [], ...config.goEnabled ? [GO_PROVIDER] : []];
6249
+ };
6250
+ const entriesFor = (providers) => [...providers.includes("opencode-direct") ? [{
6131
6251
  provider: CONSOLE_PROVIDER,
6132
6252
  displayName: "OpenCode (plugin)",
6133
6253
  settingsNs: NS,
6134
6254
  settingsPath: []
6135
- }, {
6255
+ }] : [], ...providers.includes("opencode-go-direct") ? [{
6136
6256
  provider: GO_PROVIDER,
6137
6257
  displayName: "OpenCode Go (plugin)",
6138
6258
  settingsNs: NS,
6139
6259
  settingsPath: []
6140
- }]);
6141
- ctx.llm.registerAdapter([CONSOLE_PROVIDER, GO_PROVIDER], adapter);
6142
- ctx.logger("llm-opencode").info(`registered providers: ${CONSOLE_PROVIDER}, ${GO_PROVIDER}`);
6260
+ }] : []];
6261
+ const directory = ctx.llm.registerConfigurableProviders(entriesFor(providersFor()));
6262
+ const registration = ctx.llm.registerAdapter(providersFor(), adapter);
6263
+ const syncRoutes = () => {
6264
+ const providers = providersFor();
6265
+ registration.replace(providers);
6266
+ directory.replace(entriesFor(providers));
6267
+ ctx.logger("llm-opencode").info(`enabled providers: ${providers.join(", ")}`);
6268
+ };
6269
+ ctx.logger("llm-opencode").info(`registered providers: ${providersFor().join(", ")}`);
6143
6270
  ctx.inject(["settings"], (settingsCtx) => {
6144
6271
  settingsCtx.settings.configure({ auto: false }, ctx.fiber);
6145
6272
  });
@@ -6183,7 +6310,8 @@ function apply(ctx, config) {
6183
6310
  pool: keys,
6184
6311
  usage,
6185
6312
  heatmap: readRequestHeatmap,
6186
- refreshTui: () => refreshTuiSettings?.()
6313
+ refreshTui: () => refreshTuiSettings?.(),
6314
+ syncRoutes
6187
6315
  });
6188
6316
  webCtx.effect(() => registerPluginUpdater(webCtx, {
6189
6317
  endpoint: "/dsh-opencode/update",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-opencode-provider",
3
- "version": "0.5.31",
3
+ "version": "0.5.32",
4
4
  "description": "OpenCode / OpenCode Go LLM provider plugin for DeepSeek Harness (dsh).",
5
5
  "license": "MIT",
6
6
  "type": "module",