dsh-any-deepseek 0.0.11 → 0.0.13

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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * balance.ts — reading "how much is left" out of three unrelated account surfaces.
3
+ *
4
+ * ## Why this file is mostly parsing
5
+ *
6
+ * There is no standard. Owner request (2026-10-23): 「其它渠道的查看方式你需要找到并实现它们的」.
7
+ * The three shapes below were each taken from the provider's own documentation, not guessed:
8
+ *
9
+ * · **DeepSeek** — `GET /user/balance`:
10
+ * `{ is_available, balance_infos: [{ currency, total_balance, granted_balance,
11
+ * topped_up_balance }] }` (api-docs.deepseek.com/api/get-user-balance)
12
+ * · **Ollama** — `GET /api/balance`:
13
+ * `{ included: { balance_usd, allowance_usd, period }, purchased: { balance_usd } }`,
14
+ * or, on legacy plans, `included: { session: {remaining_percent}, weekly: {…} }`
15
+ * (docs.ollama.com/api/balance)
16
+ * · **OpenAI-legacy billing** (cloned by new-api / one-api relays):
17
+ * `GET /v1/dashboard/billing/subscription` → `hard_limit_usd` / `soft_limit_usd`
18
+ * `GET /v1/dashboard/billing/usage` → `total_usage` **in cents**
19
+ *
20
+ * ## The rule that shapes every function here
21
+ *
22
+ * 🔴 **A missing number is reported as missing, never as zero.** Three of these surfaces answer
23
+ * with fields that are legitimately absent (a legacy Ollama plan has no `balance_usd`; a relay
24
+ * may omit `hard_limit_usd`). Coercing those to `0` would render as "you are out of credit" —
25
+ * a much worse statement than "not reported", and the one a user would act on.
26
+ *
27
+ * The `1e8` sentinel is the sharpest example: new-api uses `hard_limit_usd: 1e8` to mean
28
+ * *unlimited*, so printing it as dollars would claim a hundred million dollar balance.
29
+ */
30
+ import type { BalanceProbe } from './presets.js';
31
+ /** One currency-denominated remaining amount. */
32
+ export interface BalanceAmount {
33
+ /** ISO-style currency code as the provider spells it (`USD`, `CNY`, …). */
34
+ currency: string;
35
+ /** Remaining amount, in that currency's major unit. */
36
+ remaining: number;
37
+ /** Total allowance for the period, when the provider reports one. */
38
+ allowance?: number;
39
+ /** Gift/promotional part, when the provider splits it out. */
40
+ granted?: number;
41
+ /** Topped-up (purchased) part, when the provider splits it out. */
42
+ toppedUp?: number;
43
+ }
44
+ /**
45
+ * A percentage-based limit, used by providers that meter in windows rather than money.
46
+ *
47
+ * Ollama's legacy plans are the real case: `{ remaining_percent: 75, resets_at }`.
48
+ */
49
+ export interface BalanceWindow {
50
+ /** Window name as the provider spells it (`session`, `weekly`, …). */
51
+ name: string;
52
+ /** Remaining share, 0–100. */
53
+ remainingPercent: number;
54
+ /** Next reset, when reported. */
55
+ resetsAt?: string;
56
+ }
57
+ /** One channel's balance reading. */
58
+ export interface BalanceReading {
59
+ /** Amounts by currency. Empty when the provider reports percentages instead. */
60
+ amounts: BalanceAmount[];
61
+ /** Percentage windows, when the provider meters that way. */
62
+ windows: BalanceWindow[];
63
+ /** Whether the provider says the account can currently make calls. */
64
+ available?: boolean;
65
+ /** Note shown beside the numbers (period, reset, "unlimited", …). */
66
+ note?: string;
67
+ }
68
+ /**
69
+ * The endpoint a probe must call for one shape.
70
+ *
71
+ * 🔴 The path is per shape because the surfaces do not share a base: Ollama infers on `/v1` but
72
+ * reports on `/api`, and DeepSeek reports on the bare origin. This function is the single place
73
+ * that knowledge lives, so a preset cannot disagree with the transport.
74
+ * @param probe - the preset's declared probe.
75
+ * @param channelBaseURL - the channel's inference base URL, used when the probe names none.
76
+ * @returns the absolute URLs to GET, in order.
77
+ */
78
+ export declare function balanceUrls(probe: BalanceProbe, channelBaseURL: string): string[];
79
+ /**
80
+ * The URL a channel's balance lives at, when its preset declares a shape.
81
+ * @param probe - declared probe, or undefined for "no balance API".
82
+ * @param channelBaseURL - the channel's inference base URL.
83
+ * @returns the URLs, or an empty array when this endpoint has no balance surface.
84
+ */
85
+ export declare function probeUrlsFor(probe: BalanceProbe | undefined, channelBaseURL: string): string[];
86
+ /**
87
+ * Parse DeepSeek's `GET /user/balance`.
88
+ *
89
+ * `total_balance` is a STRING in their example (`"110.00"`), so it is read as a number rather
90
+ * than trusted as one — printing a string into a numeric column is how a panel ends up showing
91
+ * `"110.00"` next to arithmetic that silently treats it as NaN.
92
+ * @param body - the parsed JSON body.
93
+ * @returns the reading, or undefined when the body is not this shape.
94
+ */
95
+ export declare function parseDeepSeekBalance(body: unknown): BalanceReading | undefined;
96
+ /**
97
+ * Parse Ollama's `GET /api/balance`.
98
+ *
99
+ * Two plan generations answer this endpoint:
100
+ * · current — `included.balance_usd` / `allowance_usd` + `purchased.balance_usd`;
101
+ * · legacy — `included.session.remaining_percent` / `included.weekly.remaining_percent`.
102
+ * Both are handled because a user on an old plan must still see something true. `remaining` is
103
+ * the sum of included + purchased: both are spendable, so reporting only one understates.
104
+ * @param body - the parsed JSON body.
105
+ * @returns the reading, or undefined when the body is not this shape.
106
+ */
107
+ export declare function parseOllamaBalance(body: unknown): BalanceReading | undefined;
108
+ /**
109
+ * Parse the OpenAI-legacy billing pair.
110
+ *
111
+ * 🔴 Two conversions here are load-bearing, and both are the kind of thing that reads correctly
112
+ * while being wrong:
113
+ * · `total_usage` is in **CENTS**, while `hard_limit_usd` is in **dollars**. Comparing them
114
+ * directly divides the limit by 100 in effect — a balance that looks 99% consumed.
115
+ * · `hard_limit_usd: 1e8` is new-api's **"unlimited" sentinel**, not a hundred million dollars.
116
+ * It is reported as no allowance rather than as a number.
117
+ * @param subscription - body of `/dashboard/billing/subscription`.
118
+ * @param usage - body of `/dashboard/billing/usage`.
119
+ * @returns the reading, or undefined when neither body is this shape.
120
+ */
121
+ export declare function parseOpenAIBilling(subscription: unknown, usage: unknown): BalanceReading | undefined;
122
+ /**
123
+ * Map one channel's raw bodies onto a reading, dispatching on the declared shape.
124
+ * @param probe - the preset's declared probe.
125
+ * @param bodies - response bodies in the order {@link balanceUrls} returned URLs.
126
+ * @returns the reading, or undefined when the bodies do not match the declared shape.
127
+ */
128
+ export declare function readBalance(probe: BalanceProbe, bodies: readonly unknown[]): BalanceReading | undefined;
package/lib/channels.d.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * applied are the ones the host itself documents (capacities), and they are applied *loudly*
15
15
  * in the returned `warnings` so the panel can show them.
16
16
  */
17
- import { ADVERTISED_MODEL_ID, type ChannelApi, type ChannelModality } from './presets.js';
17
+ import { ADVERTISED_MODEL_ID, type ChannelApi, type ChannelModality, type BalanceProbe } from './presets.js';
18
18
  /** One channel as the configuration document stores it. */
19
19
  export interface ChannelEntry {
20
20
  /** Human label; falls back to the preset's or the id. */
@@ -98,6 +98,13 @@ export interface ResolvedChannel {
98
98
  reasoningEfforts?: Record<string, string | null>;
99
99
  compat?: Record<string, unknown>;
100
100
  headers?: Record<string, string>;
101
+ /**
102
+ * How this endpoint's remaining balance can be read, when it can be.
103
+ *
104
+ * Absent means "no balance API" — a real state, not a missing value. See `presets.ts` for the
105
+ * three verified shapes and why OpenCode Go deliberately has none.
106
+ */
107
+ balance?: BalanceProbe;
101
108
  /** Facts the user should see because a default was applied rather than declared. */
102
109
  warnings: string[];
103
110
  }
package/lib/client.js CHANGED
@@ -7,6 +7,43 @@ window.__ModuleLoader__.load({
7
7
  let react = require("react");
8
8
  let react_jsx_runtime = require("react/jsx-runtime");
9
9
  //#region src/client/panel-api.ts
10
+ /**
11
+ * Load per-channel balances.
12
+ *
13
+ * Degrades to an empty list rather than throwing: the panel must still render the channel table
14
+ * when this route is missing (an older server, or a deployment without it).
15
+ */
16
+ async function fetchBalances() {
17
+ try {
18
+ const response = await fetch(`${BASE}/balance`, { headers: { accept: "application/json" } });
19
+ if (!response.ok) return { channels: [] };
20
+ return await response.json();
21
+ } catch {
22
+ return { channels: [] };
23
+ }
24
+ }
25
+ /**
26
+ * Render one balance row as the text a person reads.
27
+ *
28
+ * Deliberately total: every status produces words, never an empty string. A blank cell beside a
29
+ * working channel is indistinguishable from "zero balance" — the one thing this must never imply.
30
+ * @param row - one row from `/balance`.
31
+ * @returns a short human label.
32
+ */
33
+ function formatBalance(row) {
34
+ if (row.status !== "ok") {
35
+ if (row.status === "no-key") return "未配置密钥";
36
+ if (row.status === "unavailable") return row.code === "HTTP_404" ? "不支持查询" : "读取失败";
37
+ return "不支持查询";
38
+ }
39
+ const { amounts, windows, available } = row.reading;
40
+ const parts = [];
41
+ for (const amount of amounts) parts.push(`${amount.remaining.toFixed(2)} ${amount.currency}`);
42
+ for (const window of windows) parts.push(`${window.name} ${Math.round(window.remainingPercent)}%`);
43
+ if (available === false) parts.push("(余额不足)");
44
+ if (parts.length === 0) return row.reading.note ?? "无可读数值";
45
+ return parts.join(" · ");
46
+ }
10
47
  const BASE = "/any-deepseek";
11
48
  /**
12
49
  * Load the channel list.
@@ -143,7 +180,7 @@ window.__ModuleLoader__.load({
143
180
  }
144
181
  //#endregion
145
182
  //#region \0ad-css:src/client/SettingsPage
146
- 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.ad-btn {\n font-size: 11px;\n padding: 4px 10px;\n border-radius: 6px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n\n.ad-btn:disabled {\n opacity: 0.45;\n cursor: default;\n}\n\n.ad-btn-quiet {\n opacity: 0.8;\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.ad-tabs {\n display: flex;\n gap: 6px;\n}\n\n.ad-tab {\n font-size: 11px;\n padding: 3px 10px;\n border-radius: 999px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n\n.ad-tab-on {\n border-color: var(--ad-line-strong);\n font-weight: 600;\n}\n\n.ad-tab-refresh {\n margin-left: auto;\n opacity: 0.75;\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: 8px;\n border: 1px dashed var(--ad-line);\n border-radius: 8px;\n padding: 10px 12px;\n}\n\n.ad-field {\n display: flex;\n flex-direction: column;\n gap: 3px;\n font-size: 11px;\n}\n\n.ad-field > span {\n opacity: 0.7;\n}\n\n.ad-input {\n font-size: 11px;\n padding: 4px 8px;\n border-radius: 6px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n font-family: inherit;\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/* 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";
183
+ 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.ad-btn {\n font-size: 11px;\n padding: 4px 10px;\n border-radius: 6px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n\n.ad-btn:disabled {\n opacity: 0.45;\n cursor: default;\n}\n\n.ad-btn-quiet {\n opacity: 0.8;\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.ad-tabs {\n display: flex;\n gap: 6px;\n}\n\n.ad-tab {\n font-size: 11px;\n padding: 3px 10px;\n border-radius: 999px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n\n.ad-tab-on {\n border-color: var(--ad-line-strong);\n font-weight: 600;\n}\n\n.ad-tab-refresh {\n margin-left: auto;\n opacity: 0.75;\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: 8px;\n border: 1px dashed var(--ad-line);\n border-radius: 8px;\n padding: 10px 12px;\n}\n\n.ad-field {\n display: flex;\n flex-direction: column;\n gap: 3px;\n font-size: 11px;\n}\n\n.ad-field > span {\n opacity: 0.7;\n}\n\n.ad-input {\n font-size: 11px;\n padding: 4px 8px;\n border-radius: 6px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n font-family: inherit;\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";
147
184
  if (typeof document !== "undefined" && document.querySelector("style[data-ad-css=\"SettingsPage\"]") === null) {
148
185
  const tag = document.createElement("style");
149
186
  tag.setAttribute("data-ad-css", "SettingsPage");
@@ -183,7 +220,7 @@ window.__ModuleLoader__.load({
183
220
  });
184
221
  }
185
222
  function SettingsPage(props) {
186
- const { useSettings, loadChannels, loadStats, probe, setField, removeField } = props;
223
+ const { useSettings, loadChannels, loadStats, probe, loadBalances, setField, removeField } = props;
187
224
  const settings = useSettings((snapshot) => snapshot);
188
225
  const [rows, setRows] = (0, react.useState)([]);
189
226
  const [presets, setPresets] = (0, react.useState)([]);
@@ -192,6 +229,15 @@ window.__ModuleLoader__.load({
192
229
  name: ""
193
230
  });
194
231
  const [stats, setStats] = (0, react.useState)(null);
232
+ /**
233
+ * Per-channel balance, keyed by channel id.
234
+ *
235
+ * Loaded separately from the channel list (see `loadBalances`): the balances hit each provider's
236
+ * own account surface, so one slow or dead endpoint must not hold up the table the user came to
237
+ * edit. An empty map simply means "not loaded yet", and the cell renders as such.
238
+ */
239
+ const [balances, setBalances] = (0, react.useState)({});
240
+ const [balancesLoading, setBalancesLoading] = (0, react.useState)(false);
195
241
  const [group, setGroup] = (0, react.useState)("channel");
196
242
  const [probing, setProbing] = (0, react.useState)(null);
197
243
  const [verdict, setVerdict] = (0, react.useState)({});
@@ -237,6 +283,24 @@ window.__ModuleLoader__.load({
237
283
  loadStats,
238
284
  group
239
285
  ]);
286
+ /**
287
+ * Load balances on demand, never on render.
288
+ *
289
+ * Each call reaches out to every provider's account surface, so it is a button rather than
290
+ * something that fires on mount: opening the panel to rename a channel should not spend a
291
+ * round-trip per relay, and one unreachable billing endpoint would otherwise hold the table.
292
+ */
293
+ const refreshBalances = (0, react.useCallback)(async () => {
294
+ setBalancesLoading(true);
295
+ try {
296
+ const loaded = await loadBalances();
297
+ const byId = {};
298
+ for (const row of loaded.channels) byId[row.id] = row;
299
+ setBalances(byId);
300
+ } finally {
301
+ setBalancesLoading(false);
302
+ }
303
+ }, [loadBalances]);
240
304
  (0, react.useEffect)(() => {
241
305
  refresh().catch((cause) => {
242
306
  setError(cause instanceof Error ? cause.message : String(cause));
@@ -435,9 +499,20 @@ window.__ModuleLoader__.load({
435
499
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
436
500
  className: "ad-section",
437
501
  children: [
438
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
439
- className: "ad-sectionTitle",
440
- children: "渠道"
502
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
503
+ className: "ad-sectionBar",
504
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
505
+ className: "ad-sectionTitle",
506
+ children: "渠道"
507
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
508
+ type: "button",
509
+ className: "ad-link",
510
+ disabled: balancesLoading || rows.length === 0,
511
+ onClick: () => {
512
+ refreshBalances();
513
+ },
514
+ children: balancesLoading ? "查询余额中…" : "查询余额"
515
+ })]
441
516
  }),
442
517
  rows.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
443
518
  className: "ad-empty",
@@ -578,6 +653,13 @@ window.__ModuleLoader__.load({
578
653
  children: keying === row.id ? "取消" : "填写密钥"
579
654
  })]
580
655
  }),
656
+ balances[row.id] !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
657
+ className: "ad-keyLine",
658
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
659
+ className: "ad-balance",
660
+ children: formatBalance(balances[row.id])
661
+ })
662
+ }),
581
663
  keying === row.id && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
582
664
  className: "ad-keyForm",
583
665
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
@@ -926,6 +1008,7 @@ window.__ModuleLoader__.load({
926
1008
  loadChannels: () => fetchChannels(),
927
1009
  loadStats: (group, from, to) => fetchStats(group, from, to),
928
1010
  probe: (id) => probeChannel(id),
1011
+ loadBalances: () => fetchBalances(),
929
1012
  setField: (path, value) => {
930
1013
  return form.mutate([{
931
1014
  op: "set",
package/lib/http.d.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import type { IncomingMessage, ServerResponse } from 'node:http';
19
19
  import type { ResolvedChannel } from './channels.js';
20
+ import { type BalanceReading } from './balance.js';
20
21
  /** Prefix every route in this module lives under. */
21
22
  export declare const ROUTE_PREFIX = "/any-deepseek";
22
23
  /** What the routes need from the plugin. */
@@ -98,6 +99,15 @@ export declare function probeChannel(channel: ResolvedChannel, key: string, sess
98
99
  message?: string;
99
100
  responded?: string;
100
101
  }>;
102
+ /**
103
+ * The body's `error` value, when it is actually an error.
104
+ *
105
+ * Returns `undefined` for a missing field AND for an explicit `null` — both mean "no error".
106
+ * A non-null value (object or string) is returned as JSON text for the panel to show.
107
+ * @param text - the raw response body.
108
+ * @returns the error as text, or undefined when the body reports no error.
109
+ */
110
+ export declare function errorFieldValue(text: string): string | undefined;
101
111
  /**
102
112
  * What a successful probe response says the relay called its model.
103
113
  *
@@ -108,6 +118,35 @@ export declare function probeChannel(channel: ResolvedChannel, key: string, sess
108
118
  * @returns the reported model name, or the first output text; empty when neither is present.
109
119
  */
110
120
  export declare function reportedName(text: string): string;
121
+ /**
122
+ * Fetch one channel's remaining balance from whatever surface it declares.
123
+ *
124
+ * 🔴 **Absence is a normal answer here, and is reported as such.** Three outcomes are kept apart
125
+ * because they mean different things to the user:
126
+ * · `unsupported` — the preset declares no balance shape (OpenCode Go: the provider has an open
127
+ * request for one, because today the only key-authenticated signal is an error at $0).
128
+ * · `unavailable` — a shape was declared but the endpoint did not answer as expected (404,
129
+ * non-JSON, unexpected body). The relay may simply not implement it.
130
+ * · `ok` — a reading, which itself may carry only a note (e.g. "unlimited, used $3.20").
131
+ *
132
+ * A missing number is never coerced to zero: "we could not read it" and "it is empty" lead to
133
+ * opposite actions, and only one of them is true.
134
+ * @param channel - the resolved channel.
135
+ * @param key - its resolved credential.
136
+ * @param signal - cancellation.
137
+ * @returns the outcome, always resolved (never thrown) so one dead relay cannot blank the row.
138
+ */
139
+ export declare function fetchBalance(channel: ResolvedChannel, key: string, signal?: AbortSignal): Promise<{
140
+ status: 'ok';
141
+ reading: BalanceReading;
142
+ } | {
143
+ status: 'unsupported';
144
+ detail: string;
145
+ } | {
146
+ status: 'unavailable';
147
+ detail: string;
148
+ code?: string;
149
+ }>;
111
150
  /**
112
151
  * Register the panel's routes.
113
152
  * @param ctx - plugin context (only used to bind the disposer when the host offers one).
package/lib/index.js CHANGED
@@ -278,7 +278,7 @@ const CHANNEL_PRESETS = {
278
278
  ...V41_FLASH_CAPS,
279
279
  compat: { ...DEEPSEEK_THINKING_COMPAT },
280
280
  headers: { ...OPENCODE_HEADERS },
281
- note: "Requires the OpenCode session headers; shipped with the preset."
281
+ note: "Requires the OpenCode session headers; shipped with the preset. No balance API (subscription usage only)."
282
282
  },
283
283
  commandcode: {
284
284
  displayName: "CommandCode",
@@ -293,21 +293,27 @@ const CHANNEL_PRESETS = {
293
293
  api: "openai-completions",
294
294
  baseURL: "https://ollama.com/v1",
295
295
  model: "deepseek-v4.1-flash",
296
- ...V41_FLASH_CAPS
296
+ ...V41_FLASH_CAPS,
297
+ balance: {
298
+ shape: "ollama",
299
+ baseURL: "https://ollama.com"
300
+ }
297
301
  },
298
302
  "open-design": {
299
303
  displayName: "Open Design",
300
304
  api: "openai-completions",
301
305
  baseURL: "https://amr-link.open-design.ai/v1",
302
306
  model: "deepseek-v4.1-flash",
303
- ...V41_FLASH_CAPS
307
+ ...V41_FLASH_CAPS,
308
+ balance: { shape: "openai-billing" }
304
309
  },
305
310
  "deepseek-official": {
306
311
  displayName: "DeepSeek Official",
307
312
  api: "openai-responses",
308
313
  baseURL: "https://api.deepseek.com",
309
314
  model: "deepseek-flash",
310
- ...V41_FLASH_CAPS
315
+ ...V41_FLASH_CAPS,
316
+ balance: { shape: "deepseek" }
311
317
  }
312
318
  };
313
319
  /** Every preset id, in table order — the panel's picker source. */
@@ -536,6 +542,7 @@ function resolveChannel(id, raw) {
536
542
  * and lets the entry win only on the keys it actually names.
537
543
  */
538
544
  ...mergeHeaders(preset?.headers, entry.headers) === void 0 ? {} : { headers: mergeHeaders(preset?.headers, entry.headers) },
545
+ ...preset?.balance === void 0 ? {} : { balance: preset.balance },
539
546
  warnings
540
547
  }
541
548
  };
@@ -1769,6 +1776,194 @@ function totalOf(rows) {
1769
1776
  return total;
1770
1777
  }
1771
1778
  //#endregion
1779
+ //#region src/balance.ts
1780
+ /**
1781
+ * The endpoint a probe must call for one shape.
1782
+ *
1783
+ * 🔴 The path is per shape because the surfaces do not share a base: Ollama infers on `/v1` but
1784
+ * reports on `/api`, and DeepSeek reports on the bare origin. This function is the single place
1785
+ * that knowledge lives, so a preset cannot disagree with the transport.
1786
+ * @param probe - the preset's declared probe.
1787
+ * @param channelBaseURL - the channel's inference base URL, used when the probe names none.
1788
+ * @returns the absolute URLs to GET, in order.
1789
+ */
1790
+ function balanceUrls(probe, channelBaseURL) {
1791
+ const root = stripTrailingSlash(probe.baseURL ?? channelBaseURL);
1792
+ switch (probe.shape) {
1793
+ case "deepseek": return [`${root}/user/balance`];
1794
+ case "ollama": return [`${root}/api/balance`];
1795
+ case "openai-billing": return [`${root}/dashboard/billing/subscription`, `${root}/dashboard/billing/usage`];
1796
+ }
1797
+ }
1798
+ function stripTrailingSlash(value) {
1799
+ return value.replace(/\/+$/, "");
1800
+ }
1801
+ /** Read a finite number, or undefined. Strings are accepted: these APIs quote decimals as text. */
1802
+ function num(value) {
1803
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
1804
+ if (typeof value === "string") {
1805
+ const trimmed = value.trim();
1806
+ if (trimmed === "") return void 0;
1807
+ const parsed = Number(trimmed);
1808
+ return Number.isFinite(parsed) ? parsed : void 0;
1809
+ }
1810
+ }
1811
+ function text(value) {
1812
+ return typeof value === "string" && value !== "" ? value : void 0;
1813
+ }
1814
+ function asRecord(value) {
1815
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
1816
+ }
1817
+ /**
1818
+ * Parse DeepSeek's `GET /user/balance`.
1819
+ *
1820
+ * `total_balance` is a STRING in their example (`"110.00"`), so it is read as a number rather
1821
+ * than trusted as one — printing a string into a numeric column is how a panel ends up showing
1822
+ * `"110.00"` next to arithmetic that silently treats it as NaN.
1823
+ * @param body - the parsed JSON body.
1824
+ * @returns the reading, or undefined when the body is not this shape.
1825
+ */
1826
+ function parseDeepSeekBalance(body) {
1827
+ const root = asRecord(body);
1828
+ if (root === void 0) return void 0;
1829
+ const infos = root.balance_infos;
1830
+ if (!Array.isArray(infos)) return void 0;
1831
+ const amounts = [];
1832
+ for (const item of infos) {
1833
+ const entry = asRecord(item);
1834
+ if (entry === void 0) continue;
1835
+ const remaining = num(entry.total_balance);
1836
+ if (remaining === void 0) continue;
1837
+ amounts.push({
1838
+ currency: text(entry.currency) ?? "CNY",
1839
+ remaining,
1840
+ ...num(entry.granted_balance) === void 0 ? {} : { granted: num(entry.granted_balance) },
1841
+ ...num(entry.topped_up_balance) === void 0 ? {} : { toppedUp: num(entry.topped_up_balance) }
1842
+ });
1843
+ }
1844
+ if (amounts.length === 0) return void 0;
1845
+ const available = root.is_available;
1846
+ return {
1847
+ amounts,
1848
+ windows: [],
1849
+ ...typeof available === "boolean" ? { available } : {}
1850
+ };
1851
+ }
1852
+ /**
1853
+ * Parse Ollama's `GET /api/balance`.
1854
+ *
1855
+ * Two plan generations answer this endpoint:
1856
+ * · current — `included.balance_usd` / `allowance_usd` + `purchased.balance_usd`;
1857
+ * · legacy — `included.session.remaining_percent` / `included.weekly.remaining_percent`.
1858
+ * Both are handled because a user on an old plan must still see something true. `remaining` is
1859
+ * the sum of included + purchased: both are spendable, so reporting only one understates.
1860
+ * @param body - the parsed JSON body.
1861
+ * @returns the reading, or undefined when the body is not this shape.
1862
+ */
1863
+ function parseOllamaBalance(body) {
1864
+ const root = asRecord(body);
1865
+ if (root === void 0) return void 0;
1866
+ const included = asRecord(root.included);
1867
+ const purchased = asRecord(root.purchased);
1868
+ if (included === void 0) return void 0;
1869
+ const windows = [];
1870
+ for (const name of ["session", "weekly"]) {
1871
+ const window = asRecord(included[name]);
1872
+ if (window === void 0) continue;
1873
+ const remainingPercent = num(window.remaining_percent);
1874
+ if (remainingPercent === void 0) continue;
1875
+ windows.push({
1876
+ name,
1877
+ remainingPercent,
1878
+ ...text(window.resets_at) === void 0 ? {} : { resetsAt: text(window.resets_at) }
1879
+ });
1880
+ }
1881
+ const includedBalance = num(included.balance_usd);
1882
+ const purchasedBalance = num(purchased?.balance_usd);
1883
+ const amounts = [];
1884
+ if (includedBalance !== void 0 || purchasedBalance !== void 0) amounts.push({
1885
+ currency: "USD",
1886
+ remaining: (includedBalance ?? 0) + (purchasedBalance ?? 0),
1887
+ ...includedBalance === void 0 ? {} : { allowance: num(included.allowance_usd) },
1888
+ ...purchasedBalance === void 0 ? {} : { toppedUp: purchasedBalance }
1889
+ });
1890
+ const period = asRecord(included.period);
1891
+ const note = period === void 0 ? void 0 : `period ${text(period.from) ?? "?"} → ${text(period.until) ?? "?"}`;
1892
+ if (amounts.length === 0 && windows.length === 0) return void 0;
1893
+ return {
1894
+ amounts,
1895
+ windows,
1896
+ ...note === void 0 ? {} : { note }
1897
+ };
1898
+ }
1899
+ /**
1900
+ * Parse the OpenAI-legacy billing pair.
1901
+ *
1902
+ * 🔴 Two conversions here are load-bearing, and both are the kind of thing that reads correctly
1903
+ * while being wrong:
1904
+ * · `total_usage` is in **CENTS**, while `hard_limit_usd` is in **dollars**. Comparing them
1905
+ * directly divides the limit by 100 in effect — a balance that looks 99% consumed.
1906
+ * · `hard_limit_usd: 1e8` is new-api's **"unlimited" sentinel**, not a hundred million dollars.
1907
+ * It is reported as no allowance rather than as a number.
1908
+ * @param subscription - body of `/dashboard/billing/subscription`.
1909
+ * @param usage - body of `/dashboard/billing/usage`.
1910
+ * @returns the reading, or undefined when neither body is this shape.
1911
+ */
1912
+ function parseOpenAIBilling(subscription, usage) {
1913
+ const sub = asRecord(subscription);
1914
+ const use = asRecord(usage);
1915
+ if (sub === void 0 && use === void 0) return void 0;
1916
+ const hardLimit = num(sub?.hard_limit_usd);
1917
+ const softLimit = num(sub?.soft_limit_usd);
1918
+ const usedCents = num(use?.total_usage);
1919
+ /** new-api's sentinel for "no cap". */
1920
+ const UNLIMITED = 1e8;
1921
+ const allowance = hardLimit !== void 0 && hardLimit < UNLIMITED ? hardLimit : softLimit !== void 0 && softLimit < UNLIMITED ? softLimit : void 0;
1922
+ const used = usedCents === void 0 ? void 0 : usedCents / 100;
1923
+ const amounts = [];
1924
+ if (allowance !== void 0 && used !== void 0) amounts.push({
1925
+ currency: "USD",
1926
+ remaining: Math.max(0, allowance - used),
1927
+ allowance
1928
+ });
1929
+ else if (allowance !== void 0) amounts.push({
1930
+ currency: "USD",
1931
+ remaining: allowance,
1932
+ allowance
1933
+ });
1934
+ else if (used !== void 0) {
1935
+ amounts.push({
1936
+ currency: "USD",
1937
+ remaining: 0,
1938
+ allowance: void 0
1939
+ });
1940
+ return {
1941
+ amounts: [],
1942
+ windows: [],
1943
+ note: `used $${used.toFixed(2)}; this relay reports no cap (unlimited)`
1944
+ };
1945
+ }
1946
+ if (amounts.length === 0) return void 0;
1947
+ return {
1948
+ amounts,
1949
+ windows: [],
1950
+ note: allowance === void 0 ? "no cap reported" : void 0
1951
+ };
1952
+ }
1953
+ /**
1954
+ * Map one channel's raw bodies onto a reading, dispatching on the declared shape.
1955
+ * @param probe - the preset's declared probe.
1956
+ * @param bodies - response bodies in the order {@link balanceUrls} returned URLs.
1957
+ * @returns the reading, or undefined when the bodies do not match the declared shape.
1958
+ */
1959
+ function readBalance(probe, bodies) {
1960
+ switch (probe.shape) {
1961
+ case "deepseek": return parseDeepSeekBalance(bodies[0]);
1962
+ case "ollama": return parseOllamaBalance(bodies[0]);
1963
+ case "openai-billing": return parseOpenAIBilling(bodies[0], bodies[1]);
1964
+ }
1965
+ }
1966
+ //#endregion
1772
1967
  //#region src/http.ts
1773
1968
  /** Prefix every route in this module lives under. */
1774
1969
  const ROUTE_PREFIX = "/any-deepseek";
@@ -1818,7 +2013,7 @@ function probeRequest(channel) {
1818
2013
  body: {
1819
2014
  model: channel.model,
1820
2015
  input: "ping",
1821
- max_output_tokens: 16,
2016
+ max_output_tokens: 256,
1822
2017
  stream: false
1823
2018
  }
1824
2019
  };
@@ -1890,11 +2085,12 @@ async function probeChannel(channel, key, sessionId, signal) {
1890
2085
  code: probeCode(response.status, text),
1891
2086
  message: text.slice(0, 400)
1892
2087
  };
1893
- if (text.includes("\"error\"")) return {
2088
+ const bodyError = errorFieldValue(text);
2089
+ if (bodyError !== void 0) return {
1894
2090
  ok: false,
1895
2091
  ms,
1896
2092
  code: "SERVER",
1897
- message: text.slice(0, 400)
2093
+ message: bodyError.slice(0, 400)
1898
2094
  };
1899
2095
  return {
1900
2096
  ok: true,
@@ -1911,6 +2107,25 @@ async function probeChannel(channel, key, sessionId, signal) {
1911
2107
  }
1912
2108
  }
1913
2109
  /**
2110
+ * The body's `error` value, when it is actually an error.
2111
+ *
2112
+ * Returns `undefined` for a missing field AND for an explicit `null` — both mean "no error".
2113
+ * A non-null value (object or string) is returned as JSON text for the panel to show.
2114
+ * @param text - the raw response body.
2115
+ * @returns the error as text, or undefined when the body reports no error.
2116
+ */
2117
+ function errorFieldValue(text) {
2118
+ let parsed;
2119
+ try {
2120
+ parsed = JSON.parse(text);
2121
+ } catch {
2122
+ return text.includes("\"error\"") ? text : void 0;
2123
+ }
2124
+ const candidate = parsed?.error;
2125
+ if (candidate === void 0 || candidate === null) return void 0;
2126
+ return typeof candidate === "string" ? candidate : JSON.stringify(candidate);
2127
+ }
2128
+ /**
1914
2129
  * What a successful probe response says the relay called its model.
1915
2130
  *
1916
2131
  * Three protocols, three response shapes — the old reader understood only completions
@@ -1946,6 +2161,75 @@ function reportedName(text) {
1946
2161
  }
1947
2162
  return "";
1948
2163
  }
2164
+ /**
2165
+ * Fetch one channel's remaining balance from whatever surface it declares.
2166
+ *
2167
+ * 🔴 **Absence is a normal answer here, and is reported as such.** Three outcomes are kept apart
2168
+ * because they mean different things to the user:
2169
+ * · `unsupported` — the preset declares no balance shape (OpenCode Go: the provider has an open
2170
+ * request for one, because today the only key-authenticated signal is an error at $0).
2171
+ * · `unavailable` — a shape was declared but the endpoint did not answer as expected (404,
2172
+ * non-JSON, unexpected body). The relay may simply not implement it.
2173
+ * · `ok` — a reading, which itself may carry only a note (e.g. "unlimited, used $3.20").
2174
+ *
2175
+ * A missing number is never coerced to zero: "we could not read it" and "it is empty" lead to
2176
+ * opposite actions, and only one of them is true.
2177
+ * @param channel - the resolved channel.
2178
+ * @param key - its resolved credential.
2179
+ * @param signal - cancellation.
2180
+ * @returns the outcome, always resolved (never thrown) so one dead relay cannot blank the row.
2181
+ */
2182
+ async function fetchBalance(channel, key, signal) {
2183
+ if (channel.balance === void 0) return {
2184
+ status: "unsupported",
2185
+ detail: "this endpoint exposes no balance API"
2186
+ };
2187
+ const urls = balanceUrls(channel.balance, channel.baseURL);
2188
+ const bodies = [];
2189
+ for (const url of urls) try {
2190
+ const response = await fetch(url, {
2191
+ method: "GET",
2192
+ headers: {
2193
+ accept: "application/json",
2194
+ ...channel.api === "anthropic-messages" ? {
2195
+ "x-api-key": key,
2196
+ "anthropic-version": "2023-06-01"
2197
+ } : { authorization: `Bearer ${key}` },
2198
+ ...channel.headers
2199
+ },
2200
+ ...signal === void 0 ? {} : { signal }
2201
+ });
2202
+ const text = await response.text();
2203
+ if (!response.ok) return {
2204
+ status: "unavailable",
2205
+ code: `HTTP_${response.status}`,
2206
+ detail: `balance endpoint answered HTTP ${response.status}`
2207
+ };
2208
+ try {
2209
+ bodies.push(JSON.parse(text));
2210
+ } catch {
2211
+ return {
2212
+ status: "unavailable",
2213
+ detail: "balance endpoint did not return JSON"
2214
+ };
2215
+ }
2216
+ } catch (error) {
2217
+ return {
2218
+ status: "unavailable",
2219
+ code: "TRANSPORT",
2220
+ detail: error instanceof Error ? error.message : String(error)
2221
+ };
2222
+ }
2223
+ const reading = readBalance(channel.balance, bodies);
2224
+ if (reading === void 0) return {
2225
+ status: "unavailable",
2226
+ detail: "the endpoint replied, but not in the shape this balance API uses"
2227
+ };
2228
+ return {
2229
+ status: "ok",
2230
+ reading
2231
+ };
2232
+ }
1949
2233
  /** Map an HTTP status from a probe onto the same codes the runtime uses. */
1950
2234
  function probeCode(status, body) {
1951
2235
  if (status === 401 || status === 403) return "AUTH";
@@ -2091,6 +2375,36 @@ function registerStatsRoutes(ctx, webServer, deps) {
2091
2375
  const verdict = await probeChannel(channel, key, `probe:${channel.id}`);
2092
2376
  json(res, verdict.ok ? 200 : 502, verdict);
2093
2377
  }
2378
+ },
2379
+ {
2380
+ /**
2381
+ * Remaining balance per channel.
2382
+ *
2383
+ * GET because it reads and costs nothing at the provider (all three surfaces are plain
2384
+ * account reads, not model calls). Every channel is attempted independently: one relay
2385
+ * without a balance API, or one that is down, must not blank the others — the same rule the
2386
+ * channel list follows for a broken entry.
2387
+ */
2388
+ kind: "exact",
2389
+ path: `${ROUTE_PREFIX}/balance`,
2390
+ handler: async (_req, res) => {
2391
+ const channels = deps.channels();
2392
+ json(res, 200, { channels: await Promise.all(channels.map(async (channel) => {
2393
+ const key = channel.credential.kind === "literal" ? channel.credential.value : channel.credential.kind === "reference" ? await deps.resolveKey(channel.credential.ref) : void 0;
2394
+ if (key === void 0 || key === "") return {
2395
+ id: channel.id,
2396
+ label: channel.label,
2397
+ status: "no-key",
2398
+ detail: "no key configured"
2399
+ };
2400
+ const outcome = await fetchBalance(channel, key);
2401
+ return {
2402
+ id: channel.id,
2403
+ label: channel.label,
2404
+ ...outcome
2405
+ };
2406
+ })) });
2407
+ }
2094
2408
  }
2095
2409
  ];
2096
2410
  for (const route of routes) {
package/lib/presets.d.ts CHANGED
@@ -28,6 +28,36 @@
28
28
  export type ChannelApi = 'openai-completions' | 'openai-responses' | 'anthropic-messages';
29
29
  /** Modality a request can carry. */
30
30
  export type ChannelModality = 'text' | 'image';
31
+ /**
32
+ * How to read one endpoint's remaining balance.
33
+ *
34
+ * Owner request (2026-10-23): 「其它渠道的查看方式你需要找到并实现它们的」. There is no single
35
+ * answer — every provider invented its own surface, and some have none at all. This type is that
36
+ * fact written down instead of guessed: a preset either names a known shape or says it has none.
37
+ *
38
+ * Three shapes were verified against real endpoints (see `balance.ts` for the response mapping):
39
+ * · `deepseek` — DeepSeek's own `GET /user/balance`.
40
+ * · `ollama` — `GET /api/balance` (note: under `/api`, NOT the `/v1` used for inference).
41
+ * · `openai-billing` — the OpenAI-legacy billing pair that new-api / one-api relays clone:
42
+ * `GET /v1/dashboard/billing/subscription` + `GET /v1/dashboard/billing/usage`.
43
+ *
44
+ * 🔴 **A fourth answer is legitimate and must be representable: "this endpoint has no balance
45
+ * API".** OpenCode Zen is exactly that case — its owner has an open feature request asking for
46
+ * one, because today the only key-authenticated signal is the error thrown at $0
47
+ * (anomalyco/opencode#44189). Pretending otherwise would mean showing a number nobody measured.
48
+ */
49
+ export interface BalanceProbe {
50
+ /** Which response shape to read. */
51
+ shape: 'deepseek' | 'ollama' | 'openai-billing';
52
+ /**
53
+ * Overrides the base URL for the balance call.
54
+ *
55
+ * Needed because the surface does not always live under the inference path: Ollama infers on
56
+ * `/v1` but reports balance on `/api`, and DeepSeek infers on `/v1` (implicitly) but reports on
57
+ * the bare origin. Resolved against the channel's own `baseURL` when absent.
58
+ */
59
+ baseURL?: string;
60
+ }
31
61
  /**
32
62
  * One relay's fixed facts.
33
63
  *
@@ -60,6 +90,14 @@ export interface ChannelPreset {
60
90
  compat?: Record<string, unknown>;
61
91
  /** Headers every request to this relay carries. Never secrets. */
62
92
  headers?: Record<string, string>;
93
+ /**
94
+ * How to read this endpoint's remaining balance.
95
+ *
96
+ * Absent means "this endpoint has no balance API we know of" — a real and common state, not an
97
+ * omission. The panel says so explicitly rather than leaving a blank cell that could be read
98
+ * as zero.
99
+ */
100
+ balance?: BalanceProbe;
63
101
  /** One-line note for the panel, when the relay needs explaining. */
64
102
  note?: string;
65
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-any-deepseek",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
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": {