dsh-any-deepseek 0.0.6 → 0.0.8
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 +29 -0
- package/lib/adapter.d.ts +58 -3
- package/lib/client.js +125 -6
- package/lib/index.js +251 -20
- package/lib/presets.d.ts +24 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,6 +98,35 @@ v1 is deliberately manual. A channel that fails reports its error; it does not s
|
|
|
98
98
|
over to another one. Switching invalidates the prompt cache for the running conversation, so
|
|
99
99
|
switch at a natural boundary (or start a new session).
|
|
100
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
|
+
|
|
101
130
|
## Usage accounting
|
|
102
131
|
|
|
103
132
|
The ledger is append-only JSONL, split by month, under the harness home:
|
package/lib/adapter.d.ts
CHANGED
|
@@ -48,6 +48,22 @@ export interface AdapterDeps {
|
|
|
48
48
|
resolveKey: (ref: string) => Promise<string | undefined>;
|
|
49
49
|
/** Record one finished request. Must never throw. */
|
|
50
50
|
record: (entry: LedgerEntry) => Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Read one image's request bytes from the durable attachment store.
|
|
53
|
+
*
|
|
54
|
+
* 🔴 Optional, and its absence is a real state (a composition without the attachment service).
|
|
55
|
+
* An `ImageBlock` in the harness carries only an `attachmentId` — never bytes — so without this
|
|
56
|
+
* seam an image cannot be sent at all. When it is missing, image content is replaced by a text
|
|
57
|
+
* placeholder rather than silently dropped: the model should know an image existed even when it
|
|
58
|
+
* cannot see it.
|
|
59
|
+
* @param attachmentId - durable id from the message block.
|
|
60
|
+
* @param mediaType - the block's declared media type.
|
|
61
|
+
* @returns base64 bytes and the media type to put on the wire.
|
|
62
|
+
*/
|
|
63
|
+
readImage?: (attachmentId: string, mediaType: string) => Promise<{
|
|
64
|
+
data: string;
|
|
65
|
+
mediaType: string;
|
|
66
|
+
} | undefined>;
|
|
51
67
|
/** Clock, injectable so tests are deterministic. */
|
|
52
68
|
now: () => Date;
|
|
53
69
|
/** Monotonic-ish millisecond clock for durations. */
|
|
@@ -59,6 +75,25 @@ interface CallOutcome {
|
|
|
59
75
|
code?: string;
|
|
60
76
|
usage: TokenUsage | null;
|
|
61
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Derive the value for the OpenCode session header.
|
|
80
|
+
*
|
|
81
|
+
* 🔴 This is THIS PLUGIN's job, not the harness's. `/zen/go` is one relay's private requirement;
|
|
82
|
+
* the host cannot know about it and must not be expected to stamp anything for it. Whatever the
|
|
83
|
+
* harness happens to put on a request is only an input to a decision made here.
|
|
84
|
+
*
|
|
85
|
+
* The id is a stable hash of the harness session id, so:
|
|
86
|
+
* · the same conversation keeps one affinity key (what the endpoint wants), and
|
|
87
|
+
* · the value is opaque rather than echoing an internal id to a third party.
|
|
88
|
+
*
|
|
89
|
+
* Two hard requirements, both learned from a 400:
|
|
90
|
+
* · **never absent** — the header is always sent, so a request can never go out without it;
|
|
91
|
+
* · **stable when there is no session** — falling back to a random value would give every
|
|
92
|
+
* request a different affinity key, which is worse than a constant.
|
|
93
|
+
* @param sessionId - the harness session id, when it supplied one.
|
|
94
|
+
* @returns a stable, non-empty session id.
|
|
95
|
+
*/
|
|
96
|
+
export declare function opencodeSessionId(sessionId: unknown): string;
|
|
62
97
|
/**
|
|
63
98
|
* Build the pi-ai model entry for one channel.
|
|
64
99
|
*
|
|
@@ -68,15 +103,35 @@ interface CallOutcome {
|
|
|
68
103
|
* @returns a pi-ai chat model describing that relay's view of the model.
|
|
69
104
|
*/
|
|
70
105
|
export declare function buildPiModel(channel: ResolvedChannel): Model<Api>;
|
|
106
|
+
/** One resolved image, keyed by attachment id. */
|
|
107
|
+
export type ImageBytes = Map<string, {
|
|
108
|
+
data: string;
|
|
109
|
+
mediaType: string;
|
|
110
|
+
}>;
|
|
111
|
+
/**
|
|
112
|
+
* Collect the bytes for every image in a request.
|
|
113
|
+
*
|
|
114
|
+
* 🔴 A separate pass, because the conversion itself is synchronous and the store is not. This is
|
|
115
|
+
* also how the host's own pi-ai adapter does it, for the same reason.
|
|
116
|
+
*
|
|
117
|
+
* A block that cannot be read is left OUT of the map, and the converter then substitutes a text
|
|
118
|
+
* placeholder. That is deliberate: dropping the block would tell the model nothing, while a
|
|
119
|
+
* placeholder at least says an image was attached and names why it could not be shown. Skipping
|
|
120
|
+
* it silently is the failure mode this whole change exists to remove — the user sent an image and
|
|
121
|
+
* the model answered as though the message had been text only.
|
|
122
|
+
* @param options - the harness request.
|
|
123
|
+
* @param readImage - the attachment seam, when the composition has one.
|
|
124
|
+
* @returns resolved images keyed by attachment id.
|
|
125
|
+
*/
|
|
126
|
+
export declare function collectImages(options: GenerateOptions, readImage: AdapterDeps['readImage']): Promise<ImageBytes>;
|
|
71
127
|
/**
|
|
72
128
|
* Translate one harness request into a pi-ai context.
|
|
73
129
|
*
|
|
74
|
-
* The harness already projects images and files before an adapter sees them, so this is a
|
|
75
|
-
* straightforward content mapping; anything the harness did not project is passed through.
|
|
76
130
|
* @param options - the harness request.
|
|
131
|
+
* @param images - image bytes collected by {@link collectImages}; omitted for a text-only request.
|
|
77
132
|
* @returns a pi-ai context plus the one-shot system prompt, when the caller set one.
|
|
78
133
|
*/
|
|
79
|
-
export declare function toPiContext(options: GenerateOptions): PiContext;
|
|
134
|
+
export declare function toPiContext(options: GenerateOptions, images?: ImageBytes): PiContext;
|
|
80
135
|
/** Map pi-ai usage onto the harness's disjoint counters. */
|
|
81
136
|
export declare function toTokenUsage(usage: AssistantMessage['usage']): TokenUsage;
|
|
82
137
|
/**
|
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/* ── 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";
|
|
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");
|
|
@@ -182,12 +182,24 @@ window.__ModuleLoader__.load({
|
|
|
182
182
|
* second store to hold the value) was the design the owner rejected.
|
|
183
183
|
*/
|
|
184
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)("");
|
|
185
193
|
/** Which channel currently has its inline key field open (null = none). */
|
|
186
194
|
const [keying, setKeying] = (0, react.useState)(null);
|
|
187
195
|
const [keyDraft, setKeyDraft] = (0, react.useState)("");
|
|
188
196
|
const [keySaving, setKeySaving] = (0, react.useState)(false);
|
|
189
|
-
/**
|
|
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). */
|
|
190
201
|
const lastSuggestedId = (0, react.useRef)("");
|
|
202
|
+
const lastSuggestedLabel = (0, react.useRef)("");
|
|
191
203
|
const refresh = (0, react.useCallback)(async () => {
|
|
192
204
|
const [loaded, statsNow] = await Promise.all([loadChannels(), loadStats(group)]);
|
|
193
205
|
setRows(loaded.channels);
|
|
@@ -216,6 +228,15 @@ window.__ModuleLoader__.load({
|
|
|
216
228
|
* reasons they cannot (transport). Both are facts, and silently discarding them is what makes
|
|
217
229
|
* a panel look alive while nothing is saved.
|
|
218
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
|
+
*
|
|
219
240
|
* 🔴 **The refusal message must NOT name causes we have not checked.** This used to read
|
|
220
241
|
* "只读部署,或设置服务未启用" — and the one time it actually fired, the real cause was
|
|
221
242
|
* neither: `current` was missing `.volatile()` in the schema, three layers away. Naming two
|
|
@@ -226,8 +247,14 @@ window.__ModuleLoader__.load({
|
|
|
226
247
|
*/
|
|
227
248
|
const write = (run, context = "保存失败") => {
|
|
228
249
|
run().then((accepted) => {
|
|
229
|
-
if (!accepted)
|
|
230
|
-
|
|
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
|
+
});
|
|
231
258
|
}, (cause) => {
|
|
232
259
|
setError(`${context}:${cause instanceof Error ? cause.message : String(cause)}`);
|
|
233
260
|
});
|
|
@@ -259,21 +286,35 @@ window.__ModuleLoader__.load({
|
|
|
259
286
|
* there is no second store to keep in step, so there is no ordering to get wrong and no way for
|
|
260
287
|
* "channel exists but its key does not" to be a reachable state. Pre-filling endpoint fields is
|
|
261
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.
|
|
262
293
|
*/
|
|
263
294
|
const addChannel = () => {
|
|
264
295
|
const id = newId.trim();
|
|
265
296
|
if (newPreset === "" || id === "" || newIdProblem !== null || newSecret.trim() === "") return;
|
|
266
|
-
|
|
297
|
+
const label = newLabel.trim();
|
|
298
|
+
const entry = {
|
|
267
299
|
preset: newPreset,
|
|
268
300
|
apiKey: newSecret.trim()
|
|
269
|
-
}
|
|
301
|
+
};
|
|
302
|
+
if (label !== "") entry.label = label;
|
|
303
|
+
write(() => setField(["channels", id], entry), "添加渠道失败");
|
|
270
304
|
if (current === "") write(() => setField(["current"], id), "设为当前渠道失败");
|
|
271
305
|
setNewSecret("");
|
|
272
306
|
setNewId("");
|
|
307
|
+
setNewLabel("");
|
|
273
308
|
setNewPreset("");
|
|
274
309
|
lastSuggestedId.current = "";
|
|
310
|
+
lastSuggestedLabel.current = "";
|
|
275
311
|
setAdding(false);
|
|
276
312
|
};
|
|
313
|
+
/**
|
|
314
|
+
* Remove one channel.
|
|
315
|
+
*
|
|
316
|
+
* The row disappears because `write()` re-reads on success; there is nothing to remember here.
|
|
317
|
+
*/
|
|
277
318
|
const removeChannel = (id) => {
|
|
278
319
|
write(() => removeField(["channels", id]), "移除渠道失败");
|
|
279
320
|
if (current === id) write(() => removeField(["current"]), "清除当前选择失败");
|
|
@@ -312,6 +353,32 @@ window.__ModuleLoader__.load({
|
|
|
312
353
|
setKeySaving(false);
|
|
313
354
|
}
|
|
314
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
|
+
};
|
|
315
382
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
316
383
|
className: "ad-page",
|
|
317
384
|
children: [
|
|
@@ -359,6 +426,8 @@ window.__ModuleLoader__.load({
|
|
|
359
426
|
const probeResult = verdict[row.id];
|
|
360
427
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
361
428
|
className: `ad-row${isCurrent ? " ad-row-current" : ""}`,
|
|
429
|
+
"aria-current": isCurrent ? "true" : void 0,
|
|
430
|
+
"data-current": isCurrent ? "true" : "false",
|
|
362
431
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
363
432
|
className: "ad-rowPick",
|
|
364
433
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
@@ -388,9 +457,45 @@ window.__ModuleLoader__.load({
|
|
|
388
457
|
className: "ad-warn",
|
|
389
458
|
title: row.warnings.join("\n"),
|
|
390
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 ? "取消" : "改名"
|
|
391
472
|
})
|
|
392
473
|
]
|
|
393
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
|
+
}),
|
|
394
499
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
395
500
|
className: "ad-rowMeta",
|
|
396
501
|
children: [
|
|
@@ -501,9 +606,13 @@ window.__ModuleLoader__.load({
|
|
|
501
606
|
const next = event.target.value;
|
|
502
607
|
setNewPreset(next);
|
|
503
608
|
const previousId = lastSuggestedId.current;
|
|
609
|
+
const previousLabel = lastSuggestedLabel.current;
|
|
504
610
|
const nextId = suggestId(next, rows);
|
|
611
|
+
const nextLabel = presets.find((preset) => preset.id === next)?.displayName ?? "";
|
|
505
612
|
setNewId((current) => current === "" || current === previousId ? nextId : current);
|
|
613
|
+
setNewLabel((current) => current === "" || current === previousLabel ? nextLabel : current);
|
|
506
614
|
lastSuggestedId.current = nextId;
|
|
615
|
+
lastSuggestedLabel.current = nextLabel;
|
|
507
616
|
},
|
|
508
617
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
509
618
|
value: "",
|
|
@@ -527,6 +636,15 @@ window.__ModuleLoader__.load({
|
|
|
527
636
|
className: "ad-fieldError",
|
|
528
637
|
children: newIdProblem
|
|
529
638
|
}),
|
|
639
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
640
|
+
className: "ad-field",
|
|
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
|
+
}),
|
|
530
648
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
531
649
|
className: "ad-field",
|
|
532
650
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "密钥" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
@@ -573,6 +691,7 @@ window.__ModuleLoader__.load({
|
|
|
573
691
|
onClick: () => {
|
|
574
692
|
setAdding(true);
|
|
575
693
|
lastSuggestedId.current = "";
|
|
694
|
+
lastSuggestedLabel.current = "";
|
|
576
695
|
},
|
|
577
696
|
children: "添加渠道"
|
|
578
697
|
}),
|
package/lib/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { LlmAdapter, ReasoningEffortId, ToolCallId } from "@deepseek-ai/dsh-llm";
|
|
4
4
|
import "@deepseek-ai/dsh-credentials";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
import { createModels, createProvider } from "@earendil-works/pi-ai";
|
|
6
7
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
7
8
|
import { appendFile, mkdir, readFile, readdir } from "node:fs/promises";
|
|
@@ -161,13 +162,31 @@ const DEFAULT_MAX_TOKENS = 32768;
|
|
|
161
162
|
*
|
|
162
163
|
* Kept as one constant because these are properties of the *model*, not of a relay; a relay
|
|
163
164
|
* that disagrees corrects them in its own preset.
|
|
165
|
+
*
|
|
166
|
+
* ## Why `input` lists image
|
|
167
|
+
*
|
|
168
|
+
* The model is multimodal, and the harness only projects an image into a request when the model
|
|
169
|
+
* entry declares the capability. Declaring text-only therefore did not mean "we never send
|
|
170
|
+
* images" — it meant the user's image never reached the model at all, silently. The relay side is
|
|
171
|
+
* now real: `adapter.ts` collects image bytes through the durable attachment store and maps them
|
|
172
|
+
* to pi-ai `image` content, and falls back to a visible text placeholder when it cannot.
|
|
164
173
|
*/
|
|
165
174
|
const V41_FLASH_CAPS = {
|
|
166
175
|
contextWindow: 1e6,
|
|
167
176
|
maxTokens: 384e3,
|
|
168
|
-
input: ["text"],
|
|
177
|
+
input: ["text", "image"],
|
|
178
|
+
/**
|
|
179
|
+
* Selectable thinking levels, in pi-ai's vocabulary.
|
|
180
|
+
*
|
|
181
|
+
* The VALUES are the wire spellings this model family accepts (`reasoning_effort`); `null`
|
|
182
|
+
* would mark a level as unsupported. pi-ai consumes this map directly as `thinkingLevelMap`,
|
|
183
|
+
* which is why this plugin does not implement a translation layer of its own — a second
|
|
184
|
+
* mapping would inevitably drift from the one the transport actually applies.
|
|
185
|
+
*/
|
|
169
186
|
reasoningEfforts: {
|
|
187
|
+
off: null,
|
|
170
188
|
low: "low",
|
|
189
|
+
medium: "medium",
|
|
171
190
|
high: "high",
|
|
172
191
|
max: "max"
|
|
173
192
|
}
|
|
@@ -184,19 +203,42 @@ const DEEPSEEK_THINKING_COMPAT = {
|
|
|
184
203
|
thinkingFormat: "deepseek"
|
|
185
204
|
};
|
|
186
205
|
/**
|
|
187
|
-
*
|
|
206
|
+
* Static headers the OpenCode Go endpoint wants on every request.
|
|
188
207
|
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
208
|
+
* The session id is NOT here on purpose — it is injected per request, because the endpoint wants
|
|
209
|
+
* it stable *per conversation* rather than per install. What lives here is the client identity,
|
|
210
|
+
* which does not vary. See {@link OPENCODE_SESSION_FALLBACK} for what happens when the harness
|
|
211
|
+
* gives us no session at all.
|
|
192
212
|
*/
|
|
193
213
|
const OPENCODE_HEADERS = {
|
|
194
|
-
"x-opencode-session": "dsh-any-deepseek",
|
|
195
|
-
"X-Session-ID": "dsh-any-deepseek",
|
|
196
214
|
"x-opencode-client": "dsh",
|
|
197
215
|
"x-opencode-project": "global"
|
|
198
216
|
};
|
|
199
217
|
/**
|
|
218
|
+
* The user agent a request to OpenCode Go identifies itself with.
|
|
219
|
+
*
|
|
220
|
+
* The upstream asks clients to identify themselves rather than send a generic SDK name.
|
|
221
|
+
*/
|
|
222
|
+
const CLIENT_USER_AGENT = "dsh-any-deepseek/1.0";
|
|
223
|
+
/** Header carrying the per-conversation routing id on OpenCode Go. */
|
|
224
|
+
const OPENCODE_SESSION_HEADER = "x-opencode-session";
|
|
225
|
+
/**
|
|
226
|
+
* Session id used when the harness gives us none.
|
|
227
|
+
*
|
|
228
|
+
* 🔴 **This fallback is load-bearing, not decoration**, and dropping it is a measured failure.
|
|
229
|
+
*
|
|
230
|
+
* The endpoint wants a per-conversation id, so the honest implementation is "use the harness's
|
|
231
|
+
* `sessionId`". The problem is that the harness does not stamp one on every request — and when it
|
|
232
|
+
* does not, an implementation that ONLY reads `options.sessionId` sends **no session header at
|
|
233
|
+
* all**, which the endpoint rejects with 400 `MissingSessionID`: precisely the failure the header
|
|
234
|
+
* exists to prevent. So the value is "harness session when present, this constant otherwise".
|
|
235
|
+
*
|
|
236
|
+
* A constant is a worse affinity key than a real per-session id and a far better one than none:
|
|
237
|
+
* it still gives the upstream one consistent key per install. That is the same trade the sibling
|
|
238
|
+
* `dsh-serenity-hooks` plugin made for the same endpoint, proved on a real host.
|
|
239
|
+
*/
|
|
240
|
+
const OPENCODE_SESSION_FALLBACK = "dsh-any-deepseek";
|
|
241
|
+
/**
|
|
200
242
|
* Presets keyed by a stable id.
|
|
201
243
|
*
|
|
202
244
|
* The id is what a channel entry stores (`preset: opencode-go`), so it is part of the user's
|
|
@@ -460,12 +502,42 @@ function resolveChannel(id, raw) {
|
|
|
460
502
|
input,
|
|
461
503
|
...(entry.reasoningEfforts ?? preset?.reasoningEfforts) === void 0 ? {} : { reasoningEfforts: entry.reasoningEfforts ?? preset?.reasoningEfforts },
|
|
462
504
|
...(entry.compat ?? preset?.compat) === void 0 ? {} : { compat: entry.compat ?? preset?.compat },
|
|
463
|
-
|
|
505
|
+
/**
|
|
506
|
+
* 🔴 Headers MERGE per key; they do not replace.
|
|
507
|
+
*
|
|
508
|
+
* `entry.headers ?? preset?.headers` treated the two as alternatives, so a user who added
|
|
509
|
+
* one header of their own silently lost the preset's — and for OpenCode Go those are the
|
|
510
|
+
* headers the endpoint refuses requests without. Merging keeps the preset's required set
|
|
511
|
+
* and lets the entry win only on the keys it actually names.
|
|
512
|
+
*/
|
|
513
|
+
...mergeHeaders(preset?.headers, entry.headers) === void 0 ? {} : { headers: mergeHeaders(preset?.headers, entry.headers) },
|
|
464
514
|
warnings
|
|
465
515
|
}
|
|
466
516
|
};
|
|
467
517
|
}
|
|
468
518
|
/**
|
|
519
|
+
* Merge preset headers with entry headers, the entry winning per key.
|
|
520
|
+
*
|
|
521
|
+
* HTTP field names are case-insensitive, so a user writing `X-Session-ID` must override a
|
|
522
|
+
* preset's `x-session-id` rather than appearing alongside it as a second header — sending both
|
|
523
|
+
* would leave the upstream to pick one, which is exactly the ambiguity the header exists to
|
|
524
|
+
* resolve.
|
|
525
|
+
* @param preset - headers the preset declares.
|
|
526
|
+
* @param entry - headers the entry declares.
|
|
527
|
+
* @returns the merged set, or undefined when neither side declares any.
|
|
528
|
+
*/
|
|
529
|
+
function mergeHeaders(preset, entry) {
|
|
530
|
+
if (entry === void 0) return preset === void 0 ? void 0 : { ...preset };
|
|
531
|
+
if (preset === void 0) return { ...entry };
|
|
532
|
+
const merged = {};
|
|
533
|
+
for (const [name, value] of Object.entries(preset)) merged[name] = value;
|
|
534
|
+
for (const [name, value] of Object.entries(entry)) {
|
|
535
|
+
for (const existing of Object.keys(merged)) if (existing !== name && existing.toLowerCase() === name.toLowerCase()) delete merged[existing];
|
|
536
|
+
merged[name] = value;
|
|
537
|
+
}
|
|
538
|
+
return merged;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
469
541
|
* Resolve every channel in one configuration document.
|
|
470
542
|
*
|
|
471
543
|
* Problems are collected, never thrown: one broken channel must not make the rest of the
|
|
@@ -582,6 +654,29 @@ const PROVIDER_ID = "any-deepseek";
|
|
|
582
654
|
/** Display name for the route in provider selectors. */
|
|
583
655
|
const PROVIDER_NAME = "Any DeepSeek";
|
|
584
656
|
/**
|
|
657
|
+
* Derive the value for the OpenCode session header.
|
|
658
|
+
*
|
|
659
|
+
* 🔴 This is THIS PLUGIN's job, not the harness's. `/zen/go` is one relay's private requirement;
|
|
660
|
+
* the host cannot know about it and must not be expected to stamp anything for it. Whatever the
|
|
661
|
+
* harness happens to put on a request is only an input to a decision made here.
|
|
662
|
+
*
|
|
663
|
+
* The id is a stable hash of the harness session id, so:
|
|
664
|
+
* · the same conversation keeps one affinity key (what the endpoint wants), and
|
|
665
|
+
* · the value is opaque rather than echoing an internal id to a third party.
|
|
666
|
+
*
|
|
667
|
+
* Two hard requirements, both learned from a 400:
|
|
668
|
+
* · **never absent** — the header is always sent, so a request can never go out without it;
|
|
669
|
+
* · **stable when there is no session** — falling back to a random value would give every
|
|
670
|
+
* request a different affinity key, which is worse than a constant.
|
|
671
|
+
* @param sessionId - the harness session id, when it supplied one.
|
|
672
|
+
* @returns a stable, non-empty session id.
|
|
673
|
+
*/
|
|
674
|
+
function opencodeSessionId(sessionId) {
|
|
675
|
+
const raw = sessionId === void 0 || sessionId === null ? "" : String(sessionId).trim();
|
|
676
|
+
if (raw === "") return OPENCODE_SESSION_FALLBACK;
|
|
677
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 32);
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
585
680
|
* Build the pi-ai model entry for one channel.
|
|
586
681
|
*
|
|
587
682
|
* The advertised id (`deepseek-v4.1-flash`) never reaches the wire here: each channel's own
|
|
@@ -590,6 +685,8 @@ const PROVIDER_NAME = "Any DeepSeek";
|
|
|
590
685
|
* @returns a pi-ai chat model describing that relay's view of the model.
|
|
591
686
|
*/
|
|
592
687
|
function buildPiModel(channel) {
|
|
688
|
+
const thinkingLevelMap = channel.reasoningEfforts === void 0 ? void 0 : Object.fromEntries(Object.entries(channel.reasoningEfforts).filter(([, value]) => value !== null));
|
|
689
|
+
const hasThinking = thinkingLevelMap !== void 0 && Object.keys(thinkingLevelMap).length > 0;
|
|
593
690
|
return {
|
|
594
691
|
id: channel.model,
|
|
595
692
|
name: channel.label,
|
|
@@ -605,20 +702,64 @@ function buildPiModel(channel) {
|
|
|
605
702
|
},
|
|
606
703
|
contextWindow: channel.contextWindow,
|
|
607
704
|
maxTokens: channel.maxTokens,
|
|
608
|
-
reasoning:
|
|
705
|
+
reasoning: hasThinking,
|
|
706
|
+
...hasThinking ? { thinkingLevelMap } : {},
|
|
609
707
|
...channel.compat === void 0 ? {} : { compat: channel.compat },
|
|
610
708
|
...channel.headers === void 0 ? {} : { headers: { ...channel.headers } }
|
|
611
709
|
};
|
|
612
710
|
}
|
|
613
711
|
/**
|
|
712
|
+
* Collect the bytes for every image in a request.
|
|
713
|
+
*
|
|
714
|
+
* 🔴 A separate pass, because the conversion itself is synchronous and the store is not. This is
|
|
715
|
+
* also how the host's own pi-ai adapter does it, for the same reason.
|
|
716
|
+
*
|
|
717
|
+
* A block that cannot be read is left OUT of the map, and the converter then substitutes a text
|
|
718
|
+
* placeholder. That is deliberate: dropping the block would tell the model nothing, while a
|
|
719
|
+
* placeholder at least says an image was attached and names why it could not be shown. Skipping
|
|
720
|
+
* it silently is the failure mode this whole change exists to remove — the user sent an image and
|
|
721
|
+
* the model answered as though the message had been text only.
|
|
722
|
+
* @param options - the harness request.
|
|
723
|
+
* @param readImage - the attachment seam, when the composition has one.
|
|
724
|
+
* @returns resolved images keyed by attachment id.
|
|
725
|
+
*/
|
|
726
|
+
async function collectImages(options, readImage) {
|
|
727
|
+
const images = /* @__PURE__ */ new Map();
|
|
728
|
+
if (readImage === void 0) return images;
|
|
729
|
+
const pending = [];
|
|
730
|
+
for (const message of options.messages) {
|
|
731
|
+
if (!Array.isArray(message.content)) continue;
|
|
732
|
+
for (const block of message.content) {
|
|
733
|
+
const candidate = block;
|
|
734
|
+
if (candidate.type !== "image") continue;
|
|
735
|
+
const id = candidate.attachment?.attachmentId;
|
|
736
|
+
const mediaType = candidate.attachment?.mediaType;
|
|
737
|
+
if (typeof id !== "string" || typeof mediaType !== "string") continue;
|
|
738
|
+
if (images.has(id)) continue;
|
|
739
|
+
pending.push({
|
|
740
|
+
id,
|
|
741
|
+
mediaType
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const resolved = await Promise.all(pending.map(async (item) => {
|
|
746
|
+
try {
|
|
747
|
+
return [item.id, await readImage(item.id, item.mediaType)];
|
|
748
|
+
} catch {
|
|
749
|
+
return [item.id, void 0];
|
|
750
|
+
}
|
|
751
|
+
}));
|
|
752
|
+
for (const [id, value] of resolved) if (value !== void 0) images.set(id, value);
|
|
753
|
+
return images;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
614
756
|
* Translate one harness request into a pi-ai context.
|
|
615
757
|
*
|
|
616
|
-
* The harness already projects images and files before an adapter sees them, so this is a
|
|
617
|
-
* straightforward content mapping; anything the harness did not project is passed through.
|
|
618
758
|
* @param options - the harness request.
|
|
759
|
+
* @param images - image bytes collected by {@link collectImages}; omitted for a text-only request.
|
|
619
760
|
* @returns a pi-ai context plus the one-shot system prompt, when the caller set one.
|
|
620
761
|
*/
|
|
621
|
-
function toPiContext(options) {
|
|
762
|
+
function toPiContext(options, images) {
|
|
622
763
|
const messages = options.messages.map((message) => {
|
|
623
764
|
if (message.role === "assistant") return {
|
|
624
765
|
role: "assistant",
|
|
@@ -655,7 +796,7 @@ function toPiContext(options) {
|
|
|
655
796
|
};
|
|
656
797
|
return {
|
|
657
798
|
role: "user",
|
|
658
|
-
content:
|
|
799
|
+
content: userContent(message.content, images),
|
|
659
800
|
timestamp: 0
|
|
660
801
|
};
|
|
661
802
|
});
|
|
@@ -695,14 +836,45 @@ function safeJson(raw) {
|
|
|
695
836
|
return {};
|
|
696
837
|
}
|
|
697
838
|
}
|
|
698
|
-
/**
|
|
699
|
-
|
|
839
|
+
/**
|
|
840
|
+
* Map one user message's blocks to pi-ai content.
|
|
841
|
+
*
|
|
842
|
+
* 🔴 An image block carries an `attachmentId`, never bytes — so it can only become an image when
|
|
843
|
+
* {@link collectImages} resolved it. When it did not, the block becomes a TEXT PLACEHOLDER rather
|
|
844
|
+
* than disappearing: the model should be told an image was attached and that it could not be
|
|
845
|
+
* read. The previous version dropped such blocks entirely, which is how a user's screenshot
|
|
846
|
+
* reached the plugin and the model answered as though the message had been text only.
|
|
847
|
+
* @param content - harness content blocks.
|
|
848
|
+
* @param images - resolved image bytes, when any were collected.
|
|
849
|
+
* @returns pi-ai content blocks.
|
|
850
|
+
*/
|
|
851
|
+
function userContent(content, images) {
|
|
700
852
|
const out = [];
|
|
701
853
|
for (const block of content) {
|
|
702
854
|
const typed = block;
|
|
703
|
-
if (typed.type === "text" && typeof typed.text === "string")
|
|
855
|
+
if (typed.type === "text" && typeof typed.text === "string") {
|
|
856
|
+
out.push({
|
|
857
|
+
type: "text",
|
|
858
|
+
text: typed.text
|
|
859
|
+
});
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
if (typed.type !== "image") continue;
|
|
863
|
+
const id = typed.attachment?.attachmentId;
|
|
864
|
+
const resolved = typeof id === "string" ? images?.get(id) : void 0;
|
|
865
|
+
if (resolved !== void 0) {
|
|
866
|
+
out.push({
|
|
867
|
+
type: "image",
|
|
868
|
+
data: resolved.data,
|
|
869
|
+
mimeType: resolved.mediaType
|
|
870
|
+
});
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (typed.offloaded === true || typed.attachment?.offloaded === true) continue;
|
|
874
|
+
const name = typed.attachment?.name === void 0 ? "" : ` (${typed.attachment.name})`;
|
|
875
|
+
out.push({
|
|
704
876
|
type: "text",
|
|
705
|
-
text:
|
|
877
|
+
text: `[an image was attached${name}, but this route could not read its bytes]`
|
|
706
878
|
});
|
|
707
879
|
}
|
|
708
880
|
return out;
|
|
@@ -987,7 +1159,7 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
|
|
|
987
1159
|
}
|
|
988
1160
|
async resolveModel(provider, model, _signal) {
|
|
989
1161
|
const channel = this.selected();
|
|
990
|
-
const efforts = Object.
|
|
1162
|
+
const efforts = Object.entries(channel?.reasoningEfforts ?? {}).filter(([, wire]) => wire !== null).map(([id]) => id);
|
|
991
1163
|
const reasoning = efforts.length === 0 ? {} : { reasoning: { efforts: efforts.map((id) => ({
|
|
992
1164
|
id: ReasoningEffortId(id),
|
|
993
1165
|
name: id
|
|
@@ -1084,14 +1256,38 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
|
|
|
1084
1256
|
models: [model],
|
|
1085
1257
|
api: openAICompletionsApi()
|
|
1086
1258
|
}));
|
|
1259
|
+
/**
|
|
1260
|
+
* Per-request headers.
|
|
1261
|
+
*
|
|
1262
|
+
* Two things OpenCode Go requires cannot live in a preset, because neither is a constant:
|
|
1263
|
+
*
|
|
1264
|
+
* · `x-opencode-session` must be STABLE PER CONVERSATION, and it must ALWAYS be present.
|
|
1265
|
+
* Both halves matter, and getting only the first produced a 400: an earlier version read
|
|
1266
|
+
* `options.sessionId` and sent nothing when it was absent — and the harness does not stamp
|
|
1267
|
+
* one on every request. So the value is a stable hash of the harness session id when we
|
|
1268
|
+
* have one, and a constant when we do not. A hash keeps the id opaque while staying stable
|
|
1269
|
+
* for the same conversation, which is all the endpoint's affinity key needs.
|
|
1270
|
+
* · `User-Agent` must identify THIS client rather than the generic SDK. pi-ai sets its own,
|
|
1271
|
+
* so the preset's static headers alone can never satisfy that rule.
|
|
1272
|
+
*/
|
|
1273
|
+
const requestHeaders = {};
|
|
1274
|
+
if (channel.headers !== void 0 && Object.hasOwn(channel.headers, "x-opencode-client")) {
|
|
1275
|
+
const session = opencodeSessionId(options.sessionId);
|
|
1276
|
+
requestHeaders[OPENCODE_SESSION_HEADER] = session;
|
|
1277
|
+
requestHeaders["X-Session-ID"] = session;
|
|
1278
|
+
requestHeaders["User-Agent"] = CLIENT_USER_AGENT;
|
|
1279
|
+
}
|
|
1087
1280
|
const streamOptions = {
|
|
1088
1281
|
apiKey: key,
|
|
1089
1282
|
signal: options.signal,
|
|
1090
1283
|
...options.temperature === void 0 ? {} : { temperature: options.temperature },
|
|
1091
|
-
...options.maxTokens === void 0 ? {} : { maxTokens: options.maxTokens }
|
|
1284
|
+
...options.maxTokens === void 0 ? {} : { maxTokens: options.maxTokens },
|
|
1285
|
+
...options.reasoningEffort === void 0 ? {} : { reasoning: options.reasoningEffort },
|
|
1286
|
+
...Object.keys(requestHeaders).length === 0 ? {} : { headers: requestHeaders }
|
|
1092
1287
|
};
|
|
1093
1288
|
try {
|
|
1094
|
-
const
|
|
1289
|
+
const images = await collectImages(options, this.deps.readImage);
|
|
1290
|
+
const events = models.streamSimple(model, toPiContext(options, images), streamOptions);
|
|
1095
1291
|
for await (const chunk of toStreamChunks(events, (observed) => {
|
|
1096
1292
|
outcome = observed;
|
|
1097
1293
|
})) yield chunk;
|
|
@@ -1698,10 +1894,45 @@ function apply(ctx, config) {
|
|
|
1698
1894
|
const value = unwrap((ctx.fiber?.config ?? config).current);
|
|
1699
1895
|
return typeof value === "string" ? value : void 0;
|
|
1700
1896
|
};
|
|
1897
|
+
/**
|
|
1898
|
+
* Read one image's request bytes from the harness attachment store.
|
|
1899
|
+
*
|
|
1900
|
+
* 🔴 This is the host seam, not something this plugin invents: a harness `ImageBlock` carries
|
|
1901
|
+
* only an `attachmentId`, and `readImageRequest` is the documented way to turn that into bytes.
|
|
1902
|
+
* Without this path the model cannot receive an image no matter what `input` declares — which
|
|
1903
|
+
* was exactly this plugin's state until now, and why a screenshot the user sent never arrived.
|
|
1904
|
+
*
|
|
1905
|
+
* The target bounds are the store's own normalization request, not a guess about the relay:
|
|
1906
|
+
* 2048px keeps the harness's normal form, and the byte ceiling is the one the host's pi-ai
|
|
1907
|
+
* adapter uses for the same purpose.
|
|
1908
|
+
*/
|
|
1909
|
+
const readImage = async (attachmentId, mediaType) => {
|
|
1910
|
+
const attachments = service(ctx, "attachments");
|
|
1911
|
+
if (attachments?.readImageRequest === void 0) return void 0;
|
|
1912
|
+
try {
|
|
1913
|
+
const stored = await attachments.readImageRequest({
|
|
1914
|
+
attachmentId,
|
|
1915
|
+
mediaType
|
|
1916
|
+
}, {
|
|
1917
|
+
width: 2048,
|
|
1918
|
+
height: 2048,
|
|
1919
|
+
maxBytes: 4194304
|
|
1920
|
+
});
|
|
1921
|
+
if (stored?.data === void 0) return void 0;
|
|
1922
|
+
return {
|
|
1923
|
+
data: Buffer.from(stored.data).toString("base64"),
|
|
1924
|
+
mediaType: stored.mediaType ?? mediaType
|
|
1925
|
+
};
|
|
1926
|
+
} catch (error) {
|
|
1927
|
+
console.warn(`[any-deepseek] 读不到图片 ${attachmentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
};
|
|
1701
1931
|
const adapter = new AnyDeepSeekAdapter({
|
|
1702
1932
|
channels,
|
|
1703
1933
|
current,
|
|
1704
1934
|
resolveKey,
|
|
1935
|
+
readImage,
|
|
1705
1936
|
record: makeRecorder(ctx, config),
|
|
1706
1937
|
now: () => /* @__PURE__ */ new Date(),
|
|
1707
1938
|
elapsed: () => Number(process.hrtime.bigint() / 1000000n)
|
package/lib/presets.d.ts
CHANGED
|
@@ -81,6 +81,30 @@ 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
|
+
* The upstream asks clients to identify themselves rather than send a generic SDK name.
|
|
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";
|
|
92
|
+
/**
|
|
93
|
+
* Session id used when the harness gives us none.
|
|
94
|
+
*
|
|
95
|
+
* 🔴 **This fallback is load-bearing, not decoration**, and dropping it is a measured failure.
|
|
96
|
+
*
|
|
97
|
+
* The endpoint wants a per-conversation id, so the honest implementation is "use the harness's
|
|
98
|
+
* `sessionId`". The problem is that the harness does not stamp one on every request — and when it
|
|
99
|
+
* does not, an implementation that ONLY reads `options.sessionId` sends **no session header at
|
|
100
|
+
* all**, which the endpoint rejects with 400 `MissingSessionID`: precisely the failure the header
|
|
101
|
+
* exists to prevent. So the value is "harness session when present, this constant otherwise".
|
|
102
|
+
*
|
|
103
|
+
* A constant is a worse affinity key than a real per-session id and a far better one than none:
|
|
104
|
+
* it still gives the upstream one consistent key per install. That is the same trade the sibling
|
|
105
|
+
* `dsh-serenity-hooks` plugin made for the same endpoint, proved on a real host.
|
|
106
|
+
*/
|
|
107
|
+
export declare const OPENCODE_SESSION_FALLBACK = "dsh-any-deepseek";
|
|
84
108
|
/**
|
|
85
109
|
* Presets keyed by a stable id.
|
|
86
110
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-any-deepseek",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
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": {
|