@goodandready/dsh-opencode-provider 0.5.4 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +5 -5
- package/README.ru.md +6 -5
- package/README.zh.md +6 -5
- package/lib/client.js +29 -0
- package/lib/index.d.ts +48 -28
- package/lib/index.js +130 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.6
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- 88 tests.
|
|
7
|
+
|
|
8
|
+
## 0.5.5
|
|
9
|
+
|
|
10
|
+
- **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.
|
|
11
|
+
- 87 tests.
|
|
12
|
+
|
|
3
13
|
## 0.5.4
|
|
4
14
|
|
|
5
15
|
- **Sidebar settings**: the quota-card switch moved out of Connection into its own **Sidebar** section in Settings → OpenCode and into the TUI; saving now refreshes the sidebar card immediately instead of waiting for the 60-second poll.
|
package/README.md
CHANGED
|
@@ -88,14 +88,14 @@ graph LR
|
|
|
88
88
|
- **Per-account quotas**: each account row shows its own 5-hour / weekly / monthly usage, the active account drives the sidebar and quota panel, and the panel lists every account side by side.
|
|
89
89
|
- **Per-account runway**: estimates time to exhaustion from the average burn rate of each window and marks the first window projected to run out before its reset; approximate because the API exposes only the current percentage and reset time.
|
|
90
90
|
- **Model → account rules**: first matching glob pins a preferred account for that model; the other accounts remain fallbacks if it fails or is cooling.
|
|
91
|
-
- **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. The adapter appends `{at, provider, model, purpose?, account, reason}` 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.
|
|
91
|
+
- **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.
|
|
92
92
|
- **Stable `x-opencode-session`** derived from the durable session id, so retries and every turn of a conversation reuse the same routing key.
|
|
93
93
|
- **Live catalog** from `/v1/models` with a generated `models.dev` fallback.
|
|
94
94
|
- **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.
|
|
95
95
|
- **`/opencode` command** for status, usage, accounts, models, rules, and `test [model]`.
|
|
96
96
|
- **TUI settings section** for the terminal profile (`/settings → OpenCode`) with the key, endpoints, allowlist, active account, fallback model, sticky routing and timeouts.
|
|
97
97
|
- **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.
|
|
98
|
+
- **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
99
|
- **Routing trace** in the quota panel and per-account heatmap filter.
|
|
100
100
|
- **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
101
|
- **Plan-aware Go picker**: hides models outside the Go table unless turned off; descriptions carry `Go`, `Console` or `FREE`.
|
|
@@ -123,12 +123,12 @@ npm pack
|
|
|
123
123
|
Install the **packed tarball** into a profile:
|
|
124
124
|
|
|
125
125
|
```bash
|
|
126
|
-
dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.
|
|
126
|
+
dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.6.tgz
|
|
127
127
|
# DSH Desktop:
|
|
128
|
-
dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.
|
|
128
|
+
dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.6.tgz
|
|
129
129
|
|
|
130
130
|
# Or install the published package:
|
|
131
|
-
dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.
|
|
131
|
+
dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.6
|
|
132
132
|
```
|
|
133
133
|
|
|
134
134
|
Restart dsh (or DSH Desktop), then pick `OpenCode` or `OpenCode Go` in the model picker.
|
package/README.ru.md
CHANGED
|
@@ -94,9 +94,10 @@ graph LR
|
|
|
94
94
|
- **Онбординг аккаунтов**: **Import all from auth.json** переносит все ключи OpenCode в отдельные credential ref и добавляет аккаунты; **Check all accounts** обновляет квоты и health по каждому аккаунту.
|
|
95
95
|
- **Правила «модель ➔ аккаунт»**: сопоставление по glob-паттернам закрепляет предпочтительный аккаунт за моделью; остальные аккаунты остаются в резерве.
|
|
96
96
|
- **Квотный fallback**: выберите бесплатную модель в настройках; при ошибке квоты Go тот же запрос один раз повторяется через Console/Zen. Если fallback тоже не сработал, возвращается исходная ошибка квоты.
|
|
97
|
-
- **Липкий аккаунт**: последний успешный аккаунт предпочитается в том же
|
|
97
|
+
- **Липкий аккаунт**: последний успешный аккаунт предпочитается в том же диалоге и сохраняется между перезапусками в локальном `sticky.json` (только хешированные session id и fingerprint ключей); явные правила модели, закреплённый аккаунт и квотный fallback имеют приоритет.
|
|
98
98
|
- **Трассировка маршрутизации**: панель квот показывает последние решения (`rule`, `sticky`, `rotation`, `quota-fallback`) без промптов и ключей.
|
|
99
|
-
-
|
|
99
|
+
- **Статистика по аккаунтам**: в request log пишутся `ok`, `latencyMs`, `ttftMs` и `error`; heatmap показывает таблицу запросов, ошибок и средних latency/TTFT по каждому аккаунту.
|
|
100
|
+
- **Почасовой Heatmap запросов**: сетка «день недели × час» в локальном времени плюс суточная полоса агрегации. Фильтры переключают отображение между всеми запросами, конкретной моделью и аккаунтом. Лог `~/.dsh/storages/dsh-opencode/requests.jsonl` хранит только метаданные `{at, provider, model, purpose, account, reason, ok, latencyMs, ttftMs, error}` (промпты и ответы не сохраняются).
|
|
100
101
|
- **Стабильный заголовок `x-opencode-session`**: вычисляется из устойчивого ID сессии DSH, обеспечивая одинаковый ключ маршрутизации при повторах и диалоге.
|
|
101
102
|
- **Живой каталог моделей**: автоматическое чтение `/v1/models` с резервным fallback из `models.dev`.
|
|
102
103
|
- **Оценки лимитов подписки OpenCode Go**: долларовый лимит и расчетное число запросов для окон 5h, недели и месяца для планов Go и Go Plus.
|
|
@@ -121,12 +122,12 @@ npm pack
|
|
|
121
122
|
Установка из **упакованного tarball**:
|
|
122
123
|
|
|
123
124
|
```bash
|
|
124
|
-
dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.
|
|
125
|
+
dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.6.tgz
|
|
125
126
|
# DSH Desktop:
|
|
126
|
-
dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.
|
|
127
|
+
dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.6.tgz
|
|
127
128
|
|
|
128
129
|
# Либо установка опубликованного пакета из npm:
|
|
129
|
-
dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.
|
|
130
|
+
dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.6
|
|
130
131
|
```
|
|
131
132
|
|
|
132
133
|
После установки перезапустите dsh (или DSH Desktop) и выберите **OpenCode** или **OpenCode Go** в списке моделей.
|
package/README.zh.md
CHANGED
|
@@ -94,9 +94,10 @@ graph LR
|
|
|
94
94
|
- **每账户预计可用时长**:按各窗口的平均消耗速度估算耗尽时间,并标记在重置前最先耗尽的窗口;API 仅提供当前百分比与重置时间,因此为近似值。
|
|
95
95
|
- **模型 ➔ 账户路由规则**:支持 Glob 通配符(如 `*free*`、`deepseek-*`)将模型优先绑定至特定账户,其他账户保留为备用。
|
|
96
96
|
- **配额回退**:在设置中选择免费模型;Go 返回配额错误时,同一请求通过 Console/Zen 重试一次。若回退也失败,返回原始配额错误。
|
|
97
|
-
-
|
|
97
|
+
- **会话粘性账户**:同一会话优先使用上次成功的账户,并通过本地 `sticky.json` 跨重启保留(仅哈希后的 session id 和密钥指纹);显式模型规则、固定账户和配额回退优先。
|
|
98
98
|
- **路由追踪**:配额面板显示最近的账户/模型决策(`rule`、`sticky`、`rotation`、`quota-fallback`),不记录提示词或密钥。
|
|
99
|
-
-
|
|
99
|
+
- **按账户请求统计**:请求日志记录 `ok`、`latencyMs`、`ttftMs` 和 `error`;热力图表格显示每个账户的请求数、错误数及平均延迟/TTFT。
|
|
100
|
+
- **本地时间请求热力图**:星期×小时交互式热力图,排除系统心跳与重复记录,可按模型或账户过滤,仅保存 `{at, provider, model, purpose, account, reason, ok, latencyMs, ttftMs, error}` 元数据,不保存任何提示词与回复。
|
|
100
101
|
- **稳定的 `x-opencode-session` 标头**:根据 DSH 会话 ID 派生,确保重试与连续对话复用相同的网关路由键。
|
|
101
102
|
- **实时模型目录**:自动从 `/v1/models` 同步,并以 `models.dev` 生成文件作为回退。
|
|
102
103
|
- **Go 订阅额度估算**:每月美元额度以及 5 小时、每周和每月的估算请求次数。
|
|
@@ -118,12 +119,12 @@ npm pack
|
|
|
118
119
|
通过**打包文件 (.tgz)**安装:
|
|
119
120
|
|
|
120
121
|
```bash
|
|
121
|
-
dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.
|
|
122
|
+
dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.6.tgz
|
|
122
123
|
# DSH 桌面端:
|
|
123
|
-
dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.
|
|
124
|
+
dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.6.tgz
|
|
124
125
|
|
|
125
126
|
# 或从 npm 安装已发布版本:
|
|
126
|
-
dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.
|
|
127
|
+
dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.6
|
|
127
128
|
```
|
|
128
129
|
|
|
129
130
|
安装完成后重启 dsh 或 DSH 桌面端,在模型选择器中选择 **OpenCode** 或 **OpenCode Go** 即可。
|
package/lib/client.js
CHANGED
|
@@ -104,6 +104,8 @@ window.__ModuleLoader__.load({
|
|
|
104
104
|
avgTtft: "Avg TTFT",
|
|
105
105
|
avgLatency: "Avg latency",
|
|
106
106
|
errors: "Errors",
|
|
107
|
+
latency: "Latency",
|
|
108
|
+
ttft: "TTFT",
|
|
107
109
|
requestHeatmap: "Request heatmap",
|
|
108
110
|
heatmapPeak: "Peak",
|
|
109
111
|
heatmapEmpty: "No requests recorded yet. Send a message or run Test connection.",
|
|
@@ -286,6 +288,8 @@ window.__ModuleLoader__.load({
|
|
|
286
288
|
avgTtft: "平均 TTFT",
|
|
287
289
|
avgLatency: "平均延迟",
|
|
288
290
|
errors: "错误",
|
|
291
|
+
latency: "延迟",
|
|
292
|
+
ttft: "首字延迟",
|
|
289
293
|
requestHeatmap: "请求热力图",
|
|
290
294
|
heatmapPeak: "峰值",
|
|
291
295
|
heatmapEmpty: "尚无请求记录。发送消息或运行测试连接。",
|
|
@@ -482,6 +486,11 @@ window.__ModuleLoader__.load({
|
|
|
482
486
|
function pad(hour) {
|
|
483
487
|
return String(hour).padStart(2, "0");
|
|
484
488
|
}
|
|
489
|
+
function formatMs(value) {
|
|
490
|
+
if (value === null || !Number.isFinite(value)) return "—";
|
|
491
|
+
if (value >= 1e3) return `${(value / 1e3).toFixed(1)}s`;
|
|
492
|
+
return `${Math.round(value)}ms`;
|
|
493
|
+
}
|
|
485
494
|
function RequestHeatmapView(props) {
|
|
486
495
|
const { heatmap, t } = props;
|
|
487
496
|
const [selected, setSelected] = (0, react.useState)("all");
|
|
@@ -572,6 +581,26 @@ window.__ModuleLoader__.load({
|
|
|
572
581
|
})
|
|
573
582
|
]
|
|
574
583
|
}),
|
|
584
|
+
accounts.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
585
|
+
className: "oc-table-scroll",
|
|
586
|
+
style: { marginTop: 8 },
|
|
587
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
|
|
588
|
+
className: "oc-table",
|
|
589
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
|
|
590
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("accountLabel") }),
|
|
591
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("requests") }),
|
|
592
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("errors") }),
|
|
593
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("latency") }),
|
|
594
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: t("ttft") })
|
|
595
|
+
] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: accounts.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
|
|
596
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: entry.account }),
|
|
597
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: entry.total }),
|
|
598
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: entry.errors }),
|
|
599
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: formatMs(entry.avgLatencyMs) }),
|
|
600
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: formatMs(entry.avgTtftMs) })
|
|
601
|
+
] }, entry.account)) })]
|
|
602
|
+
})
|
|
603
|
+
}),
|
|
575
604
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
576
605
|
className: "oc-heat-scroll",
|
|
577
606
|
children: [
|
package/lib/index.d.ts
CHANGED
|
@@ -116,6 +116,44 @@ declare function createKeyPool(ctx: Context, config: () => ResolvedConfig): KeyP
|
|
|
116
116
|
/** Backwards-compatible single-key facade over the pool. */
|
|
117
117
|
declare function createKeyResolver(ctx: Context, config: () => ResolvedConfig): KeyResolver;
|
|
118
118
|
//#endregion
|
|
119
|
+
//#region src/request-log.d.ts
|
|
120
|
+
interface RequestOutcome {
|
|
121
|
+
ok: boolean;
|
|
122
|
+
latencyMs: number;
|
|
123
|
+
/** Request start; the log uses it as the event timestamp. */
|
|
124
|
+
at?: number;
|
|
125
|
+
ttftMs?: number;
|
|
126
|
+
error?: string;
|
|
127
|
+
}
|
|
128
|
+
interface ModelRequestHeatmap {
|
|
129
|
+
provider: string;
|
|
130
|
+
model: string;
|
|
131
|
+
total: number;
|
|
132
|
+
hours: number[];
|
|
133
|
+
grid: number[][];
|
|
134
|
+
}
|
|
135
|
+
interface AccountRequestHeatmap {
|
|
136
|
+
account: string;
|
|
137
|
+
total: number;
|
|
138
|
+
errors: number;
|
|
139
|
+
avgLatencyMs: number | null;
|
|
140
|
+
avgTtftMs: number | null;
|
|
141
|
+
hours: number[];
|
|
142
|
+
grid: number[][];
|
|
143
|
+
}
|
|
144
|
+
interface RequestHeatmap {
|
|
145
|
+
since: number;
|
|
146
|
+
total: number;
|
|
147
|
+
/** Requests per hour of day, local time. */
|
|
148
|
+
hours: number[];
|
|
149
|
+
/** Monday-first weekday × hour grid, local time. */
|
|
150
|
+
grid: number[][];
|
|
151
|
+
firstAt: number | null;
|
|
152
|
+
lastAt: number | null;
|
|
153
|
+
models: ModelRequestHeatmap[];
|
|
154
|
+
accounts: AccountRequestHeatmap[];
|
|
155
|
+
}
|
|
156
|
+
//#endregion
|
|
119
157
|
//#region src/adapter.d.ts
|
|
120
158
|
type RoutingReason = 'rule' | 'sticky' | 'pinned' | 'default' | 'rotation' | 'quota-fallback';
|
|
121
159
|
interface RoutingTraceEntry {
|
|
@@ -135,9 +173,11 @@ interface OpenCodeAdapterDeps {
|
|
|
135
173
|
readonly resolveAttachments?: () => AttachmentStore | undefined;
|
|
136
174
|
readonly fetchImpl?: typeof fetch;
|
|
137
175
|
/** Test seam; defaults to the local append-only request log. */
|
|
138
|
-
readonly recordRequest?: (provider: string, model: string, purpose?: string, account?: string, reason?: string) => void;
|
|
176
|
+
readonly recordRequest?: (provider: string, model: string, purpose?: string, account?: string, reason?: string, outcome?: RequestOutcome) => void;
|
|
139
177
|
/** Test seam; defaults to ~/.dsh/storages/dsh-opencode/cooldowns.json. */
|
|
140
178
|
readonly cooldownPath?: string;
|
|
179
|
+
/** Test seam; defaults to ~/.dsh/storages/dsh-opencode/sticky.json. */
|
|
180
|
+
readonly stickyPath?: string;
|
|
141
181
|
/** Receives a one-time warning when the provider tool limit drops tools. */
|
|
142
182
|
readonly warn?: (message: string) => void;
|
|
143
183
|
}
|
|
@@ -145,7 +185,9 @@ declare class OpenCodeAdapter extends LlmAdapter {
|
|
|
145
185
|
private readonly deps;
|
|
146
186
|
private readonly fetchImpl;
|
|
147
187
|
private readonly cooldownPath;
|
|
188
|
+
private readonly stickyPath;
|
|
148
189
|
private cooldownWrites;
|
|
190
|
+
private stickyWrites;
|
|
149
191
|
/** key fingerprint → cooldown deadline and the rejection that caused it. */
|
|
150
192
|
private readonly cooldowns;
|
|
151
193
|
private readonly sticky;
|
|
@@ -163,6 +205,11 @@ declare class OpenCodeAdapter extends LlmAdapter {
|
|
|
163
205
|
stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
164
206
|
private run;
|
|
165
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;
|
|
166
213
|
private pushTrace;
|
|
167
214
|
private parse;
|
|
168
215
|
private send;
|
|
@@ -307,33 +354,6 @@ interface AnalyticsReport {
|
|
|
307
354
|
sessions: SessionUsageRow[];
|
|
308
355
|
}
|
|
309
356
|
//#endregion
|
|
310
|
-
//#region src/request-log.d.ts
|
|
311
|
-
interface ModelRequestHeatmap {
|
|
312
|
-
provider: string;
|
|
313
|
-
model: string;
|
|
314
|
-
total: number;
|
|
315
|
-
hours: number[];
|
|
316
|
-
grid: number[][];
|
|
317
|
-
}
|
|
318
|
-
interface AccountRequestHeatmap {
|
|
319
|
-
account: string;
|
|
320
|
-
total: number;
|
|
321
|
-
hours: number[];
|
|
322
|
-
grid: number[][];
|
|
323
|
-
}
|
|
324
|
-
interface RequestHeatmap {
|
|
325
|
-
since: number;
|
|
326
|
-
total: number;
|
|
327
|
-
/** Requests per hour of day, local time. */
|
|
328
|
-
hours: number[];
|
|
329
|
-
/** Monday-first weekday × hour grid, local time. */
|
|
330
|
-
grid: number[][];
|
|
331
|
-
firstAt: number | null;
|
|
332
|
-
lastAt: number | null;
|
|
333
|
-
models: ModelRequestHeatmap[];
|
|
334
|
-
accounts: AccountRequestHeatmap[];
|
|
335
|
-
}
|
|
336
|
-
//#endregion
|
|
337
357
|
//#region src/routes.d.ts
|
|
338
358
|
interface RouteDeps {
|
|
339
359
|
readonly config: () => ResolvedConfig;
|
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.
|
|
20
|
+
const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.6";
|
|
21
21
|
const DEFAULT_CONTEXT_WINDOW = 262144;
|
|
22
22
|
const DEFAULT_MAX_TOKENS = 32768;
|
|
23
23
|
const DEFAULT_GENERATE_MAX_TOKENS = 32768;
|
|
@@ -1526,14 +1526,20 @@ function requestLogPath() {
|
|
|
1526
1526
|
let queue = Promise.resolve();
|
|
1527
1527
|
let readyDirectory = "";
|
|
1528
1528
|
let appends = 0;
|
|
1529
|
-
function recordRequest(provider, model, at = Date.now(), purpose, account, reason) {
|
|
1529
|
+
function recordRequest(provider, model, at = Date.now(), purpose, account, reason, outcome) {
|
|
1530
1530
|
const line = `${JSON.stringify({
|
|
1531
1531
|
at,
|
|
1532
1532
|
provider,
|
|
1533
1533
|
model,
|
|
1534
1534
|
...purpose === void 0 ? {} : { purpose },
|
|
1535
1535
|
...account === void 0 ? {} : { account },
|
|
1536
|
-
...reason === void 0 ? {} : { reason }
|
|
1536
|
+
...reason === void 0 ? {} : { reason },
|
|
1537
|
+
...outcome === void 0 ? {} : {
|
|
1538
|
+
ok: outcome.ok,
|
|
1539
|
+
latencyMs: outcome.latencyMs,
|
|
1540
|
+
...outcome.ttftMs === void 0 ? {} : { ttftMs: outcome.ttftMs },
|
|
1541
|
+
...outcome.error === void 0 ? {} : { error: outcome.error }
|
|
1542
|
+
}
|
|
1537
1543
|
})}\n`;
|
|
1538
1544
|
queue = queue.then(async () => {
|
|
1539
1545
|
try {
|
|
@@ -1565,6 +1571,7 @@ async function readRequestHeatmap(days, now = Date.now()) {
|
|
|
1565
1571
|
const grid = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0));
|
|
1566
1572
|
const models = /* @__PURE__ */ new Map();
|
|
1567
1573
|
const accounts = /* @__PURE__ */ new Map();
|
|
1574
|
+
const accountMetrics = /* @__PURE__ */ new Map();
|
|
1568
1575
|
const seen = /* @__PURE__ */ new Set();
|
|
1569
1576
|
let total = 0;
|
|
1570
1577
|
let firstAt = null;
|
|
@@ -1632,12 +1639,37 @@ async function readRequestHeatmap(days, now = Date.now()) {
|
|
|
1632
1639
|
accountEntry = {
|
|
1633
1640
|
account,
|
|
1634
1641
|
total: 0,
|
|
1642
|
+
errors: 0,
|
|
1643
|
+
avgLatencyMs: null,
|
|
1644
|
+
avgTtftMs: null,
|
|
1635
1645
|
hours: Array.from({ length: 24 }, () => 0),
|
|
1636
1646
|
grid: Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0))
|
|
1637
1647
|
};
|
|
1638
1648
|
accounts.set(account, accountEntry);
|
|
1639
1649
|
}
|
|
1640
1650
|
accountEntry.total += 1;
|
|
1651
|
+
let metrics = accountMetrics.get(account);
|
|
1652
|
+
if (metrics === void 0) {
|
|
1653
|
+
metrics = {
|
|
1654
|
+
errors: 0,
|
|
1655
|
+
latencySum: 0,
|
|
1656
|
+
latencyCount: 0,
|
|
1657
|
+
ttftSum: 0,
|
|
1658
|
+
ttftCount: 0
|
|
1659
|
+
};
|
|
1660
|
+
accountMetrics.set(account, metrics);
|
|
1661
|
+
}
|
|
1662
|
+
if (parsed.ok === false) metrics.errors += 1;
|
|
1663
|
+
const latency = parsed.latencyMs;
|
|
1664
|
+
if (typeof latency === "number" && Number.isFinite(latency) && latency >= 0) {
|
|
1665
|
+
metrics.latencySum += latency;
|
|
1666
|
+
metrics.latencyCount += 1;
|
|
1667
|
+
}
|
|
1668
|
+
const ttft = parsed.ttftMs;
|
|
1669
|
+
if (typeof ttft === "number" && Number.isFinite(ttft) && ttft >= 0) {
|
|
1670
|
+
metrics.ttftSum += ttft;
|
|
1671
|
+
metrics.ttftCount += 1;
|
|
1672
|
+
}
|
|
1641
1673
|
accountEntry.hours[hour] = (accountEntry.hours[hour] ?? 0) + 1;
|
|
1642
1674
|
const accountRow = accountEntry.grid[day];
|
|
1643
1675
|
if (accountRow !== void 0) accountRow[hour] = (accountRow[hour] ?? 0) + 1;
|
|
@@ -1653,7 +1685,15 @@ async function readRequestHeatmap(days, now = Date.now()) {
|
|
|
1653
1685
|
firstAt,
|
|
1654
1686
|
lastAt,
|
|
1655
1687
|
models: [...models.values()].sort((a, b) => b.total - a.total || a.model.localeCompare(b.model)),
|
|
1656
|
-
accounts: [...accounts.values()].
|
|
1688
|
+
accounts: [...accounts.values()].map((entry) => {
|
|
1689
|
+
const metrics = accountMetrics.get(entry.account);
|
|
1690
|
+
return {
|
|
1691
|
+
...entry,
|
|
1692
|
+
errors: metrics?.errors ?? 0,
|
|
1693
|
+
avgLatencyMs: metrics !== void 0 && metrics.latencyCount > 0 ? metrics.latencySum / metrics.latencyCount : null,
|
|
1694
|
+
avgTtftMs: metrics !== void 0 && metrics.ttftCount > 0 ? metrics.ttftSum / metrics.ttftCount : null
|
|
1695
|
+
};
|
|
1696
|
+
}).sort((a, b) => b.total - a.total || a.account.localeCompare(b.account))
|
|
1657
1697
|
};
|
|
1658
1698
|
}
|
|
1659
1699
|
//#endregion
|
|
@@ -3036,11 +3076,17 @@ function defaultCooldownPath() {
|
|
|
3036
3076
|
const home = process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
|
|
3037
3077
|
return join(home, "storages", "dsh-opencode", "cooldowns.json");
|
|
3038
3078
|
}
|
|
3079
|
+
function defaultStickyPath() {
|
|
3080
|
+
const home = process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
|
|
3081
|
+
return join(home, "storages", "dsh-opencode", "sticky.json");
|
|
3082
|
+
}
|
|
3039
3083
|
var OpenCodeAdapter = class extends LlmAdapter {
|
|
3040
3084
|
deps;
|
|
3041
3085
|
fetchImpl;
|
|
3042
3086
|
cooldownPath;
|
|
3087
|
+
stickyPath;
|
|
3043
3088
|
cooldownWrites = Promise.resolve();
|
|
3089
|
+
stickyWrites = Promise.resolve();
|
|
3044
3090
|
/** key fingerprint → cooldown deadline and the rejection that caused it. */
|
|
3045
3091
|
cooldowns = /* @__PURE__ */ new Map();
|
|
3046
3092
|
sticky = /* @__PURE__ */ new Map();
|
|
@@ -3051,7 +3097,9 @@ var OpenCodeAdapter = class extends LlmAdapter {
|
|
|
3051
3097
|
this.deps = deps;
|
|
3052
3098
|
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
3053
3099
|
this.cooldownPath = deps.cooldownPath ?? defaultCooldownPath();
|
|
3100
|
+
this.stickyPath = deps.stickyPath ?? defaultStickyPath();
|
|
3054
3101
|
this.loadCooldowns();
|
|
3102
|
+
this.loadSticky();
|
|
3055
3103
|
}
|
|
3056
3104
|
providerInfo(provider) {
|
|
3057
3105
|
return {
|
|
@@ -3122,8 +3170,9 @@ var OpenCodeAdapter = class extends LlmAdapter {
|
|
|
3122
3170
|
return this.run(options, false);
|
|
3123
3171
|
}
|
|
3124
3172
|
async *run(options, fallback) {
|
|
3125
|
-
const record = this.deps.recordRequest ?? ((provider, model, purpose, account, reason) => recordRequest(provider, model, Date.now(), purpose, account, reason));
|
|
3173
|
+
const record = this.deps.recordRequest ?? ((provider, model, purpose, account, reason, outcome) => recordRequest(provider, model, outcome?.at ?? Date.now(), purpose, account, reason, outcome));
|
|
3126
3174
|
const config = this.deps.config();
|
|
3175
|
+
const runStartedAt = Date.now();
|
|
3127
3176
|
const model = effectiveModel(options.model, config);
|
|
3128
3177
|
const sessionId = openCodeSessionId(options);
|
|
3129
3178
|
const rawKeys = orderKeysForModel(this.deps.resolveKeys !== void 0 ? await this.deps.resolveKeys(options.provider) : [await this.deps.resolveKey(options.provider)], options.model, config.modelAccountRules);
|
|
@@ -3143,7 +3192,6 @@ var OpenCodeAdapter = class extends LlmAdapter {
|
|
|
3143
3192
|
}
|
|
3144
3193
|
}
|
|
3145
3194
|
const initial = keys.find((entry) => fallback || !this.coolingDown(entry.key)) ?? keys[0];
|
|
3146
|
-
if (initial !== void 0) record(options.provider, options.model, options.purpose, initial.label ?? "default", reason);
|
|
3147
3195
|
const url = endpointFor(options.provider, model.transport, options.model, config);
|
|
3148
3196
|
let images;
|
|
3149
3197
|
if (hasImages(options)) {
|
|
@@ -3166,18 +3214,35 @@ var OpenCodeAdapter = class extends LlmAdapter {
|
|
|
3166
3214
|
const body = model.transport === "openai-chat" ? buildChatBody(bodyOptions, model, config, images) : model.transport === "anthropic" ? buildAnthropicBody(bodyOptions, model, config, images) : model.transport === "openai-responses" ? buildResponsesBody(bodyOptions, model, config, images) : buildGoogleBody(bodyOptions, model, config, images);
|
|
3167
3215
|
let lastError;
|
|
3168
3216
|
let lastAccount = initial?.label ?? "default";
|
|
3217
|
+
let loggedAny = false;
|
|
3169
3218
|
for (const [index, entry] of keys.entries()) {
|
|
3170
3219
|
if (!fallback && this.coolingDown(entry.key)) continue;
|
|
3171
3220
|
lastAccount = entry.label ?? "default";
|
|
3172
3221
|
const attemptReason = index === 0 ? reason : "rotation";
|
|
3222
|
+
const attemptStartedAt = Date.now();
|
|
3173
3223
|
let started = false;
|
|
3174
3224
|
let responseFailure = false;
|
|
3225
|
+
let firstChunkAt;
|
|
3226
|
+
let logged = false;
|
|
3227
|
+
const logAttempt = (ok, errorCode) => {
|
|
3228
|
+
if (logged) return;
|
|
3229
|
+
logged = true;
|
|
3230
|
+
loggedAny = true;
|
|
3231
|
+
record(options.provider, options.model, options.purpose, entry.label ?? "default", attemptReason, {
|
|
3232
|
+
at: attemptStartedAt,
|
|
3233
|
+
ok,
|
|
3234
|
+
latencyMs: Math.max(0, Date.now() - attemptStartedAt),
|
|
3235
|
+
...firstChunkAt === void 0 ? {} : { ttftMs: Math.max(0, firstChunkAt - attemptStartedAt) },
|
|
3236
|
+
...errorCode === void 0 ? {} : { error: errorCode }
|
|
3237
|
+
});
|
|
3238
|
+
};
|
|
3175
3239
|
try {
|
|
3176
3240
|
const response = await this.send(url, entry.key, sessionId, model.transport, body, config, options.signal);
|
|
3177
3241
|
if (!response.ok) {
|
|
3178
3242
|
const text = await response.text().catch(() => "");
|
|
3179
3243
|
const error = classifyResponseError(response.status, text, response.headers);
|
|
3180
3244
|
lastError = error;
|
|
3245
|
+
logAttempt(false, error.code);
|
|
3181
3246
|
const canRotate = index < keys.length - 1;
|
|
3182
3247
|
if (error.code === "INVALID_CREDENTIAL" || error.code === "RATE_LIMIT" || error.code === "QUOTA") {
|
|
3183
3248
|
if (!fallback) this.markCooldown(entry.key, response.status, response.headers, error.code);
|
|
@@ -3197,13 +3262,16 @@ var OpenCodeAdapter = class extends LlmAdapter {
|
|
|
3197
3262
|
ok: true
|
|
3198
3263
|
});
|
|
3199
3264
|
for await (const chunk of this.parse(response, model.transport, config, options.signal)) {
|
|
3265
|
+
if (firstChunkAt === void 0) firstChunkAt = Date.now();
|
|
3200
3266
|
started = true;
|
|
3201
3267
|
yield chunk;
|
|
3202
3268
|
}
|
|
3269
|
+
logAttempt(true);
|
|
3203
3270
|
return;
|
|
3204
3271
|
} catch (error) {
|
|
3205
|
-
if (started || options.signal?.aborted === true) throw error;
|
|
3206
3272
|
const classified = error instanceof LlmError ? error : new LlmError(error instanceof Error ? error.message : "OpenCode request failed", "TRANSPORT", { cause: error });
|
|
3273
|
+
logAttempt(false, classified.code);
|
|
3274
|
+
if (started || options.signal?.aborted === true) throw error;
|
|
3207
3275
|
lastError = classified;
|
|
3208
3276
|
const transient = this.isTransient(classified);
|
|
3209
3277
|
if (transient && !responseFailure && !fallback) this.markCooldown(entry.key, 0);
|
|
@@ -3214,6 +3282,12 @@ var OpenCodeAdapter = class extends LlmAdapter {
|
|
|
3214
3282
|
}
|
|
3215
3283
|
}
|
|
3216
3284
|
const finalError = lastError ?? new LlmError("llm-opencode: every configured account is cooling down after provider rejections", "RATE_LIMIT");
|
|
3285
|
+
if (!loggedAny) record(options.provider, options.model, options.purpose, lastAccount, reason, {
|
|
3286
|
+
at: runStartedAt,
|
|
3287
|
+
ok: false,
|
|
3288
|
+
latencyMs: 0,
|
|
3289
|
+
error: finalError.code
|
|
3290
|
+
});
|
|
3217
3291
|
this.pushTrace({
|
|
3218
3292
|
at: Date.now(),
|
|
3219
3293
|
provider: options.provider,
|
|
@@ -3249,6 +3323,55 @@ var OpenCodeAdapter = class extends LlmAdapter {
|
|
|
3249
3323
|
model: options.model,
|
|
3250
3324
|
at: Date.now()
|
|
3251
3325
|
});
|
|
3326
|
+
this.persistSticky();
|
|
3327
|
+
}
|
|
3328
|
+
/** Await pending sticky persistence writes (tests and shutdown). */
|
|
3329
|
+
async flushSticky() {
|
|
3330
|
+
await this.stickyWrites;
|
|
3331
|
+
}
|
|
3332
|
+
loadSticky() {
|
|
3333
|
+
try {
|
|
3334
|
+
const entries = JSON.parse(readFileSync(this.stickyPath, "utf8")).sticky;
|
|
3335
|
+
if (typeof entries !== "object" || entries === null || Array.isArray(entries)) return;
|
|
3336
|
+
for (const [sessionId, value] of Object.entries(entries)) {
|
|
3337
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
3338
|
+
const record = value;
|
|
3339
|
+
const fingerprint = record.fingerprint;
|
|
3340
|
+
const label = record.label;
|
|
3341
|
+
const provider = record.provider;
|
|
3342
|
+
const model = record.model;
|
|
3343
|
+
const at = record.at;
|
|
3344
|
+
if (typeof fingerprint !== "string" || !/^[0-9a-f]{16}$/.test(fingerprint)) continue;
|
|
3345
|
+
if (typeof label !== "string" || label === "") continue;
|
|
3346
|
+
if (typeof provider !== "string" || provider === "") continue;
|
|
3347
|
+
if (typeof model !== "string" || model === "") continue;
|
|
3348
|
+
if (typeof at !== "number" || !Number.isFinite(at)) continue;
|
|
3349
|
+
this.sticky.set(sessionId, {
|
|
3350
|
+
fingerprint,
|
|
3351
|
+
label,
|
|
3352
|
+
provider,
|
|
3353
|
+
model,
|
|
3354
|
+
at
|
|
3355
|
+
});
|
|
3356
|
+
}
|
|
3357
|
+
this.pruneSticky();
|
|
3358
|
+
} catch {}
|
|
3359
|
+
}
|
|
3360
|
+
pruneSticky() {
|
|
3361
|
+
const cutoff = Date.now() - 12096e5;
|
|
3362
|
+
for (const [sessionId, entry] of this.sticky) if (entry.at < cutoff) this.sticky.delete(sessionId);
|
|
3363
|
+
const overflow = this.sticky.size - 500;
|
|
3364
|
+
if (overflow <= 0) return;
|
|
3365
|
+
const oldest = [...this.sticky.entries()].sort((a, b) => a[1].at - b[1].at).slice(0, overflow);
|
|
3366
|
+
for (const [sessionId] of oldest) this.sticky.delete(sessionId);
|
|
3367
|
+
}
|
|
3368
|
+
persistSticky() {
|
|
3369
|
+
this.pruneSticky();
|
|
3370
|
+
const payload = JSON.stringify({ sticky: Object.fromEntries(this.sticky) });
|
|
3371
|
+
this.stickyWrites = this.stickyWrites.then(async () => {
|
|
3372
|
+
await mkdir(dirname(this.stickyPath), { recursive: true });
|
|
3373
|
+
await writeFile(this.stickyPath, payload, "utf8");
|
|
3374
|
+
}).catch(() => {});
|
|
3252
3375
|
}
|
|
3253
3376
|
pushTrace(entry) {
|
|
3254
3377
|
this.routing.unshift(entry);
|