dsh-any-deepseek 0.0.12 → 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.
- package/lib/balance.d.ts +128 -0
- package/lib/channels.d.ts +8 -1
- package/lib/client.js +88 -5
- package/lib/http.d.ts +30 -0
- package/lib/index.js +298 -4
- package/lib/presets.d.ts +38 -0
- package/package.json +1 -1
package/lib/balance.d.ts
ADDED
|
@@ -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.
|
|
439
|
-
className: "ad-
|
|
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. */
|
|
@@ -117,6 +118,35 @@ export declare function errorFieldValue(text: string): string | undefined;
|
|
|
117
118
|
* @returns the reported model name, or the first output text; empty when neither is present.
|
|
118
119
|
*/
|
|
119
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
|
+
}>;
|
|
120
150
|
/**
|
|
121
151
|
* Register the panel's routes.
|
|
122
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";
|
|
@@ -1966,6 +2161,75 @@ function reportedName(text) {
|
|
|
1966
2161
|
}
|
|
1967
2162
|
return "";
|
|
1968
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
|
+
}
|
|
1969
2233
|
/** Map an HTTP status from a probe onto the same codes the runtime uses. */
|
|
1970
2234
|
function probeCode(status, body) {
|
|
1971
2235
|
if (status === 401 || status === 403) return "AUTH";
|
|
@@ -2111,6 +2375,36 @@ function registerStatsRoutes(ctx, webServer, deps) {
|
|
|
2111
2375
|
const verdict = await probeChannel(channel, key, `probe:${channel.id}`);
|
|
2112
2376
|
json(res, verdict.ok ? 200 : 502, verdict);
|
|
2113
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
|
+
}
|
|
2114
2408
|
}
|
|
2115
2409
|
];
|
|
2116
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.
|
|
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": {
|