dsh-any-deepseek 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -4
- package/lib/adapter.d.ts +6 -1
- package/lib/channels.d.ts +76 -8
- package/lib/client.js +256 -137
- package/lib/config.d.ts +22 -3
- package/lib/http.d.ts +12 -2
- package/lib/index.js +124 -40
- package/lib/route.d.ts +6 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,11 +50,9 @@ dsh-any-deepseek:
|
|
|
50
50
|
current: opencode-go # the live channel (manual)
|
|
51
51
|
channels:
|
|
52
52
|
opencode-go:
|
|
53
|
-
|
|
54
|
-
enabled: true
|
|
53
|
+
apiKey: sk-… # the key itself
|
|
55
54
|
commandcode:
|
|
56
|
-
|
|
57
|
-
enabled: true
|
|
55
|
+
apiKey: sk-… # the key itself
|
|
58
56
|
usage:
|
|
59
57
|
enabled: true
|
|
60
58
|
```
|
|
@@ -66,6 +64,24 @@ dsh-any-deepseek:
|
|
|
66
64
|
`openai-completions`, and `contextWindow` / `maxTokens` fall back to documented defaults
|
|
67
65
|
(the panel shows a warning next to the row when a default was applied rather than declared).
|
|
68
66
|
|
|
67
|
+
### Where the key lives
|
|
68
|
+
|
|
69
|
+
`apiKey` holds the key **itself**, on the channel. The panel never asks you to invent a name for
|
|
70
|
+
it — you paste the key, and it is written here.
|
|
71
|
+
|
|
72
|
+
⚠️ **That means the key is in your profile configuration.** `dsh --dump-config` prints patch
|
|
73
|
+
layers verbatim and does not redact, so a diagnostic dump, a `git diff` of the profile, or a
|
|
74
|
+
pasted config includes it. Do not share the profile document. Environment-variable injection
|
|
75
|
+
(`-e SOME_KEY=…`) is not consulted — nothing resolves a name.
|
|
76
|
+
|
|
77
|
+
`apiKeyEnv` is still **read** for configurations written before this change: when its value
|
|
78
|
+
matches the credential-reference grammar (`^[A-Za-z_][A-Za-z0-9_]*$`) it is resolved as a name;
|
|
79
|
+
when it does not, it cannot be a name, so it is adopted as the key itself. `apiKey` always wins
|
|
80
|
+
when both are present, so re-keying through the panel is enough to migrate an entry.
|
|
81
|
+
|
|
82
|
+
A channel with no key still **appears** in the panel — that is how you find and fix it. Requests
|
|
83
|
+
through it fail with `MISSING_CREDENTIAL`, and the row shows an unconfigured dot.
|
|
84
|
+
|
|
69
85
|
### Channels vs. protocols
|
|
70
86
|
|
|
71
87
|
`openai-completions` is the default and the recommended protocol: it is what almost every
|
package/lib/adapter.d.ts
CHANGED
|
@@ -39,7 +39,12 @@ export interface AdapterDeps {
|
|
|
39
39
|
channels: () => readonly ResolvedChannel[];
|
|
40
40
|
/** The configured channel id. */
|
|
41
41
|
current: () => string | undefined;
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a credential REFERENCE name to its value; undefined when unconfigured.
|
|
44
|
+
*
|
|
45
|
+
* Only consulted for `credential.kind === 'reference'` (a legacy entry). A literal key is
|
|
46
|
+
* carried by the channel itself and never travels through this seam.
|
|
47
|
+
*/
|
|
43
48
|
resolveKey: (ref: string) => Promise<string | undefined>;
|
|
44
49
|
/** Record one finished request. Must never throw. */
|
|
45
50
|
record: (entry: LedgerEntry) => Promise<void>;
|
package/lib/channels.d.ts
CHANGED
|
@@ -27,9 +27,31 @@ export interface ChannelEntry {
|
|
|
27
27
|
baseURL?: string;
|
|
28
28
|
/** Upstream model id. Required for a channel that names no preset. */
|
|
29
29
|
model?: string;
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* The key itself, stored in the entry.
|
|
32
|
+
*
|
|
33
|
+
* 🔴 Prefer this over {@link apiKeyEnv}. Owner decision 2026-10-21: the panel takes the key the
|
|
34
|
+
* user has and writes it here — no invented reference name, no second store to consult. The
|
|
35
|
+
* accepted cost (a key visible in `--dump-config` output and in the profile patch) is recorded
|
|
36
|
+
* on `ChannelConfigSchema`.
|
|
37
|
+
*/
|
|
38
|
+
apiKey?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Legacy: a credential reference name, resolved through the harness credential seam or the
|
|
41
|
+
* process environment. Still honoured so a configuration written before the change keeps
|
|
42
|
+
* working, but the panel no longer produces one.
|
|
43
|
+
*/
|
|
31
44
|
apiKeyEnv?: string;
|
|
32
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* @deprecated Retired. Kept in the schema only so an existing configuration that still carries
|
|
47
|
+
* the field does not fail validation; it is **ignored**.
|
|
48
|
+
*
|
|
49
|
+
* The design has exactly one axis for "which relay serves requests": `current`. A second
|
|
50
|
+
* on/off axis says the same thing twice and can contradict itself — a channel that is
|
|
51
|
+
* `enabled` but not `current` does nothing, and one that is `current` but disabled is a
|
|
52
|
+
* straight incoherence. Removing the axis also removes a whole class of UI state: there is no
|
|
53
|
+
* such thing as a "closed channel" to render greyed out.
|
|
54
|
+
*/
|
|
33
55
|
enabled?: boolean;
|
|
34
56
|
/** Overrides applied on top of a preset, or the whole declaration for a custom channel. */
|
|
35
57
|
contextWindow?: number;
|
|
@@ -48,7 +70,28 @@ export interface ResolvedChannel {
|
|
|
48
70
|
baseURL: string;
|
|
49
71
|
/** Upstream model id, as the relay spells it. */
|
|
50
72
|
model: string;
|
|
51
|
-
|
|
73
|
+
/**
|
|
74
|
+
* How this channel authenticates.
|
|
75
|
+
*
|
|
76
|
+
* A discriminated shape rather than two optional strings, so no caller can read the wrong one
|
|
77
|
+
* and so "no key at all" is a state the type forces someone to handle.
|
|
78
|
+
*/
|
|
79
|
+
credential: {
|
|
80
|
+
kind: 'literal';
|
|
81
|
+
value: string;
|
|
82
|
+
} | {
|
|
83
|
+
kind: 'reference';
|
|
84
|
+
ref: string;
|
|
85
|
+
} | {
|
|
86
|
+
kind: 'none';
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Preset id this channel was built from, when it named one.
|
|
90
|
+
*
|
|
91
|
+
* Kept through resolution so the panel can label a channel it did not fully recognise, and so
|
|
92
|
+
* a future preset change can be reasoned about per channel.
|
|
93
|
+
*/
|
|
94
|
+
preset?: string;
|
|
52
95
|
contextWindow: number;
|
|
53
96
|
maxTokens: number;
|
|
54
97
|
input: ChannelModality[];
|
|
@@ -61,7 +104,7 @@ export interface ResolvedChannel {
|
|
|
61
104
|
/** Why one entry could not be resolved. */
|
|
62
105
|
export interface ChannelProblem {
|
|
63
106
|
id: string;
|
|
64
|
-
code: 'UNKNOWN_PRESET' | '
|
|
107
|
+
code: 'UNKNOWN_PRESET' | 'MISSING_FIELD' | 'INVALID_API' | 'UNKNOWN_KEY';
|
|
65
108
|
detail: string;
|
|
66
109
|
}
|
|
67
110
|
/** Result of resolving a whole configuration's channel map. */
|
|
@@ -71,6 +114,25 @@ export interface ChannelResolution {
|
|
|
71
114
|
/** Entries that could not resolve; each is reported rather than skipped silently. */
|
|
72
115
|
problems: ChannelProblem[];
|
|
73
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* The grammar a credential REFERENCE name must match.
|
|
119
|
+
*
|
|
120
|
+
* Transcribed from the harness's own credential seam
|
|
121
|
+
* (`@deepseek-ai/dsh-credentials` → `REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/`).
|
|
122
|
+
*
|
|
123
|
+
* This plugin no longer WRITES references (the panel stores the key itself), but it still READS
|
|
124
|
+
* them, and the grammar is what lets the read distinguish the two shapes. A string that cannot be
|
|
125
|
+
* an environment-variable name cannot be a reference — so when one appears in `apiKeyEnv` it can
|
|
126
|
+
* only be a value in the wrong slot, which is a repairable configuration error rather than a
|
|
127
|
+
* mystery.
|
|
128
|
+
*/
|
|
129
|
+
export declare const CREDENTIAL_REF_PATTERN: RegExp;
|
|
130
|
+
/**
|
|
131
|
+
* Whether a string could name a credential reference at all.
|
|
132
|
+
* @param value - candidate name.
|
|
133
|
+
* @returns true when the field can be read as a reference.
|
|
134
|
+
*/
|
|
135
|
+
export declare function isCredentialRefName(value: string): boolean;
|
|
74
136
|
/**
|
|
75
137
|
* Resolve one channel entry.
|
|
76
138
|
*
|
|
@@ -98,11 +160,17 @@ export declare function resolveChannel(id: string, raw: unknown): {
|
|
|
98
160
|
*/
|
|
99
161
|
export declare function resolveChannels(entries: unknown): ChannelResolution;
|
|
100
162
|
/**
|
|
101
|
-
*
|
|
163
|
+
* Every resolved channel, in configuration order — the panel's list and the router's candidate set.
|
|
164
|
+
*
|
|
165
|
+
* 🔴 There is no filtering step here, deliberately. This used to be `enabledChannels()`, which
|
|
166
|
+
* dropped entries with `enabled: false`. That made two things impossible:
|
|
167
|
+
* · the panel could not show a channel it had hidden, so you could not switch it back on;
|
|
168
|
+
* · "which relay serves requests" had two answers (`enabled` AND `current`) that could disagree.
|
|
169
|
+
* The design has one axis — `current` — so the only list is "all channels", and selection is a
|
|
170
|
+
* separate fact.
|
|
102
171
|
* @param resolution - output of {@link resolveChannels}.
|
|
103
|
-
* @
|
|
104
|
-
* @returns enabled channels, configuration order preserved.
|
|
172
|
+
* @returns all resolved channels (the router picks one of them via `current`).
|
|
105
173
|
*/
|
|
106
|
-
export declare function
|
|
174
|
+
export declare function allChannels(resolution: ChannelResolution): ResolvedChannel[];
|
|
107
175
|
/** The advertised model id, re-exported so callers need one import for routing facts. */
|
|
108
176
|
export { ADVERTISED_MODEL_ID };
|
package/lib/client.js
CHANGED
|
@@ -117,7 +117,7 @@ window.__ModuleLoader__.load({
|
|
|
117
117
|
}
|
|
118
118
|
//#endregion
|
|
119
119
|
//#region \0ad-css:src/client/SettingsPage
|
|
120
|
-
const css = "/* Panel styles. Uses the harness's alias tokens where they exist, plain values otherwise, so\n the page follows the surrounding theme instead of hardcoding a palette. */\n\n.ad-page {\n display: flex;\n flex-direction: column;\n gap: 20px;\n padding: 4px 2px 12px;\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(--dsw-alias-color-danger, #c0392b);\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(--dsw-alias-color-border, rgba(0, 0, 0, 0.12));\n border-radius: 8px;\n padding: 10px 12px;\n}\n\n.ad-row-current {\n border-color: var(--dsw-alias-color-accent, #3b82f6);\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(--dsw-alias-color-border, rgba(0, 0, 0, 0.16));\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.ad-switch {\n position: relative;\n display: inline-flex;\n align-items: center;\n cursor: pointer;\n}\n\n.ad-switch input {\n position: absolute;\n opacity: 0;\n width: 0;\n height: 0;\n}\n\n.ad-switchTrack {\n width: 30px;\n height: 16px;\n border-radius: 999px;\n background: var(--dsw-alias-color-border, rgba(0, 0, 0, 0.2));\n transition: background 120ms ease;\n}\n\n.ad-switch input:checked + .ad-switchTrack {\n background: var(--dsw-alias-color-accent, #3b82f6);\n}\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(--dsw-alias-color-border, rgba(0, 0, 0, 0.16));\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n\n.ad-tab-on {\n border-color: var(--dsw-alias-color-accent, #3b82f6);\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(--dsw-alias-color-border, rgba(0, 0, 0, 0.08));\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(--dsw-alias-color-border, rgba(0, 0, 0, 0.16));\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(--dsw-alias-color-border, rgba(0, 0, 0, 0.16));\n background: transparent;\n color: inherit;\n font-family: inherit;\n}\n\n.ad-fieldError {\n margin: 0;\n font-size: 11px;\n color: #c53030;\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(--dsw-alias-color-danger, #c0392b);\n color: var(--dsw-alias-color-danger, #c0392b);\n}\n";
|
|
120
|
+
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 --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\n.ad-row-current {\n border-color: var(--ad-line-strong);\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";
|
|
121
121
|
if (typeof document !== "undefined" && document.querySelector("style[data-ad-css=\"SettingsPage\"]") === null) {
|
|
122
122
|
const tag = document.createElement("style");
|
|
123
123
|
tag.setAttribute("data-ad-css", "SettingsPage");
|
|
@@ -126,17 +126,25 @@ window.__ModuleLoader__.load({
|
|
|
126
126
|
}
|
|
127
127
|
//#endregion
|
|
128
128
|
//#region src/client/SettingsPage.tsx
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
129
|
+
/**
|
|
130
|
+
* A channel id derived from a preset id, made unique against what already exists.
|
|
131
|
+
*
|
|
132
|
+
* The id is a configuration key, so it must match `^[a-z0-9][a-z0-9_-]*$`. Preset ids already do,
|
|
133
|
+
* so the only work is de-duplication: adding two Open Design relays is legitimate, and the second
|
|
134
|
+
* must not silently overwrite the first.
|
|
135
|
+
* @param presetId - the chosen preset.
|
|
136
|
+
* @param existing - rows already configured.
|
|
137
|
+
* @returns a free id like `open-design`, `open-design-2`, …
|
|
138
|
+
*/
|
|
139
|
+
function suggestId(presetId, existing) {
|
|
140
|
+
if (presetId === "") return "";
|
|
141
|
+
const taken = new Set(existing.map((row) => row.id));
|
|
142
|
+
if (!taken.has(presetId)) return presetId;
|
|
143
|
+
for (let n = 2; n < 1e3; n++) {
|
|
144
|
+
const candidate = `${presetId}-${n}`;
|
|
145
|
+
if (!taken.has(candidate)) return candidate;
|
|
146
|
+
}
|
|
147
|
+
return `${presetId}-${Date.now()}`;
|
|
140
148
|
}
|
|
141
149
|
/** A status dot: filled when confirmed configured, hollow when confirmed absent, half otherwise. */
|
|
142
150
|
function KeyDot(props) {
|
|
@@ -166,7 +174,20 @@ window.__ModuleLoader__.load({
|
|
|
166
174
|
const [adding, setAdding] = (0, react.useState)(false);
|
|
167
175
|
const [newId, setNewId] = (0, react.useState)("");
|
|
168
176
|
const [newPreset, setNewPreset] = (0, react.useState)("");
|
|
169
|
-
|
|
177
|
+
/**
|
|
178
|
+
* The key the user typed in the add form.
|
|
179
|
+
*
|
|
180
|
+
* One field, one meaning: the value goes into the channel entry as `apiKey`. There is no
|
|
181
|
+
* separate "reference name" any more — that pairing (a name the user had to invent, plus a
|
|
182
|
+
* second store to hold the value) was the design the owner rejected.
|
|
183
|
+
*/
|
|
184
|
+
const [newSecret, setNewSecret] = (0, react.useState)("");
|
|
185
|
+
/** Which channel currently has its inline key field open (null = none). */
|
|
186
|
+
const [keying, setKeying] = (0, react.useState)(null);
|
|
187
|
+
const [keyDraft, setKeyDraft] = (0, react.useState)("");
|
|
188
|
+
const [keySaving, setKeySaving] = (0, react.useState)(false);
|
|
189
|
+
/** The channel id the form last derived from a preset (see the select's onChange). */
|
|
190
|
+
const lastSuggestedId = (0, react.useRef)("");
|
|
170
191
|
const refresh = (0, react.useCallback)(async () => {
|
|
171
192
|
const [loaded, statsNow] = await Promise.all([loadChannels(), loadStats(group)]);
|
|
172
193
|
setRows(loaded.channels);
|
|
@@ -184,17 +205,9 @@ window.__ModuleLoader__.load({
|
|
|
184
205
|
});
|
|
185
206
|
}, [refresh]);
|
|
186
207
|
const current = settings.value?.current ?? "";
|
|
187
|
-
const channels = settings.value?.channels ?? {};
|
|
188
208
|
const select = (id) => {
|
|
189
209
|
write(() => setField(["current"], id));
|
|
190
210
|
};
|
|
191
|
-
const toggleChannel = (id, on) => {
|
|
192
|
-
write(() => setField([
|
|
193
|
-
"channels",
|
|
194
|
-
id,
|
|
195
|
-
"enabled"
|
|
196
|
-
], on), "切换渠道开关失败");
|
|
197
|
-
};
|
|
198
211
|
/**
|
|
199
212
|
* Run one settings write and surface a refusal.
|
|
200
213
|
*
|
|
@@ -236,33 +249,69 @@ window.__ModuleLoader__.load({
|
|
|
236
249
|
const id = newId.trim();
|
|
237
250
|
if (id === "") return null;
|
|
238
251
|
if (!/^[a-z0-9][a-z0-9_-]*$/.test(id)) return "只能用小写字母、数字、- 和 _,且不能以符号开头";
|
|
239
|
-
if (
|
|
252
|
+
if (rows.some((row) => row.id === id)) return "这个 id 已经有了";
|
|
240
253
|
return null;
|
|
241
254
|
})();
|
|
242
255
|
/**
|
|
243
256
|
* Create one channel from the form.
|
|
244
257
|
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
258
|
+
* ONE write, one store. The key goes into the channel entry as `apiKey`, beside the preset id —
|
|
259
|
+
* there is no second store to keep in step, so there is no ordering to get wrong and no way for
|
|
260
|
+
* "channel exists but its key does not" to be a reachable state. Pre-filling endpoint fields is
|
|
261
|
+
* still avoided: a preset-backed channel records the preset, so later preset fixes reach it.
|
|
248
262
|
*/
|
|
249
263
|
const addChannel = () => {
|
|
250
264
|
const id = newId.trim();
|
|
251
|
-
if (id === "" || newIdProblem !== null ||
|
|
265
|
+
if (newPreset === "" || id === "" || newIdProblem !== null || newSecret.trim() === "") return;
|
|
252
266
|
write(() => setField(["channels", id], {
|
|
253
|
-
|
|
254
|
-
|
|
267
|
+
preset: newPreset,
|
|
268
|
+
apiKey: newSecret.trim()
|
|
255
269
|
}), "添加渠道失败");
|
|
256
270
|
if (current === "") write(() => setField(["current"], id), "设为当前渠道失败");
|
|
271
|
+
setNewSecret("");
|
|
257
272
|
setNewId("");
|
|
258
|
-
setNewKeyRef("");
|
|
259
273
|
setNewPreset("");
|
|
274
|
+
lastSuggestedId.current = "";
|
|
260
275
|
setAdding(false);
|
|
261
276
|
};
|
|
262
277
|
const removeChannel = (id) => {
|
|
263
278
|
write(() => removeField(["channels", id]), "移除渠道失败");
|
|
264
279
|
if (current === id) write(() => removeField(["current"]), "清除当前选择失败");
|
|
265
280
|
};
|
|
281
|
+
/**
|
|
282
|
+
* Store one channel's key from the inline field.
|
|
283
|
+
*
|
|
284
|
+
* The "I have the key / mine changed" path. It is a plain settings write to
|
|
285
|
+
* `channels.<id>.apiKey`, which means it inherits everything the rest of the panel already
|
|
286
|
+
* relies on: a real path (not a dotted string), a visible refusal, and the host's own
|
|
287
|
+
* validation. Nothing is consulted, resolved or named.
|
|
288
|
+
*
|
|
289
|
+
* A legacy entry whose key lives in `apiKeyEnv` is handled by the runtime, not here: that field
|
|
290
|
+
* is read as a literal key when it cannot be a reference name, so the user's first typed key
|
|
291
|
+
* simply supersedes it.
|
|
292
|
+
* @param row - the channel whose key is being set.
|
|
293
|
+
*/
|
|
294
|
+
const saveKey = async (row) => {
|
|
295
|
+
setKeySaving(true);
|
|
296
|
+
try {
|
|
297
|
+
if (!await setField([
|
|
298
|
+
"channels",
|
|
299
|
+
row.id,
|
|
300
|
+
"apiKey"
|
|
301
|
+
], keyDraft.trim())) {
|
|
302
|
+
setError("保存密钥失败:宿主拒绝了这个修改");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
setError(null);
|
|
306
|
+
setKeyDraft("");
|
|
307
|
+
setKeying(null);
|
|
308
|
+
await refresh();
|
|
309
|
+
} catch (cause) {
|
|
310
|
+
setError(`保存密钥失败:${cause instanceof Error ? cause.message : String(cause)}`);
|
|
311
|
+
} finally {
|
|
312
|
+
setKeySaving(false);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
266
315
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
267
316
|
className: "ad-page",
|
|
268
317
|
children: [
|
|
@@ -274,9 +323,14 @@ window.__ModuleLoader__.load({
|
|
|
274
323
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
275
324
|
className: "ad-intro",
|
|
276
325
|
children: [
|
|
277
|
-
"
|
|
278
|
-
|
|
279
|
-
"
|
|
326
|
+
"这里定义的是 ",
|
|
327
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: "any-deepseek 这一个 provider" }),
|
|
328
|
+
" 的内部渠道, 与 DSH 别处的设置无关。本插件对外只暴露一个模型 (",
|
|
329
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
330
|
+
className: "ad-mono",
|
|
331
|
+
children: model.name || "DeepSeek V4.1 Flash"
|
|
332
|
+
}),
|
|
333
|
+
"); 下面勾选哪一个渠道,请求就只发到那一个,不会自动切换。"
|
|
280
334
|
]
|
|
281
335
|
})]
|
|
282
336
|
}),
|
|
@@ -298,95 +352,139 @@ window.__ModuleLoader__.load({
|
|
|
298
352
|
}),
|
|
299
353
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
300
354
|
className: "ad-rows",
|
|
355
|
+
role: "radiogroup",
|
|
356
|
+
"aria-label": "选择当前渠道",
|
|
301
357
|
children: rows.map((row) => {
|
|
302
|
-
const enabled = channels[row.id]?.enabled !== false;
|
|
303
358
|
const isCurrent = current === row.id;
|
|
304
359
|
const probeResult = verdict[row.id];
|
|
305
360
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
306
361
|
className: `ad-row${isCurrent ? " ad-row-current" : ""}`,
|
|
307
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("
|
|
308
|
-
className: "ad-
|
|
309
|
-
children: [
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
children: row.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
362
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
363
|
+
className: "ad-rowPick",
|
|
364
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
365
|
+
type: "radio",
|
|
366
|
+
name: "ad-current-channel",
|
|
367
|
+
checked: isCurrent,
|
|
368
|
+
onChange: () => select(row.id)
|
|
369
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
370
|
+
className: "ad-rowMain",
|
|
371
|
+
children: [
|
|
372
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
373
|
+
className: "ad-rowTitleLine",
|
|
374
|
+
children: [
|
|
375
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(KeyDot, {
|
|
376
|
+
configured: row.keyConfigured,
|
|
377
|
+
known: row.keySource !== void 0 || row.keyConfigured
|
|
378
|
+
}),
|
|
379
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
380
|
+
className: "ad-rowTitle",
|
|
381
|
+
children: row.label
|
|
382
|
+
}),
|
|
383
|
+
isCurrent && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
384
|
+
className: "ad-badge",
|
|
385
|
+
children: "当前"
|
|
386
|
+
}),
|
|
387
|
+
row.warnings.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
388
|
+
className: "ad-warn",
|
|
389
|
+
title: row.warnings.join("\n"),
|
|
390
|
+
children: "⚠"
|
|
391
|
+
})
|
|
392
|
+
]
|
|
393
|
+
}),
|
|
394
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
395
|
+
className: "ad-rowMeta",
|
|
396
|
+
children: [
|
|
397
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
398
|
+
className: "ad-mono",
|
|
399
|
+
children: row.model
|
|
400
|
+
}),
|
|
401
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
402
|
+
className: "ad-sep",
|
|
403
|
+
children: "·"
|
|
404
|
+
}),
|
|
405
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: row.api }),
|
|
406
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
407
|
+
className: "ad-sep",
|
|
408
|
+
children: "·"
|
|
409
|
+
}),
|
|
410
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
411
|
+
className: "ad-mono",
|
|
412
|
+
children: row.baseURL
|
|
413
|
+
})
|
|
414
|
+
]
|
|
415
|
+
}),
|
|
416
|
+
probeResult !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
417
|
+
className: `ad-probe ad-probe-${probeResult.ok ? "ok" : "bad"}`,
|
|
418
|
+
children: probeResult.ok ? `通(${probeResult.ms}ms)` : `不通:${probeResult.code ?? "ERROR"}${probeResult.message === void 0 ? "" : ` — ${probeResult.message}`}`
|
|
419
|
+
}),
|
|
420
|
+
row.keyConfigured ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
421
|
+
className: "ad-keyLine",
|
|
422
|
+
children: [
|
|
423
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
424
|
+
className: "ad-keyOn",
|
|
425
|
+
children: "密钥已配置"
|
|
426
|
+
}),
|
|
427
|
+
row.keyKind === "reference" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
|
|
428
|
+
className: "ad-mono ad-keyName",
|
|
429
|
+
children: row.keyRef
|
|
430
|
+
}),
|
|
431
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
432
|
+
type: "button",
|
|
433
|
+
className: "ad-link",
|
|
434
|
+
onClick: () => setKeying(keying === row.id ? null : row.id),
|
|
435
|
+
children: keying === row.id ? "取消" : "更换"
|
|
436
|
+
})
|
|
437
|
+
]
|
|
438
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
439
|
+
className: "ad-keyLine",
|
|
440
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
441
|
+
className: "ad-keyOff",
|
|
442
|
+
children: "未配置密钥"
|
|
443
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
444
|
+
type: "button",
|
|
445
|
+
className: "ad-link",
|
|
446
|
+
onClick: () => setKeying(keying === row.id ? null : row.id),
|
|
447
|
+
children: keying === row.id ? "取消" : "填写密钥"
|
|
448
|
+
})]
|
|
449
|
+
}),
|
|
450
|
+
keying === row.id && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
451
|
+
className: "ad-keyForm",
|
|
452
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
453
|
+
className: "ad-input",
|
|
454
|
+
type: "password",
|
|
455
|
+
autoComplete: "off",
|
|
456
|
+
value: keyDraft,
|
|
457
|
+
placeholder: "粘贴 key 本体",
|
|
458
|
+
onChange: (event) => setKeyDraft(event.target.value)
|
|
459
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
460
|
+
type: "button",
|
|
461
|
+
className: "ad-btn",
|
|
462
|
+
disabled: keyDraft.trim() === "" || keySaving,
|
|
463
|
+
onClick: () => {
|
|
464
|
+
saveKey(row);
|
|
465
|
+
},
|
|
466
|
+
children: keySaving ? "保存中…" : "保存密钥"
|
|
467
|
+
})]
|
|
468
|
+
})
|
|
469
|
+
]
|
|
470
|
+
})]
|
|
359
471
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
360
472
|
className: "ad-rowActions",
|
|
361
|
-
children: [
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
children: probing === row.id ? "测试中…" : "测试"
|
|
377
|
-
}),
|
|
378
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
379
|
-
type: "button",
|
|
380
|
-
className: "ad-btn ad-btn-danger",
|
|
381
|
-
title: "移除此渠道(不删除凭据本身)",
|
|
382
|
-
onClick: () => removeChannel(row.id),
|
|
383
|
-
children: "移除"
|
|
384
|
-
}),
|
|
385
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toggle, {
|
|
386
|
-
checked: enabled,
|
|
387
|
-
onChange: (on) => toggleChannel(row.id, on)
|
|
388
|
-
})
|
|
389
|
-
]
|
|
473
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
474
|
+
type: "button",
|
|
475
|
+
className: "ad-btn ad-btn-quiet",
|
|
476
|
+
disabled: probing === row.id,
|
|
477
|
+
onClick: () => {
|
|
478
|
+
runProbe(row.id);
|
|
479
|
+
},
|
|
480
|
+
children: probing === row.id ? "测试中…" : "测试"
|
|
481
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
482
|
+
type: "button",
|
|
483
|
+
className: "ad-btn ad-btn-danger",
|
|
484
|
+
title: "移除此渠道(不删除凭据本身)",
|
|
485
|
+
onClick: () => removeChannel(row.id),
|
|
486
|
+
children: "移除"
|
|
487
|
+
})]
|
|
390
488
|
})]
|
|
391
489
|
}, row.id);
|
|
392
490
|
})
|
|
@@ -394,28 +492,22 @@ window.__ModuleLoader__.load({
|
|
|
394
492
|
adding ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
395
493
|
className: "ad-addForm",
|
|
396
494
|
children: [
|
|
397
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
398
|
-
className: "ad-field",
|
|
399
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "渠道 id" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
400
|
-
className: "ad-input",
|
|
401
|
-
value: newId,
|
|
402
|
-
placeholder: "例如 opencode-go",
|
|
403
|
-
onChange: (event) => setNewId(event.target.value)
|
|
404
|
-
})]
|
|
405
|
-
}),
|
|
406
|
-
newIdProblem !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
407
|
-
className: "ad-fieldError",
|
|
408
|
-
children: newIdProblem
|
|
409
|
-
}),
|
|
410
495
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
411
496
|
className: "ad-field",
|
|
412
497
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "规格" }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
|
|
413
498
|
className: "ad-input",
|
|
414
499
|
value: newPreset,
|
|
415
|
-
onChange: (event) =>
|
|
500
|
+
onChange: (event) => {
|
|
501
|
+
const next = event.target.value;
|
|
502
|
+
setNewPreset(next);
|
|
503
|
+
const previousId = lastSuggestedId.current;
|
|
504
|
+
const nextId = suggestId(next, rows);
|
|
505
|
+
setNewId((current) => current === "" || current === previousId ? nextId : current);
|
|
506
|
+
lastSuggestedId.current = nextId;
|
|
507
|
+
},
|
|
416
508
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
417
509
|
value: "",
|
|
418
|
-
children: "
|
|
510
|
+
children: "选择规格…"
|
|
419
511
|
}), presets.map((preset) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
420
512
|
value: preset.id,
|
|
421
513
|
children: preset.displayName
|
|
@@ -424,23 +516,47 @@ window.__ModuleLoader__.load({
|
|
|
424
516
|
}),
|
|
425
517
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
426
518
|
className: "ad-field",
|
|
427
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "
|
|
519
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "渠道 id" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
520
|
+
className: "ad-input",
|
|
521
|
+
value: newId,
|
|
522
|
+
placeholder: newPreset === "" ? "先选上面的规格" : suggestId(newPreset, rows),
|
|
523
|
+
onChange: (event) => setNewId(event.target.value)
|
|
524
|
+
})]
|
|
525
|
+
}),
|
|
526
|
+
newIdProblem !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
527
|
+
className: "ad-fieldError",
|
|
528
|
+
children: newIdProblem
|
|
529
|
+
}),
|
|
530
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
531
|
+
className: "ad-field",
|
|
532
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "密钥" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
428
533
|
className: "ad-input",
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
534
|
+
type: "password",
|
|
535
|
+
autoComplete: "off",
|
|
536
|
+
value: newSecret,
|
|
537
|
+
placeholder: newPreset === "" ? "先选上面的规格" : "sk-…",
|
|
538
|
+
onChange: (event) => setNewSecret(event.target.value)
|
|
432
539
|
})]
|
|
433
540
|
}),
|
|
434
|
-
/* @__PURE__ */ (0, react_jsx_runtime.
|
|
541
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
435
542
|
className: "ad-hint",
|
|
436
|
-
children:
|
|
543
|
+
children: [
|
|
544
|
+
"密钥写在本机 profile 配置里(",
|
|
545
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
|
|
546
|
+
className: "ad-mono",
|
|
547
|
+
children: "channels.<id>.apiKey"
|
|
548
|
+
}),
|
|
549
|
+
")。 它",
|
|
550
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: "不" }),
|
|
551
|
+
"外传,但会因为配置文件而被看到——别把这个 profile 分享出去。"
|
|
552
|
+
]
|
|
437
553
|
}),
|
|
438
554
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
439
555
|
className: "ad-rowActions",
|
|
440
556
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
441
557
|
type: "button",
|
|
442
558
|
className: "ad-btn",
|
|
443
|
-
disabled: newId.trim() === "" || newIdProblem !== null ||
|
|
559
|
+
disabled: newPreset === "" || newId.trim() === "" || newIdProblem !== null || newSecret.trim() === "",
|
|
444
560
|
onClick: addChannel,
|
|
445
561
|
children: "添加"
|
|
446
562
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
@@ -454,7 +570,10 @@ window.__ModuleLoader__.load({
|
|
|
454
570
|
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
455
571
|
type: "button",
|
|
456
572
|
className: "ad-btn",
|
|
457
|
-
onClick: () =>
|
|
573
|
+
onClick: () => {
|
|
574
|
+
setAdding(true);
|
|
575
|
+
lastSuggestedId.current = "";
|
|
576
|
+
},
|
|
458
577
|
children: "添加渠道"
|
|
459
578
|
}),
|
|
460
579
|
current === "" && rows.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
package/lib/config.d.ts
CHANGED
|
@@ -41,8 +41,10 @@ export interface ChannelConfigInput {
|
|
|
41
41
|
api?: string;
|
|
42
42
|
baseURL?: string;
|
|
43
43
|
model?: string;
|
|
44
|
+
/** The key itself, stored in the channel entry. The panel writes this. */
|
|
45
|
+
apiKey?: string;
|
|
46
|
+
/** Legacy: a credential reference name, resolved through the harness seam / environment. */
|
|
44
47
|
apiKeyEnv?: string;
|
|
45
|
-
enabled?: boolean;
|
|
46
48
|
contextWindow?: number;
|
|
47
49
|
maxTokens?: number;
|
|
48
50
|
input?: Array<'text' | 'image'>;
|
|
@@ -62,8 +64,8 @@ export interface ChannelConfigValue {
|
|
|
62
64
|
readonly api?: string;
|
|
63
65
|
readonly baseURL?: string;
|
|
64
66
|
readonly model?: string;
|
|
67
|
+
readonly apiKey?: string;
|
|
65
68
|
readonly apiKeyEnv?: string;
|
|
66
|
-
readonly enabled?: boolean;
|
|
67
69
|
readonly contextWindow?: number;
|
|
68
70
|
readonly maxTokens?: number;
|
|
69
71
|
readonly input?: ReadonlyArray<'text' | 'image'>;
|
|
@@ -75,9 +77,26 @@ export interface ChannelConfigValue {
|
|
|
75
77
|
* One channel entry schema.
|
|
76
78
|
*
|
|
77
79
|
* Fields split into two groups the panel treats differently:
|
|
78
|
-
* - the key group (`
|
|
80
|
+
* - the key group (`apiKey`) — the only thing a user must supply for a preset channel;
|
|
79
81
|
* - the endpoint group (`api` / `baseURL` / `model` / capacities) — filled from a preset, or
|
|
80
82
|
* declared by hand for a custom channel.
|
|
83
|
+
*
|
|
84
|
+
* ## Why the key is stored here and not behind a reference
|
|
85
|
+
*
|
|
86
|
+
* This plugin used to take a credential REFERENCE NAME and store the value through
|
|
87
|
+
* `ctx.remote.credentials`. Owner decision 2026-10-21: store the key in the entry itself.
|
|
88
|
+
*
|
|
89
|
+
* The price is explicit and accepted, recorded here so nobody rediscovers it as a bug:
|
|
90
|
+
* · `dsh --dump-config` renders patch layers verbatim and does NOT redact — a key in the
|
|
91
|
+
* configuration tree prints in full in diagnostics and diffs;
|
|
92
|
+
* · the environment-injection path (`-e SOME_KEY=…`) no longer reaches this plugin, because
|
|
93
|
+
* nothing resolves a name any more; a container run must carry the value in the profile.
|
|
94
|
+
* What it buys: one storage location, no invented naming layer, and a panel that asks only for
|
|
95
|
+
* the thing the user actually has.
|
|
96
|
+
*
|
|
97
|
+
* `role('secret')` is still declared. It does nothing for `dump-config` (measured: that path has
|
|
98
|
+
* no redaction hook), but it makes the REMOTE settings read strip the field, so the browser form
|
|
99
|
+
* never receives a value it did not type.
|
|
81
100
|
*/
|
|
82
101
|
export declare const ChannelConfigSchema: SchemaOf<ChannelConfigInput, ChannelConfigValue>;
|
|
83
102
|
/** Ledger settings. */
|
package/lib/http.d.ts
CHANGED
|
@@ -21,9 +21,9 @@ import type { ResolvedChannel } from './channels.js';
|
|
|
21
21
|
export declare const ROUTE_PREFIX = "/any-deepseek";
|
|
22
22
|
/** What the routes need from the plugin. */
|
|
23
23
|
export interface RouteDeps {
|
|
24
|
-
/**
|
|
24
|
+
/** Every resolved channel, in configuration order. */
|
|
25
25
|
channels: () => readonly ResolvedChannel[];
|
|
26
|
-
/** Resolve a credential
|
|
26
|
+
/** Resolve a credential REFERENCE to its value (legacy entries only). */
|
|
27
27
|
resolveKey: (ref: string) => Promise<string | undefined>;
|
|
28
28
|
/** Preset ids and labels for the "add channel" picker. */
|
|
29
29
|
listPresets: () => Array<{
|
|
@@ -40,6 +40,16 @@ export interface RouteDeps {
|
|
|
40
40
|
configured: boolean;
|
|
41
41
|
source?: string;
|
|
42
42
|
}>;
|
|
43
|
+
/**
|
|
44
|
+
* The ledger directory the plugin actually WRITES to.
|
|
45
|
+
*
|
|
46
|
+
* 🔴 This must be passed in, not recomputed. The reader used to call
|
|
47
|
+
* `ledgerDirectory(undefined)` — which silently dropped the configured `usage.path` — while the
|
|
48
|
+
* writer used `ledgerDirectory(usage.path)`. Whenever a user set a custom ledger directory the
|
|
49
|
+
* two pointed at different places, so the panel read an empty book forever and reported
|
|
50
|
+
* "no usage yet" as though no request had ever run. Read and write must resolve one directory.
|
|
51
|
+
*/
|
|
52
|
+
ledgerDir: () => string;
|
|
43
53
|
}
|
|
44
54
|
/** Structural view of the harness web server (only `register` is used). */
|
|
45
55
|
export interface WebServerLike {
|
package/lib/index.js
CHANGED
|
@@ -43,9 +43,26 @@ import { appendFile, mkdir, readFile, readdir } from "node:fs/promises";
|
|
|
43
43
|
* One channel entry schema.
|
|
44
44
|
*
|
|
45
45
|
* Fields split into two groups the panel treats differently:
|
|
46
|
-
* - the key group (`
|
|
46
|
+
* - the key group (`apiKey`) — the only thing a user must supply for a preset channel;
|
|
47
47
|
* - the endpoint group (`api` / `baseURL` / `model` / capacities) — filled from a preset, or
|
|
48
48
|
* declared by hand for a custom channel.
|
|
49
|
+
*
|
|
50
|
+
* ## Why the key is stored here and not behind a reference
|
|
51
|
+
*
|
|
52
|
+
* This plugin used to take a credential REFERENCE NAME and store the value through
|
|
53
|
+
* `ctx.remote.credentials`. Owner decision 2026-10-21: store the key in the entry itself.
|
|
54
|
+
*
|
|
55
|
+
* The price is explicit and accepted, recorded here so nobody rediscovers it as a bug:
|
|
56
|
+
* · `dsh --dump-config` renders patch layers verbatim and does NOT redact — a key in the
|
|
57
|
+
* configuration tree prints in full in diagnostics and diffs;
|
|
58
|
+
* · the environment-injection path (`-e SOME_KEY=…`) no longer reaches this plugin, because
|
|
59
|
+
* nothing resolves a name any more; a container run must carry the value in the profile.
|
|
60
|
+
* What it buys: one storage location, no invented naming layer, and a panel that asks only for
|
|
61
|
+
* the thing the user actually has.
|
|
62
|
+
*
|
|
63
|
+
* `role('secret')` is still declared. It does nothing for `dump-config` (measured: that path has
|
|
64
|
+
* no redaction hook), but it makes the REMOTE settings read strip the field, so the browser form
|
|
65
|
+
* never receives a value it did not type.
|
|
49
66
|
*/
|
|
50
67
|
const ChannelConfigSchema = z.object({
|
|
51
68
|
/** Label shown in the panel; falls back to the preset's display name, then the id. */
|
|
@@ -55,8 +72,8 @@ const ChannelConfigSchema = z.object({
|
|
|
55
72
|
api: z.string().description("接口协议:openai-completions(默认)/ openai-responses / anthropic-messages"),
|
|
56
73
|
baseURL: z.string().description("端点根地址"),
|
|
57
74
|
model: z.string().description("上游模型 id(各家拼写不同,必填)"),
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
apiKey: z.string().role("secret").description("密钥本体(面板里粘贴,写在这条渠道上)"),
|
|
76
|
+
apiKeyEnv: z.string().description("旧式:凭据引用名(环境变量名)。仅当 apiKey 为空时才用"),
|
|
60
77
|
contextWindow: z.number().description("上下文窗口(tokens)"),
|
|
61
78
|
maxTokens: z.number().description("单次输出上限(tokens)"),
|
|
62
79
|
input: z.array(z.union([z.const("text"), z.const("image")])).description("输入模态"),
|
|
@@ -277,6 +294,7 @@ const KNOWN_KEYS = /* @__PURE__ */ new Set([
|
|
|
277
294
|
"api",
|
|
278
295
|
"baseURL",
|
|
279
296
|
"model",
|
|
297
|
+
"apiKey",
|
|
280
298
|
"apiKeyEnv",
|
|
281
299
|
"enabled",
|
|
282
300
|
"contextWindow",
|
|
@@ -294,6 +312,27 @@ function nonEmpty(value) {
|
|
|
294
312
|
const trimmed = value.trim();
|
|
295
313
|
return trimmed === "" ? void 0 : trimmed;
|
|
296
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* The grammar a credential REFERENCE name must match.
|
|
317
|
+
*
|
|
318
|
+
* Transcribed from the harness's own credential seam
|
|
319
|
+
* (`@deepseek-ai/dsh-credentials` → `REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/`).
|
|
320
|
+
*
|
|
321
|
+
* This plugin no longer WRITES references (the panel stores the key itself), but it still READS
|
|
322
|
+
* them, and the grammar is what lets the read distinguish the two shapes. A string that cannot be
|
|
323
|
+
* an environment-variable name cannot be a reference — so when one appears in `apiKeyEnv` it can
|
|
324
|
+
* only be a value in the wrong slot, which is a repairable configuration error rather than a
|
|
325
|
+
* mystery.
|
|
326
|
+
*/
|
|
327
|
+
const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
328
|
+
/**
|
|
329
|
+
* Whether a string could name a credential reference at all.
|
|
330
|
+
* @param value - candidate name.
|
|
331
|
+
* @returns true when the field can be read as a reference.
|
|
332
|
+
*/
|
|
333
|
+
function isCredentialRefName(value) {
|
|
334
|
+
return CREDENTIAL_REF_PATTERN.test(value);
|
|
335
|
+
}
|
|
297
336
|
function positiveInt(value) {
|
|
298
337
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : void 0;
|
|
299
338
|
}
|
|
@@ -335,14 +374,31 @@ function resolveChannel(id, raw) {
|
|
|
335
374
|
detail: `unknown channel field "${key}"`
|
|
336
375
|
}
|
|
337
376
|
};
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
377
|
+
/**
|
|
378
|
+
* How this channel authenticates — never a reason to fail resolution.
|
|
379
|
+
*
|
|
380
|
+
* 🔴 A channel with no key still RESOLVES. The panel has to be able to show it (that is how the
|
|
381
|
+
* user gets to fix it), and the model directory must not depend on a secret being present
|
|
382
|
+
* (decision 26: a provider that vanishes when unconfigured looks exactly like one that is not
|
|
383
|
+
* installed). A missing key is answered per request — `MISSING_CREDENTIAL` — and per row.
|
|
384
|
+
*
|
|
385
|
+
* Migration, by grammar: `apiKey` wins when present. Otherwise `apiKeyEnv` is read as a
|
|
386
|
+
* reference ONLY when it matches the host's reference grammar. A string that cannot be a name
|
|
387
|
+
* is a key in the wrong slot — an observed state, since the previous version of this panel
|
|
388
|
+
* asked for a name and one user's YAML holds `sk-…` there. Adopting it is a repair, not a
|
|
389
|
+
* guess: no environment variable can have that spelling.
|
|
390
|
+
*/
|
|
391
|
+
const literal = nonEmpty(entry.apiKey);
|
|
392
|
+
const legacy = nonEmpty(entry.apiKeyEnv);
|
|
393
|
+
const credential = literal !== void 0 ? {
|
|
394
|
+
kind: "literal",
|
|
395
|
+
value: literal
|
|
396
|
+
} : legacy === void 0 ? { kind: "none" } : isCredentialRefName(legacy) ? {
|
|
397
|
+
kind: "reference",
|
|
398
|
+
ref: legacy
|
|
399
|
+
} : {
|
|
400
|
+
kind: "literal",
|
|
401
|
+
value: legacy
|
|
346
402
|
};
|
|
347
403
|
const presetId = nonEmpty(entry.preset);
|
|
348
404
|
const preset = presetId === void 0 ? void 0 : presetById(presetId);
|
|
@@ -397,7 +453,8 @@ function resolveChannel(id, raw) {
|
|
|
397
453
|
api: apiRaw,
|
|
398
454
|
baseURL,
|
|
399
455
|
model,
|
|
400
|
-
|
|
456
|
+
credential,
|
|
457
|
+
...presetId === void 0 ? {} : { preset: presetId },
|
|
401
458
|
contextWindow,
|
|
402
459
|
maxTokens,
|
|
403
460
|
input,
|
|
@@ -434,19 +491,19 @@ function resolveChannels(entries) {
|
|
|
434
491
|
};
|
|
435
492
|
}
|
|
436
493
|
/**
|
|
437
|
-
*
|
|
494
|
+
* Every resolved channel, in configuration order — the panel's list and the router's candidate set.
|
|
495
|
+
*
|
|
496
|
+
* 🔴 There is no filtering step here, deliberately. This used to be `enabledChannels()`, which
|
|
497
|
+
* dropped entries with `enabled: false`. That made two things impossible:
|
|
498
|
+
* · the panel could not show a channel it had hidden, so you could not switch it back on;
|
|
499
|
+
* · "which relay serves requests" had two answers (`enabled` AND `current`) that could disagree.
|
|
500
|
+
* The design has one axis — `current` — so the only list is "all channels", and selection is a
|
|
501
|
+
* separate fact.
|
|
438
502
|
* @param resolution - output of {@link resolveChannels}.
|
|
439
|
-
* @
|
|
440
|
-
* @returns enabled channels, configuration order preserved.
|
|
503
|
+
* @returns all resolved channels (the router picks one of them via `current`).
|
|
441
504
|
*/
|
|
442
|
-
function
|
|
443
|
-
|
|
444
|
-
const map = isPlainObject(entries) ? entries : {};
|
|
445
|
-
for (const [id, channel] of resolution.channels) {
|
|
446
|
-
const raw = map[id];
|
|
447
|
-
if (!(isPlainObject(raw) && raw.enabled === false)) out.push(channel);
|
|
448
|
-
}
|
|
449
|
-
return out;
|
|
505
|
+
function allChannels(resolution) {
|
|
506
|
+
return [...resolution.channels.values()];
|
|
450
507
|
}
|
|
451
508
|
//#endregion
|
|
452
509
|
//#region src/route.ts
|
|
@@ -456,7 +513,11 @@ function enabledChannels(resolution, entries) {
|
|
|
456
513
|
* The order of the checks matters for the message the user sees: an empty configuration is a
|
|
457
514
|
* setup problem, while an unset `current` is a choice not yet made. Reporting them separately
|
|
458
515
|
* keeps the panel's guidance accurate.
|
|
459
|
-
*
|
|
516
|
+
*
|
|
517
|
+
* 🔴 There is no "disabled" branch any more. `current` is the single axis, so a selection either
|
|
518
|
+
* names a channel that exists or names one that does not — the old `CHANNEL_DISABLED` code
|
|
519
|
+
* described a state the configuration can no longer express.
|
|
520
|
+
* @param channels - every resolved channel, in configuration order.
|
|
460
521
|
* @param current - the configured channel id.
|
|
461
522
|
* @returns the selected channel, or the reason there is none.
|
|
462
523
|
*/
|
|
@@ -465,7 +526,7 @@ function selectChannel(channels, current) {
|
|
|
465
526
|
ok: false,
|
|
466
527
|
failure: {
|
|
467
528
|
code: "NO_CHANNELS",
|
|
468
|
-
detail: "no channel is configured
|
|
529
|
+
detail: "no channel is configured; add one in Settings → Any DeepSeek"
|
|
469
530
|
}
|
|
470
531
|
};
|
|
471
532
|
const wanted = typeof current === "string" ? current.trim() : "";
|
|
@@ -473,7 +534,7 @@ function selectChannel(channels, current) {
|
|
|
473
534
|
ok: false,
|
|
474
535
|
failure: {
|
|
475
536
|
code: "NO_CHANNEL_SELECTED",
|
|
476
|
-
detail: "no channel is selected; pick one in Settings →
|
|
537
|
+
detail: "no channel is selected; pick one in Settings → Any DeepSeek"
|
|
477
538
|
}
|
|
478
539
|
};
|
|
479
540
|
const found = channels.find((channel) => channel.id === wanted);
|
|
@@ -484,8 +545,8 @@ function selectChannel(channels, current) {
|
|
|
484
545
|
return {
|
|
485
546
|
ok: false,
|
|
486
547
|
failure: {
|
|
487
|
-
code: "
|
|
488
|
-
detail: `selected channel "${wanted}"
|
|
548
|
+
code: "CHANNEL_UNKNOWN",
|
|
549
|
+
detail: `selected channel "${wanted}" does not exist; available: ${channels.map((channel) => channel.id).join(", ")}`
|
|
489
550
|
}
|
|
490
551
|
};
|
|
491
552
|
}
|
|
@@ -990,7 +1051,7 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
|
|
|
990
1051
|
return;
|
|
991
1052
|
}
|
|
992
1053
|
channel = selection.channel;
|
|
993
|
-
const key = await this.deps.resolveKey(channel.
|
|
1054
|
+
const key = channel.credential.kind === "literal" ? channel.credential.value : channel.credential.kind === "reference" ? await this.deps.resolveKey(channel.credential.ref) : void 0;
|
|
994
1055
|
if (key === void 0 || key === "") {
|
|
995
1056
|
outcome = {
|
|
996
1057
|
ok: false,
|
|
@@ -1002,7 +1063,7 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
|
|
|
1002
1063
|
reason: {
|
|
1003
1064
|
kind: "error",
|
|
1004
1065
|
failure: {
|
|
1005
|
-
message: `
|
|
1066
|
+
message: `channel "${channel.id}" has no key configured`,
|
|
1006
1067
|
code: "MISSING_CREDENTIAL"
|
|
1007
1068
|
}
|
|
1008
1069
|
}
|
|
@@ -1438,7 +1499,19 @@ function registerStatsRoutes(ctx, webServer, deps) {
|
|
|
1438
1499
|
handler: async (_req, res) => {
|
|
1439
1500
|
json(res, 200, {
|
|
1440
1501
|
channels: await Promise.all(deps.channels().map(async (channel) => {
|
|
1441
|
-
|
|
1502
|
+
/**
|
|
1503
|
+
* 🔴 The key is a property of the CHANNEL now, so "is one configured?" is answered
|
|
1504
|
+
* without consulting any credential store. A legacy reference still asks the harness,
|
|
1505
|
+
* and that answer is the only thing the panel ever learns about a reference: whether
|
|
1506
|
+
* it exists.
|
|
1507
|
+
*/
|
|
1508
|
+
const key = channel.credential.kind === "literal" ? {
|
|
1509
|
+
configured: channel.credential.value !== "",
|
|
1510
|
+
source: "channel"
|
|
1511
|
+
} : channel.credential.kind === "reference" ? await deps.describeKey(channel.credential.ref) : {
|
|
1512
|
+
configured: false,
|
|
1513
|
+
source: void 0
|
|
1514
|
+
};
|
|
1442
1515
|
return {
|
|
1443
1516
|
id: channel.id,
|
|
1444
1517
|
label: channel.label,
|
|
@@ -1450,7 +1523,9 @@ function registerStatsRoutes(ctx, webServer, deps) {
|
|
|
1450
1523
|
input: [...channel.input],
|
|
1451
1524
|
warnings: [...channel.warnings],
|
|
1452
1525
|
keyConfigured: key.configured,
|
|
1453
|
-
...key.source === void 0 ? {} : { keySource: key.source }
|
|
1526
|
+
...key.source === void 0 ? {} : { keySource: key.source },
|
|
1527
|
+
keyKind: channel.credential.kind,
|
|
1528
|
+
...channel.credential.kind === "reference" ? { keyRef: channel.credential.ref } : {}
|
|
1454
1529
|
};
|
|
1455
1530
|
})),
|
|
1456
1531
|
presets: deps.listPresets(),
|
|
@@ -1466,7 +1541,7 @@ function registerStatsRoutes(ctx, webServer, deps) {
|
|
|
1466
1541
|
const from = queryDate(url.searchParams.get("from"));
|
|
1467
1542
|
const to = queryDate(url.searchParams.get("to"));
|
|
1468
1543
|
const group = url.searchParams.get("group") ?? "channel";
|
|
1469
|
-
const entries = await readLedgerRange(
|
|
1544
|
+
const entries = await readLedgerRange(deps.ledgerDir(), from, to);
|
|
1470
1545
|
const rows = group === "day" ? summarize(entries, (entry) => dayKey(new Date(entry.at))) : summarize(entries, (entry) => entry.channel);
|
|
1471
1546
|
json(res, 200, {
|
|
1472
1547
|
range: {
|
|
@@ -1502,13 +1577,13 @@ function registerStatsRoutes(ctx, webServer, deps) {
|
|
|
1502
1577
|
}
|
|
1503
1578
|
const channel = deps.channels().find((candidate) => candidate.id === id);
|
|
1504
1579
|
if (channel === void 0) {
|
|
1505
|
-
json(res, 404, { error: `no
|
|
1580
|
+
json(res, 404, { error: `no channel named "${id}"` });
|
|
1506
1581
|
return;
|
|
1507
1582
|
}
|
|
1508
|
-
const key = await deps.resolveKey(channel.
|
|
1583
|
+
const key = channel.credential.kind === "literal" ? channel.credential.value : channel.credential.kind === "reference" ? await deps.resolveKey(channel.credential.ref) : void 0;
|
|
1509
1584
|
if (key === void 0 || key === "") {
|
|
1510
1585
|
json(res, 409, {
|
|
1511
|
-
error: `channel "${id}" has no configured
|
|
1586
|
+
error: `channel "${id}" has no key configured`,
|
|
1512
1587
|
code: "MISSING_CREDENTIAL"
|
|
1513
1588
|
});
|
|
1514
1589
|
return;
|
|
@@ -1593,7 +1668,11 @@ function makeRecorder(ctx, config) {
|
|
|
1593
1668
|
*/
|
|
1594
1669
|
function apply(ctx, config) {
|
|
1595
1670
|
const credentials = () => service(ctx, "credentials");
|
|
1596
|
-
/**
|
|
1671
|
+
/**
|
|
1672
|
+
* Resolve a credential REFERENCE, distinguishing "absent" from "not configured".
|
|
1673
|
+
*
|
|
1674
|
+
* Only legacy channels reach this: a channel that stores its own key never asks the seam.
|
|
1675
|
+
*/
|
|
1597
1676
|
const resolveKey = async (ref) => {
|
|
1598
1677
|
const seam = credentials();
|
|
1599
1678
|
if (seam?.resolve === void 0) return process.env[ref];
|
|
@@ -1604,14 +1683,16 @@ function apply(ctx, config) {
|
|
|
1604
1683
|
}
|
|
1605
1684
|
};
|
|
1606
1685
|
/**
|
|
1607
|
-
*
|
|
1686
|
+
* Every configured channel, in configuration order.
|
|
1608
1687
|
*
|
|
1609
1688
|
* Read fresh on every call rather than captured at apply time: the settings document can be
|
|
1610
1689
|
* edited while the plugin runs, and `fiber.config` is updated in place by the loader.
|
|
1690
|
+
*
|
|
1691
|
+
* No filtering: the list is what the panel shows AND what the router chooses from. Which one
|
|
1692
|
+
* serves requests is answered solely by `current` — there is no second on/off axis.
|
|
1611
1693
|
*/
|
|
1612
1694
|
const channels = () => {
|
|
1613
|
-
|
|
1614
|
-
return enabledChannels(resolveChannels(map), map);
|
|
1695
|
+
return allChannels(resolveChannels(unwrapChannels((ctx.fiber?.config ?? config).channels)));
|
|
1615
1696
|
};
|
|
1616
1697
|
const current = () => {
|
|
1617
1698
|
const value = unwrap((ctx.fiber?.config ?? config).current);
|
|
@@ -1657,6 +1738,9 @@ function apply(ctx, config) {
|
|
|
1657
1738
|
} catch {
|
|
1658
1739
|
return { configured: false };
|
|
1659
1740
|
}
|
|
1741
|
+
},
|
|
1742
|
+
ledgerDir: () => {
|
|
1743
|
+
return ledgerDirectory((ctx.fiber?.config ?? config).usage?.path);
|
|
1660
1744
|
}
|
|
1661
1745
|
});
|
|
1662
1746
|
}
|
package/lib/route.d.ts
CHANGED
|
@@ -21,10 +21,7 @@ export type RouteFailure = {
|
|
|
21
21
|
code: 'NO_CHANNEL_SELECTED';
|
|
22
22
|
detail: string;
|
|
23
23
|
} | {
|
|
24
|
-
code: '
|
|
25
|
-
detail: string;
|
|
26
|
-
} | {
|
|
27
|
-
code: 'CHANNEL_DISABLED';
|
|
24
|
+
code: 'CHANNEL_UNKNOWN';
|
|
28
25
|
detail: string;
|
|
29
26
|
} | {
|
|
30
27
|
code: 'NO_CHANNELS';
|
|
@@ -44,7 +41,11 @@ export type RouteResult = {
|
|
|
44
41
|
* The order of the checks matters for the message the user sees: an empty configuration is a
|
|
45
42
|
* setup problem, while an unset `current` is a choice not yet made. Reporting them separately
|
|
46
43
|
* keeps the panel's guidance accurate.
|
|
47
|
-
*
|
|
44
|
+
*
|
|
45
|
+
* 🔴 There is no "disabled" branch any more. `current` is the single axis, so a selection either
|
|
46
|
+
* names a channel that exists or names one that does not — the old `CHANNEL_DISABLED` code
|
|
47
|
+
* described a state the configuration can no longer express.
|
|
48
|
+
* @param channels - every resolved channel, in configuration order.
|
|
48
49
|
* @param current - the configured channel id.
|
|
49
50
|
* @returns the selected channel, or the reason there is none.
|
|
50
51
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-any-deepseek",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
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": {
|