dsh-any-deepseek 0.0.17 → 0.0.19

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/CONTRIBUTING.md CHANGED
@@ -29,7 +29,7 @@
29
29
 
30
30
  加完这一条,面板的规格下拉里就有它了 —— 不需要改别处。
31
31
 
32
- **两条硬规矩**(都是付过代价的):
32
+ **三条硬规矩**(都是付过代价的):
33
33
 
34
34
  1. **注释里不要写「我探过了」,除非真的探过。**
35
35
  这个仓里曾经有一条 `Probed live: the billing pair answers` 的注释,而那个路由**从没被调用过**,
@@ -39,6 +39,22 @@
39
39
  我们对外只暴露 `deepseek-v4.1-flash` 一个 id,各家的拼写由预设吸收。
40
40
  **这正是这个插件存在的理由** —— 所以别在预设里图省事写我们的 id。
41
41
 
42
+ 三种拼写的真实例子(都在仓内):
43
+
44
+ | 渠道 | 它要的写法 | 说明 |
45
+ |---|---|---|
46
+ | OpenCode Go / Ollama / Open Design | `deepseek-v4.1-flash` | 中转站的写法 |
47
+ | DeepSeek Official | `deepseek-flash` | 官方自己的短拼写 |
48
+ | **Tencent TokenHub** | **`deepseek-v4-flash-202605`** | **`-202605` 是 id 的一部分,不是日期** |
49
+
50
+ ⚠️ 最后那一行是最典型的坑:把后缀"规整"掉看起来像在清理,实际是让每条请求都被上游拒。
51
+
52
+ 3. **没有余额接口就写「没有」,别猜一个形状。**
53
+ 判法用状态码,不用猜:不带 key 时,**真路由回 401**(存在、需鉴权),**假路径回 404**。
54
+ 然后逐个探候选路径 —— 全 404 就是真的没有。此时**不声明 `balance`**,
55
+ 面板会自动退回「测通」模式(发一次最小真实请求,成功就显示「余额未尽」)。
56
+ 反面教材:仓里 open-design 那条曾声明过 `openai-billing` 并注释"已探过",实际每次 404。
57
+
42
58
  ### 2. 修一处文案
43
59
 
44
60
  面板上的每一句都应该是一个**能在页面上验证的事实**。读到含糊、过时、或替宿主猜原因的,直接改。
package/README.md CHANGED
@@ -56,7 +56,7 @@ dsh plugin --profile web add dsh-any-deepseek
56
56
  **渠道 id** 会自动填好(可以改,只是配置里的键名)。**显示名**留空的话用规格的默认名 ——
57
57
  同一家开两个号时,填上「主号」「备号」之类会好认。
58
58
 
59
- 内置规格:DeepSeek Official、OpenCode Go、Ollama Cloud、Open Design、CommandCode
59
+ 内置规格:DeepSeek Official、OpenCode Go、Ollama Cloud、Open Design、CommandCode、Tencent TokenHub
60
60
  (这些名字与下拉框里显示的完全一致)。
61
61
  要接别家,见 [接一个新渠道](#接一个新渠道)。
62
62
 
@@ -95,6 +95,9 @@ dsh plugin --profile web add dsh-any-deepseek
95
95
  > **「余额未尽」不等于「还剩多少」。** 一次成功的请求只证明三件事:地址通、key 对、账户没因为欠费被拒
96
96
  > (欠费的账户会回 402,不会回 200)。所以这里只写「未尽」,**不会编一个金额给你**。
97
97
 
98
+ **哪些内置规格没有余额接口?** 目前是 **Open Design** 和 **Tencent TokenHub** ——
99
+ 这两家只开推理面,所以它们永远显示「余额未尽」而不是金额。这是如实报告,不是缺陷。
100
+
98
101
  ### 看这个月花了多少
99
102
 
100
103
  切到 **用量** 分页。
package/lib/client.js CHANGED
@@ -161,6 +161,19 @@ window.__ModuleLoader__.load({
161
161
  return `${(value / 1e6).toFixed(value < 1e7 ? 2 : 1)}M`;
162
162
  }
163
163
  /**
164
+ * Format a mean-context figure, or an em dash when nothing reported usage.
165
+ *
166
+ * 🔴 Same discipline as {@link formatSpeed}: `null` is not `0`. "No request reported usage" and
167
+ * "every request carried zero tokens" are different statements, and only the first one is true
168
+ * here — so the unmeasurable case renders as an em dash.
169
+ * @param value - mean tokens per request, or null/undefined.
170
+ * @returns e.g. `28.1K`, or `—`.
171
+ */
172
+ function formatMeanContext(value) {
173
+ if (value === null || value === void 0 || !Number.isFinite(value) || value <= 0) return "—";
174
+ return formatTokens(Math.round(value));
175
+ }
176
+ /**
164
177
  * Format a throughput figure, or an em dash when it is unmeasurable.
165
178
  *
166
179
  * 🔴 `null` is NOT zero: a group with no first-token measurement has no speed, and printing `0`
@@ -188,7 +201,7 @@ window.__ModuleLoader__.load({
188
201
  }
189
202
  //#endregion
190
203
  //#region \0ad-css:src/client/SettingsPage
191
- const css = "/* Panel styles.\n *\n * 🔴 There are NO `--dsw-alias-*` design tokens on this host (measured 2026-10-21: the document\n * declares 29 custom properties and not one is a colour). This file used to reference a dozen of\n * them, every one falling back to a hardcoded LIGHT value (`rgba(0,0,0,0.12)` and friends) — so\n * on the dark theme the borders and dividers were effectively invisible.\n *\n * ⇒ Derive everything from `currentColor`, which the host sets per theme. `color-mix` gives the\n * low-contrast borders and fills that a token would have provided, and it follows the theme\n * automatically instead of assuming one palette.\n */\n\n.ad-page {\n display: flex;\n flex-direction: column;\n gap: 20px;\n padding: 4px 2px 12px;\n}\n\n/* One place to change the two theme-derived values the whole panel uses. */\n.ad-page {\n --ad-line: color-mix(in srgb, currentColor 18%, transparent);\n --ad-line-strong: color-mix(in srgb, currentColor 32%, transparent);\n --ad-fill-subtle: color-mix(in srgb, currentColor 7%, transparent);\n /* The current row's fill: visible against both themes because it is derived from the text\n colour the host already picked, not from an assumed palette. */\n --ad-fill-current: color-mix(in srgb, currentColor 11%, transparent);\n --ad-danger: #e5534b;\n}\n\n.ad-head {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.ad-title {\n margin: 0;\n font-size: 15px;\n font-weight: 600;\n}\n\n.ad-intro {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n opacity: 0.72;\n}\n\n.ad-error {\n border: 1px solid var(--ad-danger);\n border-radius: 6px;\n padding: 8px 10px;\n font-size: 12px;\n}\n\n.ad-section {\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.ad-sectionTitle {\n margin: 0;\n font-size: 12px;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n opacity: 0.6;\n}\n\n.ad-rows {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.ad-row {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 12px;\n border: 1px solid var(--ad-line);\n border-radius: 8px;\n padding: 10px 12px;\n /*\n * The left edge is reserved on EVERY row so the current one does not shift its content when\n * selected — a row that reindents on click makes the list feel like it moved.\n */\n border-left: 3px solid transparent;\n}\n\n/*\n * 🔴 The current channel is marked three ways, not one.\n *\n * It used to be a small badge plus a barely-different border, and the owner could not tell at a\n * glance which relay was live — glanceability is the whole point of a single-choice list. So:\n * a colour bar down the left edge, a stronger border, and a tinted fill. All derived from\n * `currentColor`, because this host ships no colour tokens (measured: 29 custom properties, none\n * of them a colour), so any hardcoded value would assume the wrong theme.\n */\n.ad-row-current {\n border-color: var(--ad-line-strong);\n border-left-color: currentColor;\n background: var(--ad-fill-current);\n}\n\n/* The 当前 badge keeps its place as the textual confirmation, for when colour is not enough. */\n.ad-row-current .ad-badge {\n border-color: currentColor;\n opacity: 1;\n font-weight: 600;\n}\n\n/* A rename button must not look like the row's own selection affordance. */\n.ad-renameBtn {\n margin-left: auto;\n flex: none;\n}\n\n.ad-rowMain {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 0;\n}\n\n.ad-rowTitleLine {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 13px;\n font-weight: 500;\n}\n\n.ad-rowTitle {\n white-space: nowrap;\n}\n\n.ad-badge {\n font-size: 10px;\n padding: 1px 6px;\n border-radius: 999px;\n border: 1px solid currentColor;\n opacity: 0.8;\n}\n\n.ad-warn {\n color: #b7791f;\n cursor: help;\n}\n\n.ad-rowMeta {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n opacity: 0.66;\n min-width: 0;\n}\n\n.ad-mono {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n word-break: break-all;\n}\n\n.ad-sep {\n opacity: 0.5;\n}\n\n.ad-rowActions {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n}\n\n/*\n * Buttons: sized to be hit, not guessed at.\n *\n * The previous 4px/10px padding made a 22px-tall target, which is below the comfortable\n * threshold and made the row actions (测试 / 移除) look like text rather than controls. 6px/12px\n * with a minimum width gives each one a real box while keeping the row compact — a row action is\n * secondary, so it should be easy to hit and easy to ignore.\n */\n.ad-btn {\n font-size: 11px;\n font-family: inherit;\n line-height: 1.5;\n padding: 5px 12px;\n min-width: 56px;\n min-height: 26px;\n border-radius: 6px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n cursor: pointer;\n transition: background-color 120ms ease, border-color 120ms ease, opacity 120ms ease;\n}\n\n.ad-btn:hover:not(:disabled) {\n border-color: var(--ad-line-strong);\n background: var(--ad-fill-subtle);\n}\n\n.ad-btn:disabled {\n opacity: 0.45;\n cursor: default;\n}\n\n.ad-btn-quiet {\n opacity: 0.85;\n}\n\n.ad-probe {\n font-size: 11px;\n padding-top: 2px;\n}\n\n.ad-probe-ok {\n color: #2f855a;\n}\n\n.ad-probe-bad {\n color: #c53030;\n word-break: break-word;\n}\n\n.ad-hint,\n.ad-empty,\n.ad-note {\n margin: 0;\n font-size: 11px;\n line-height: 1.6;\n opacity: 0.66;\n}\n\n.ad-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n flex-shrink: 0;\n display: inline-block;\n}\n\n.ad-dot-on {\n background: #2f855a;\n}\n\n.ad-dot-off {\n border: 1px solid currentColor;\n opacity: 0.5;\n}\n\n.ad-dot-unknown {\n background: linear-gradient(90deg, currentColor 50%, transparent 50%);\n opacity: 0.5;\n}\n\n/* The channel row is a radio \"pick\" zone: the whole left side is clickable, and the radio sits\n at the start of the line so \"which one is live\" is readable down a single column. */\n.ad-rowPick {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n flex: 1;\n min-width: 0;\n cursor: pointer;\n}\n\n.ad-rowPick input[type='radio'] {\n flex: none;\n /* Optically centred against the first text line rather than the row box. */\n margin-top: 3px;\n accent-color: var(--ad-line-strong);\n cursor: pointer;\n}\n\n/* The switch is gone: selection is the only axis (see `channels.ts`). Keeping the rules would\n invite someone to bring the second axis back. */\n\n/* ── top-level tabs (渠道 / 用量) ──────────────────────────────────────────── */\n\n/*\n * The panel answers two questions asked at different moments: \"which relay serves me and does\n * its key work\" and \"what has each relay cost\". They used to be stacked on one scroll, so\n * reading the cost table meant scrolling past every channel row. As tabs, each answer gets the\n * whole panel and the reader never scrolls to reach the second one.\n *\n * The selected tab is marked by a bottom rule plus full opacity — not by colour alone, because\n * this host ships no colour tokens and a colour-only cue would vary with the theme.\n */\n.ad-tabs {\n display: flex;\n gap: 4px;\n border-bottom: 1px solid var(--ad-line);\n}\n\n.ad-tab {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n /* Comfortable click target: 28px tall is enough to hit without aiming, and still compact\n enough that two tabs do not dominate the panel. */\n padding: 6px 14px;\n font-size: 12px;\n font-family: inherit;\n border: none;\n border-bottom: 2px solid transparent;\n /* Pulled down over the container's rule so the selected tab's underline replaces it. */\n margin-bottom: -1px;\n background: transparent;\n color: inherit;\n opacity: 0.62;\n cursor: pointer;\n}\n\n.ad-tab:hover {\n opacity: 0.85;\n}\n\n.ad-tab-on {\n opacity: 1;\n font-weight: 600;\n border-bottom-color: currentColor;\n}\n\n/* A count on the channel tab, so \"how many do I have\" is answered without opening it. */\n.ad-tabCount {\n font-size: 10px;\n font-weight: 500;\n min-width: 16px;\n padding: 0 4px;\n border-radius: 999px;\n text-align: center;\n background: var(--ad-fill-subtle);\n opacity: 0.8;\n}\n\n/* ── segmented control (按渠道 / 按天 / 刷新) ─────────────────────────────── */\n\n/*\n * A segmented control rather than free-floating pill buttons: these three share one meaning\n * (\"how the table below is grouped, and re-read it\"), so they are drawn as one object. The\n * borders collapse to avoid double lines between neighbours.\n */\n.ad-segmented {\n display: inline-flex;\n align-items: stretch;\n border: 1px solid var(--ad-line);\n border-radius: 7px;\n overflow: hidden;\n}\n\n.ad-seg {\n padding: 4px 11px;\n font-size: 11px;\n font-family: inherit;\n border: none;\n border-right: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n opacity: 0.7;\n cursor: pointer;\n}\n\n.ad-seg:last-child {\n border-right: none;\n}\n\n.ad-seg:hover:not(:disabled) {\n opacity: 1;\n background: var(--ad-fill-subtle);\n}\n\n.ad-seg-on {\n opacity: 1;\n font-weight: 600;\n background: var(--ad-fill-subtle);\n}\n\n/* Refresh is an action, not a grouping — a gap separates it from the choice it refreshes. */\n.ad-seg-refresh {\n border-left: 1px solid var(--ad-line);\n}\n\n.ad-table {\n width: 100%;\n border-collapse: collapse;\n font-size: 11px;\n}\n\n.ad-table th,\n.ad-table td {\n padding: 5px 8px;\n border-bottom: 1px solid var(--ad-line);\n text-align: left;\n}\n\n.ad-table th {\n font-weight: 500;\n opacity: 0.6;\n}\n\n.ad-num {\n text-align: right;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n\n.ad-total td {\n font-weight: 600;\n border-bottom: none;\n}\n\n/* ── add-channel form ─────────────────────────────────────────────────────── */\n\n.ad-addForm {\n display: flex;\n flex-direction: column;\n gap: 14px;\n border: 1px dashed var(--ad-line);\n border-radius: 8px;\n padding: 14px;\n}\n\n.ad-field {\n display: flex;\n flex-direction: column;\n gap: 5px;\n font-size: 11px;\n}\n\n/* The label row: the field name, plus a required/optional marker that says which is which. */\n.ad-field > span:first-child {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 12px;\n font-weight: 500;\n opacity: 0.9;\n}\n\n/*\n * 必选 / 可空 as a small marker rather than a sentence.\n *\n * The add form used to leave the reader guessing which fields could be skipped — and the one\n * field that could not be skipped (the key) looked exactly like the two that could. The marker\n * is text, not colour, so it survives both themes and any colour-blindness.\n */\n.ad-req,\n.ad-opt {\n font-style: normal;\n font-size: 10px;\n font-weight: 400;\n padding: 1px 5px;\n border-radius: 4px;\n opacity: 0.7;\n}\n\n.ad-req {\n border: 1px solid var(--ad-line-strong);\n}\n\n.ad-opt {\n border: 1px dotted var(--ad-line);\n}\n\n/* Why this field exists, in one line — printed under the control, not hidden in a tooltip. */\n.ad-fieldHelp {\n font-size: 11px;\n line-height: 1.55;\n opacity: 0.6;\n}\n\n.ad-input {\n font-size: 12px;\n font-family: inherit;\n /* 6px/9px keeps the control the same height as a `.ad-btn` (26px), so a field and its button\n on one line align without a per-case override. */\n padding: 5px 9px;\n min-height: 26px;\n border-radius: 6px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n}\n\n.ad-input:focus-visible {\n outline: 2px solid var(--ad-line-strong);\n outline-offset: 1px;\n border-color: var(--ad-line-strong);\n}\n\n/*\n * 🔴 A native `<select>` cannot be themed by leaving the background transparent.\n *\n * The popup list is drawn by the browser, not by the closed control's box: the options inherit\n * nothing from it, so `background: transparent` made them render in the platform default (light)\n * palette against this dark theme — reported by the owner and measured on the real host.\n *\n * 🔴 SECOND correction (measured 2026-10-21): this file used `var(--dsw-alias-color-*, #fallback)`\n * throughout, and **none of those variables exist**. The host declares only 29 custom properties\n * in total and not one is a colour token, so every `var()` here was falling back — and a fallback\n * that happens to be set inside a shorthand can be silently dropped (the select's background\n * computed to `rgba(0,0,0,0)`, i.e. still transparent, which is exactly the bug).\n *\n * ⇒ Use only values this file controls, derived from `currentColor` (the host sets a light text\n * colour on both themes) plus explicit translucent layers. No host token is assumed.\n */\nselect.ad-input {\n /* Tells the engine which palette to use for the popup. This part DID work. */\n color-scheme: dark light;\n /*\n * Paint the closed box with a layer that follows the surrounding text colour, so it matches\n * whichever theme is active instead of hardcoding a dark value into a light theme.\n */\n background-color: color-mix(in srgb, currentColor 8%, transparent);\n color: inherit;\n cursor: pointer;\n padding-right: 22px;\n /* Room for the chevron drawn below, so text never runs under it. */\n appearance: none;\n background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),\n linear-gradient(135deg, currentColor 50%, transparent 50%);\n background-position: calc(100% - 12px) calc(50% + 1px), calc(100% - 8px) calc(50% + 1px);\n background-size: 4px 4px, 4px 4px;\n background-repeat: no-repeat;\n}\n\nselect.ad-input:hover:not(:disabled) {\n background-color: color-mix(in srgb, currentColor 14%, transparent);\n}\n\nselect.ad-input:focus-visible {\n outline: 2px solid currentColor;\n outline-offset: 1px;\n}\n\n/*\n * The popup itself follows `color-scheme`; these keep the option rows explicit for engines that\n * honour per-option colours. `color-scheme: dark` above is what actually stops the light-on-dark\n * list, because the popup is native chrome rather than a styled element.\n */\nselect.ad-input option {\n background-color: Canvas;\n color: CanvasText;\n}\n\n.ad-fieldError {\n margin: 0;\n font-size: 11px;\n color: #c53030;\n}\n\n/* ── key row ──────────────────────────────────────────────────────────────── */\n\n/*\n * The key line states a FACT and offers the fix beside it: \"configured / not configured\",\n * the reference name the secret is filed under, and one text button that opens the input.\n *\n * The value is never shown — the harness credential seam answers \"configured?\" and refuses to\n * answer anything else, so there is nothing to render even if we wanted to.\n */\n.ad-keyLine {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n padding-top: 2px;\n}\n\n.ad-keyOn {\n color: #2f855a;\n}\n\n.ad-keyOff {\n color: #b7791f;\n}\n\n/*\n * Remaining balance.\n *\n * Same line shape as the key state — it is another fact about this channel, not a control. The\n * subtle fill marks it as a measurement (like `ad-keyName`) rather than a warning: the words\n * themselves say whether it is fine (\"72.50 USD\") or not (\"(余额不足)\").\n */\n.ad-balance {\n font-variant-numeric: tabular-nums;\n font-size: 11px;\n padding: 1px 6px;\n border-radius: 4px;\n background: var(--ad-fill-subtle);\n}\n\n/* A section heading with one action on its right (currently: 查询余额). */\n.ad-sectionBar {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n}\n\n/* The reference name, not a secret: monospace so it lines up and misreads as a name, not a value. */\n.ad-keyName {\n font-size: 10px;\n padding: 1px 5px;\n border-radius: 4px;\n background: var(--ad-fill-subtle);\n opacity: 0.8;\n}\n\n/* A text-shaped action. Styled as a link because it opens an inline editor, not a dialog. */\n.ad-link {\n font-size: 11px;\n padding: 0;\n border: none;\n background: none;\n color: inherit;\n text-decoration: underline;\n text-underline-offset: 2px;\n opacity: 0.75;\n cursor: pointer;\n}\n\n.ad-link:hover {\n opacity: 1;\n}\n\n/* The inline key editor: one masked field and one button, on the same line as the fact above. */\n.ad-keyForm {\n display: flex;\n align-items: center;\n gap: 6px;\n padding-top: 4px;\n}\n\n.ad-keyForm .ad-input {\n flex: 1;\n min-width: 0;\n}\n\n/* A destructive action is quiet until hovered: it should be findable, not inviting. */\n.ad-btn-danger {\n opacity: 0.6;\n}\n\n.ad-btn-danger:hover:not(:disabled) {\n opacity: 1;\n border-color: var(--ad-danger);\n color: var(--ad-danger);\n}\n\n/*\n * The one hint that must not be skimmed past: where the key ends up.\n *\n * It carries a left rule and slightly higher opacity than an ordinary hint, because a reader who\n * skips it has been told something false by omission — the key IS in the profile document.\n */\n.ad-hint-warn {\n padding: 8px 10px;\n border-left: 3px solid var(--ad-line-strong);\n background: var(--ad-fill-subtle);\n border-radius: 0 6px 6px 0;\n opacity: 0.82;\n}\n\n/*\n * The table can be wider than a narrow settings pane (ten columns of numbers). Scrolling it\n * inside its own box keeps the tab strip and the channel list reachable, instead of pushing the\n * whole panel sideways.\n */\n.ad-tableWrap {\n overflow-x: auto;\n /* A hint that there is more to the right, without a scrollbar competing with the borders. */\n scrollbar-width: thin;\n}\n\n.ad-table {\n width: 100%;\n border-collapse: collapse;\n font-size: 11px;\n}\n\n/* The first column is a name, not a number: keep it from being squeezed by the numeric columns. */\n.ad-table th:first-child,\n.ad-table td:first-child {\n white-space: nowrap;\n padding-right: 14px;\n}\n\n/* ── footer ───────────────────────────────────────────────────────────────── */\n\n/*\n * States what this thing is (MIT, open, improvable) and where it lives. A user deciding whether\n * to fork it or add a relay should not have to search for the source.\n */\n.ad-foot {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 8px;\n padding-top: 10px;\n border-top: 1px solid var(--ad-line);\n font-size: 11px;\n opacity: 0.6;\n}\n\n.ad-footLink {\n color: inherit;\n text-decoration: underline;\n text-underline-offset: 2px;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 10px;\n}\n\n.ad-footLink:hover {\n opacity: 0.9;\n}\n";
204
+ const css = "/* Panel styles.\n *\n * 🔴 There are NO `--dsw-alias-*` design tokens on this host (measured 2026-10-21: the document\n * declares 29 custom properties and not one is a colour). This file used to reference a dozen of\n * them, every one falling back to a hardcoded LIGHT value (`rgba(0,0,0,0.12)` and friends) — so\n * on the dark theme the borders and dividers were effectively invisible.\n *\n * ⇒ Derive everything from `currentColor`, which the host sets per theme. `color-mix` gives the\n * low-contrast borders and fills that a token would have provided, and it follows the theme\n * automatically instead of assuming one palette.\n */\n\n.ad-page {\n display: flex;\n flex-direction: column;\n gap: 20px;\n padding: 4px 2px 12px;\n}\n\n/* One place to change the two theme-derived values the whole panel uses. */\n.ad-page {\n --ad-line: color-mix(in srgb, currentColor 18%, transparent);\n --ad-line-strong: color-mix(in srgb, currentColor 32%, transparent);\n --ad-fill-subtle: color-mix(in srgb, currentColor 7%, transparent);\n /* The current row's fill: visible against both themes because it is derived from the text\n colour the host already picked, not from an assumed palette. */\n --ad-fill-current: color-mix(in srgb, currentColor 11%, transparent);\n --ad-danger: #e5534b;\n}\n\n.ad-head {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.ad-title {\n margin: 0;\n font-size: 15px;\n font-weight: 600;\n}\n\n.ad-intro {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n opacity: 0.72;\n}\n\n.ad-error {\n border: 1px solid var(--ad-danger);\n border-radius: 6px;\n padding: 8px 10px;\n font-size: 12px;\n}\n\n.ad-section {\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.ad-sectionTitle {\n margin: 0;\n font-size: 12px;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n opacity: 0.6;\n}\n\n.ad-rows {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n/*\n * 🔴 Compact rows (owner request 2026-10-24: 「每个渠道高度希望小一些,紧凑些」).\n *\n * The row used to be a four-line stack — title / model·api·baseURL / key line / balance — and\n * with five channels that is most of the panel spent on air. The fix is layout, not information:\n * everything stays on screen, but the secondary facts move BESIDE the name instead of under it.\n *\n * What changed and why:\n * · padding 10px→7px and gap 6px→4px: the row now reads as one line of text plus a hairline of\n * metadata, instead of a card with its own interior spacing;\n * · `ad-rowMain` became a ROW (was a column): the title line and the meta line share one axis,\n * so the row is two text lines tall at most, not four;\n * · the meta line keeps `flex-wrap` — on a narrow panel it may wrap, and that is the correct\n * trade: a row that stays readable beats one that clips the endpoint URL.\n */\n.ad-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 10px;\n border: 1px solid var(--ad-line);\n border-radius: 8px;\n padding: 7px 10px;\n /*\n * The left edge is reserved on EVERY row so the current one does not shift its content when\n * selected — a row that reindents on click makes the list feel like it moved.\n */\n border-left: 3px solid transparent;\n}\n\n/*\n * 🔴 The current channel is marked three ways, not one.\n *\n * It used to be a small badge plus a barely-different border, and the owner could not tell at a\n * glance which relay was live — glanceability is the whole point of a single-choice list. So:\n * a colour bar down the left edge, a stronger border, and a tinted fill. All derived from\n * `currentColor`, because this host ships no colour tokens (measured: 29 custom properties, none\n * of them a colour), so any hardcoded value would assume the wrong theme.\n */\n.ad-row-current {\n border-color: var(--ad-line-strong);\n border-left-color: currentColor;\n background: var(--ad-fill-current);\n}\n\n/* The 当前 badge keeps its place as the textual confirmation, for when colour is not enough. */\n.ad-row-current .ad-badge {\n border-color: currentColor;\n opacity: 1;\n font-weight: 600;\n}\n\n/* A rename button must not look like the row's own selection affordance. */\n.ad-renameBtn {\n margin-left: auto;\n flex: none;\n}\n\n/*\n * The row's content is a COLUMN of at most two lines: (1) name + status, (2) everything else.\n * It was a four-line stack before the compactness pass — see the note on `.ad-row`.\n */\n.ad-rowMain {\n display: flex;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n flex: 1 1 auto;\n}\n\n.ad-rowTitleLine {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 13px;\n font-weight: 500;\n min-width: 0;\n}\n\n.ad-rowTitle {\n white-space: nowrap;\n}\n\n.ad-badge {\n font-size: 10px;\n padding: 1px 6px;\n border-radius: 999px;\n border: 1px solid currentColor;\n opacity: 0.8;\n}\n\n.ad-warn {\n color: #b7791f;\n cursor: help;\n}\n\n/*\n * The single detail line under a channel's name.\n *\n * It carries what used to be FOUR stacked lines (endpoint identity / key state / probe verdict /\n * balance). `flex-wrap` is deliberate: on a narrow panel these wrap to a second line, which is a\n * better outcome than clipping the endpoint URL. `row-gap` is tighter than `column-gap` so a wrap\n * still reads as one paragraph rather than as separate rows.\n */\n.ad-rowDetail {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n column-gap: 6px;\n row-gap: 2px;\n font-size: 11px;\n opacity: 0.66;\n min-width: 0;\n}\n\n.ad-mono {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n word-break: break-all;\n}\n\n.ad-sep {\n opacity: 0.5;\n}\n\n.ad-rowActions {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n}\n\n/*\n * Buttons: sized to be hit, not guessed at.\n *\n * The previous 4px/10px padding made a 22px-tall target, which is below the comfortable\n * threshold and made the row actions (测试 / 移除) look like text rather than controls. 6px/12px\n * with a minimum width gives each one a real box while keeping the row compact — a row action is\n * secondary, so it should be easy to hit and easy to ignore.\n */\n.ad-btn {\n font-size: 11px;\n font-family: inherit;\n line-height: 1.5;\n padding: 5px 12px;\n min-width: 56px;\n min-height: 26px;\n border-radius: 6px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n cursor: pointer;\n transition: background-color 120ms ease, border-color 120ms ease, opacity 120ms ease;\n}\n\n.ad-btn:hover:not(:disabled) {\n border-color: var(--ad-line-strong);\n background: var(--ad-fill-subtle);\n}\n\n.ad-btn:disabled {\n opacity: 0.45;\n cursor: default;\n}\n\n.ad-btn-quiet {\n opacity: 0.85;\n}\n\n.ad-probe {\n font-size: 11px;\n padding-top: 2px;\n}\n\n.ad-probe-ok {\n color: #2f855a;\n}\n\n.ad-probe-bad {\n color: #c53030;\n word-break: break-word;\n}\n\n.ad-hint,\n.ad-empty,\n.ad-note {\n margin: 0;\n font-size: 11px;\n line-height: 1.6;\n opacity: 0.66;\n}\n\n.ad-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n flex-shrink: 0;\n display: inline-block;\n}\n\n.ad-dot-on {\n background: #2f855a;\n}\n\n.ad-dot-off {\n border: 1px solid currentColor;\n opacity: 0.5;\n}\n\n.ad-dot-unknown {\n background: linear-gradient(90deg, currentColor 50%, transparent 50%);\n opacity: 0.5;\n}\n\n/* The channel row is a radio \"pick\" zone: the whole left side is clickable, and the radio sits\n at the start of the line so \"which one is live\" is readable down a single column. */\n.ad-rowPick {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n flex: 1;\n min-width: 0;\n cursor: pointer;\n}\n\n.ad-rowPick input[type='radio'] {\n flex: none;\n /* Optically centred against the first text line rather than the row box. */\n margin-top: 3px;\n accent-color: var(--ad-line-strong);\n cursor: pointer;\n}\n\n/* The switch is gone: selection is the only axis (see `channels.ts`). Keeping the rules would\n invite someone to bring the second axis back. */\n\n/* ── top-level tabs (渠道 / 用量) ──────────────────────────────────────────── */\n\n/*\n * The panel answers two questions asked at different moments: \"which relay serves me and does\n * its key work\" and \"what has each relay cost\". They used to be stacked on one scroll, so\n * reading the cost table meant scrolling past every channel row. As tabs, each answer gets the\n * whole panel and the reader never scrolls to reach the second one.\n *\n * The selected tab is marked by a bottom rule plus full opacity — not by colour alone, because\n * this host ships no colour tokens and a colour-only cue would vary with the theme.\n */\n.ad-tabs {\n display: flex;\n gap: 4px;\n border-bottom: 1px solid var(--ad-line);\n}\n\n.ad-tab {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n /* Comfortable click target: 28px tall is enough to hit without aiming, and still compact\n enough that two tabs do not dominate the panel. */\n padding: 6px 14px;\n font-size: 12px;\n font-family: inherit;\n border: none;\n border-bottom: 2px solid transparent;\n /* Pulled down over the container's rule so the selected tab's underline replaces it. */\n margin-bottom: -1px;\n background: transparent;\n color: inherit;\n opacity: 0.62;\n cursor: pointer;\n}\n\n.ad-tab:hover {\n opacity: 0.85;\n}\n\n.ad-tab-on {\n opacity: 1;\n font-weight: 600;\n border-bottom-color: currentColor;\n}\n\n/* A count on the channel tab, so \"how many do I have\" is answered without opening it. */\n.ad-tabCount {\n font-size: 10px;\n font-weight: 500;\n min-width: 16px;\n padding: 0 4px;\n border-radius: 999px;\n text-align: center;\n background: var(--ad-fill-subtle);\n opacity: 0.8;\n}\n\n/* ── segmented control (按渠道 / 按天 / 刷新) ─────────────────────────────── */\n\n/*\n * A segmented control rather than free-floating pill buttons: these three share one meaning\n * (\"how the table below is grouped, and re-read it\"), so they are drawn as one object. The\n * borders collapse to avoid double lines between neighbours.\n */\n.ad-segmented {\n display: inline-flex;\n align-items: stretch;\n border: 1px solid var(--ad-line);\n border-radius: 7px;\n overflow: hidden;\n}\n\n.ad-seg {\n padding: 4px 11px;\n font-size: 11px;\n font-family: inherit;\n border: none;\n border-right: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n opacity: 0.7;\n cursor: pointer;\n}\n\n.ad-seg:last-child {\n border-right: none;\n}\n\n.ad-seg:hover:not(:disabled) {\n opacity: 1;\n background: var(--ad-fill-subtle);\n}\n\n.ad-seg-on {\n opacity: 1;\n font-weight: 600;\n background: var(--ad-fill-subtle);\n}\n\n/* Refresh is an action, not a grouping — a gap separates it from the choice it refreshes. */\n.ad-seg-refresh {\n border-left: 1px solid var(--ad-line);\n}\n\n.ad-table {\n width: 100%;\n border-collapse: collapse;\n font-size: 11px;\n}\n\n.ad-table th,\n.ad-table td {\n padding: 5px 8px;\n border-bottom: 1px solid var(--ad-line);\n text-align: left;\n}\n\n.ad-table th {\n font-weight: 500;\n opacity: 0.6;\n}\n\n.ad-num {\n text-align: right;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n\n.ad-total td {\n font-weight: 600;\n border-bottom: none;\n}\n\n/* ── add-channel form ─────────────────────────────────────────────────────── */\n\n.ad-addForm {\n display: flex;\n flex-direction: column;\n gap: 14px;\n border: 1px dashed var(--ad-line);\n border-radius: 8px;\n padding: 14px;\n}\n\n.ad-field {\n display: flex;\n flex-direction: column;\n gap: 5px;\n font-size: 11px;\n}\n\n/* The label row: the field name, plus a required/optional marker that says which is which. */\n.ad-field > span:first-child {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 12px;\n font-weight: 500;\n opacity: 0.9;\n}\n\n/*\n * 必选 / 可空 as a small marker rather than a sentence.\n *\n * The add form used to leave the reader guessing which fields could be skipped — and the one\n * field that could not be skipped (the key) looked exactly like the two that could. The marker\n * is text, not colour, so it survives both themes and any colour-blindness.\n */\n.ad-req,\n.ad-opt {\n font-style: normal;\n font-size: 10px;\n font-weight: 400;\n padding: 1px 5px;\n border-radius: 4px;\n opacity: 0.7;\n}\n\n.ad-req {\n border: 1px solid var(--ad-line-strong);\n}\n\n.ad-opt {\n border: 1px dotted var(--ad-line);\n}\n\n/* Why this field exists, in one line — printed under the control, not hidden in a tooltip. */\n.ad-fieldHelp {\n font-size: 11px;\n line-height: 1.55;\n opacity: 0.6;\n}\n\n.ad-input {\n font-size: 12px;\n font-family: inherit;\n /* 6px/9px keeps the control the same height as a `.ad-btn` (26px), so a field and its button\n on one line align without a per-case override. */\n padding: 5px 9px;\n min-height: 26px;\n border-radius: 6px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n}\n\n.ad-input:focus-visible {\n outline: 2px solid var(--ad-line-strong);\n outline-offset: 1px;\n border-color: var(--ad-line-strong);\n}\n\n/*\n * 🔴 A native `<select>` cannot be themed by leaving the background transparent.\n *\n * The popup list is drawn by the browser, not by the closed control's box: the options inherit\n * nothing from it, so `background: transparent` made them render in the platform default (light)\n * palette against this dark theme — reported by the owner and measured on the real host.\n *\n * 🔴 SECOND correction (measured 2026-10-21): this file used `var(--dsw-alias-color-*, #fallback)`\n * throughout, and **none of those variables exist**. The host declares only 29 custom properties\n * in total and not one is a colour token, so every `var()` here was falling back — and a fallback\n * that happens to be set inside a shorthand can be silently dropped (the select's background\n * computed to `rgba(0,0,0,0)`, i.e. still transparent, which is exactly the bug).\n *\n * ⇒ Use only values this file controls, derived from `currentColor` (the host sets a light text\n * colour on both themes) plus explicit translucent layers. No host token is assumed.\n */\nselect.ad-input {\n /* Tells the engine which palette to use for the popup. This part DID work. */\n color-scheme: dark light;\n /*\n * Paint the closed box with a layer that follows the surrounding text colour, so it matches\n * whichever theme is active instead of hardcoding a dark value into a light theme.\n */\n background-color: color-mix(in srgb, currentColor 8%, transparent);\n color: inherit;\n cursor: pointer;\n padding-right: 22px;\n /* Room for the chevron drawn below, so text never runs under it. */\n appearance: none;\n background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),\n linear-gradient(135deg, currentColor 50%, transparent 50%);\n background-position: calc(100% - 12px) calc(50% + 1px), calc(100% - 8px) calc(50% + 1px);\n background-size: 4px 4px, 4px 4px;\n background-repeat: no-repeat;\n}\n\nselect.ad-input:hover:not(:disabled) {\n background-color: color-mix(in srgb, currentColor 14%, transparent);\n}\n\nselect.ad-input:focus-visible {\n outline: 2px solid currentColor;\n outline-offset: 1px;\n}\n\n/*\n * The popup itself follows `color-scheme`; these keep the option rows explicit for engines that\n * honour per-option colours. `color-scheme: dark` above is what actually stops the light-on-dark\n * list, because the popup is native chrome rather than a styled element.\n */\nselect.ad-input option {\n background-color: Canvas;\n color: CanvasText;\n}\n\n.ad-fieldError {\n margin: 0;\n font-size: 11px;\n color: #c53030;\n}\n\n/* ── key row ──────────────────────────────────────────────────────────────── */\n\n/*\n * The key line states a FACT and offers the fix beside it: \"configured / not configured\",\n * the reference name the secret is filed under, and one text button that opens the input.\n *\n * The value is never shown — the harness credential seam answers \"configured?\" and refuses to\n * answer anything else, so there is nothing to render even if we wanted to.\n *\n * 🔴 `inline-flex`, not `flex`, and NO vertical padding: since the compactness pass this sits\n * INSIDE `.ad-rowDetail` (one wrapping line), so a block-level flex box with `padding-top: 2px`\n * would add a break and a 2px bump to every row. Inline keeps \"密钥已配置 更换\" together as one\n * token in the flow.\n */\n.ad-keyLine {\n display: inline-flex;\n flex-wrap: nowrap;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n}\n\n.ad-keyOn {\n color: #2f855a;\n}\n\n.ad-keyOff {\n color: #b7791f;\n}\n\n/*\n * Remaining balance.\n *\n * Same line shape as the key state — it is another fact about this channel, not a control. The\n * subtle fill marks it as a measurement (like `ad-keyName`) rather than a warning: the words\n * themselves say whether it is fine (\"72.50 USD\") or not (\"(余额不足)\").\n */\n.ad-balance {\n font-variant-numeric: tabular-nums;\n font-size: 11px;\n padding: 1px 6px;\n border-radius: 4px;\n background: var(--ad-fill-subtle);\n}\n\n/* A section heading with one action on its right (currently: 查询余额). */\n.ad-sectionBar {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n}\n\n/* The reference name, not a secret: monospace so it lines up and misreads as a name, not a value. */\n.ad-keyName {\n font-size: 10px;\n padding: 1px 5px;\n border-radius: 4px;\n background: var(--ad-fill-subtle);\n opacity: 0.8;\n}\n\n/* A text-shaped action. Styled as a link because it opens an inline editor, not a dialog. */\n.ad-link {\n font-size: 11px;\n padding: 0;\n border: none;\n background: none;\n color: inherit;\n text-decoration: underline;\n text-underline-offset: 2px;\n opacity: 0.75;\n cursor: pointer;\n}\n\n.ad-link:hover {\n opacity: 1;\n}\n\n/* The inline key editor: one masked field and one button, on the same line as the fact above. */\n.ad-keyForm {\n display: flex;\n align-items: center;\n gap: 6px;\n padding-top: 4px;\n}\n\n.ad-keyForm .ad-input {\n flex: 1;\n min-width: 0;\n}\n\n/* A destructive action is quiet until hovered: it should be findable, not inviting. */\n.ad-btn-danger {\n opacity: 0.6;\n}\n\n.ad-btn-danger:hover:not(:disabled) {\n opacity: 1;\n border-color: var(--ad-danger);\n color: var(--ad-danger);\n}\n\n/*\n * The one hint that must not be skimmed past: where the key ends up.\n *\n * It carries a left rule and slightly higher opacity than an ordinary hint, because a reader who\n * skips it has been told something false by omission — the key IS in the profile document.\n */\n.ad-hint-warn {\n padding: 8px 10px;\n border-left: 3px solid var(--ad-line-strong);\n background: var(--ad-fill-subtle);\n border-radius: 0 6px 6px 0;\n opacity: 0.82;\n}\n\n/*\n * The table can be wider than a narrow settings pane (ten columns of numbers). Scrolling it\n * inside its own box keeps the tab strip and the channel list reachable, instead of pushing the\n * whole panel sideways.\n */\n.ad-tableWrap {\n overflow-x: auto;\n /* A hint that there is more to the right, without a scrollbar competing with the borders. */\n scrollbar-width: thin;\n}\n\n.ad-table {\n width: 100%;\n border-collapse: collapse;\n font-size: 11px;\n}\n\n/* The first column is a name, not a number: keep it from being squeezed by the numeric columns. */\n.ad-table th:first-child,\n.ad-table td:first-child {\n white-space: nowrap;\n padding-right: 14px;\n}\n\n/* ── footer ───────────────────────────────────────────────────────────────── */\n\n/*\n * States what this thing is (MIT, open, improvable) and where it lives. A user deciding whether\n * to fork it or add a relay should not have to search for the source.\n */\n.ad-foot {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 8px;\n padding-top: 10px;\n border-top: 1px solid var(--ad-line);\n font-size: 11px;\n opacity: 0.6;\n}\n\n.ad-footLink {\n color: inherit;\n text-decoration: underline;\n text-underline-offset: 2px;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 10px;\n}\n\n.ad-footLink:hover {\n opacity: 0.9;\n}\n";
192
205
  if (typeof document !== "undefined" && document.querySelector("style[data-ad-css=\"SettingsPage\"]") === null) {
193
206
  const tag = document.createElement("style");
194
207
  tag.setAttribute("data-ad-css", "SettingsPage");
@@ -649,67 +662,75 @@ window.__ModuleLoader__.load({
649
662
  })]
650
663
  }),
651
664
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
652
- className: "ad-rowMeta",
665
+ className: "ad-rowDetail",
653
666
  children: [
654
667
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
655
668
  className: "ad-mono",
669
+ title: "上游模型 id",
656
670
  children: row.model
657
671
  }),
658
672
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
659
673
  className: "ad-sep",
660
674
  children: "·"
661
675
  }),
662
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: row.api }),
676
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
677
+ title: "接口协议",
678
+ children: row.api
679
+ }),
663
680
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
664
681
  className: "ad-sep",
665
682
  children: "·"
666
683
  }),
667
684
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
668
685
  className: "ad-mono",
686
+ title: "端点地址",
669
687
  children: row.baseURL
670
- })
671
- ]
672
- }),
673
- probeResult !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
674
- className: `ad-probe ad-probe-${probeResult.ok ? "ok" : "bad"}`,
675
- children: probeResult.ok ? `通(${probeResult.ms}ms)` : `不通:${probeResult.code ?? "ERROR"}${probeResult.message === void 0 ? "" : ` — ${probeResult.message}`}`
676
- }),
677
- row.keyConfigured ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
678
- className: "ad-keyLine",
679
- children: [
680
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
681
- className: "ad-keyOn",
682
- children: "密钥已配置"
683
688
  }),
684
- row.keyKind === "reference" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
685
- className: "ad-mono ad-keyName",
686
- children: row.keyRef
689
+ probeResult !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
690
+ className: `ad-probe ad-probe-${probeResult.ok ? "ok" : "bad"}`,
691
+ children: probeResult.ok ? `通(${probeResult.ms}ms)` : `不通:${probeResult.code ?? "ERROR"}${probeResult.message === void 0 ? "" : ` — ${probeResult.message}`}`
687
692
  }),
688
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
689
- type: "button",
690
- className: "ad-link",
691
- onClick: () => setKeying(keying === row.id ? null : row.id),
692
- children: keying === row.id ? "取消" : "更换"
693
+ row.keyConfigured ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
694
+ className: "ad-keyLine",
695
+ children: [
696
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
697
+ className: "ad-keyOn",
698
+ children: "密钥已配置"
699
+ }),
700
+ row.keyKind === "reference" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
701
+ className: "ad-mono ad-keyName",
702
+ children: row.keyRef
703
+ }),
704
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
705
+ type: "button",
706
+ className: "ad-link",
707
+ onClick: (event) => {
708
+ event.preventDefault();
709
+ setKeying(keying === row.id ? null : row.id);
710
+ },
711
+ children: keying === row.id ? "取消" : "更换"
712
+ })
713
+ ]
714
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
715
+ className: "ad-keyLine",
716
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
717
+ className: "ad-keyOff",
718
+ children: "未配置密钥"
719
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
720
+ type: "button",
721
+ className: "ad-link",
722
+ onClick: (event) => {
723
+ event.preventDefault();
724
+ setKeying(keying === row.id ? null : row.id);
725
+ },
726
+ children: keying === row.id ? "取消" : "填写密钥"
727
+ })]
728
+ }),
729
+ balances[row.id] !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
730
+ className: "ad-balance",
731
+ children: formatBalance(balances[row.id])
693
732
  })
694
733
  ]
695
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
696
- className: "ad-keyLine",
697
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
698
- className: "ad-keyOff",
699
- children: "未配置密钥"
700
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
701
- type: "button",
702
- className: "ad-link",
703
- onClick: () => setKeying(keying === row.id ? null : row.id),
704
- children: keying === row.id ? "取消" : "填写密钥"
705
- })]
706
- }),
707
- balances[row.id] !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
708
- className: "ad-keyLine",
709
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
710
- className: "ad-balance",
711
- children: formatBalance(balances[row.id])
712
- })
713
734
  }),
714
735
  keying === row.id && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
715
736
  className: "ad-keyForm",
@@ -1005,6 +1026,11 @@ window.__ModuleLoader__.load({
1005
1026
  className: "ad-num",
1006
1027
  children: "合计"
1007
1028
  }),
1029
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
1030
+ className: "ad-num",
1031
+ title: "每次请求平均携带的 token 数(含缓存命中)—— 与模型的 1M 窗口对照看,可判成本由对话长度还是窗口驱动",
1032
+ children: "平均上下文"
1033
+ }),
1008
1034
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
1009
1035
  className: "ad-num",
1010
1036
  title: "生成阶段速度(不含首字等待)",
@@ -1045,6 +1071,10 @@ window.__ModuleLoader__.load({
1045
1071
  className: "ad-num",
1046
1072
  children: formatTokens(row.aggregate.total)
1047
1073
  }),
1074
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1075
+ className: "ad-num",
1076
+ children: formatMeanContext(row.meanContext)
1077
+ }),
1048
1078
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1049
1079
  className: "ad-num",
1050
1080
  children: formatSpeed(row.tokensPerSecond)
@@ -1085,6 +1115,10 @@ window.__ModuleLoader__.load({
1085
1115
  className: "ad-num",
1086
1116
  children: formatTokens(stats.total.total)
1087
1117
  }),
1118
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1119
+ className: "ad-num",
1120
+ children: formatMeanContext(stats.meanContext)
1121
+ }),
1088
1122
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1089
1123
  className: "ad-num",
1090
1124
  children: formatSpeed(stats.totalSpeed)
@@ -1104,7 +1138,9 @@ window.__ModuleLoader__.load({
1104
1138
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: "输入" }),
1105
1139
  "只算未命中缓存的输入;缓存读 / 缓存写单独成列, 三者相加才是",
1106
1140
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: "计费输入" }),
1107
- "。 速度只算生成阶段(总时长减去首字等待),",
1141
+ "。",
1142
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: "平均上下文" }),
1143
+ "是每次请求平均携带的 token 数(含缓存命中)—— 与模型的 1M 窗口对照看:远低于窗口说明成本由对话长度驱动,不是窗口。 速度只算生成阶段(总时长减去首字等待),",
1108
1144
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1109
1145
  className: "ad-mono",
1110
1146
  children: "—"
@@ -1116,6 +1152,14 @@ window.__ModuleLoader__.load({
1116
1152
  " 次请求上游未回报用量,不计入合计。"
1117
1153
  ] })
1118
1154
  ]
1155
+ }),
1156
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1157
+ className: "ad-note",
1158
+ children: [
1159
+ "范围:",
1160
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: "近 7 天" }),
1161
+ "。更早的账本仍在磁盘上(按月的 JSONL,未删除), 只是这个视图默认不展示——历史记录是花销的唯一凭据,删掉就回不来了。"
1162
+ ]
1119
1163
  })
1120
1164
  ]
1121
1165
  }),
package/lib/index.js CHANGED
@@ -306,6 +306,87 @@ const CHANNEL_PRESETS = {
306
306
  ...V41_FLASH_CAPS,
307
307
  compat: { ...DEEPSEEK_THINKING_COMPAT }
308
308
  },
309
+ /**
310
+ * Tencent Cloud's TokenHub — a first-party forwarding endpoint for DeepSeek.
311
+ *
312
+ * Owner request (2026-10-23): 「我本机dsh还配了tokenhub这个渠道,这个渠道是腾讯云的渠道,
313
+ * 它的模型名称是固定的0605后缀就是向官方的转发」.
314
+ *
315
+ * 🔴 **The model id is frozen at `deepseek-v4-flash-202605` — the `-202605` suffix is part of the
316
+ * id, not a date we append.** This is the sharpest illustration of why presets exist: the same
317
+ * model is `deepseek-v4.1-flash` on the relays, `deepseek-flash` on the official API, and
318
+ * `deepseek-v4-flash-202605` here. Getting it wrong does not fail locally — the upstream
319
+ * rejects it on the first request.
320
+ *
321
+ * Endpoint and model id verified against TWO independent records in this container (the
322
+ * 2026-10-15 model-aggregator survey and the 2026-10-19 research note), and against the live
323
+ * `/v1/models` route (401 = present, auth required).
324
+ */
325
+ "tencent-tokenhub": {
326
+ displayName: "Tencent TokenHub",
327
+ api: "openai-completions",
328
+ baseURL: "https://tokenhub.tencentmaas.com/v1/",
329
+ model: "deepseek-v4-flash-202605",
330
+ contextWindow: 1024e3,
331
+ maxTokens: 384e3,
332
+ /**
333
+ * 🔴 **`image` is declared, and the earlier `['text']` was a FABRICATED justification.**
334
+ *
335
+ * 0.0.18 shipped `input: ['text']` with the comment "per the owner's own live profile
336
+ * (`input: [text]`)". That citation was false: the owner's profile declares BOTH. Measured
337
+ * from this machine's own composed profile tree
338
+ * (`msm("dsh-develop", ["dump-config"])` → `llm-pi-ai.providers.zy-tecenthub.models[0]`):
339
+ *
340
+ * id: deepseek-v4-flash-202605
341
+ * input: [text, image]
342
+ *
343
+ * This is the same class of error as the "Probed live" comment this repo retracted once
344
+ * already: an assertion of evidence nobody gathered. It is worse than silence here because
345
+ * it made a REAL defect look like a documented endpoint limitation — see the failure
346
+ * mechanism below.
347
+ *
348
+ * ## What the false declaration actually did (owner report 2026-10-23: 「能用,但收不到图」)
349
+ *
350
+ * A channel that omits `image` does not merely "not offer" images — pi-ai REWRITES them out
351
+ * on the way to the wire: `downgradeUnsupportedImages()` replaces every image block with
352
+ * `"(image omitted: model does not support images)"` when `!model.input.includes('image')`
353
+ * (`pi-ai/dist/api/transform-messages.js:38-57`, applied at
354
+ * `openai-completions.js:899`). The request still succeeds, nothing throws, and the model
355
+ * answers as though the message were text — the exact silent shape the decision-32 fix was
356
+ * written to remove. The declaration IS the delivery switch.
357
+ *
358
+ * ## Why `image` is the honest value here
359
+ *
360
+ * Two independent records agree, and neither is an inference:
361
+ * · the owner's own working profile (quoted above);
362
+ * · Tencent's model list, Chinese edition, updated 2026-10-21, lists this exact id with
363
+ * 「图片理解」: https://cloud.tencent.com/document/product/1823/130051
364
+ * ⚠️ The INTL edition of the same page (updated 2026-09-25) omits it. Both are official and
365
+ * they disagree; the newer one is the one to follow, and the divergence is recorded in
366
+ * `note` rather than hidden — so the next reader knows this claim had a live dispute, not a
367
+ * clean consensus.
368
+ */
369
+ input: ["text", "image"],
370
+ reasoningEfforts: { ...V41_FLASH_CAPS.reasoningEfforts },
371
+ compat: { ...DEEPSEEK_THINKING_COMPAT },
372
+ /**
373
+ * 🔴 **No balance surface — measured by status-code discrimination, not assumed.**
374
+ *
375
+ * Without a key, the inference route answers **401** while a made-up path answers **404**,
376
+ * so 401 here means "route exists, auth required". Four balance candidates were then probed
377
+ * at the same prefix and ALL answered 404, i.e. they do not exist:
378
+ * /v1/dashboard/billing/subscription 404
379
+ * /v1/user/balance 404
380
+ * /v1/credits 404
381
+ *
382
+ * ⇒ No `balance` is declared. The panel therefore reports "no balance API" and falls back to
383
+ * ONE minimal live request (`http.ts` `balanceRow`), showing 「余额未尽」 with the serving
384
+ * model and round-trip time — exactly what the owner asked for (「余额查询是没有的做成测通
385
+ * 查询即可」). Declaring a speculative `openai-billing` shape would have produced a 404 on
386
+ * every check, which is the mistake this repo already made once and recorded.
387
+ */
388
+ note: "Tencent Cloud forwarding endpoint; model id is frozen with a -202605 suffix. Takes images (owner profile + Tencent model list, 2026-10-21). No balance API — the panel verifies liveness instead."
389
+ },
309
390
  ollama: {
310
391
  displayName: "Ollama Cloud",
311
392
  api: "openai-completions",
@@ -1552,6 +1633,39 @@ function meanFirstTokenMs(aggregate) {
1552
1633
  if (aggregate.timed <= 0) return null;
1553
1634
  return aggregate.ttftMs / aggregate.timed;
1554
1635
  }
1636
+ /**
1637
+ * Mean context size per request, in tokens — the owner's 「平均的上下文窗口大小」.
1638
+ *
1639
+ * ## What this number is, precisely
1640
+ *
1641
+ * It is the mean of *tokens carried by one request*, not of the model's capacity: the model's
1642
+ * window is 1M for every channel, so reporting that number again would say nothing about how the
1643
+ * thing is actually used. The useful reading is the gap between the two — a mean context far
1644
+ * below the window means the window is not the constraint and cost is driven by conversation
1645
+ * length, which is what makes this number a cost-optimisation reference rather than trivia.
1646
+ *
1647
+ * Numerator is `total` (= billable input + output), i.e. everything that crossed the wire for
1648
+ * that request. Cache hits are INCLUDED: a cached token still occupies the window even though it
1649
+ * is billed at a lower rate, so leaving it out would understate context pressure.
1650
+ *
1651
+ * ## Why the denominator is not `requests`
1652
+ *
1653
+ * `requests` counts every call, including the ones the relay answered without reporting usage
1654
+ * (`unreported`). Those contributed nothing to the numerator, so dividing by them would silently
1655
+ * deflate the average — the same trap `timed` exists to avoid for the speed figures.
1656
+ *
1657
+ * 🔴 **Not `requests - unreported - failures` either.** A failed call that reported no usage is
1658
+ * already inside `unreported` (`addToAggregate` increments both), so subtracting `failures` as
1659
+ * well would remove it twice and overstate the mean. The single correct denominator is the count
1660
+ * of entries that actually contributed to `total`, which is exactly `requests - unreported`.
1661
+ * @param aggregate - one group's figures.
1662
+ * @returns mean tokens per usage-reporting request, or null when none reported.
1663
+ */
1664
+ function meanContextTokens(aggregate) {
1665
+ const reported = aggregate.requests - aggregate.unreported;
1666
+ if (reported <= 0) return null;
1667
+ return aggregate.total / reported;
1668
+ }
1555
1669
  /** Empty aggregate, used both as an accumulator seed and as a zero row. */
1556
1670
  function emptyAggregate() {
1557
1671
  return {
@@ -2438,8 +2552,9 @@ function registerStatsRoutes(ctx, webServer, deps) {
2438
2552
  path: `${ROUTE_PREFIX}/stats`,
2439
2553
  handler: async (req, res) => {
2440
2554
  const url = new URL(req.url ?? "/", "http://localhost");
2441
- const from = queryDate(url.searchParams.get("from"));
2442
- const to = queryDate(url.searchParams.get("to"));
2555
+ const requestedFrom = queryDate(url.searchParams.get("from"));
2556
+ const to = queryDate(url.searchParams.get("to")) ?? (requestedFrom === void 0 ? /* @__PURE__ */ new Date() : void 0);
2557
+ const from = requestedFrom ?? (to === void 0 ? void 0 : /* @__PURE__ */ new Date(to.getTime() - 6048e5));
2443
2558
  const group = url.searchParams.get("group") ?? "channel";
2444
2559
  const entries = await readLedgerRange(deps.ledgerDir(), from, to);
2445
2560
  const rows = group === "day" ? summarize(entries, (entry) => dayKey(new Date(entry.at))) : summarize(entries, (entry) => entry.channel);
@@ -2462,7 +2577,8 @@ function registerStatsRoutes(ctx, webServer, deps) {
2462
2577
  ...row,
2463
2578
  label: group === "day" ? row.key : labels.get(row.key) ?? row.key,
2464
2579
  tokensPerSecond: outputTokensPerSecond(row.aggregate),
2465
- firstTokenMs: meanFirstTokenMs(row.aggregate)
2580
+ firstTokenMs: meanFirstTokenMs(row.aggregate),
2581
+ meanContext: meanContextTokens(row.aggregate)
2466
2582
  }));
2467
2583
  const totalAggregate = totalOf(rows);
2468
2584
  json(res, 200, {
@@ -2480,7 +2596,8 @@ function registerStatsRoutes(ctx, webServer, deps) {
2480
2596
  */
2481
2597
  totalSpeed: outputTokensPerSecond(totalAggregate),
2482
2598
  totalFirstTokenMs: meanFirstTokenMs(totalAggregate),
2483
- note: "input is uncached input only; billable input = input + cacheRead + cacheWrite; tokensPerSecond counts generation time only (total minus time-to-first-token)"
2599
+ meanContext: meanContextTokens(totalAggregate),
2600
+ note: "input is uncached input only; billable input = input + cacheRead + cacheWrite; tokensPerSecond counts generation time only (total minus time-to-first-token); meanContext is total tokens per usage-reporting request"
2484
2601
  });
2485
2602
  }
2486
2603
  },
@@ -107,6 +107,35 @@ export declare function outputTokensPerSecond(aggregate: LedgerAggregate): numbe
107
107
  * @param aggregate - one group's figures.
108
108
  */
109
109
  export declare function meanFirstTokenMs(aggregate: LedgerAggregate): number | null;
110
+ /**
111
+ * Mean context size per request, in tokens — the owner's 「平均的上下文窗口大小」.
112
+ *
113
+ * ## What this number is, precisely
114
+ *
115
+ * It is the mean of *tokens carried by one request*, not of the model's capacity: the model's
116
+ * window is 1M for every channel, so reporting that number again would say nothing about how the
117
+ * thing is actually used. The useful reading is the gap between the two — a mean context far
118
+ * below the window means the window is not the constraint and cost is driven by conversation
119
+ * length, which is what makes this number a cost-optimisation reference rather than trivia.
120
+ *
121
+ * Numerator is `total` (= billable input + output), i.e. everything that crossed the wire for
122
+ * that request. Cache hits are INCLUDED: a cached token still occupies the window even though it
123
+ * is billed at a lower rate, so leaving it out would understate context pressure.
124
+ *
125
+ * ## Why the denominator is not `requests`
126
+ *
127
+ * `requests` counts every call, including the ones the relay answered without reporting usage
128
+ * (`unreported`). Those contributed nothing to the numerator, so dividing by them would silently
129
+ * deflate the average — the same trap `timed` exists to avoid for the speed figures.
130
+ *
131
+ * 🔴 **Not `requests - unreported - failures` either.** A failed call that reported no usage is
132
+ * already inside `unreported` (`addToAggregate` increments both), so subtracting `failures` as
133
+ * well would remove it twice and overstate the mean. The single correct denominator is the count
134
+ * of entries that actually contributed to `total`, which is exactly `requests - unreported`.
135
+ * @param aggregate - one group's figures.
136
+ * @returns mean tokens per usage-reporting request, or null when none reported.
137
+ */
138
+ export declare function meanContextTokens(aggregate: LedgerAggregate): number | null;
110
139
  /** Aggregation result for one grouping. */
111
140
  export interface LedgerSummary {
112
141
  /** Group key (channel id, or `YYYY-MM-DD`). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-any-deepseek",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "One DeepSeek V4.1 Flash provider for DeepSeek Harness: paste a key per channel, switch channels manually, token usage booked per real upstream.",
5
5
  "license": "MIT",
6
6
  "repository": {