dsh-any-deepseek 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/adapter.d.ts +121 -3
- package/lib/http.d.ts +8 -1
- package/lib/index.js +308 -52
- package/lib/presets.d.ts +52 -1
- package/package.json +1 -1
package/lib/adapter.d.ts
CHANGED
|
@@ -27,12 +27,51 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm';
|
|
|
27
27
|
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, PreparedAdapterCall, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm';
|
|
28
28
|
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
29
29
|
import { type Api, type AssistantMessage, type AssistantMessageEvent, type Context as PiContext, type Model } from '@earendil-works/pi-ai';
|
|
30
|
+
import { type ChannelModality } from './presets.js';
|
|
30
31
|
import type { ResolvedChannel } from './channels.js';
|
|
31
32
|
import type { LedgerEntry } from './usage-ledger.js';
|
|
32
33
|
/** The single provider route this plugin owns. */
|
|
33
34
|
export declare const PROVIDER_ID = "any-deepseek";
|
|
34
35
|
/** Display name for the route in provider selectors. */
|
|
35
36
|
export declare const PROVIDER_NAME = "Any DeepSeek";
|
|
37
|
+
/**
|
|
38
|
+
* Input modalities this provider's one model accepts.
|
|
39
|
+
*
|
|
40
|
+
* 🔴 **Declared at registration, because the harness asks the model — not the channel.**
|
|
41
|
+
*
|
|
42
|
+
* `LlmModelInfo.inputModalities` is documented as "absent means unknown" (`dsh-llm/lib/types/
|
|
43
|
+
* types.d.ts:330-331`), and the host reads it at dispatch to decide whether an image survives
|
|
44
|
+
* projection at all: `if (modelInfo.inputModalities !== undefined &&
|
|
45
|
+
* !modelInfo.inputModalities.includes('image') && …) projectImagesForTextModel(…)`
|
|
46
|
+
* (`dsh-llm/lib/index.js:2337`). Omitting it therefore did not mean "we never send images"; it
|
|
47
|
+
* meant a user's screenshot was replaced by a placeholder *before* this plugin ever saw it, which
|
|
48
|
+
* is why the model answered as though the message had been text.
|
|
49
|
+
*
|
|
50
|
+
* The value is the MODEL's capability, so it does not depend on which channel is selected, on
|
|
51
|
+
* whether a key exists, or on whether `current` is set — the same rule as the catalog itself.
|
|
52
|
+
* A relay that genuinely cannot carry images declares `input: ['text']` on its own channel, and
|
|
53
|
+
* the transport (`pi-ai`) downgrades the image per channel; the harness-level answer stays the
|
|
54
|
+
* model's.
|
|
55
|
+
*/
|
|
56
|
+
export declare const MODEL_INPUT_MODALITIES: readonly ChannelModality[];
|
|
57
|
+
/**
|
|
58
|
+
* The reasoning levels in force for one channel.
|
|
59
|
+
*
|
|
60
|
+
* 🔴 **A channel that says nothing inherits the model's table, and that is the whole fix for the
|
|
61
|
+
* missing thinking control.**
|
|
62
|
+
*
|
|
63
|
+
* The old code read `channel.reasoningEfforts` alone. That value only existed once a channel
|
|
64
|
+
* declared it — so during first-run setup (no channel, or a channel written before the panel
|
|
65
|
+
* offered levels) the answer was "no reasoning at all", and the composer's strength selector was
|
|
66
|
+
* absent. But the model always had levels; the CHANNEL was merely silent about them.
|
|
67
|
+
*
|
|
68
|
+
* An empty map counts as silence, not as "explicitly unsupported": `z.dict()` produces `{}` when
|
|
69
|
+
* the panel writes the field, and treating that as a negative capability is how the picker broke
|
|
70
|
+
* with `INVALID_MODEL_REASONING` in the first place.
|
|
71
|
+
* @param channel - the selected channel, when there is one.
|
|
72
|
+
* @returns the levels to advertise and to send, keyed by level name.
|
|
73
|
+
*/
|
|
74
|
+
export declare function effectiveReasoningEfforts(channel: ResolvedChannel | undefined): Record<string, string | null>;
|
|
36
75
|
/** What the adapter needs from the plugin, so it can be tested without a harness context. */
|
|
37
76
|
export interface AdapterDeps {
|
|
38
77
|
/** Enabled, resolved channels in configuration order. */
|
|
@@ -48,17 +87,73 @@ export interface AdapterDeps {
|
|
|
48
87
|
resolveKey: (ref: string) => Promise<string | undefined>;
|
|
49
88
|
/** Record one finished request. Must never throw. */
|
|
50
89
|
record: (entry: LedgerEntry) => Promise<void>;
|
|
90
|
+
/**
|
|
91
|
+
* Read one image's request bytes from the durable attachment store.
|
|
92
|
+
*
|
|
93
|
+
* 🔴 Optional, and its absence is a real state (a composition without the attachment service).
|
|
94
|
+
* An `ImageBlock` in the harness carries only an `attachmentId` — never bytes — so without this
|
|
95
|
+
* seam an image cannot be sent at all. When it is missing, image content is replaced by a text
|
|
96
|
+
* placeholder rather than silently dropped: the model should know an image existed even when it
|
|
97
|
+
* cannot see it.
|
|
98
|
+
* @param ref - the block's attachment reference, passed through WHOLE.
|
|
99
|
+
* @returns base64 bytes and the media type to put on the wire.
|
|
100
|
+
*/
|
|
101
|
+
readImage?: (ref: HarnessImageRef) => Promise<{
|
|
102
|
+
data: string;
|
|
103
|
+
mediaType: string;
|
|
104
|
+
} | undefined>;
|
|
51
105
|
/** Clock, injectable so tests are deterministic. */
|
|
52
106
|
now: () => Date;
|
|
53
107
|
/** Monotonic-ish millisecond clock for durations. */
|
|
54
108
|
elapsed: () => number;
|
|
55
109
|
}
|
|
110
|
+
/** A durable image reference as a harness message block carries it. */
|
|
111
|
+
export interface HarnessImageRef {
|
|
112
|
+
attachmentId: string;
|
|
113
|
+
mediaType: string;
|
|
114
|
+
bytes?: number;
|
|
115
|
+
width?: number;
|
|
116
|
+
height?: number;
|
|
117
|
+
name?: string;
|
|
118
|
+
}
|
|
56
119
|
/** One finished call, as the adapter observed it. */
|
|
57
120
|
interface CallOutcome {
|
|
58
121
|
ok: boolean;
|
|
59
122
|
code?: string;
|
|
60
123
|
usage: TokenUsage | null;
|
|
61
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Derive the value for the OpenCode session header.
|
|
127
|
+
*
|
|
128
|
+
* 🔴 This is THIS PLUGIN's job, not the harness's. `/zen/go` is one relay's private requirement;
|
|
129
|
+
* the host cannot know about it and must not be expected to stamp anything for it. Whatever the
|
|
130
|
+
* harness happens to put on a request is only an input to a decision made here.
|
|
131
|
+
*
|
|
132
|
+
* The id is a stable hash of the harness session id, so:
|
|
133
|
+
* · the same conversation keeps one affinity key (what the endpoint wants), and
|
|
134
|
+
* · the value is opaque rather than echoing an internal id to a third party.
|
|
135
|
+
*
|
|
136
|
+
* Two hard requirements, both learned from a 400:
|
|
137
|
+
* · **never absent** — the header is always sent, so a request can never go out without it;
|
|
138
|
+
* · **stable when there is no session** — falling back to a random value would give every
|
|
139
|
+
* request a different affinity key, which is worse than a constant.
|
|
140
|
+
* @param sessionId - the harness session id, when it supplied one.
|
|
141
|
+
* @returns a stable, non-empty session id.
|
|
142
|
+
*/
|
|
143
|
+
export declare function opencodeSessionId(sessionId: unknown): string;
|
|
144
|
+
/**
|
|
145
|
+
* Build the per-request headers one channel needs.
|
|
146
|
+
*
|
|
147
|
+
* 🔴 **Shared by BOTH paths that talk to a relay** — the runtime `stream()` and the panel's
|
|
148
|
+
* `/probe`. They used to differ: `stream()` injected the OpenCode session header and `/probe`
|
|
149
|
+
* did not, so "测试" reported 400 `MissingSessionID` for a channel that worked in real use. A
|
|
150
|
+
* probe that checks something other than what a real request does is not a probe; it is a second
|
|
151
|
+
* implementation of the truth, and the two will disagree.
|
|
152
|
+
* @param channel - the resolved channel.
|
|
153
|
+
* @param sessionId - the harness session id, when the caller has one.
|
|
154
|
+
* @returns headers to send, on top of whatever the channel already declares.
|
|
155
|
+
*/
|
|
156
|
+
export declare function requestHeadersFor(channel: ResolvedChannel, sessionId: unknown): Record<string, string>;
|
|
62
157
|
/**
|
|
63
158
|
* Build the pi-ai model entry for one channel.
|
|
64
159
|
*
|
|
@@ -68,15 +163,35 @@ interface CallOutcome {
|
|
|
68
163
|
* @returns a pi-ai chat model describing that relay's view of the model.
|
|
69
164
|
*/
|
|
70
165
|
export declare function buildPiModel(channel: ResolvedChannel): Model<Api>;
|
|
166
|
+
/** One resolved image, keyed by attachment id. */
|
|
167
|
+
export type ImageBytes = Map<string, {
|
|
168
|
+
data: string;
|
|
169
|
+
mediaType: string;
|
|
170
|
+
}>;
|
|
171
|
+
/**
|
|
172
|
+
* Collect the bytes for every image in a request.
|
|
173
|
+
*
|
|
174
|
+
* 🔴 A separate pass, because the conversion itself is synchronous and the store is not. This is
|
|
175
|
+
* also how the host's own pi-ai adapter does it, for the same reason.
|
|
176
|
+
*
|
|
177
|
+
* A block that cannot be read is left OUT of the map, and the converter then substitutes a text
|
|
178
|
+
* placeholder. That is deliberate: dropping the block would tell the model nothing, while a
|
|
179
|
+
* placeholder at least says an image was attached and names why it could not be shown. Skipping
|
|
180
|
+
* it silently is the failure mode this whole change exists to remove — the user sent an image and
|
|
181
|
+
* the model answered as though the message had been text only.
|
|
182
|
+
* @param options - the harness request.
|
|
183
|
+
* @param readImage - the attachment seam, when the composition has one.
|
|
184
|
+
* @returns resolved images keyed by attachment id.
|
|
185
|
+
*/
|
|
186
|
+
export declare function collectImages(options: GenerateOptions, readImage: AdapterDeps['readImage']): Promise<ImageBytes>;
|
|
71
187
|
/**
|
|
72
188
|
* Translate one harness request into a pi-ai context.
|
|
73
189
|
*
|
|
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
190
|
* @param options - the harness request.
|
|
191
|
+
* @param images - image bytes collected by {@link collectImages}; omitted for a text-only request.
|
|
77
192
|
* @returns a pi-ai context plus the one-shot system prompt, when the caller set one.
|
|
78
193
|
*/
|
|
79
|
-
export declare function toPiContext(options: GenerateOptions): PiContext;
|
|
194
|
+
export declare function toPiContext(options: GenerateOptions, images?: ImageBytes): PiContext;
|
|
80
195
|
/** Map pi-ai usage onto the harness's disjoint counters. */
|
|
81
196
|
export declare function toTokenUsage(usage: AssistantMessage['usage']): TokenUsage;
|
|
82
197
|
/**
|
|
@@ -123,6 +238,9 @@ export declare class AnyDeepSeekAdapter extends LlmAdapter {
|
|
|
123
238
|
* ⇒ The catalog answers "what does this provider serve", which is a property of the plugin.
|
|
124
239
|
* Key presence is answered per request (`MISSING_CREDENTIAL`) and per row in the panel
|
|
125
240
|
* (`keyConfigured`), where the user can actually act on it.
|
|
241
|
+
*
|
|
242
|
+
* The same reasoning extends to `inputModalities`: it describes the model this route serves,
|
|
243
|
+
* so it is reported here unconditionally rather than derived from a channel.
|
|
126
244
|
*/
|
|
127
245
|
listModels(_provider: string): Promise<LlmModelInfo[]>;
|
|
128
246
|
resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise<LlmResolvedModelInfo>;
|
package/lib/http.d.ts
CHANGED
|
@@ -64,12 +64,19 @@ export interface WebServerLike {
|
|
|
64
64
|
*
|
|
65
65
|
* This spends tokens, which is why it is an explicit route rather than something the panel does
|
|
66
66
|
* on render. The request asks for one output token, so the cost is a prompt plus a token.
|
|
67
|
+
*
|
|
68
|
+
* 🔴 **The headers come from `requestHeadersFor`, the same function the runtime uses.** They used
|
|
69
|
+
* to be built here separately, which meant this probe sent no OpenCode session header while real
|
|
70
|
+
* requests did — so "测试" reported 400 `MissingSessionID` on a channel that worked in use. A
|
|
71
|
+
* probe that does not send what a real request sends is not testing the channel.
|
|
67
72
|
* @param channel - the channel to probe.
|
|
68
73
|
* @param key - its resolved credential.
|
|
74
|
+
* @param sessionId - session identity for per-request headers (the probe has no conversation of
|
|
75
|
+
* its own, so callers pass the selected channel's own stable value).
|
|
69
76
|
* @param signal - cancellation.
|
|
70
77
|
* @returns a verdict and, on success, what the relay reported.
|
|
71
78
|
*/
|
|
72
|
-
export declare function probeChannel(channel: ResolvedChannel, key: string, signal?: AbortSignal): Promise<{
|
|
79
|
+
export declare function probeChannel(channel: ResolvedChannel, key: string, sessionId?: string, signal?: AbortSignal): Promise<{
|
|
73
80
|
ok: boolean;
|
|
74
81
|
ms: number;
|
|
75
82
|
code?: string;
|
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";
|
|
@@ -162,17 +163,25 @@ const DEFAULT_MAX_TOKENS = 32768;
|
|
|
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.
|
|
164
165
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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.
|
|
173
|
+
*
|
|
174
|
+
* ## Why this is exported
|
|
175
|
+
*
|
|
176
|
+
* The adapter must report the SAME capabilities through `listModels()` / `resolveModel()`, which
|
|
177
|
+
* describe the advertised model rather than any one channel. Reaching for this constant (instead
|
|
178
|
+
* of re-listing the numbers there) keeps the catalog and the exact-model answer from drifting —
|
|
179
|
+
* and they are read by different host paths, so a drift is silent.
|
|
171
180
|
*/
|
|
172
181
|
const V41_FLASH_CAPS = {
|
|
173
182
|
contextWindow: 1e6,
|
|
174
183
|
maxTokens: 384e3,
|
|
175
|
-
input: ["text"],
|
|
184
|
+
input: ["text", "image"],
|
|
176
185
|
/**
|
|
177
186
|
* Selectable thinking levels, in pi-ai's vocabulary.
|
|
178
187
|
*
|
|
@@ -201,21 +210,12 @@ const DEEPSEEK_THINKING_COMPAT = {
|
|
|
201
210
|
thinkingFormat: "deepseek"
|
|
202
211
|
};
|
|
203
212
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
* `/zen/go` answers 400 `Request is missing x-opencode-session` without it, and the official
|
|
207
|
-
* documentation adds two requirements this preset has to respect:
|
|
208
|
-
*
|
|
209
|
-
* 1. the session id must be **stable per conversation** — a constant shared by every
|
|
210
|
-
* conversation violates the thing the header exists for (routing and cache affinity);
|
|
211
|
-
* 2. the client must **identify itself with its own user agent** rather than a generic SDK name.
|
|
213
|
+
* Static headers the OpenCode Go endpoint wants on every request.
|
|
212
214
|
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* endpoint ("session information arrives on some model paths, but is missing on others"). Even
|
|
218
|
-
* with everything here correct, expect this relay to be stricter than the others.
|
|
215
|
+
* The session id is NOT here on purpose — it is injected per request, because the endpoint wants
|
|
216
|
+
* it stable *per conversation* rather than per install. What lives here is the client identity,
|
|
217
|
+
* which does not vary. See {@link OPENCODE_SESSION_FALLBACK} for what happens when the harness
|
|
218
|
+
* gives us no session at all.
|
|
219
219
|
*/
|
|
220
220
|
const OPENCODE_HEADERS = {
|
|
221
221
|
"x-opencode-client": "dsh",
|
|
@@ -224,12 +224,28 @@ const OPENCODE_HEADERS = {
|
|
|
224
224
|
/**
|
|
225
225
|
* The user agent a request to OpenCode Go identifies itself with.
|
|
226
226
|
*
|
|
227
|
-
*
|
|
227
|
+
* The upstream asks clients to identify themselves rather than send a generic SDK name.
|
|
228
228
|
*/
|
|
229
229
|
const CLIENT_USER_AGENT = "dsh-any-deepseek/1.0";
|
|
230
230
|
/** Header carrying the per-conversation routing id on OpenCode Go. */
|
|
231
231
|
const OPENCODE_SESSION_HEADER = "x-opencode-session";
|
|
232
232
|
/**
|
|
233
|
+
* Session id used when the harness gives us none.
|
|
234
|
+
*
|
|
235
|
+
* 🔴 **This fallback is load-bearing, not decoration**, and dropping it is a measured failure.
|
|
236
|
+
*
|
|
237
|
+
* The endpoint wants a per-conversation id, so the honest implementation is "use the harness's
|
|
238
|
+
* `sessionId`". The problem is that the harness does not stamp one on every request — and when it
|
|
239
|
+
* does not, an implementation that ONLY reads `options.sessionId` sends **no session header at
|
|
240
|
+
* all**, which the endpoint rejects with 400 `MissingSessionID`: precisely the failure the header
|
|
241
|
+
* exists to prevent. So the value is "harness session when present, this constant otherwise".
|
|
242
|
+
*
|
|
243
|
+
* A constant is a worse affinity key than a real per-session id and a far better one than none:
|
|
244
|
+
* it still gives the upstream one consistent key per install. That is the same trade the sibling
|
|
245
|
+
* `dsh-serenity-hooks` plugin made for the same endpoint, proved on a real host.
|
|
246
|
+
*/
|
|
247
|
+
const OPENCODE_SESSION_FALLBACK = "dsh-any-deepseek";
|
|
248
|
+
/**
|
|
233
249
|
* Presets keyed by a stable id.
|
|
234
250
|
*
|
|
235
251
|
* The id is what a channel entry stores (`preset: opencode-go`), so it is part of the user's
|
|
@@ -493,12 +509,42 @@ function resolveChannel(id, raw) {
|
|
|
493
509
|
input,
|
|
494
510
|
...(entry.reasoningEfforts ?? preset?.reasoningEfforts) === void 0 ? {} : { reasoningEfforts: entry.reasoningEfforts ?? preset?.reasoningEfforts },
|
|
495
511
|
...(entry.compat ?? preset?.compat) === void 0 ? {} : { compat: entry.compat ?? preset?.compat },
|
|
496
|
-
|
|
512
|
+
/**
|
|
513
|
+
* 🔴 Headers MERGE per key; they do not replace.
|
|
514
|
+
*
|
|
515
|
+
* `entry.headers ?? preset?.headers` treated the two as alternatives, so a user who added
|
|
516
|
+
* one header of their own silently lost the preset's — and for OpenCode Go those are the
|
|
517
|
+
* headers the endpoint refuses requests without. Merging keeps the preset's required set
|
|
518
|
+
* and lets the entry win only on the keys it actually names.
|
|
519
|
+
*/
|
|
520
|
+
...mergeHeaders(preset?.headers, entry.headers) === void 0 ? {} : { headers: mergeHeaders(preset?.headers, entry.headers) },
|
|
497
521
|
warnings
|
|
498
522
|
}
|
|
499
523
|
};
|
|
500
524
|
}
|
|
501
525
|
/**
|
|
526
|
+
* Merge preset headers with entry headers, the entry winning per key.
|
|
527
|
+
*
|
|
528
|
+
* HTTP field names are case-insensitive, so a user writing `X-Session-ID` must override a
|
|
529
|
+
* preset's `x-session-id` rather than appearing alongside it as a second header — sending both
|
|
530
|
+
* would leave the upstream to pick one, which is exactly the ambiguity the header exists to
|
|
531
|
+
* resolve.
|
|
532
|
+
* @param preset - headers the preset declares.
|
|
533
|
+
* @param entry - headers the entry declares.
|
|
534
|
+
* @returns the merged set, or undefined when neither side declares any.
|
|
535
|
+
*/
|
|
536
|
+
function mergeHeaders(preset, entry) {
|
|
537
|
+
if (entry === void 0) return preset === void 0 ? void 0 : { ...preset };
|
|
538
|
+
if (preset === void 0) return { ...entry };
|
|
539
|
+
const merged = {};
|
|
540
|
+
for (const [name, value] of Object.entries(preset)) merged[name] = value;
|
|
541
|
+
for (const [name, value] of Object.entries(entry)) {
|
|
542
|
+
for (const existing of Object.keys(merged)) if (existing !== name && existing.toLowerCase() === name.toLowerCase()) delete merged[existing];
|
|
543
|
+
merged[name] = value;
|
|
544
|
+
}
|
|
545
|
+
return merged;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
502
548
|
* Resolve every channel in one configuration document.
|
|
503
549
|
*
|
|
504
550
|
* Problems are collected, never thrown: one broken channel must not make the rest of the
|
|
@@ -615,6 +661,93 @@ const PROVIDER_ID = "any-deepseek";
|
|
|
615
661
|
/** Display name for the route in provider selectors. */
|
|
616
662
|
const PROVIDER_NAME = "Any DeepSeek";
|
|
617
663
|
/**
|
|
664
|
+
* Input modalities this provider's one model accepts.
|
|
665
|
+
*
|
|
666
|
+
* 🔴 **Declared at registration, because the harness asks the model — not the channel.**
|
|
667
|
+
*
|
|
668
|
+
* `LlmModelInfo.inputModalities` is documented as "absent means unknown" (`dsh-llm/lib/types/
|
|
669
|
+
* types.d.ts:330-331`), and the host reads it at dispatch to decide whether an image survives
|
|
670
|
+
* projection at all: `if (modelInfo.inputModalities !== undefined &&
|
|
671
|
+
* !modelInfo.inputModalities.includes('image') && …) projectImagesForTextModel(…)`
|
|
672
|
+
* (`dsh-llm/lib/index.js:2337`). Omitting it therefore did not mean "we never send images"; it
|
|
673
|
+
* meant a user's screenshot was replaced by a placeholder *before* this plugin ever saw it, which
|
|
674
|
+
* is why the model answered as though the message had been text.
|
|
675
|
+
*
|
|
676
|
+
* The value is the MODEL's capability, so it does not depend on which channel is selected, on
|
|
677
|
+
* whether a key exists, or on whether `current` is set — the same rule as the catalog itself.
|
|
678
|
+
* A relay that genuinely cannot carry images declares `input: ['text']` on its own channel, and
|
|
679
|
+
* the transport (`pi-ai`) downgrades the image per channel; the harness-level answer stays the
|
|
680
|
+
* model's.
|
|
681
|
+
*/
|
|
682
|
+
const MODEL_INPUT_MODALITIES = V41_FLASH_CAPS.input;
|
|
683
|
+
/**
|
|
684
|
+
* The reasoning levels in force for one channel.
|
|
685
|
+
*
|
|
686
|
+
* 🔴 **A channel that says nothing inherits the model's table, and that is the whole fix for the
|
|
687
|
+
* missing thinking control.**
|
|
688
|
+
*
|
|
689
|
+
* The old code read `channel.reasoningEfforts` alone. That value only existed once a channel
|
|
690
|
+
* declared it — so during first-run setup (no channel, or a channel written before the panel
|
|
691
|
+
* offered levels) the answer was "no reasoning at all", and the composer's strength selector was
|
|
692
|
+
* absent. But the model always had levels; the CHANNEL was merely silent about them.
|
|
693
|
+
*
|
|
694
|
+
* An empty map counts as silence, not as "explicitly unsupported": `z.dict()` produces `{}` when
|
|
695
|
+
* the panel writes the field, and treating that as a negative capability is how the picker broke
|
|
696
|
+
* with `INVALID_MODEL_REASONING` in the first place.
|
|
697
|
+
* @param channel - the selected channel, when there is one.
|
|
698
|
+
* @returns the levels to advertise and to send, keyed by level name.
|
|
699
|
+
*/
|
|
700
|
+
function effectiveReasoningEfforts(channel) {
|
|
701
|
+
const declared = channel?.reasoningEfforts;
|
|
702
|
+
if (declared !== void 0 && Object.keys(declared).length > 0) return declared;
|
|
703
|
+
return V41_FLASH_CAPS.reasoningEfforts;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Derive the value for the OpenCode session header.
|
|
707
|
+
*
|
|
708
|
+
* 🔴 This is THIS PLUGIN's job, not the harness's. `/zen/go` is one relay's private requirement;
|
|
709
|
+
* the host cannot know about it and must not be expected to stamp anything for it. Whatever the
|
|
710
|
+
* harness happens to put on a request is only an input to a decision made here.
|
|
711
|
+
*
|
|
712
|
+
* The id is a stable hash of the harness session id, so:
|
|
713
|
+
* · the same conversation keeps one affinity key (what the endpoint wants), and
|
|
714
|
+
* · the value is opaque rather than echoing an internal id to a third party.
|
|
715
|
+
*
|
|
716
|
+
* Two hard requirements, both learned from a 400:
|
|
717
|
+
* · **never absent** — the header is always sent, so a request can never go out without it;
|
|
718
|
+
* · **stable when there is no session** — falling back to a random value would give every
|
|
719
|
+
* request a different affinity key, which is worse than a constant.
|
|
720
|
+
* @param sessionId - the harness session id, when it supplied one.
|
|
721
|
+
* @returns a stable, non-empty session id.
|
|
722
|
+
*/
|
|
723
|
+
function opencodeSessionId(sessionId) {
|
|
724
|
+
const raw = sessionId === void 0 || sessionId === null ? "" : String(sessionId).trim();
|
|
725
|
+
if (raw === "") return OPENCODE_SESSION_FALLBACK;
|
|
726
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 32);
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Build the per-request headers one channel needs.
|
|
730
|
+
*
|
|
731
|
+
* 🔴 **Shared by BOTH paths that talk to a relay** — the runtime `stream()` and the panel's
|
|
732
|
+
* `/probe`. They used to differ: `stream()` injected the OpenCode session header and `/probe`
|
|
733
|
+
* did not, so "测试" reported 400 `MissingSessionID` for a channel that worked in real use. A
|
|
734
|
+
* probe that checks something other than what a real request does is not a probe; it is a second
|
|
735
|
+
* implementation of the truth, and the two will disagree.
|
|
736
|
+
* @param channel - the resolved channel.
|
|
737
|
+
* @param sessionId - the harness session id, when the caller has one.
|
|
738
|
+
* @returns headers to send, on top of whatever the channel already declares.
|
|
739
|
+
*/
|
|
740
|
+
function requestHeadersFor(channel, sessionId) {
|
|
741
|
+
const headers = {};
|
|
742
|
+
if (channel.headers !== void 0 && Object.hasOwn(channel.headers, "x-opencode-client")) {
|
|
743
|
+
const session = opencodeSessionId(sessionId);
|
|
744
|
+
headers[OPENCODE_SESSION_HEADER] = session;
|
|
745
|
+
headers["X-Session-ID"] = session;
|
|
746
|
+
headers["User-Agent"] = CLIENT_USER_AGENT;
|
|
747
|
+
}
|
|
748
|
+
return headers;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
618
751
|
* Build the pi-ai model entry for one channel.
|
|
619
752
|
*
|
|
620
753
|
* The advertised id (`deepseek-v4.1-flash`) never reaches the wire here: each channel's own
|
|
@@ -623,8 +756,12 @@ const PROVIDER_NAME = "Any DeepSeek";
|
|
|
623
756
|
* @returns a pi-ai chat model describing that relay's view of the model.
|
|
624
757
|
*/
|
|
625
758
|
function buildPiModel(channel) {
|
|
626
|
-
const thinkingLevelMap =
|
|
627
|
-
|
|
759
|
+
const thinkingLevelMap = (() => {
|
|
760
|
+
const declared = {};
|
|
761
|
+
for (const [level, wire] of Object.entries(effectiveReasoningEfforts(channel))) if (typeof wire === "string") declared[level] = wire;
|
|
762
|
+
return Object.keys(declared).length === 0 ? void 0 : declared;
|
|
763
|
+
})();
|
|
764
|
+
const hasThinking = thinkingLevelMap !== void 0;
|
|
628
765
|
return {
|
|
629
766
|
id: channel.model,
|
|
630
767
|
name: channel.label,
|
|
@@ -647,14 +784,54 @@ function buildPiModel(channel) {
|
|
|
647
784
|
};
|
|
648
785
|
}
|
|
649
786
|
/**
|
|
787
|
+
* Collect the bytes for every image in a request.
|
|
788
|
+
*
|
|
789
|
+
* 🔴 A separate pass, because the conversion itself is synchronous and the store is not. This is
|
|
790
|
+
* also how the host's own pi-ai adapter does it, for the same reason.
|
|
791
|
+
*
|
|
792
|
+
* A block that cannot be read is left OUT of the map, and the converter then substitutes a text
|
|
793
|
+
* placeholder. That is deliberate: dropping the block would tell the model nothing, while a
|
|
794
|
+
* placeholder at least says an image was attached and names why it could not be shown. Skipping
|
|
795
|
+
* it silently is the failure mode this whole change exists to remove — the user sent an image and
|
|
796
|
+
* the model answered as though the message had been text only.
|
|
797
|
+
* @param options - the harness request.
|
|
798
|
+
* @param readImage - the attachment seam, when the composition has one.
|
|
799
|
+
* @returns resolved images keyed by attachment id.
|
|
800
|
+
*/
|
|
801
|
+
async function collectImages(options, readImage) {
|
|
802
|
+
const images = /* @__PURE__ */ new Map();
|
|
803
|
+
if (readImage === void 0) return images;
|
|
804
|
+
const pending = [];
|
|
805
|
+
for (const message of options.messages) {
|
|
806
|
+
if (!Array.isArray(message.content)) continue;
|
|
807
|
+
for (const block of message.content) {
|
|
808
|
+
const candidate = block;
|
|
809
|
+
if (candidate.type !== "image") continue;
|
|
810
|
+
const ref = candidate.attachment;
|
|
811
|
+
if (ref === void 0 || typeof ref.attachmentId !== "string") continue;
|
|
812
|
+
if (images.has(ref.attachmentId)) continue;
|
|
813
|
+
if (pending.some((item) => item.attachmentId === ref.attachmentId)) continue;
|
|
814
|
+
pending.push(ref);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const resolved = await Promise.all(pending.map(async (ref) => {
|
|
818
|
+
try {
|
|
819
|
+
return [ref.attachmentId, await readImage(ref)];
|
|
820
|
+
} catch {
|
|
821
|
+
return [ref.attachmentId, void 0];
|
|
822
|
+
}
|
|
823
|
+
}));
|
|
824
|
+
for (const [id, value] of resolved) if (value !== void 0) images.set(id, value);
|
|
825
|
+
return images;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
650
828
|
* Translate one harness request into a pi-ai context.
|
|
651
829
|
*
|
|
652
|
-
* The harness already projects images and files before an adapter sees them, so this is a
|
|
653
|
-
* straightforward content mapping; anything the harness did not project is passed through.
|
|
654
830
|
* @param options - the harness request.
|
|
831
|
+
* @param images - image bytes collected by {@link collectImages}; omitted for a text-only request.
|
|
655
832
|
* @returns a pi-ai context plus the one-shot system prompt, when the caller set one.
|
|
656
833
|
*/
|
|
657
|
-
function toPiContext(options) {
|
|
834
|
+
function toPiContext(options, images) {
|
|
658
835
|
const messages = options.messages.map((message) => {
|
|
659
836
|
if (message.role === "assistant") return {
|
|
660
837
|
role: "assistant",
|
|
@@ -691,7 +868,7 @@ function toPiContext(options) {
|
|
|
691
868
|
};
|
|
692
869
|
return {
|
|
693
870
|
role: "user",
|
|
694
|
-
content:
|
|
871
|
+
content: userContent(message.content, images),
|
|
695
872
|
timestamp: 0
|
|
696
873
|
};
|
|
697
874
|
});
|
|
@@ -731,14 +908,45 @@ function safeJson(raw) {
|
|
|
731
908
|
return {};
|
|
732
909
|
}
|
|
733
910
|
}
|
|
734
|
-
/**
|
|
735
|
-
|
|
911
|
+
/**
|
|
912
|
+
* Map one user message's blocks to pi-ai content.
|
|
913
|
+
*
|
|
914
|
+
* 🔴 An image block carries an `attachmentId`, never bytes — so it can only become an image when
|
|
915
|
+
* {@link collectImages} resolved it. When it did not, the block becomes a TEXT PLACEHOLDER rather
|
|
916
|
+
* than disappearing: the model should be told an image was attached and that it could not be
|
|
917
|
+
* read. The previous version dropped such blocks entirely, which is how a user's screenshot
|
|
918
|
+
* reached the plugin and the model answered as though the message had been text only.
|
|
919
|
+
* @param content - harness content blocks.
|
|
920
|
+
* @param images - resolved image bytes, when any were collected.
|
|
921
|
+
* @returns pi-ai content blocks.
|
|
922
|
+
*/
|
|
923
|
+
function userContent(content, images) {
|
|
736
924
|
const out = [];
|
|
737
925
|
for (const block of content) {
|
|
738
926
|
const typed = block;
|
|
739
|
-
if (typed.type === "text" && typeof typed.text === "string")
|
|
927
|
+
if (typed.type === "text" && typeof typed.text === "string") {
|
|
928
|
+
out.push({
|
|
929
|
+
type: "text",
|
|
930
|
+
text: typed.text
|
|
931
|
+
});
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
if (typed.type !== "image") continue;
|
|
935
|
+
const id = typed.attachment?.attachmentId;
|
|
936
|
+
const resolved = typeof id === "string" ? images?.get(id) : void 0;
|
|
937
|
+
if (resolved !== void 0) {
|
|
938
|
+
out.push({
|
|
939
|
+
type: "image",
|
|
940
|
+
data: resolved.data,
|
|
941
|
+
mimeType: resolved.mediaType
|
|
942
|
+
});
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
if (typed.offloaded === true || typed.attachment?.offloaded === true) continue;
|
|
946
|
+
const name = typed.attachment?.name === void 0 ? "" : ` (${typed.attachment.name})`;
|
|
947
|
+
out.push({
|
|
740
948
|
type: "text",
|
|
741
|
-
text:
|
|
949
|
+
text: `[an image was attached${name}, but this route could not read its bytes]`
|
|
742
950
|
});
|
|
743
951
|
}
|
|
744
952
|
return out;
|
|
@@ -1009,6 +1217,9 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
|
|
|
1009
1217
|
* ⇒ The catalog answers "what does this provider serve", which is a property of the plugin.
|
|
1010
1218
|
* Key presence is answered per request (`MISSING_CREDENTIAL`) and per row in the panel
|
|
1011
1219
|
* (`keyConfigured`), where the user can actually act on it.
|
|
1220
|
+
*
|
|
1221
|
+
* The same reasoning extends to `inputModalities`: it describes the model this route serves,
|
|
1222
|
+
* so it is reported here unconditionally rather than derived from a channel.
|
|
1012
1223
|
*/
|
|
1013
1224
|
async listModels(_provider) {
|
|
1014
1225
|
const selected = this.selected();
|
|
@@ -1018,12 +1229,13 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
|
|
|
1018
1229
|
provider: PROVIDER_ID,
|
|
1019
1230
|
id: ADVERTISED_MODEL_ID,
|
|
1020
1231
|
name: ADVERTISED_MODEL_NAME,
|
|
1021
|
-
description: label === void 0 ? "no channel configured yet" : `via ${label}
|
|
1232
|
+
description: label === void 0 ? "no channel configured yet" : `via ${label}`,
|
|
1233
|
+
inputModalities: [...MODEL_INPUT_MODALITIES]
|
|
1022
1234
|
}];
|
|
1023
1235
|
}
|
|
1024
1236
|
async resolveModel(provider, model, _signal) {
|
|
1025
1237
|
const channel = this.selected();
|
|
1026
|
-
const efforts = Object.entries(channel
|
|
1238
|
+
const efforts = Object.entries(effectiveReasoningEfforts(channel)).filter(([, wire]) => wire !== null).map(([id]) => id);
|
|
1027
1239
|
const reasoning = efforts.length === 0 ? {} : { reasoning: { efforts: efforts.map((id) => ({
|
|
1028
1240
|
id: ReasoningEffortId(id),
|
|
1029
1241
|
name: id
|
|
@@ -1035,6 +1247,7 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
|
|
|
1035
1247
|
description: channel === void 0 ? void 0 : `via ${channel.label}`,
|
|
1036
1248
|
context: { contextWindow: channel?.contextWindow ?? 1e6 },
|
|
1037
1249
|
defaultMaxTokens: channel?.maxTokens,
|
|
1250
|
+
inputModalities: [...MODEL_INPUT_MODALITIES],
|
|
1038
1251
|
...reasoning
|
|
1039
1252
|
};
|
|
1040
1253
|
}
|
|
@@ -1125,22 +1338,18 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
|
|
|
1125
1338
|
*
|
|
1126
1339
|
* Two things OpenCode Go requires cannot live in a preset, because neither is a constant:
|
|
1127
1340
|
*
|
|
1128
|
-
* · `x-opencode-session` must be STABLE PER CONVERSATION
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1341
|
+
* · `x-opencode-session` must be STABLE PER CONVERSATION, and it must ALWAYS be present.
|
|
1342
|
+
* Both halves matter, and getting only the first produced a 400: an earlier version read
|
|
1343
|
+
* `options.sessionId` and sent nothing when it was absent — and the harness does not stamp
|
|
1344
|
+
* one on every request. So the value is a stable hash of the harness session id when we
|
|
1345
|
+
* have one, and a constant when we do not. A hash keeps the id opaque while staying stable
|
|
1346
|
+
* for the same conversation, which is all the endpoint's affinity key needs.
|
|
1132
1347
|
* · `User-Agent` must identify THIS client rather than the generic SDK. pi-ai sets its own,
|
|
1133
1348
|
* so the preset's static headers alone can never satisfy that rule.
|
|
1349
|
+
*
|
|
1350
|
+
* The construction lives in `requestHeadersFor` so the panel's probe sends the same thing.
|
|
1134
1351
|
*/
|
|
1135
|
-
const requestHeaders =
|
|
1136
|
-
if (channel.headers !== void 0 && Object.hasOwn(channel.headers, "x-opencode-client")) {
|
|
1137
|
-
const session = options.sessionId === void 0 ? void 0 : String(options.sessionId);
|
|
1138
|
-
if (session !== void 0 && session !== "") {
|
|
1139
|
-
requestHeaders[OPENCODE_SESSION_HEADER] = session;
|
|
1140
|
-
requestHeaders["X-Session-ID"] = session;
|
|
1141
|
-
}
|
|
1142
|
-
requestHeaders["User-Agent"] = CLIENT_USER_AGENT;
|
|
1143
|
-
}
|
|
1352
|
+
const requestHeaders = requestHeadersFor(channel, options.sessionId);
|
|
1144
1353
|
const streamOptions = {
|
|
1145
1354
|
apiKey: key,
|
|
1146
1355
|
signal: options.signal,
|
|
@@ -1150,7 +1359,8 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
|
|
|
1150
1359
|
...Object.keys(requestHeaders).length === 0 ? {} : { headers: requestHeaders }
|
|
1151
1360
|
};
|
|
1152
1361
|
try {
|
|
1153
|
-
const
|
|
1362
|
+
const images = await collectImages(options, this.deps.readImage);
|
|
1363
|
+
const events = models.streamSimple(model, toPiContext(options, images), streamOptions);
|
|
1154
1364
|
for await (const chunk of toStreamChunks(events, (observed) => {
|
|
1155
1365
|
outcome = observed;
|
|
1156
1366
|
})) yield chunk;
|
|
@@ -1472,12 +1682,19 @@ function queryDate(value) {
|
|
|
1472
1682
|
*
|
|
1473
1683
|
* This spends tokens, which is why it is an explicit route rather than something the panel does
|
|
1474
1684
|
* on render. The request asks for one output token, so the cost is a prompt plus a token.
|
|
1685
|
+
*
|
|
1686
|
+
* 🔴 **The headers come from `requestHeadersFor`, the same function the runtime uses.** They used
|
|
1687
|
+
* to be built here separately, which meant this probe sent no OpenCode session header while real
|
|
1688
|
+
* requests did — so "测试" reported 400 `MissingSessionID` on a channel that worked in use. A
|
|
1689
|
+
* probe that does not send what a real request sends is not testing the channel.
|
|
1475
1690
|
* @param channel - the channel to probe.
|
|
1476
1691
|
* @param key - its resolved credential.
|
|
1692
|
+
* @param sessionId - session identity for per-request headers (the probe has no conversation of
|
|
1693
|
+
* its own, so callers pass the selected channel's own stable value).
|
|
1477
1694
|
* @param signal - cancellation.
|
|
1478
1695
|
* @returns a verdict and, on success, what the relay reported.
|
|
1479
1696
|
*/
|
|
1480
|
-
async function probeChannel(channel, key, signal) {
|
|
1697
|
+
async function probeChannel(channel, key, sessionId, signal) {
|
|
1481
1698
|
const started = Date.now();
|
|
1482
1699
|
const url = `${channel.baseURL.replace(/\/+$/, "")}/chat/completions`;
|
|
1483
1700
|
try {
|
|
@@ -1486,7 +1703,8 @@ async function probeChannel(channel, key, signal) {
|
|
|
1486
1703
|
headers: {
|
|
1487
1704
|
"content-type": "application/json",
|
|
1488
1705
|
authorization: `Bearer ${key}`,
|
|
1489
|
-
...channel.headers
|
|
1706
|
+
...channel.headers,
|
|
1707
|
+
...requestHeadersFor(channel, sessionId)
|
|
1490
1708
|
},
|
|
1491
1709
|
body: JSON.stringify({
|
|
1492
1710
|
model: channel.model,
|
|
@@ -1647,7 +1865,7 @@ function registerStatsRoutes(ctx, webServer, deps) {
|
|
|
1647
1865
|
});
|
|
1648
1866
|
return;
|
|
1649
1867
|
}
|
|
1650
|
-
const verdict = await probeChannel(channel, key);
|
|
1868
|
+
const verdict = await probeChannel(channel, key, `probe:${channel.id}`);
|
|
1651
1869
|
json(res, verdict.ok ? 200 : 502, verdict);
|
|
1652
1870
|
}
|
|
1653
1871
|
}
|
|
@@ -1757,10 +1975,48 @@ function apply(ctx, config) {
|
|
|
1757
1975
|
const value = unwrap((ctx.fiber?.config ?? config).current);
|
|
1758
1976
|
return typeof value === "string" ? value : void 0;
|
|
1759
1977
|
};
|
|
1978
|
+
/**
|
|
1979
|
+
* Read one image's request bytes from the harness attachment store.
|
|
1980
|
+
*
|
|
1981
|
+
* 🔴 This is the host seam, not something this plugin invents: a harness `ImageBlock` carries
|
|
1982
|
+
* only an `attachmentId`, and `readImageRequest` is the documented way to turn that into bytes.
|
|
1983
|
+
* Without this path the model cannot receive an image no matter what `input` declares — which
|
|
1984
|
+
* was exactly this plugin's state until now, and why a screenshot the user sent never arrived.
|
|
1985
|
+
*
|
|
1986
|
+
* The target bounds are the store's own normalization request, not a guess about the relay:
|
|
1987
|
+
* 2048px keeps the harness's normal form, and the byte ceiling is the one the host's pi-ai
|
|
1988
|
+
* adapter uses for the same purpose.
|
|
1989
|
+
*/
|
|
1990
|
+
const readImage = async (ref) => {
|
|
1991
|
+
const attachments = service(ctx, "attachments");
|
|
1992
|
+
if (attachments?.readImageRequest === void 0) {
|
|
1993
|
+
console.warn("[any-deepseek] 图片无法读取:本组合没有装配 attachments 服务(readImageRequest 不可用)");
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
try {
|
|
1997
|
+
const stored = await attachments.readImageRequest(ref, {
|
|
1998
|
+
width: 2048,
|
|
1999
|
+
height: 2048,
|
|
2000
|
+
maxBytes: 4194304
|
|
2001
|
+
});
|
|
2002
|
+
if (stored?.data === void 0) {
|
|
2003
|
+
console.warn(`[any-deepseek] 图片 ${ref.attachmentId} 读取结果为空`);
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
return {
|
|
2007
|
+
data: Buffer.from(stored.data).toString("base64"),
|
|
2008
|
+
mediaType: stored.mediaType ?? ref.mediaType
|
|
2009
|
+
};
|
|
2010
|
+
} catch (error) {
|
|
2011
|
+
console.warn(`[any-deepseek] 读不到图片 ${ref.attachmentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
};
|
|
1760
2015
|
const adapter = new AnyDeepSeekAdapter({
|
|
1761
2016
|
channels,
|
|
1762
2017
|
current,
|
|
1763
2018
|
resolveKey,
|
|
2019
|
+
readImage,
|
|
1764
2020
|
record: makeRecorder(ctx, config),
|
|
1765
2021
|
now: () => /* @__PURE__ */ new Date(),
|
|
1766
2022
|
elapsed: () => Number(process.hrtime.bigint() / 1000000n)
|
package/lib/presets.d.ts
CHANGED
|
@@ -81,14 +81,65 @@ 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
|
+
* Capability numbers shared by every DeepSeek V4.1 Flash deployment we know of.
|
|
86
|
+
*
|
|
87
|
+
* Kept as one constant because these are properties of the *model*, not of a relay; a relay
|
|
88
|
+
* that disagrees corrects them in its own preset.
|
|
89
|
+
*
|
|
90
|
+
* ## Why `input` lists image
|
|
91
|
+
*
|
|
92
|
+
* The model is multimodal, and the harness only projects an image into a request when the model
|
|
93
|
+
* entry declares the capability. Declaring text-only therefore did not mean "we never send
|
|
94
|
+
* images" — it meant the user's image never reached the model at all, silently. The relay side is
|
|
95
|
+
* now real: `adapter.ts` collects image bytes through the durable attachment store and maps them
|
|
96
|
+
* to pi-ai `image` content, and falls back to a visible text placeholder when it cannot.
|
|
97
|
+
*
|
|
98
|
+
* ## Why this is exported
|
|
99
|
+
*
|
|
100
|
+
* The adapter must report the SAME capabilities through `listModels()` / `resolveModel()`, which
|
|
101
|
+
* describe the advertised model rather than any one channel. Reaching for this constant (instead
|
|
102
|
+
* of re-listing the numbers there) keeps the catalog and the exact-model answer from drifting —
|
|
103
|
+
* and they are read by different host paths, so a drift is silent.
|
|
104
|
+
*/
|
|
105
|
+
export declare const V41_FLASH_CAPS: {
|
|
106
|
+
contextWindow: number;
|
|
107
|
+
maxTokens: number;
|
|
108
|
+
input: ChannelModality[];
|
|
109
|
+
/**
|
|
110
|
+
* Selectable thinking levels, in pi-ai's vocabulary.
|
|
111
|
+
*
|
|
112
|
+
* The VALUES are the wire spellings this model family accepts (`reasoning_effort`); `null`
|
|
113
|
+
* would mark a level as unsupported. pi-ai consumes this map directly as `thinkingLevelMap`,
|
|
114
|
+
* which is why this plugin does not implement a translation layer of its own — a second
|
|
115
|
+
* mapping would inevitably drift from the one the transport actually applies.
|
|
116
|
+
*/
|
|
117
|
+
reasoningEfforts: Record<string, string | null>;
|
|
118
|
+
};
|
|
84
119
|
/**
|
|
85
120
|
* The user agent a request to OpenCode Go identifies itself with.
|
|
86
121
|
*
|
|
87
|
-
*
|
|
122
|
+
* The upstream asks clients to identify themselves rather than send a generic SDK name.
|
|
88
123
|
*/
|
|
89
124
|
export declare const CLIENT_USER_AGENT = "dsh-any-deepseek/1.0";
|
|
90
125
|
/** Header carrying the per-conversation routing id on OpenCode Go. */
|
|
91
126
|
export declare const OPENCODE_SESSION_HEADER = "x-opencode-session";
|
|
127
|
+
/**
|
|
128
|
+
* Session id used when the harness gives us none.
|
|
129
|
+
*
|
|
130
|
+
* 🔴 **This fallback is load-bearing, not decoration**, and dropping it is a measured failure.
|
|
131
|
+
*
|
|
132
|
+
* The endpoint wants a per-conversation id, so the honest implementation is "use the harness's
|
|
133
|
+
* `sessionId`". The problem is that the harness does not stamp one on every request — and when it
|
|
134
|
+
* does not, an implementation that ONLY reads `options.sessionId` sends **no session header at
|
|
135
|
+
* all**, which the endpoint rejects with 400 `MissingSessionID`: precisely the failure the header
|
|
136
|
+
* exists to prevent. So the value is "harness session when present, this constant otherwise".
|
|
137
|
+
*
|
|
138
|
+
* A constant is a worse affinity key than a real per-session id and a far better one than none:
|
|
139
|
+
* it still gives the upstream one consistent key per install. That is the same trade the sibling
|
|
140
|
+
* `dsh-serenity-hooks` plugin made for the same endpoint, proved on a real host.
|
|
141
|
+
*/
|
|
142
|
+
export declare const OPENCODE_SESSION_FALLBACK = "dsh-any-deepseek";
|
|
92
143
|
/**
|
|
93
144
|
* Presets keyed by a stable id.
|
|
94
145
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-any-deepseek",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
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": {
|