@goodandready/dsh-opencode-provider 0.5.5 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.7
4
+
5
+ - **Provider-id conflict fix**: the plugin now registers `opencode-direct` and `opencode-go-direct` instead of `opencode`/`opencode-go`. The built-in `llm-pi-ai` catalog owns those ids; reusing them triggered `DUPLICATE_DIRECTORY`, dropped the built-in catalog, hid other providers and disabled the add button. Existing sessions recorded under the old ids stay on the built-in routes; select **OpenCode (plugin)** or **OpenCode Go (plugin)** for this plugin's features.
6
+ - 89 tests.
7
+
8
+ ## 0.5.6
9
+
10
+ - **Persistent sticky routing**: the last successful account/model per conversation survives adapter and Desktop restarts. `~/.dsh/storages/dsh-opencode/sticky.json` stores hashed session ids and key fingerprints only (no keys or prompt text), keeps 500 entries for 14 days.
11
+ - 88 tests.
12
+
3
13
  ## 0.5.5
4
14
 
5
15
  - **Per-account request stats**: every upstream attempt now records its outcome, latency and TTFT in the local request log; the heatmap adds a per-account table with requests, errors and average latency/TTFT. No prompt or response content is stored.
package/README.md CHANGED
@@ -30,11 +30,18 @@ It registers two native LLM routes:
30
30
 
31
31
  | Route | Product | Base |
32
32
  |---|---|---|
33
- | `opencode` | OpenCode Console / Zen (pay-as-you-go) | `https://opencode.ai/inference` |
34
- | `opencode-go` | OpenCode Go / Go Plus subscription | `https://opencode.ai/inference/go` |
33
+ | `opencode-direct` | OpenCode Console / Zen (pay-as-you-go) | `https://opencode.ai/inference` |
34
+ | `opencode-go-direct` | OpenCode Go / Go Plus subscription | `https://opencode.ai/inference/go` |
35
35
 
36
36
  Requests resolve the key per call, so a changed credential reaches the next request without a restart.
37
37
 
38
+ The ids use the `-direct` suffix because the built-in `llm-pi-ai` catalog already owns
39
+ `opencode` and `opencode-go`. Reusing those ids makes the core registry reject the
40
+ duplicate (`DUPLICATE_DIRECTORY`) and drop the built-in catalog, which hides the other
41
+ providers and disables the add button. Existing sessions recorded under the old ids stay on
42
+ the built-in routes; select **OpenCode (plugin)** or **OpenCode Go (plugin)** for this
43
+ plugin's accounts, quotas, fallback and telemetry.
44
+
38
45
  ## Architecture
39
46
 
40
47
  ```mermaid
@@ -58,14 +65,14 @@ graph LR
58
65
  end
59
66
 
60
67
  subgraph OpenCode["OpenCode Gateway"]
61
- Console["opencode (Console / Zen)"]
62
- Go["opencode-go (Go / Go Plus)"]
68
+ Console["opencode-direct (Console / Zen)"]
69
+ Go["opencode-go-direct (Go / Go Plus)"]
63
70
  end
64
71
 
65
72
  Agent --> Router
66
73
  Creds -.-> Pool
67
- Router -->|opencode| Pool
68
- Router -->|opencode-go| Pool
74
+ Router -->|opencode-direct| Pool
75
+ Router -->|opencode-go-direct| Pool
69
76
  Pool --> Heatmap
70
77
  Pool --> Transports
71
78
  T1 & T2 & T3 & T4 --> Console & Go
@@ -95,7 +102,7 @@ graph LR
95
102
  - **`/opencode` command** for status, usage, accounts, models, rules, and `test [model]`.
96
103
  - **TUI settings section** for the terminal profile (`/settings → OpenCode`) with the key, endpoints, allowlist, active account, fallback model, sticky routing and timeouts.
97
104
  - **Quota fallback**: choose a free model in Settings → OpenCode; a Go quota rejection retries once through Console/Zen.
98
- - **Sticky account**: the last successful account is preferred for the same conversation; explicit model rules and pinned accounts win.
105
+ - **Sticky account**: the last successful account is preferred for the same conversation and survives restarts via a local `sticky.json` (hashed session ids and key fingerprints only); explicit model rules and pinned accounts win.
99
106
  - **Routing trace** in the quota panel and per-account heatmap filter.
100
107
  - **One-click plugin update** in Settings → OpenCode: current/latest version, an update badge, and an **Update now** button that installs the exact npm version through the DSH CLI and asks for a restart. The host endpoint is `GET/POST /dsh-opencode/update`.
101
108
  - **Plan-aware Go picker**: hides models outside the Go table unless turned off; descriptions carry `Go`, `Console` or `FREE`.
@@ -123,15 +130,15 @@ npm pack
123
130
  Install the **packed tarball** into a profile:
124
131
 
125
132
  ```bash
126
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.5.tgz
133
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.7.tgz
127
134
  # DSH Desktop:
128
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.5.tgz
135
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.7.tgz
129
136
 
130
137
  # Or install the published package:
131
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.5
138
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.7
132
139
  ```
133
140
 
134
- Restart dsh (or DSH Desktop), then pick `OpenCode` or `OpenCode Go` in the model picker.
141
+ Restart dsh (or DSH Desktop), then pick **OpenCode (plugin)** or **OpenCode Go (plugin)** in the model picker.
135
142
 
136
143
  > **Use the tarball, not the directory.** On dsh 0.2.1-rc.2 the Desktop CLI aliases a local
137
144
  > directory passed to `dsh plugin add` as `@deepseek-ai/dsh-desktop-runtime` and overwrites
@@ -205,7 +212,7 @@ Cooldowns survive restarts: deadlines and reasons are stored in `~/.dsh/storages
205
212
 
206
213
  ### Model → account rules
207
214
 
208
- Pin a model to a preferred account with a glob. The first matching rule wins; `*` and `?` are supported and matching is case-insensitive. The named account is tried first, and the other accounts remain fallbacks if it is cooling, missing, or rejected — a rule is a preference, not a hard filter. An unknown account label is ignored, and rules apply to both `opencode` and `opencode-go`.
215
+ Pin a model to a preferred account with a glob. The first matching rule wins; `*` and `?` are supported and matching is case-insensitive. The named account is tried first, and the other accounts remain fallbacks if it is cooling, missing, or rejected — a rule is a preference, not a hard filter. An unknown account label is ignored, and rules apply to both `opencode-direct` and `opencode-go-direct`.
209
216
 
210
217
  Configure them in **Settings → OpenCode → Model → account rules**, or in YAML:
211
218
 
@@ -298,7 +305,7 @@ GitHub Actions runs `npm run check` and `npm pack` on push/PR (`.github/workflow
298
305
  - Automatic update requires the DSH CLI entry in the runtime. When it is not exposed, the update card shows the current/latest status and directs the user to the DSH plugin manager.
299
306
  - Subscription request counts are OpenCode's published estimates, not enforced quotas. They can change when OpenCode updates the Go pricing table; the gateway is the final authority.
300
307
  - Usage-by-model aggregates the local projection cache and attributes a whole session to its last-used model; a session that switched models is therefore approximate. It never reads prompt text or titles.
301
- - `opencode` (Console) billing shows no per-model allowance; the Go route is the primary target.
308
+ - `opencode-direct` (Console) billing shows no per-model allowance; the Go route is the primary target.
302
309
  - The anonymous free lane (`Bearer public` + CLI impersonation) is not implemented.
303
310
  - The UI requires a web profile (the Host needs `ctx.webServer`); the TUI keeps the `/opencode` command and the model routes.
304
311
  - Write routes are loopback-only; a web profile exposed to a LAN can read state but cannot save configuration or keys until the host auth token is integrated.
package/README.ru.md CHANGED
@@ -30,11 +30,18 @@
30
30
 
31
31
  | Маршрут | Продукт / Тариф | Базовый эндпоинт | Назначение |
32
32
  |---|---|---|---|
33
- | `opencode` | OpenCode Console / Zen | `https://opencode.ai/inference` | Поминутная тарификация (pay-as-you-go) |
34
- | `opencode-go` | OpenCode Go / Go Plus | `https://opencode.ai/inference/go` | Фиксированная подписка с лимитами 5h / недели / месяца |
33
+ | `opencode-direct` | OpenCode Console / Zen | `https://opencode.ai/inference` | Поминутная тарификация (pay-as-you-go) |
34
+ | `opencode-go-direct` | OpenCode Go / Go Plus | `https://opencode.ai/inference/go` | Фиксированная подписка с лимитами 5h / недели / месяца |
35
35
 
36
36
  Ключи разрешаются динамически на каждый вызов: смена токена в настройках или в файле окружения вступает в силу мгновенно без перезапуска сервера.
37
37
 
38
+ Суффикс `-direct` нужен потому, что встроенный каталог `llm-pi-ai` уже объявляет
39
+ `opencode` и `opencode-go`. Повтор этих id даёт `DUPLICATE_DIRECTORY`, из-за чего
40
+ ядро отбрасывает встроенный каталог, исчезают остальные провайдеры и отключается
41
+ кнопка добавления. Старые сессии с прежними id остаются на встроенных маршрутах;
42
+ для возможностей плагина выбирайте **OpenCode (plugin)** или
43
+ **OpenCode Go (plugin)**.
44
+
38
45
  ## Архитектура
39
46
 
40
47
  ```mermaid
@@ -58,14 +65,14 @@ graph LR
58
65
  end
59
66
 
60
67
  subgraph OpenCode["Шлюз OpenCode Gateway"]
61
- Console["opencode (Console / Zen)"]
62
- Go["opencode-go (Go / Go Plus)"]
68
+ Console["opencode-direct (Console / Zen)"]
69
+ Go["opencode-go-direct (Go / Go Plus)"]
63
70
  end
64
71
 
65
72
  Agent --> Router
66
73
  Creds -.-> Pool
67
- Router -->|opencode| Pool
68
- Router -->|opencode-go| Pool
74
+ Router -->|opencode-direct| Pool
75
+ Router -->|opencode-go-direct| Pool
69
76
  Pool --> Heatmap
70
77
  Pool --> Transports
71
78
  T1 & T2 & T3 & T4 --> Console & Go
@@ -94,7 +101,7 @@ graph LR
94
101
  - **Онбординг аккаунтов**: **Import all from auth.json** переносит все ключи OpenCode в отдельные credential ref и добавляет аккаунты; **Check all accounts** обновляет квоты и health по каждому аккаунту.
95
102
  - **Правила «модель ➔ аккаунт»**: сопоставление по glob-паттернам закрепляет предпочтительный аккаунт за моделью; остальные аккаунты остаются в резерве.
96
103
  - **Квотный fallback**: выберите бесплатную модель в настройках; при ошибке квоты Go тот же запрос один раз повторяется через Console/Zen. Если fallback тоже не сработал, возвращается исходная ошибка квоты.
97
- - **Липкий аккаунт**: последний успешный аккаунт предпочитается в том же диалоге; явные правила модели, закреплённый аккаунт и квотный fallback имеют приоритет.
104
+ - **Липкий аккаунт**: последний успешный аккаунт предпочитается в том же диалоге и сохраняется между перезапусками в локальном `sticky.json` (только хешированные session id и fingerprint ключей); явные правила модели, закреплённый аккаунт и квотный fallback имеют приоритет.
98
105
  - **Трассировка маршрутизации**: панель квот показывает последние решения (`rule`, `sticky`, `rotation`, `quota-fallback`) без промптов и ключей.
99
106
  - **Статистика по аккаунтам**: в request log пишутся `ok`, `latencyMs`, `ttftMs` и `error`; heatmap показывает таблицу запросов, ошибок и средних latency/TTFT по каждому аккаунту.
100
107
  - **Почасовой Heatmap запросов**: сетка «день недели × час» в локальном времени плюс суточная полоса агрегации. Фильтры переключают отображение между всеми запросами, конкретной моделью и аккаунтом. Лог `~/.dsh/storages/dsh-opencode/requests.jsonl` хранит только метаданные `{at, provider, model, purpose, account, reason, ok, latencyMs, ttftMs, error}` (промпты и ответы не сохраняются).
@@ -122,15 +129,15 @@ npm pack
122
129
  Установка из **упакованного tarball**:
123
130
 
124
131
  ```bash
125
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.5.tgz
132
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.7.tgz
126
133
  # DSH Desktop:
127
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.5.tgz
134
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.7.tgz
128
135
 
129
136
  # Либо установка опубликованного пакета из npm:
130
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.5
137
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.7
131
138
  ```
132
139
 
133
- После установки перезапустите dsh (или DSH Desktop) и выберите **OpenCode** или **OpenCode Go** в списке моделей.
140
+ После установки перезапустите dsh (или DSH Desktop) и выберите **OpenCode (plugin)** или **OpenCode Go (plugin)** в списке моделей.
134
141
 
135
142
  ## Конфигурация
136
143
 
package/README.zh.md CHANGED
@@ -30,11 +30,16 @@
30
30
 
31
31
  | 路由 | 产品 / 方案 | 基础接口 | 特性说明 |
32
32
  |---|---|---|---|
33
- | `opencode` | OpenCode Console / Zen | `https://opencode.ai/inference` | 按量计费(pay-as-you-go) |
34
- | `opencode-go` | OpenCode Go / Go Plus | `https://opencode.ai/inference/go` | 订阅专属,含 5小时/每周/每月配额估算 |
33
+ | `opencode-direct` | OpenCode Console / Zen | `https://opencode.ai/inference` | 按量计费(pay-as-you-go) |
34
+ | `opencode-go-direct` | OpenCode Go / Go Plus | `https://opencode.ai/inference/go` | 订阅专属,含 5小时/每周/每月配额估算 |
35
35
 
36
36
  密钥在每次调用时动态解析:在设置中或环境文件中更换凭据无需重启服务器即可立即生效。
37
37
 
38
+ `-direct` 后缀用于避免与内置 `llm-pi-ai` 已声明的 `opencode` / `opencode-go` 冲突。
39
+ 重复 id 会触发 `DUPLICATE_DIRECTORY`,导致核心丢弃内置目录、其他提供商消失并禁用添加按钮。
40
+ 旧会话继续使用内置路由;要使用本插件的账户、配额、回退和遥测,请选择
41
+ **OpenCode (plugin)** 或 **OpenCode Go (plugin)**。
42
+
38
43
  ## 架构示意
39
44
 
40
45
  ```mermaid
@@ -58,14 +63,14 @@ graph LR
58
63
  end
59
64
 
60
65
  subgraph OpenCode["OpenCode 网关"]
61
- Console["opencode (Console / Zen)"]
62
- Go["opencode-go (Go / Go Plus)"]
66
+ Console["opencode-direct (Console / Zen)"]
67
+ Go["opencode-go-direct (Go / Go Plus)"]
63
68
  end
64
69
 
65
70
  Agent --> Router
66
71
  Creds -.-> Pool
67
- Router -->|opencode| Pool
68
- Router -->|opencode-go| Pool
72
+ Router -->|opencode-direct| Pool
73
+ Router -->|opencode-go-direct| Pool
69
74
  Pool --> Heatmap
70
75
  Pool --> Transports
71
76
  T1 & T2 & T3 & T4 --> Console & Go
@@ -94,7 +99,7 @@ graph LR
94
99
  - **每账户预计可用时长**:按各窗口的平均消耗速度估算耗尽时间,并标记在重置前最先耗尽的窗口;API 仅提供当前百分比与重置时间,因此为近似值。
95
100
  - **模型 ➔ 账户路由规则**:支持 Glob 通配符(如 `*free*`、`deepseek-*`)将模型优先绑定至特定账户,其他账户保留为备用。
96
101
  - **配额回退**:在设置中选择免费模型;Go 返回配额错误时,同一请求通过 Console/Zen 重试一次。若回退也失败,返回原始配额错误。
97
- - **会话粘性账户**:同一会话优先使用上次成功的账户;显式模型规则、固定账户和配额回退优先。
102
+ - **会话粘性账户**:同一会话优先使用上次成功的账户,并通过本地 `sticky.json` 跨重启保留(仅哈希后的 session id 和密钥指纹);显式模型规则、固定账户和配额回退优先。
98
103
  - **路由追踪**:配额面板显示最近的账户/模型决策(`rule`、`sticky`、`rotation`、`quota-fallback`),不记录提示词或密钥。
99
104
  - **按账户请求统计**:请求日志记录 `ok`、`latencyMs`、`ttftMs` 和 `error`;热力图表格显示每个账户的请求数、错误数及平均延迟/TTFT。
100
105
  - **本地时间请求热力图**:星期×小时交互式热力图,排除系统心跳与重复记录,可按模型或账户过滤,仅保存 `{at, provider, model, purpose, account, reason, ok, latencyMs, ttftMs, error}` 元数据,不保存任何提示词与回复。
@@ -119,15 +124,15 @@ npm pack
119
124
  通过**打包文件 (.tgz)**安装:
120
125
 
121
126
  ```bash
122
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.5.tgz
127
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.7.tgz
123
128
  # DSH 桌面端:
124
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.5.tgz
129
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.7.tgz
125
130
 
126
131
  # 或从 npm 安装已发布版本:
127
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.5
132
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.7
128
133
  ```
129
134
 
130
- 安装完成后重启 dsh 或 DSH 桌面端,在模型选择器中选择 **OpenCode** 或 **OpenCode Go** 即可。
135
+ 安装完成后重启 dsh 或 DSH 桌面端,在模型选择器中选择 **OpenCode (plugin)** 或 **OpenCode Go (plugin)** 即可。
131
136
 
132
137
  ## 配置示例
133
138
 
package/lib/client.js CHANGED
@@ -379,6 +379,9 @@ window.__ModuleLoader__.load({
379
379
  }
380
380
  };
381
381
  //#endregion
382
+ //#region src/provider-ids.ts
383
+ const GO_PROVIDER = "opencode-go-direct";
384
+ //#endregion
382
385
  //#region src/client/api.ts
383
386
  async function call(path, init) {
384
387
  const response = await fetch(path, {
@@ -452,7 +455,7 @@ window.__ModuleLoader__.load({
452
455
  function fetchAnalytics(days = 30) {
453
456
  return call(`/dsh-opencode/analytics?days=${encodeURIComponent(String(days))}`);
454
457
  }
455
- function testConnection(model, provider = "opencode-go") {
458
+ function testConnection(model, provider = GO_PROVIDER) {
456
459
  return call("/dsh-opencode/test", {
457
460
  method: "POST",
458
461
  body: JSON.stringify({
@@ -2205,7 +2208,7 @@ window.__ModuleLoader__.load({
2205
2208
  testConnection(testModel).then(setTestResult).catch((caught) => setTestResult({
2206
2209
  ok: false,
2207
2210
  model: testModel,
2208
- provider: "opencode-go",
2211
+ provider: GO_PROVIDER,
2209
2212
  latencyMs: 0,
2210
2213
  error: caught instanceof Error ? caught.message : t("failed")
2211
2214
  })).finally(() => setTesting(false));
package/lib/index.d.ts CHANGED
@@ -176,6 +176,8 @@ interface OpenCodeAdapterDeps {
176
176
  readonly recordRequest?: (provider: string, model: string, purpose?: string, account?: string, reason?: string, outcome?: RequestOutcome) => void;
177
177
  /** Test seam; defaults to ~/.dsh/storages/dsh-opencode/cooldowns.json. */
178
178
  readonly cooldownPath?: string;
179
+ /** Test seam; defaults to ~/.dsh/storages/dsh-opencode/sticky.json. */
180
+ readonly stickyPath?: string;
179
181
  /** Receives a one-time warning when the provider tool limit drops tools. */
180
182
  readonly warn?: (message: string) => void;
181
183
  }
@@ -183,7 +185,9 @@ declare class OpenCodeAdapter extends LlmAdapter {
183
185
  private readonly deps;
184
186
  private readonly fetchImpl;
185
187
  private readonly cooldownPath;
188
+ private readonly stickyPath;
186
189
  private cooldownWrites;
190
+ private stickyWrites;
187
191
  /** key fingerprint → cooldown deadline and the rejection that caused it. */
188
192
  private readonly cooldowns;
189
193
  private readonly sticky;
@@ -201,6 +205,11 @@ declare class OpenCodeAdapter extends LlmAdapter {
201
205
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
202
206
  private run;
203
207
  private rememberSticky;
208
+ /** Await pending sticky persistence writes (tests and shutdown). */
209
+ flushSticky(): Promise<void>;
210
+ private loadSticky;
211
+ private pruneSticky;
212
+ private persistSticky;
204
213
  private pushTrace;
205
214
  private parse;
206
215
  private send;
package/lib/index.js CHANGED
@@ -17,7 +17,7 @@ const DEFAULT_GO_API_BASE = "https://opencode.ai/inference/go";
17
17
  const DEFAULT_GO_ZEN_BASE = "https://opencode.ai/zen/go/v1";
18
18
  const DEFAULT_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
19
19
  const DEFAULT_AUTH_ENTRY = "opencode-go";
20
- const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.5";
20
+ const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.7";
21
21
  const DEFAULT_CONTEXT_WINDOW = 262144;
22
22
  const DEFAULT_MAX_TOKENS = 32768;
23
23
  const DEFAULT_GENERATE_MAX_TOKENS = 32768;
@@ -791,9 +791,24 @@ const GENERATED_MODELS = {
791
791
  }
792
792
  };
793
793
  //#endregion
794
+ //#region src/provider-ids.ts
795
+ /**
796
+ * Provider ids owned by this plugin. The built-in llm-pi-ai catalog already
797
+ * declares `opencode` and `opencode-go`; the core registry rejects duplicate
798
+ * configurable provider ids and would drop the whole catalog when they clash.
799
+ */
800
+ const CONSOLE_PROVIDER = "opencode-direct";
801
+ const GO_PROVIDER = "opencode-go-direct";
802
+ const PLUGIN_AND_LEGACY_PROVIDERS = /* @__PURE__ */ new Set([
803
+ CONSOLE_PROVIDER,
804
+ GO_PROVIDER,
805
+ "opencode",
806
+ "opencode-go"
807
+ ]);
808
+ //#endregion
794
809
  //#region src/routing.ts
795
810
  function isGoProvider(provider) {
796
- return provider === "opencode-go";
811
+ return provider === "opencode-go-direct" || provider === "opencode-go";
797
812
  }
798
813
  function rootFor(provider, config) {
799
814
  return isGoProvider(provider) ? config.goApiBase : config.apiBase;
@@ -1059,7 +1074,7 @@ async function discoverModelIds(provider, config, apiKey, fetchImpl = fetch) {
1059
1074
  return Object.keys(GENERATED_MODELS);
1060
1075
  }
1061
1076
  function sortForPicker(ids, config, unfiltered = false, provider) {
1062
- return ids.filter((id) => unfiltered || modelVisible(id, config) && (provider !== "opencode-go" || modelInGoPlan(id, config))).sort((left, right) => {
1077
+ return ids.filter((id) => unfiltered || modelVisible(id, config) && (!isGoProvider(provider) || modelInGoPlan(id, config))).sort((left, right) => {
1063
1078
  const a = effectiveModel(left, config);
1064
1079
  const b = effectiveModel(right, config);
1065
1080
  if (a.free !== b.free) return a.free ? -1 : 1;
@@ -1072,7 +1087,7 @@ function describeModel(meta, provider) {
1072
1087
  else if (meta.cost.input > 0 || meta.cost.output > 0) parts.push(`$${meta.cost.input}/$${meta.cost.output} per 1M`);
1073
1088
  if (meta.input.includes("image")) parts.push("Image");
1074
1089
  if (meta.reasoning.length > 0) parts.push(`reasoning: ${meta.reasoning.join("/")}`);
1075
- if (provider === "opencode-go") {
1090
+ if (isGoProvider(provider)) {
1076
1091
  const limits = limitsFor(meta.id);
1077
1092
  if (!meta.free) parts.unshift(limits === void 0 ? "Console" : "Go");
1078
1093
  if (limits !== void 0) parts.push(describeLimitsShort(limits));
@@ -3076,11 +3091,17 @@ function defaultCooldownPath() {
3076
3091
  const home = process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
3077
3092
  return join(home, "storages", "dsh-opencode", "cooldowns.json");
3078
3093
  }
3094
+ function defaultStickyPath() {
3095
+ const home = process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
3096
+ return join(home, "storages", "dsh-opencode", "sticky.json");
3097
+ }
3079
3098
  var OpenCodeAdapter = class extends LlmAdapter {
3080
3099
  deps;
3081
3100
  fetchImpl;
3082
3101
  cooldownPath;
3102
+ stickyPath;
3083
3103
  cooldownWrites = Promise.resolve();
3104
+ stickyWrites = Promise.resolve();
3084
3105
  /** key fingerprint → cooldown deadline and the rejection that caused it. */
3085
3106
  cooldowns = /* @__PURE__ */ new Map();
3086
3107
  sticky = /* @__PURE__ */ new Map();
@@ -3091,12 +3112,14 @@ var OpenCodeAdapter = class extends LlmAdapter {
3091
3112
  this.deps = deps;
3092
3113
  this.fetchImpl = deps.fetchImpl ?? fetch;
3093
3114
  this.cooldownPath = deps.cooldownPath ?? defaultCooldownPath();
3115
+ this.stickyPath = deps.stickyPath ?? defaultStickyPath();
3094
3116
  this.loadCooldowns();
3117
+ this.loadSticky();
3095
3118
  }
3096
3119
  providerInfo(provider) {
3097
3120
  return {
3098
3121
  id: provider,
3099
- name: isGoProvider(provider) ? "OpenCode Go" : "OpenCode"
3122
+ name: isGoProvider(provider) ? "OpenCode Go (plugin)" : "OpenCode (plugin)"
3100
3123
  };
3101
3124
  }
3102
3125
  providerRetryPolicy(_provider) {
@@ -3268,7 +3291,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3268
3291
  const transient = this.isTransient(classified);
3269
3292
  if (transient && !responseFailure && !fallback) this.markCooldown(entry.key, 0);
3270
3293
  if (!(index < keys.length - 1 && transient)) {
3271
- if (!fallback && config.quotaFallbackModel !== "" && options.provider === "opencode-go" && classified.code === "QUOTA") break;
3294
+ if (!fallback && config.quotaFallbackModel !== "" && isGoProvider(options.provider) && classified.code === "QUOTA") break;
3272
3295
  throw classified;
3273
3296
  }
3274
3297
  }
@@ -3290,10 +3313,10 @@ var OpenCodeAdapter = class extends LlmAdapter {
3290
3313
  ok: false,
3291
3314
  error: finalError.code
3292
3315
  });
3293
- if (!fallback && config.quotaFallbackModel !== "" && options.provider === "opencode-go" && (lastError === void 0 || lastError.code === "QUOTA")) try {
3316
+ if (!fallback && config.quotaFallbackModel !== "" && isGoProvider(options.provider) && (lastError === void 0 || lastError.code === "QUOTA")) try {
3294
3317
  yield* this.run({
3295
3318
  ...options,
3296
- provider: "opencode",
3319
+ provider: CONSOLE_PROVIDER,
3297
3320
  model: config.quotaFallbackModel
3298
3321
  }, true);
3299
3322
  return;
@@ -3315,6 +3338,55 @@ var OpenCodeAdapter = class extends LlmAdapter {
3315
3338
  model: options.model,
3316
3339
  at: Date.now()
3317
3340
  });
3341
+ this.persistSticky();
3342
+ }
3343
+ /** Await pending sticky persistence writes (tests and shutdown). */
3344
+ async flushSticky() {
3345
+ await this.stickyWrites;
3346
+ }
3347
+ loadSticky() {
3348
+ try {
3349
+ const entries = JSON.parse(readFileSync(this.stickyPath, "utf8")).sticky;
3350
+ if (typeof entries !== "object" || entries === null || Array.isArray(entries)) return;
3351
+ for (const [sessionId, value] of Object.entries(entries)) {
3352
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
3353
+ const record = value;
3354
+ const fingerprint = record.fingerprint;
3355
+ const label = record.label;
3356
+ const provider = record.provider;
3357
+ const model = record.model;
3358
+ const at = record.at;
3359
+ if (typeof fingerprint !== "string" || !/^[0-9a-f]{16}$/.test(fingerprint)) continue;
3360
+ if (typeof label !== "string" || label === "") continue;
3361
+ if (typeof provider !== "string" || provider === "") continue;
3362
+ if (typeof model !== "string" || model === "") continue;
3363
+ if (typeof at !== "number" || !Number.isFinite(at)) continue;
3364
+ this.sticky.set(sessionId, {
3365
+ fingerprint,
3366
+ label,
3367
+ provider,
3368
+ model,
3369
+ at
3370
+ });
3371
+ }
3372
+ this.pruneSticky();
3373
+ } catch {}
3374
+ }
3375
+ pruneSticky() {
3376
+ const cutoff = Date.now() - 12096e5;
3377
+ for (const [sessionId, entry] of this.sticky) if (entry.at < cutoff) this.sticky.delete(sessionId);
3378
+ const overflow = this.sticky.size - 500;
3379
+ if (overflow <= 0) return;
3380
+ const oldest = [...this.sticky.entries()].sort((a, b) => a[1].at - b[1].at).slice(0, overflow);
3381
+ for (const [sessionId] of oldest) this.sticky.delete(sessionId);
3382
+ }
3383
+ persistSticky() {
3384
+ this.pruneSticky();
3385
+ const payload = JSON.stringify({ sticky: Object.fromEntries(this.sticky) });
3386
+ this.stickyWrites = this.stickyWrites.then(async () => {
3387
+ await mkdir(dirname(this.stickyPath), { recursive: true });
3388
+ await writeFile(this.stickyPath, payload, "utf8");
3389
+ }).catch(() => {});
3318
3390
  }
3319
3391
  pushTrace(entry) {
3320
3392
  this.routing.unshift(entry);
@@ -3492,7 +3564,7 @@ function relative(until) {
3492
3564
  }
3493
3565
  async function accountsText(deps) {
3494
3566
  const lines = [];
3495
- for (const provider of ["opencode-go", "opencode"]) {
3567
+ for (const provider of [GO_PROVIDER, CONSOLE_PROVIDER]) {
3496
3568
  let keys;
3497
3569
  try {
3498
3570
  keys = await deps.pool.resolveAll(provider);
@@ -3508,7 +3580,7 @@ async function accountsText(deps) {
3508
3580
  lines.push(`${provider}:`);
3509
3581
  for (const [index, entry] of keys.entries()) {
3510
3582
  const health = deps.adapter.accountHealth(entry.key);
3511
- const usage = provider === "opencode-go" ? await deps.usage(provider, entry.key) : void 0;
3583
+ const usage = provider === "opencode-go-direct" ? await deps.usage(provider, entry.key) : void 0;
3512
3584
  const tags = [
3513
3585
  entry.source,
3514
3586
  entry === active ? "active" : "",
@@ -3528,7 +3600,7 @@ function rulesText(deps) {
3528
3600
  async function modelsText(deps) {
3529
3601
  const config = deps.config();
3530
3602
  try {
3531
- const models = await deps.adapter.listModels("opencode-go", { unfiltered: true });
3603
+ const models = await deps.adapter.listModels(GO_PROVIDER, { unfiltered: true });
3532
3604
  if (models.length === 0) return "No models.";
3533
3605
  return models.map((model) => {
3534
3606
  const meta = effectiveModel(model.id, config);
@@ -3545,7 +3617,7 @@ async function testText(deps, model) {
3545
3617
  let text = "";
3546
3618
  try {
3547
3619
  for await (const chunk of deps.adapter.stream({
3548
- provider: "opencode-go",
3620
+ provider: GO_PROVIDER,
3549
3621
  model,
3550
3622
  messages: [{
3551
3623
  role: "user",
@@ -3593,7 +3665,7 @@ function applyCommands(ctx, deps) {
3593
3665
  text: await statusText(deps)
3594
3666
  };
3595
3667
  case "usage": {
3596
- const usage = await deps.usage("opencode-go");
3668
+ const usage = await deps.usage(GO_PROVIDER);
3597
3669
  if (usage?.report === void 0) return {
3598
3670
  kind: "error",
3599
3671
  text: `Quota unavailable: ${usage?.error ?? "no report"}`
@@ -4138,7 +4210,7 @@ function aggregateSessions(sessions, days, now = Date.now()) {
4138
4210
  for (const session of sessions) {
4139
4211
  const provider = session.lastUsed?.provider;
4140
4212
  const model = session.lastUsed?.model;
4141
- if (provider !== "opencode" && provider !== "opencode-go" || model === void 0 || model === "") continue;
4213
+ if (typeof provider !== "string" || !PLUGIN_AND_LEGACY_PROVIDERS.has(provider) || model === void 0 || model === "") continue;
4142
4214
  const usage = session.usage;
4143
4215
  if (usage === void 0) continue;
4144
4216
  const input = count(usage.uncachedInputTokens);
@@ -4563,14 +4635,14 @@ async function buildState(ctx, deps) {
4563
4635
  ...info.source === void 0 ? {} : { source: info.source }
4564
4636
  };
4565
4637
  } catch {}
4566
- const resolved = await deps.pool.resolveAll("opencode-go").catch(() => []);
4638
+ const resolved = await deps.pool.resolveAll(GO_PROVIDER).catch(() => []);
4567
4639
  if (!keyInfo.configured) keyInfo = {
4568
4640
  ...keyInfo,
4569
4641
  configured: resolved.length > 0
4570
4642
  };
4571
4643
  const usageByKey = /* @__PURE__ */ new Map();
4572
4644
  await Promise.all([...new Set(resolved.map((entry) => entry.key))].map(async (key) => {
4573
- const result = await deps.usage("opencode-go", key);
4645
+ const result = await deps.usage(GO_PROVIDER, key);
4574
4646
  usageByKey.set(key, result ?? null);
4575
4647
  }));
4576
4648
  const usageByLabel = /* @__PURE__ */ new Map();
@@ -4617,7 +4689,7 @@ async function buildState(ctx, deps) {
4617
4689
  }
4618
4690
  let models = [];
4619
4691
  try {
4620
- models = (await deps.adapter.listModels("opencode-go", { unfiltered: true })).map((model) => {
4692
+ models = (await deps.adapter.listModels(GO_PROVIDER, { unfiltered: true })).map((model) => {
4621
4693
  const meta = effectiveModel(model.id, config);
4622
4694
  return {
4623
4695
  id: model.id,
@@ -4690,10 +4762,10 @@ function applyRoutes(ctx, web, deps) {
4690
4762
  }
4691
4763
  try {
4692
4764
  const config = deps.config();
4693
- const models = await deps.adapter.listModels("opencode-go", { unfiltered: true }).catch(() => []);
4765
+ const models = await deps.adapter.listModels(GO_PROVIDER, { unfiltered: true }).catch(() => []);
4694
4766
  json(res, 200, {
4695
4767
  ok: true,
4696
- providers: ["opencode", "opencode-go"],
4768
+ providers: [CONSOLE_PROVIDER, GO_PROVIDER],
4697
4769
  accounts: config.accounts.length + 1,
4698
4770
  models: models.length
4699
4771
  });
@@ -4847,7 +4919,7 @@ function applyRoutes(ctx, web, deps) {
4847
4919
  return;
4848
4920
  }
4849
4921
  const model = typeof body.model === "string" && body.model !== "" ? body.model : "deepseek-v4.1-flash";
4850
- const provider = body.provider === "opencode" ? "opencode" : "opencode-go";
4922
+ const provider = body.provider === "opencode-direct" ? CONSOLE_PROVIDER : GO_PROVIDER;
4851
4923
  const started = Date.now();
4852
4924
  const controller = new AbortController();
4853
4925
  const timer = setTimeout(() => controller.abort(), 6e4);
@@ -5170,7 +5242,7 @@ function fingerprint(key) {
5170
5242
  function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5171
5243
  const cache = /* @__PURE__ */ new Map();
5172
5244
  const read = async (provider, explicitKey) => {
5173
- if (provider !== "opencode-go") return void 0;
5245
+ if (provider !== "opencode-go-direct" && provider !== "opencode-go") return void 0;
5174
5246
  const current = config();
5175
5247
  let key;
5176
5248
  if (explicitKey !== void 0 && explicitKey !== "") key = explicitKey;
@@ -5252,18 +5324,18 @@ function apply(ctx, config) {
5252
5324
  warn: (message) => ctx.logger("llm-opencode").warn(message)
5253
5325
  });
5254
5326
  ctx.llm.registerConfigurableProviders([{
5255
- provider: "opencode",
5256
- displayName: "OpenCode",
5327
+ provider: CONSOLE_PROVIDER,
5328
+ displayName: "OpenCode (plugin)",
5257
5329
  settingsNs: NS,
5258
5330
  settingsPath: []
5259
5331
  }, {
5260
- provider: "opencode-go",
5261
- displayName: "OpenCode Go",
5332
+ provider: GO_PROVIDER,
5333
+ displayName: "OpenCode Go (plugin)",
5262
5334
  settingsNs: NS,
5263
5335
  settingsPath: []
5264
5336
  }]);
5265
- ctx.llm.registerAdapter(["opencode", "opencode-go"], adapter);
5266
- ctx.logger("llm-opencode").info("registered providers: opencode, opencode-go");
5337
+ ctx.llm.registerAdapter([CONSOLE_PROVIDER, GO_PROVIDER], adapter);
5338
+ ctx.logger("llm-opencode").info(`registered providers: ${CONSOLE_PROVIDER}, ${GO_PROVIDER}`);
5267
5339
  ctx.inject(["settings"], (settingsCtx) => {
5268
5340
  settingsCtx.settings.configure({ auto: false }, ctx.fiber);
5269
5341
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-opencode-provider",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "OpenCode / OpenCode Go LLM provider plugin for DeepSeek Harness (dsh).",
5
5
  "license": "MIT",
6
6
  "type": "module",