dsh-any-deepseek 0.0.5 → 0.0.7
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 +49 -4
- package/lib/adapter.d.ts +6 -1
- package/lib/channels.d.ts +56 -3
- package/lib/client.js +239 -45
- package/lib/config.d.ts +22 -1
- package/lib/http.d.ts +1 -1
- package/lib/index.js +163 -28
- package/lib/presets.d.ts +8 -0
- 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
|
|
@@ -82,6 +98,35 @@ v1 is deliberately manual. A channel that fails reports its error; it does not s
|
|
|
82
98
|
over to another one. Switching invalidates the prompt cache for the running conversation, so
|
|
83
99
|
switch at a natural boundary (or start a new session).
|
|
84
100
|
|
|
101
|
+
The current channel is marked on its row (left colour bar, stronger border, tinted background)
|
|
102
|
+
rather than by a small badge — a single-choice list has to be readable at a glance.
|
|
103
|
+
|
|
104
|
+
### Thinking level
|
|
105
|
+
|
|
106
|
+
Each channel declares its selectable thinking levels, and the level picked in the harness is
|
|
107
|
+
passed through to the relay:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
harness reasoningEffort → pi-ai ThinkingLevel → model.thinkingLevelMap → wire value
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
There is no mapping layer in this plugin: the `thinkingLevelMap` the transport applies **is**
|
|
114
|
+
the mapping, and a second one here would drift from it. A level declared `null` is unsupported
|
|
115
|
+
and is not offered in the picker.
|
|
116
|
+
|
|
117
|
+
### Display names
|
|
118
|
+
|
|
119
|
+
`label` is a free-form display name, and it is what tells two accounts on the same relay apart.
|
|
120
|
+
Rename a channel from the panel; the channel **id** never changes, because the id is the
|
|
121
|
+
channel's identity (its key and its `current` target hang off it).
|
|
122
|
+
|
|
123
|
+
### Image input
|
|
124
|
+
|
|
125
|
+
Not supported yet, and declared as such. A harness `ImageBlock` carries only an
|
|
126
|
+
`attachmentId`; turning that into request bytes requires the durable attachment service, which
|
|
127
|
+
this adapter does not consult. Declaring image support anyway would make the harness stop
|
|
128
|
+
substituting its placeholder text and drop the image silently — worse than declaring text-only.
|
|
129
|
+
|
|
85
130
|
## Usage accounting
|
|
86
131
|
|
|
87
132
|
The ledger is append-only JSONL, split by month, under the harness home:
|
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,7 +27,20 @@ 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
|
/**
|
|
33
46
|
* @deprecated Retired. Kept in the schema only so an existing configuration that still carries
|
|
@@ -57,7 +70,28 @@ export interface ResolvedChannel {
|
|
|
57
70
|
baseURL: string;
|
|
58
71
|
/** Upstream model id, as the relay spells it. */
|
|
59
72
|
model: string;
|
|
60
|
-
|
|
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;
|
|
61
95
|
contextWindow: number;
|
|
62
96
|
maxTokens: number;
|
|
63
97
|
input: ChannelModality[];
|
|
@@ -70,7 +104,7 @@ export interface ResolvedChannel {
|
|
|
70
104
|
/** Why one entry could not be resolved. */
|
|
71
105
|
export interface ChannelProblem {
|
|
72
106
|
id: string;
|
|
73
|
-
code: 'UNKNOWN_PRESET' | '
|
|
107
|
+
code: 'UNKNOWN_PRESET' | 'MISSING_FIELD' | 'INVALID_API' | 'UNKNOWN_KEY';
|
|
74
108
|
detail: string;
|
|
75
109
|
}
|
|
76
110
|
/** Result of resolving a whole configuration's channel map. */
|
|
@@ -80,6 +114,25 @@ export interface ChannelResolution {
|
|
|
80
114
|
/** Entries that could not resolve; each is reported rather than skipped silently. */
|
|
81
115
|
problems: ChannelProblem[];
|
|
82
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;
|
|
83
136
|
/**
|
|
84
137
|
* Resolve one channel entry.
|
|
85
138
|
*
|
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.\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/* 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";
|
|
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 /* 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";
|
|
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");
|
|
@@ -146,19 +146,6 @@ window.__ModuleLoader__.load({
|
|
|
146
146
|
}
|
|
147
147
|
return `${presetId}-${Date.now()}`;
|
|
148
148
|
}
|
|
149
|
-
/**
|
|
150
|
-
* The credential reference name a preset conventionally uses.
|
|
151
|
-
*
|
|
152
|
-
* This is a CONVENTION, not a lookup: the reference is just the environment-variable name the
|
|
153
|
-
* user's key is stored under, and any name works. Pre-filling the conventional one saves typing;
|
|
154
|
-
* `OPEN_DESIGN` → `OPEN_DESIGN_API_KEY`.
|
|
155
|
-
* @param presetId - the chosen preset.
|
|
156
|
-
* @returns the suggested reference name ('' when no preset is chosen).
|
|
157
|
-
*/
|
|
158
|
-
function suggestKeyRef(presetId) {
|
|
159
|
-
if (presetId === "") return "";
|
|
160
|
-
return `${presetId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
|
|
161
|
-
}
|
|
162
149
|
/** A status dot: filled when confirmed configured, hollow when confirmed absent, half otherwise. */
|
|
163
150
|
function KeyDot(props) {
|
|
164
151
|
const state = props.known ? props.configured ? "on" : "off" : "unknown";
|
|
@@ -187,17 +174,32 @@ window.__ModuleLoader__.load({
|
|
|
187
174
|
const [adding, setAdding] = (0, react.useState)(false);
|
|
188
175
|
const [newId, setNewId] = (0, react.useState)("");
|
|
189
176
|
const [newPreset, setNewPreset] = (0, react.useState)("");
|
|
190
|
-
const [newKeyRef, setNewKeyRef] = (0, react.useState)("");
|
|
191
177
|
/**
|
|
192
|
-
* The
|
|
178
|
+
* The key the user typed in the add form.
|
|
193
179
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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.
|
|
197
183
|
*/
|
|
198
|
-
const
|
|
199
|
-
/**
|
|
184
|
+
const [newSecret, setNewSecret] = (0, react.useState)("");
|
|
185
|
+
/**
|
|
186
|
+
* The channel's display name, as typed.
|
|
187
|
+
*
|
|
188
|
+
* Empty means "use the preset's name". It is a real field (`label`) that the runtime has always
|
|
189
|
+
* honoured — the panel simply never exposed it, which made "two accounts on the same relay"
|
|
190
|
+
* impossible to tell apart.
|
|
191
|
+
*/
|
|
192
|
+
const [newLabel, setNewLabel] = (0, react.useState)("");
|
|
193
|
+
/** Which channel currently has its inline key field open (null = none). */
|
|
194
|
+
const [keying, setKeying] = (0, react.useState)(null);
|
|
195
|
+
const [keyDraft, setKeyDraft] = (0, react.useState)("");
|
|
196
|
+
const [keySaving, setKeySaving] = (0, react.useState)(false);
|
|
197
|
+
/** Which channel currently has its inline RENAME field open (null = none). */
|
|
198
|
+
const [renaming, setRenaming] = (0, react.useState)(null);
|
|
199
|
+
const [renameDraft, setRenameDraft] = (0, react.useState)("");
|
|
200
|
+
/** The channel id and the label the form last derived from a preset (see the select). */
|
|
200
201
|
const lastSuggestedId = (0, react.useRef)("");
|
|
202
|
+
const lastSuggestedLabel = (0, react.useRef)("");
|
|
201
203
|
const refresh = (0, react.useCallback)(async () => {
|
|
202
204
|
const [loaded, statsNow] = await Promise.all([loadChannels(), loadStats(group)]);
|
|
203
205
|
setRows(loaded.channels);
|
|
@@ -226,6 +228,15 @@ window.__ModuleLoader__.load({
|
|
|
226
228
|
* reasons they cannot (transport). Both are facts, and silently discarding them is what makes
|
|
227
229
|
* a panel look alive while nothing is saved.
|
|
228
230
|
*
|
|
231
|
+
* 🔴 **A successful write RE-READS, right here.** The rows the panel renders come from
|
|
232
|
+
* `/any-deepseek/channels`, which is computed from the configuration — not a live push. So a
|
|
233
|
+
* write that does not re-read leaves the panel showing the document as it was BEFORE the write,
|
|
234
|
+
* and the only way to see the result was to close and reopen the panel. Reported by the owner
|
|
235
|
+
* as "adding or removing a channel does not show up until I reopen".
|
|
236
|
+
*
|
|
237
|
+
* Doing it centrally matters: the previous code had `refresh()` at one call site and not at the
|
|
238
|
+
* others, so correctness depended on remembering. A new write path now inherits it.
|
|
239
|
+
*
|
|
229
240
|
* 🔴 **The refusal message must NOT name causes we have not checked.** This used to read
|
|
230
241
|
* "只读部署,或设置服务未启用" — and the one time it actually fired, the real cause was
|
|
231
242
|
* neither: `current` was missing `.volatile()` in the schema, three layers away. Naming two
|
|
@@ -236,8 +247,14 @@ window.__ModuleLoader__.load({
|
|
|
236
247
|
*/
|
|
237
248
|
const write = (run, context = "保存失败") => {
|
|
238
249
|
run().then((accepted) => {
|
|
239
|
-
if (!accepted)
|
|
240
|
-
|
|
250
|
+
if (!accepted) {
|
|
251
|
+
setError(`${context}:宿主拒绝了这个修改`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
setError(null);
|
|
255
|
+
refresh().catch((cause) => {
|
|
256
|
+
setError(`已写入,但重新读取失败:${cause instanceof Error ? cause.message : String(cause)}`);
|
|
257
|
+
});
|
|
241
258
|
}, (cause) => {
|
|
242
259
|
setError(`${context}:${cause instanceof Error ? cause.message : String(cause)}`);
|
|
243
260
|
});
|
|
@@ -265,29 +282,103 @@ window.__ModuleLoader__.load({
|
|
|
265
282
|
/**
|
|
266
283
|
* Create one channel from the form.
|
|
267
284
|
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
285
|
+
* ONE write, one store. The key goes into the channel entry as `apiKey`, beside the preset id —
|
|
286
|
+
* there is no second store to keep in step, so there is no ordering to get wrong and no way for
|
|
287
|
+
* "channel exists but its key does not" to be a reachable state. Pre-filling endpoint fields is
|
|
288
|
+
* still avoided: a preset-backed channel records the preset, so later preset fixes reach it.
|
|
289
|
+
*
|
|
290
|
+
* `label` is only written when the user changed it. Leaving it out lets the preset's display
|
|
291
|
+
* name apply and keep tracking preset changes; writing the same string back would freeze
|
|
292
|
+
* today's name into the document.
|
|
271
293
|
*/
|
|
272
294
|
const addChannel = () => {
|
|
273
295
|
const id = newId.trim();
|
|
274
|
-
if (newPreset === "" || id === "" || newIdProblem !== null ||
|
|
275
|
-
|
|
296
|
+
if (newPreset === "" || id === "" || newIdProblem !== null || newSecret.trim() === "") return;
|
|
297
|
+
const label = newLabel.trim();
|
|
298
|
+
const entry = {
|
|
276
299
|
preset: newPreset,
|
|
277
|
-
|
|
278
|
-
}
|
|
300
|
+
apiKey: newSecret.trim()
|
|
301
|
+
};
|
|
302
|
+
if (label !== "") entry.label = label;
|
|
303
|
+
write(() => setField(["channels", id], entry), "添加渠道失败");
|
|
279
304
|
if (current === "") write(() => setField(["current"], id), "设为当前渠道失败");
|
|
305
|
+
setNewSecret("");
|
|
280
306
|
setNewId("");
|
|
281
|
-
|
|
307
|
+
setNewLabel("");
|
|
282
308
|
setNewPreset("");
|
|
283
|
-
lastSuggestedKeyRef.current = "";
|
|
284
309
|
lastSuggestedId.current = "";
|
|
310
|
+
lastSuggestedLabel.current = "";
|
|
285
311
|
setAdding(false);
|
|
286
312
|
};
|
|
313
|
+
/**
|
|
314
|
+
* Remove one channel.
|
|
315
|
+
*
|
|
316
|
+
* The row disappears because `write()` re-reads on success; there is nothing to remember here.
|
|
317
|
+
*/
|
|
287
318
|
const removeChannel = (id) => {
|
|
288
319
|
write(() => removeField(["channels", id]), "移除渠道失败");
|
|
289
320
|
if (current === id) write(() => removeField(["current"]), "清除当前选择失败");
|
|
290
321
|
};
|
|
322
|
+
/**
|
|
323
|
+
* Store one channel's key from the inline field.
|
|
324
|
+
*
|
|
325
|
+
* The "I have the key / mine changed" path. It is a plain settings write to
|
|
326
|
+
* `channels.<id>.apiKey`, which means it inherits everything the rest of the panel already
|
|
327
|
+
* relies on: a real path (not a dotted string), a visible refusal, and the host's own
|
|
328
|
+
* validation. Nothing is consulted, resolved or named.
|
|
329
|
+
*
|
|
330
|
+
* A legacy entry whose key lives in `apiKeyEnv` is handled by the runtime, not here: that field
|
|
331
|
+
* is read as a literal key when it cannot be a reference name, so the user's first typed key
|
|
332
|
+
* simply supersedes it.
|
|
333
|
+
* @param row - the channel whose key is being set.
|
|
334
|
+
*/
|
|
335
|
+
const saveKey = async (row) => {
|
|
336
|
+
setKeySaving(true);
|
|
337
|
+
try {
|
|
338
|
+
if (!await setField([
|
|
339
|
+
"channels",
|
|
340
|
+
row.id,
|
|
341
|
+
"apiKey"
|
|
342
|
+
], keyDraft.trim())) {
|
|
343
|
+
setError("保存密钥失败:宿主拒绝了这个修改");
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
setError(null);
|
|
347
|
+
setKeyDraft("");
|
|
348
|
+
setKeying(null);
|
|
349
|
+
await refresh();
|
|
350
|
+
} catch (cause) {
|
|
351
|
+
setError(`保存密钥失败:${cause instanceof Error ? cause.message : String(cause)}`);
|
|
352
|
+
} finally {
|
|
353
|
+
setKeySaving(false);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
/**
|
|
357
|
+
* Rename one channel.
|
|
358
|
+
*
|
|
359
|
+
* `label` is the display name and nothing else: the channel's identity is its id (the
|
|
360
|
+
* configuration key), which renaming must never touch — a renamed key would be a different
|
|
361
|
+
* channel, with a different key and a different `current` target.
|
|
362
|
+
*
|
|
363
|
+
* Clearing the name removes the field, which restores the preset's name rather than leaving an
|
|
364
|
+
* empty label in the document.
|
|
365
|
+
* @param row - the channel being renamed.
|
|
366
|
+
*/
|
|
367
|
+
const renameChannel = (row) => {
|
|
368
|
+
const next = renameDraft.trim();
|
|
369
|
+
if (next === "") write(() => removeField([
|
|
370
|
+
"channels",
|
|
371
|
+
row.id,
|
|
372
|
+
"label"
|
|
373
|
+
]), "恢复默认名失败");
|
|
374
|
+
else write(() => setField([
|
|
375
|
+
"channels",
|
|
376
|
+
row.id,
|
|
377
|
+
"label"
|
|
378
|
+
], next), "重命名失败");
|
|
379
|
+
setRenaming(null);
|
|
380
|
+
setRenameDraft("");
|
|
381
|
+
};
|
|
291
382
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
292
383
|
className: "ad-page",
|
|
293
384
|
children: [
|
|
@@ -335,6 +426,8 @@ window.__ModuleLoader__.load({
|
|
|
335
426
|
const probeResult = verdict[row.id];
|
|
336
427
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
337
428
|
className: `ad-row${isCurrent ? " ad-row-current" : ""}`,
|
|
429
|
+
"aria-current": isCurrent ? "true" : void 0,
|
|
430
|
+
"data-current": isCurrent ? "true" : "false",
|
|
338
431
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
339
432
|
className: "ad-rowPick",
|
|
340
433
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
@@ -364,9 +457,45 @@ window.__ModuleLoader__.load({
|
|
|
364
457
|
className: "ad-warn",
|
|
365
458
|
title: row.warnings.join("\n"),
|
|
366
459
|
children: "⚠"
|
|
460
|
+
}),
|
|
461
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
462
|
+
type: "button",
|
|
463
|
+
className: "ad-link ad-renameBtn",
|
|
464
|
+
title: "改显示名(渠道 id 不变)",
|
|
465
|
+
onClick: (event) => {
|
|
466
|
+
event.preventDefault();
|
|
467
|
+
event.stopPropagation();
|
|
468
|
+
setRenaming(renaming === row.id ? null : row.id);
|
|
469
|
+
setRenameDraft(renaming === row.id ? "" : row.label);
|
|
470
|
+
},
|
|
471
|
+
children: renaming === row.id ? "取消" : "改名"
|
|
367
472
|
})
|
|
368
473
|
]
|
|
369
474
|
}),
|
|
475
|
+
renaming === row.id && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
476
|
+
className: "ad-keyForm",
|
|
477
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
478
|
+
className: "ad-input",
|
|
479
|
+
value: renameDraft,
|
|
480
|
+
placeholder: "显示名(留空则用预设默认名)",
|
|
481
|
+
onClick: (event) => event.preventDefault(),
|
|
482
|
+
onChange: (event) => setRenameDraft(event.target.value),
|
|
483
|
+
onKeyDown: (event) => {
|
|
484
|
+
if (event.key === "Enter") {
|
|
485
|
+
event.preventDefault();
|
|
486
|
+
renameChannel(row);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
490
|
+
type: "button",
|
|
491
|
+
className: "ad-btn",
|
|
492
|
+
onClick: (event) => {
|
|
493
|
+
event.preventDefault();
|
|
494
|
+
renameChannel(row);
|
|
495
|
+
},
|
|
496
|
+
children: "保存名字"
|
|
497
|
+
})]
|
|
498
|
+
}),
|
|
370
499
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
371
500
|
className: "ad-rowMeta",
|
|
372
501
|
children: [
|
|
@@ -392,6 +521,55 @@ window.__ModuleLoader__.load({
|
|
|
392
521
|
probeResult !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
393
522
|
className: `ad-probe ad-probe-${probeResult.ok ? "ok" : "bad"}`,
|
|
394
523
|
children: probeResult.ok ? `通(${probeResult.ms}ms)` : `不通:${probeResult.code ?? "ERROR"}${probeResult.message === void 0 ? "" : ` — ${probeResult.message}`}`
|
|
524
|
+
}),
|
|
525
|
+
row.keyConfigured ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
526
|
+
className: "ad-keyLine",
|
|
527
|
+
children: [
|
|
528
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
529
|
+
className: "ad-keyOn",
|
|
530
|
+
children: "密钥已配置"
|
|
531
|
+
}),
|
|
532
|
+
row.keyKind === "reference" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
|
|
533
|
+
className: "ad-mono ad-keyName",
|
|
534
|
+
children: row.keyRef
|
|
535
|
+
}),
|
|
536
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
537
|
+
type: "button",
|
|
538
|
+
className: "ad-link",
|
|
539
|
+
onClick: () => setKeying(keying === row.id ? null : row.id),
|
|
540
|
+
children: keying === row.id ? "取消" : "更换"
|
|
541
|
+
})
|
|
542
|
+
]
|
|
543
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
544
|
+
className: "ad-keyLine",
|
|
545
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
546
|
+
className: "ad-keyOff",
|
|
547
|
+
children: "未配置密钥"
|
|
548
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
549
|
+
type: "button",
|
|
550
|
+
className: "ad-link",
|
|
551
|
+
onClick: () => setKeying(keying === row.id ? null : row.id),
|
|
552
|
+
children: keying === row.id ? "取消" : "填写密钥"
|
|
553
|
+
})]
|
|
554
|
+
}),
|
|
555
|
+
keying === row.id && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
556
|
+
className: "ad-keyForm",
|
|
557
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
558
|
+
className: "ad-input",
|
|
559
|
+
type: "password",
|
|
560
|
+
autoComplete: "off",
|
|
561
|
+
value: keyDraft,
|
|
562
|
+
placeholder: "粘贴 key 本体",
|
|
563
|
+
onChange: (event) => setKeyDraft(event.target.value)
|
|
564
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
565
|
+
type: "button",
|
|
566
|
+
className: "ad-btn",
|
|
567
|
+
disabled: keyDraft.trim() === "" || keySaving,
|
|
568
|
+
onClick: () => {
|
|
569
|
+
saveKey(row);
|
|
570
|
+
},
|
|
571
|
+
children: keySaving ? "保存中…" : "保存密钥"
|
|
572
|
+
})]
|
|
395
573
|
})
|
|
396
574
|
]
|
|
397
575
|
})]
|
|
@@ -428,13 +606,13 @@ window.__ModuleLoader__.load({
|
|
|
428
606
|
const next = event.target.value;
|
|
429
607
|
setNewPreset(next);
|
|
430
608
|
const previousId = lastSuggestedId.current;
|
|
431
|
-
const
|
|
609
|
+
const previousLabel = lastSuggestedLabel.current;
|
|
432
610
|
const nextId = suggestId(next, rows);
|
|
433
|
-
const
|
|
611
|
+
const nextLabel = presets.find((preset) => preset.id === next)?.displayName ?? "";
|
|
434
612
|
setNewId((current) => current === "" || current === previousId ? nextId : current);
|
|
435
|
-
|
|
613
|
+
setNewLabel((current) => current === "" || current === previousLabel ? nextLabel : current);
|
|
436
614
|
lastSuggestedId.current = nextId;
|
|
437
|
-
|
|
615
|
+
lastSuggestedLabel.current = nextLabel;
|
|
438
616
|
},
|
|
439
617
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
440
618
|
value: "",
|
|
@@ -460,19 +638,35 @@ window.__ModuleLoader__.load({
|
|
|
460
638
|
}),
|
|
461
639
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
462
640
|
className: "ad-field",
|
|
463
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "
|
|
641
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "显示名" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
642
|
+
className: "ad-input",
|
|
643
|
+
value: newLabel,
|
|
644
|
+
placeholder: newPreset === "" ? "先选上面的规格" : "例如「Open Design(主号)」",
|
|
645
|
+
onChange: (event) => setNewLabel(event.target.value)
|
|
646
|
+
})]
|
|
647
|
+
}),
|
|
648
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
649
|
+
className: "ad-field",
|
|
650
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "密钥" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
464
651
|
className: "ad-input",
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
652
|
+
type: "password",
|
|
653
|
+
autoComplete: "off",
|
|
654
|
+
value: newSecret,
|
|
655
|
+
placeholder: newPreset === "" ? "先选上面的规格" : "sk-…",
|
|
656
|
+
onChange: (event) => setNewSecret(event.target.value)
|
|
468
657
|
})]
|
|
469
658
|
}),
|
|
470
659
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
471
660
|
className: "ad-hint",
|
|
472
661
|
children: [
|
|
473
|
-
"
|
|
474
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("
|
|
475
|
-
|
|
662
|
+
"密钥写在本机 profile 配置里(",
|
|
663
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
|
|
664
|
+
className: "ad-mono",
|
|
665
|
+
children: "channels.<id>.apiKey"
|
|
666
|
+
}),
|
|
667
|
+
")。 它",
|
|
668
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: "不" }),
|
|
669
|
+
"外传,但会因为配置文件而被看到——别把这个 profile 分享出去。"
|
|
476
670
|
]
|
|
477
671
|
}),
|
|
478
672
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -480,7 +674,7 @@ window.__ModuleLoader__.load({
|
|
|
480
674
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
481
675
|
type: "button",
|
|
482
676
|
className: "ad-btn",
|
|
483
|
-
disabled: newPreset === "" || newId.trim() === "" || newIdProblem !== null ||
|
|
677
|
+
disabled: newPreset === "" || newId.trim() === "" || newIdProblem !== null || newSecret.trim() === "",
|
|
484
678
|
onClick: addChannel,
|
|
485
679
|
children: "添加"
|
|
486
680
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
@@ -497,7 +691,7 @@ window.__ModuleLoader__.load({
|
|
|
497
691
|
onClick: () => {
|
|
498
692
|
setAdding(true);
|
|
499
693
|
lastSuggestedId.current = "";
|
|
500
|
-
|
|
694
|
+
lastSuggestedLabel.current = "";
|
|
501
695
|
},
|
|
502
696
|
children: "添加渠道"
|
|
503
697
|
}),
|
package/lib/config.d.ts
CHANGED
|
@@ -41,6 +41,9 @@ 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
48
|
contextWindow?: number;
|
|
46
49
|
maxTokens?: number;
|
|
@@ -61,6 +64,7 @@ export interface ChannelConfigValue {
|
|
|
61
64
|
readonly api?: string;
|
|
62
65
|
readonly baseURL?: string;
|
|
63
66
|
readonly model?: string;
|
|
67
|
+
readonly apiKey?: string;
|
|
64
68
|
readonly apiKeyEnv?: string;
|
|
65
69
|
readonly contextWindow?: number;
|
|
66
70
|
readonly maxTokens?: number;
|
|
@@ -73,9 +77,26 @@ export interface ChannelConfigValue {
|
|
|
73
77
|
* One channel entry schema.
|
|
74
78
|
*
|
|
75
79
|
* Fields split into two groups the panel treats differently:
|
|
76
|
-
* - the key group (`
|
|
80
|
+
* - the key group (`apiKey`) — the only thing a user must supply for a preset channel;
|
|
77
81
|
* - the endpoint group (`api` / `baseURL` / `model` / capacities) — filled from a preset, or
|
|
78
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.
|
|
79
100
|
*/
|
|
80
101
|
export declare const ChannelConfigSchema: SchemaOf<ChannelConfigInput, ChannelConfigValue>;
|
|
81
102
|
/** Ledger settings. */
|
package/lib/http.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export declare const ROUTE_PREFIX = "/any-deepseek";
|
|
|
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<{
|
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,7 +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
|
-
|
|
75
|
+
apiKey: z.string().role("secret").description("密钥本体(面板里粘贴,写在这条渠道上)"),
|
|
76
|
+
apiKeyEnv: z.string().description("旧式:凭据引用名(环境变量名)。仅当 apiKey 为空时才用"),
|
|
59
77
|
contextWindow: z.number().description("上下文窗口(tokens)"),
|
|
60
78
|
maxTokens: z.number().description("单次输出上限(tokens)"),
|
|
61
79
|
input: z.array(z.union([z.const("text"), z.const("image")])).description("输入模态"),
|
|
@@ -143,13 +161,30 @@ const DEFAULT_MAX_TOKENS = 32768;
|
|
|
143
161
|
*
|
|
144
162
|
* Kept as one constant because these are properties of the *model*, not of a relay; a relay
|
|
145
163
|
* that disagrees corrects them in its own preset.
|
|
164
|
+
*
|
|
165
|
+
* ⚠️ `input` is `['text']` **on purpose, for now**. The model is multimodal, but declaring image
|
|
166
|
+
* support is a promise this adapter cannot keep yet: a harness `ImageBlock` carries only an
|
|
167
|
+
* `attachmentId`, and turning that into bytes requires the durable attachment service
|
|
168
|
+
* (`ctx.attachments.readImageRequest`), which the adapter does not consult. Declaring images
|
|
169
|
+
* without that path would be worse than declaring text — the harness would stop substituting its
|
|
170
|
+
* placeholder text and the image would be silently dropped. See SESSION decision 38.
|
|
146
171
|
*/
|
|
147
172
|
const V41_FLASH_CAPS = {
|
|
148
173
|
contextWindow: 1e6,
|
|
149
174
|
maxTokens: 384e3,
|
|
150
175
|
input: ["text"],
|
|
176
|
+
/**
|
|
177
|
+
* Selectable thinking levels, in pi-ai's vocabulary.
|
|
178
|
+
*
|
|
179
|
+
* The VALUES are the wire spellings this model family accepts (`reasoning_effort`); `null`
|
|
180
|
+
* would mark a level as unsupported. pi-ai consumes this map directly as `thinkingLevelMap`,
|
|
181
|
+
* which is why this plugin does not implement a translation layer of its own — a second
|
|
182
|
+
* mapping would inevitably drift from the one the transport actually applies.
|
|
183
|
+
*/
|
|
151
184
|
reasoningEfforts: {
|
|
185
|
+
off: null,
|
|
152
186
|
low: "low",
|
|
187
|
+
medium: "medium",
|
|
153
188
|
high: "high",
|
|
154
189
|
max: "max"
|
|
155
190
|
}
|
|
@@ -166,19 +201,35 @@ const DEEPSEEK_THINKING_COMPAT = {
|
|
|
166
201
|
thinkingFormat: "deepseek"
|
|
167
202
|
};
|
|
168
203
|
/**
|
|
169
|
-
*
|
|
204
|
+
* Headers the OpenCode Go endpoint requires, MINUS the session id.
|
|
205
|
+
*
|
|
206
|
+
* `/zen/go` answers 400 `Request is missing x-opencode-session` without it, and the official
|
|
207
|
+
* documentation adds two requirements this preset has to respect:
|
|
170
208
|
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
209
|
+
* 1. the session id must be **stable per conversation** — a constant shared by every
|
|
210
|
+
* conversation violates the thing the header exists for (routing and cache affinity);
|
|
211
|
+
* 2. the client must **identify itself with its own user agent** rather than a generic SDK name.
|
|
212
|
+
*
|
|
213
|
+
* So the session id is deliberately NOT here: `adapter.ts` injects it per request from the
|
|
214
|
+
* harness's own session identity. What stays here is the static identity of this client.
|
|
215
|
+
*
|
|
216
|
+
* ⚠️ The upstream documentation lists **DeepSeek Harness as a known-problematic client** for this
|
|
217
|
+
* endpoint ("session information arrives on some model paths, but is missing on others"). Even
|
|
218
|
+
* with everything here correct, expect this relay to be stricter than the others.
|
|
174
219
|
*/
|
|
175
220
|
const OPENCODE_HEADERS = {
|
|
176
|
-
"x-opencode-session": "dsh-any-deepseek",
|
|
177
|
-
"X-Session-ID": "dsh-any-deepseek",
|
|
178
221
|
"x-opencode-client": "dsh",
|
|
179
222
|
"x-opencode-project": "global"
|
|
180
223
|
};
|
|
181
224
|
/**
|
|
225
|
+
* The user agent a request to OpenCode Go identifies itself with.
|
|
226
|
+
*
|
|
227
|
+
* Requirement 2 above: a generic SDK name is exactly what they ask clients not to send.
|
|
228
|
+
*/
|
|
229
|
+
const CLIENT_USER_AGENT = "dsh-any-deepseek/1.0";
|
|
230
|
+
/** Header carrying the per-conversation routing id on OpenCode Go. */
|
|
231
|
+
const OPENCODE_SESSION_HEADER = "x-opencode-session";
|
|
232
|
+
/**
|
|
182
233
|
* Presets keyed by a stable id.
|
|
183
234
|
*
|
|
184
235
|
* The id is what a channel entry stores (`preset: opencode-go`), so it is part of the user's
|
|
@@ -276,6 +327,7 @@ const KNOWN_KEYS = /* @__PURE__ */ new Set([
|
|
|
276
327
|
"api",
|
|
277
328
|
"baseURL",
|
|
278
329
|
"model",
|
|
330
|
+
"apiKey",
|
|
279
331
|
"apiKeyEnv",
|
|
280
332
|
"enabled",
|
|
281
333
|
"contextWindow",
|
|
@@ -293,6 +345,27 @@ function nonEmpty(value) {
|
|
|
293
345
|
const trimmed = value.trim();
|
|
294
346
|
return trimmed === "" ? void 0 : trimmed;
|
|
295
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* The grammar a credential REFERENCE name must match.
|
|
350
|
+
*
|
|
351
|
+
* Transcribed from the harness's own credential seam
|
|
352
|
+
* (`@deepseek-ai/dsh-credentials` → `REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/`).
|
|
353
|
+
*
|
|
354
|
+
* This plugin no longer WRITES references (the panel stores the key itself), but it still READS
|
|
355
|
+
* them, and the grammar is what lets the read distinguish the two shapes. A string that cannot be
|
|
356
|
+
* an environment-variable name cannot be a reference — so when one appears in `apiKeyEnv` it can
|
|
357
|
+
* only be a value in the wrong slot, which is a repairable configuration error rather than a
|
|
358
|
+
* mystery.
|
|
359
|
+
*/
|
|
360
|
+
const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
361
|
+
/**
|
|
362
|
+
* Whether a string could name a credential reference at all.
|
|
363
|
+
* @param value - candidate name.
|
|
364
|
+
* @returns true when the field can be read as a reference.
|
|
365
|
+
*/
|
|
366
|
+
function isCredentialRefName(value) {
|
|
367
|
+
return CREDENTIAL_REF_PATTERN.test(value);
|
|
368
|
+
}
|
|
296
369
|
function positiveInt(value) {
|
|
297
370
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : void 0;
|
|
298
371
|
}
|
|
@@ -334,14 +407,31 @@ function resolveChannel(id, raw) {
|
|
|
334
407
|
detail: `unknown channel field "${key}"`
|
|
335
408
|
}
|
|
336
409
|
};
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
410
|
+
/**
|
|
411
|
+
* How this channel authenticates — never a reason to fail resolution.
|
|
412
|
+
*
|
|
413
|
+
* 🔴 A channel with no key still RESOLVES. The panel has to be able to show it (that is how the
|
|
414
|
+
* user gets to fix it), and the model directory must not depend on a secret being present
|
|
415
|
+
* (decision 26: a provider that vanishes when unconfigured looks exactly like one that is not
|
|
416
|
+
* installed). A missing key is answered per request — `MISSING_CREDENTIAL` — and per row.
|
|
417
|
+
*
|
|
418
|
+
* Migration, by grammar: `apiKey` wins when present. Otherwise `apiKeyEnv` is read as a
|
|
419
|
+
* reference ONLY when it matches the host's reference grammar. A string that cannot be a name
|
|
420
|
+
* is a key in the wrong slot — an observed state, since the previous version of this panel
|
|
421
|
+
* asked for a name and one user's YAML holds `sk-…` there. Adopting it is a repair, not a
|
|
422
|
+
* guess: no environment variable can have that spelling.
|
|
423
|
+
*/
|
|
424
|
+
const literal = nonEmpty(entry.apiKey);
|
|
425
|
+
const legacy = nonEmpty(entry.apiKeyEnv);
|
|
426
|
+
const credential = literal !== void 0 ? {
|
|
427
|
+
kind: "literal",
|
|
428
|
+
value: literal
|
|
429
|
+
} : legacy === void 0 ? { kind: "none" } : isCredentialRefName(legacy) ? {
|
|
430
|
+
kind: "reference",
|
|
431
|
+
ref: legacy
|
|
432
|
+
} : {
|
|
433
|
+
kind: "literal",
|
|
434
|
+
value: legacy
|
|
345
435
|
};
|
|
346
436
|
const presetId = nonEmpty(entry.preset);
|
|
347
437
|
const preset = presetId === void 0 ? void 0 : presetById(presetId);
|
|
@@ -396,7 +486,8 @@ function resolveChannel(id, raw) {
|
|
|
396
486
|
api: apiRaw,
|
|
397
487
|
baseURL,
|
|
398
488
|
model,
|
|
399
|
-
|
|
489
|
+
credential,
|
|
490
|
+
...presetId === void 0 ? {} : { preset: presetId },
|
|
400
491
|
contextWindow,
|
|
401
492
|
maxTokens,
|
|
402
493
|
input,
|
|
@@ -532,6 +623,8 @@ const PROVIDER_NAME = "Any DeepSeek";
|
|
|
532
623
|
* @returns a pi-ai chat model describing that relay's view of the model.
|
|
533
624
|
*/
|
|
534
625
|
function buildPiModel(channel) {
|
|
626
|
+
const thinkingLevelMap = channel.reasoningEfforts === void 0 ? void 0 : Object.fromEntries(Object.entries(channel.reasoningEfforts).filter(([, value]) => value !== null));
|
|
627
|
+
const hasThinking = thinkingLevelMap !== void 0 && Object.keys(thinkingLevelMap).length > 0;
|
|
535
628
|
return {
|
|
536
629
|
id: channel.model,
|
|
537
630
|
name: channel.label,
|
|
@@ -547,7 +640,8 @@ function buildPiModel(channel) {
|
|
|
547
640
|
},
|
|
548
641
|
contextWindow: channel.contextWindow,
|
|
549
642
|
maxTokens: channel.maxTokens,
|
|
550
|
-
reasoning:
|
|
643
|
+
reasoning: hasThinking,
|
|
644
|
+
...hasThinking ? { thinkingLevelMap } : {},
|
|
551
645
|
...channel.compat === void 0 ? {} : { compat: channel.compat },
|
|
552
646
|
...channel.headers === void 0 ? {} : { headers: { ...channel.headers } }
|
|
553
647
|
};
|
|
@@ -929,7 +1023,7 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
|
|
|
929
1023
|
}
|
|
930
1024
|
async resolveModel(provider, model, _signal) {
|
|
931
1025
|
const channel = this.selected();
|
|
932
|
-
const efforts = Object.
|
|
1026
|
+
const efforts = Object.entries(channel?.reasoningEfforts ?? {}).filter(([, wire]) => wire !== null).map(([id]) => id);
|
|
933
1027
|
const reasoning = efforts.length === 0 ? {} : { reasoning: { efforts: efforts.map((id) => ({
|
|
934
1028
|
id: ReasoningEffortId(id),
|
|
935
1029
|
name: id
|
|
@@ -993,7 +1087,7 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
|
|
|
993
1087
|
return;
|
|
994
1088
|
}
|
|
995
1089
|
channel = selection.channel;
|
|
996
|
-
const key = await this.deps.resolveKey(channel.
|
|
1090
|
+
const key = channel.credential.kind === "literal" ? channel.credential.value : channel.credential.kind === "reference" ? await this.deps.resolveKey(channel.credential.ref) : void 0;
|
|
997
1091
|
if (key === void 0 || key === "") {
|
|
998
1092
|
outcome = {
|
|
999
1093
|
ok: false,
|
|
@@ -1005,7 +1099,7 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
|
|
|
1005
1099
|
reason: {
|
|
1006
1100
|
kind: "error",
|
|
1007
1101
|
failure: {
|
|
1008
|
-
message: `
|
|
1102
|
+
message: `channel "${channel.id}" has no key configured`,
|
|
1009
1103
|
code: "MISSING_CREDENTIAL"
|
|
1010
1104
|
}
|
|
1011
1105
|
}
|
|
@@ -1026,11 +1120,34 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
|
|
|
1026
1120
|
models: [model],
|
|
1027
1121
|
api: openAICompletionsApi()
|
|
1028
1122
|
}));
|
|
1123
|
+
/**
|
|
1124
|
+
* Per-request headers.
|
|
1125
|
+
*
|
|
1126
|
+
* Two things OpenCode Go requires cannot live in a preset, because neither is a constant:
|
|
1127
|
+
*
|
|
1128
|
+
* · `x-opencode-session` must be STABLE PER CONVERSATION. A single shared literal satisfied
|
|
1129
|
+
* the letter of "send the header" while defeating its purpose (routing and prompt-cache
|
|
1130
|
+
* affinity), which is why the previous preset's fixed value produced 400s. The harness
|
|
1131
|
+
* already stamps every request with `sessionId`; that is the correct source.
|
|
1132
|
+
* · `User-Agent` must identify THIS client rather than the generic SDK. pi-ai sets its own,
|
|
1133
|
+
* so the preset's static headers alone can never satisfy that rule.
|
|
1134
|
+
*/
|
|
1135
|
+
const requestHeaders = {};
|
|
1136
|
+
if (channel.headers !== void 0 && Object.hasOwn(channel.headers, "x-opencode-client")) {
|
|
1137
|
+
const session = options.sessionId === void 0 ? void 0 : String(options.sessionId);
|
|
1138
|
+
if (session !== void 0 && session !== "") {
|
|
1139
|
+
requestHeaders[OPENCODE_SESSION_HEADER] = session;
|
|
1140
|
+
requestHeaders["X-Session-ID"] = session;
|
|
1141
|
+
}
|
|
1142
|
+
requestHeaders["User-Agent"] = CLIENT_USER_AGENT;
|
|
1143
|
+
}
|
|
1029
1144
|
const streamOptions = {
|
|
1030
1145
|
apiKey: key,
|
|
1031
1146
|
signal: options.signal,
|
|
1032
1147
|
...options.temperature === void 0 ? {} : { temperature: options.temperature },
|
|
1033
|
-
...options.maxTokens === void 0 ? {} : { maxTokens: options.maxTokens }
|
|
1148
|
+
...options.maxTokens === void 0 ? {} : { maxTokens: options.maxTokens },
|
|
1149
|
+
...options.reasoningEffort === void 0 ? {} : { reasoning: options.reasoningEffort },
|
|
1150
|
+
...Object.keys(requestHeaders).length === 0 ? {} : { headers: requestHeaders }
|
|
1034
1151
|
};
|
|
1035
1152
|
try {
|
|
1036
1153
|
const events = models.streamSimple(model, toPiContext(options), streamOptions);
|
|
@@ -1441,7 +1558,19 @@ function registerStatsRoutes(ctx, webServer, deps) {
|
|
|
1441
1558
|
handler: async (_req, res) => {
|
|
1442
1559
|
json(res, 200, {
|
|
1443
1560
|
channels: await Promise.all(deps.channels().map(async (channel) => {
|
|
1444
|
-
|
|
1561
|
+
/**
|
|
1562
|
+
* 🔴 The key is a property of the CHANNEL now, so "is one configured?" is answered
|
|
1563
|
+
* without consulting any credential store. A legacy reference still asks the harness,
|
|
1564
|
+
* and that answer is the only thing the panel ever learns about a reference: whether
|
|
1565
|
+
* it exists.
|
|
1566
|
+
*/
|
|
1567
|
+
const key = channel.credential.kind === "literal" ? {
|
|
1568
|
+
configured: channel.credential.value !== "",
|
|
1569
|
+
source: "channel"
|
|
1570
|
+
} : channel.credential.kind === "reference" ? await deps.describeKey(channel.credential.ref) : {
|
|
1571
|
+
configured: false,
|
|
1572
|
+
source: void 0
|
|
1573
|
+
};
|
|
1445
1574
|
return {
|
|
1446
1575
|
id: channel.id,
|
|
1447
1576
|
label: channel.label,
|
|
@@ -1453,7 +1582,9 @@ function registerStatsRoutes(ctx, webServer, deps) {
|
|
|
1453
1582
|
input: [...channel.input],
|
|
1454
1583
|
warnings: [...channel.warnings],
|
|
1455
1584
|
keyConfigured: key.configured,
|
|
1456
|
-
...key.source === void 0 ? {} : { keySource: key.source }
|
|
1585
|
+
...key.source === void 0 ? {} : { keySource: key.source },
|
|
1586
|
+
keyKind: channel.credential.kind,
|
|
1587
|
+
...channel.credential.kind === "reference" ? { keyRef: channel.credential.ref } : {}
|
|
1457
1588
|
};
|
|
1458
1589
|
})),
|
|
1459
1590
|
presets: deps.listPresets(),
|
|
@@ -1505,13 +1636,13 @@ function registerStatsRoutes(ctx, webServer, deps) {
|
|
|
1505
1636
|
}
|
|
1506
1637
|
const channel = deps.channels().find((candidate) => candidate.id === id);
|
|
1507
1638
|
if (channel === void 0) {
|
|
1508
|
-
json(res, 404, { error: `no
|
|
1639
|
+
json(res, 404, { error: `no channel named "${id}"` });
|
|
1509
1640
|
return;
|
|
1510
1641
|
}
|
|
1511
|
-
const key = await deps.resolveKey(channel.
|
|
1642
|
+
const key = channel.credential.kind === "literal" ? channel.credential.value : channel.credential.kind === "reference" ? await deps.resolveKey(channel.credential.ref) : void 0;
|
|
1512
1643
|
if (key === void 0 || key === "") {
|
|
1513
1644
|
json(res, 409, {
|
|
1514
|
-
error: `channel "${id}" has no configured
|
|
1645
|
+
error: `channel "${id}" has no key configured`,
|
|
1515
1646
|
code: "MISSING_CREDENTIAL"
|
|
1516
1647
|
});
|
|
1517
1648
|
return;
|
|
@@ -1596,7 +1727,11 @@ function makeRecorder(ctx, config) {
|
|
|
1596
1727
|
*/
|
|
1597
1728
|
function apply(ctx, config) {
|
|
1598
1729
|
const credentials = () => service(ctx, "credentials");
|
|
1599
|
-
/**
|
|
1730
|
+
/**
|
|
1731
|
+
* Resolve a credential REFERENCE, distinguishing "absent" from "not configured".
|
|
1732
|
+
*
|
|
1733
|
+
* Only legacy channels reach this: a channel that stores its own key never asks the seam.
|
|
1734
|
+
*/
|
|
1600
1735
|
const resolveKey = async (ref) => {
|
|
1601
1736
|
const seam = credentials();
|
|
1602
1737
|
if (seam?.resolve === void 0) return process.env[ref];
|
package/lib/presets.d.ts
CHANGED
|
@@ -81,6 +81,14 @@ export declare const ADVERTISED_MODEL_NAME = "DeepSeek V4.1 Flash";
|
|
|
81
81
|
*/
|
|
82
82
|
export declare const DEFAULT_CONTEXT_WINDOW = 262144;
|
|
83
83
|
export declare const DEFAULT_MAX_TOKENS = 32768;
|
|
84
|
+
/**
|
|
85
|
+
* The user agent a request to OpenCode Go identifies itself with.
|
|
86
|
+
*
|
|
87
|
+
* Requirement 2 above: a generic SDK name is exactly what they ask clients not to send.
|
|
88
|
+
*/
|
|
89
|
+
export declare const CLIENT_USER_AGENT = "dsh-any-deepseek/1.0";
|
|
90
|
+
/** Header carrying the per-conversation routing id on OpenCode Go. */
|
|
91
|
+
export declare const OPENCODE_SESSION_HEADER = "x-opencode-session";
|
|
84
92
|
/**
|
|
85
93
|
* Presets keyed by a stable id.
|
|
86
94
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-any-deepseek",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
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": {
|