dsh-any-deepseek 0.0.5 → 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 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
- apiKeyEnv: ANY_DEEPSEEK_OPENCODE_GO_KEY
54
- enabled: true
53
+ apiKey: sk-… # the key itself
55
54
  commandcode:
56
- apiKeyEnv: ANY_DEEPSEEK_COMMANDCODE_KEY
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
- /** Resolve a credential reference to its value; undefined when unconfigured. */
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
- /** Credential reference (environment-variable name). Required. */
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
- apiKeyEnv: string;
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' | 'MISSING_API_KEY_REF' | 'MISSING_FIELD' | 'INVALID_API' | 'UNKNOWN_KEY';
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 --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");
@@ -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,16 +174,19 @@ 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 reference name the form last derived from a preset.
178
+ * The key the user typed in the add form.
193
179
  *
194
- * Needed to tell "the user never touched this field" from "the user typed it": only a value that
195
- * still equals the last suggestion may be replaced when the preset changes. Without it,
196
- * switching presets after typing a custom reference would silently discard the user's input.
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 lastSuggestedKeyRef = (0, react.useRef)("");
199
- /** Same idea for the derived channel id (see the select's onChange). */
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). */
200
190
  const lastSuggestedId = (0, react.useRef)("");
201
191
  const refresh = (0, react.useCallback)(async () => {
202
192
  const [loaded, statsNow] = await Promise.all([loadChannels(), loadStats(group)]);
@@ -265,22 +255,22 @@ window.__ModuleLoader__.load({
265
255
  /**
266
256
  * Create one channel from the form.
267
257
  *
268
- * Writes only the fields the user actually chose: a preset-backed channel needs nothing but
269
- * a key reference, and pre-filling endpoint fields would freeze today's preset values into the
270
- * user's document (later preset fixes would never reach them).
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.
271
262
  */
272
263
  const addChannel = () => {
273
264
  const id = newId.trim();
274
- if (newPreset === "" || id === "" || newIdProblem !== null || newKeyRef.trim() === "") return;
265
+ if (newPreset === "" || id === "" || newIdProblem !== null || newSecret.trim() === "") return;
275
266
  write(() => setField(["channels", id], {
276
267
  preset: newPreset,
277
- apiKeyEnv: newKeyRef.trim()
268
+ apiKey: newSecret.trim()
278
269
  }), "添加渠道失败");
279
270
  if (current === "") write(() => setField(["current"], id), "设为当前渠道失败");
271
+ setNewSecret("");
280
272
  setNewId("");
281
- setNewKeyRef("");
282
273
  setNewPreset("");
283
- lastSuggestedKeyRef.current = "";
284
274
  lastSuggestedId.current = "";
285
275
  setAdding(false);
286
276
  };
@@ -288,6 +278,40 @@ window.__ModuleLoader__.load({
288
278
  write(() => removeField(["channels", id]), "移除渠道失败");
289
279
  if (current === id) write(() => removeField(["current"]), "清除当前选择失败");
290
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
+ };
291
315
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
292
316
  className: "ad-page",
293
317
  children: [
@@ -392,6 +416,55 @@ window.__ModuleLoader__.load({
392
416
  probeResult !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
393
417
  className: `ad-probe ad-probe-${probeResult.ok ? "ok" : "bad"}`,
394
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
+ })]
395
468
  })
396
469
  ]
397
470
  })]
@@ -428,13 +501,9 @@ window.__ModuleLoader__.load({
428
501
  const next = event.target.value;
429
502
  setNewPreset(next);
430
503
  const previousId = lastSuggestedId.current;
431
- const previousKey = lastSuggestedKeyRef.current;
432
504
  const nextId = suggestId(next, rows);
433
- const nextKey = suggestKeyRef(next);
434
505
  setNewId((current) => current === "" || current === previousId ? nextId : current);
435
- setNewKeyRef((current) => current === "" || current === previousKey ? nextKey : current);
436
506
  lastSuggestedId.current = nextId;
437
- lastSuggestedKeyRef.current = nextKey;
438
507
  },
439
508
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
440
509
  value: "",
@@ -460,19 +529,26 @@ window.__ModuleLoader__.load({
460
529
  }),
461
530
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
462
531
  className: "ad-field",
463
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "凭据引用名" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
532
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "密钥" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
464
533
  className: "ad-input",
465
- value: newKeyRef,
466
- placeholder: newPreset === "" ? "例如 OPENCODE_GO_API_KEY" : suggestKeyRef(newPreset),
467
- onChange: (event) => setNewKeyRef(event.target.value)
534
+ type: "password",
535
+ autoComplete: "off",
536
+ value: newSecret,
537
+ placeholder: newPreset === "" ? "先选上面的规格" : "sk-…",
538
+ onChange: (event) => setNewSecret(event.target.value)
468
539
  })]
469
540
  }),
470
541
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
471
542
  className: "ad-hint",
472
543
  children: [
473
- "这里填的是",
474
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: "引用名" }),
475
- "(环境变量名),不是密钥本身。密钥请通过凭据中心设置。"
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 分享出去。"
476
552
  ]
477
553
  }),
478
554
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -480,7 +556,7 @@ window.__ModuleLoader__.load({
480
556
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
481
557
  type: "button",
482
558
  className: "ad-btn",
483
- disabled: newPreset === "" || newId.trim() === "" || newIdProblem !== null || newKeyRef.trim() === "",
559
+ disabled: newPreset === "" || newId.trim() === "" || newIdProblem !== null || newSecret.trim() === "",
484
560
  onClick: addChannel,
485
561
  children: "添加"
486
562
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
@@ -497,7 +573,6 @@ window.__ModuleLoader__.load({
497
573
  onClick: () => {
498
574
  setAdding(true);
499
575
  lastSuggestedId.current = "";
500
- lastSuggestedKeyRef.current = "";
501
576
  },
502
577
  children: "添加渠道"
503
578
  }),
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 (`apiKeyEnv`) — the only thing a user must supply for a preset channel;
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 reference to its value. */
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 (`apiKeyEnv`) — the only thing a user must supply for a preset channel;
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
- apiKeyEnv: z.string().description("凭据引用名(环境变量名);真实 key 存在凭据库里,不落配置"),
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("输入模态"),
@@ -276,6 +294,7 @@ const KNOWN_KEYS = /* @__PURE__ */ new Set([
276
294
  "api",
277
295
  "baseURL",
278
296
  "model",
297
+ "apiKey",
279
298
  "apiKeyEnv",
280
299
  "enabled",
281
300
  "contextWindow",
@@ -293,6 +312,27 @@ function nonEmpty(value) {
293
312
  const trimmed = value.trim();
294
313
  return trimmed === "" ? void 0 : trimmed;
295
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
+ }
296
336
  function positiveInt(value) {
297
337
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : void 0;
298
338
  }
@@ -334,14 +374,31 @@ function resolveChannel(id, raw) {
334
374
  detail: `unknown channel field "${key}"`
335
375
  }
336
376
  };
337
- const apiKeyEnv = nonEmpty(entry.apiKeyEnv);
338
- if (apiKeyEnv === void 0) return {
339
- ok: false,
340
- problem: {
341
- id,
342
- code: "MISSING_API_KEY_REF",
343
- detail: "apiKeyEnv is required: a channel without a credential reference cannot authenticate"
344
- }
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
345
402
  };
346
403
  const presetId = nonEmpty(entry.preset);
347
404
  const preset = presetId === void 0 ? void 0 : presetById(presetId);
@@ -396,7 +453,8 @@ function resolveChannel(id, raw) {
396
453
  api: apiRaw,
397
454
  baseURL,
398
455
  model,
399
- apiKeyEnv,
456
+ credential,
457
+ ...presetId === void 0 ? {} : { preset: presetId },
400
458
  contextWindow,
401
459
  maxTokens,
402
460
  input,
@@ -993,7 +1051,7 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
993
1051
  return;
994
1052
  }
995
1053
  channel = selection.channel;
996
- const key = await this.deps.resolveKey(channel.apiKeyEnv);
1054
+ const key = channel.credential.kind === "literal" ? channel.credential.value : channel.credential.kind === "reference" ? await this.deps.resolveKey(channel.credential.ref) : void 0;
997
1055
  if (key === void 0 || key === "") {
998
1056
  outcome = {
999
1057
  ok: false,
@@ -1005,7 +1063,7 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
1005
1063
  reason: {
1006
1064
  kind: "error",
1007
1065
  failure: {
1008
- message: `no credential is configured for channel "${channel.id}" (reference ${channel.apiKeyEnv})`,
1066
+ message: `channel "${channel.id}" has no key configured`,
1009
1067
  code: "MISSING_CREDENTIAL"
1010
1068
  }
1011
1069
  }
@@ -1441,7 +1499,19 @@ function registerStatsRoutes(ctx, webServer, deps) {
1441
1499
  handler: async (_req, res) => {
1442
1500
  json(res, 200, {
1443
1501
  channels: await Promise.all(deps.channels().map(async (channel) => {
1444
- const key = await deps.describeKey(channel.apiKeyEnv);
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
+ };
1445
1515
  return {
1446
1516
  id: channel.id,
1447
1517
  label: channel.label,
@@ -1453,7 +1523,9 @@ function registerStatsRoutes(ctx, webServer, deps) {
1453
1523
  input: [...channel.input],
1454
1524
  warnings: [...channel.warnings],
1455
1525
  keyConfigured: key.configured,
1456
- ...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 } : {}
1457
1529
  };
1458
1530
  })),
1459
1531
  presets: deps.listPresets(),
@@ -1505,13 +1577,13 @@ function registerStatsRoutes(ctx, webServer, deps) {
1505
1577
  }
1506
1578
  const channel = deps.channels().find((candidate) => candidate.id === id);
1507
1579
  if (channel === void 0) {
1508
- json(res, 404, { error: `no enabled channel named "${id}"` });
1580
+ json(res, 404, { error: `no channel named "${id}"` });
1509
1581
  return;
1510
1582
  }
1511
- const key = await deps.resolveKey(channel.apiKeyEnv);
1583
+ const key = channel.credential.kind === "literal" ? channel.credential.value : channel.credential.kind === "reference" ? await deps.resolveKey(channel.credential.ref) : void 0;
1512
1584
  if (key === void 0 || key === "") {
1513
1585
  json(res, 409, {
1514
- error: `channel "${id}" has no configured credential`,
1586
+ error: `channel "${id}" has no key configured`,
1515
1587
  code: "MISSING_CREDENTIAL"
1516
1588
  });
1517
1589
  return;
@@ -1596,7 +1668,11 @@ function makeRecorder(ctx, config) {
1596
1668
  */
1597
1669
  function apply(ctx, config) {
1598
1670
  const credentials = () => service(ctx, "credentials");
1599
- /** Resolve a credential reference, distinguishing "absent" from "not configured". */
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
+ */
1600
1676
  const resolveKey = async (ref) => {
1601
1677
  const seam = credentials();
1602
1678
  if (seam?.resolve === void 0) return process.env[ref];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-any-deepseek",
3
- "version": "0.0.5",
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": {