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 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
- * ⚠️ `input` is `['text']` **on purpose, for now**. The model is multimodal, but declaring image
166
- * support is a promise this adapter cannot keep yet: a harness `ImageBlock` carries only an
167
- * `attachmentId`, and turning that into bytes requires the durable attachment service
168
- * (`ctx.attachments.readImageRequest`), which the adapter does not consult. Declaring images
169
- * without that path would be worse than declaring text — the harness would stop substituting its
170
- * placeholder text and the image would be silently dropped. See SESSION decision 38.
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
- * Headers the OpenCode Go endpoint requires, MINUS the session id.
205
- *
206
- * `/zen/go` answers 400 `Request is missing x-opencode-session` without it, and the official
207
- * documentation adds two requirements this preset has to respect:
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
- * So the session id is deliberately NOT here: `adapter.ts` injects it per request from the
214
- * harness's own session identity. What stays here is the static identity of this client.
215
- *
216
- * ⚠️ The upstream documentation lists **DeepSeek Harness as a known-problematic client** for this
217
- * endpoint ("session information arrives on some model paths, but is missing on others"). Even
218
- * with everything here correct, expect this relay to be stricter than the others.
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
- * Requirement 2 above: a generic SDK name is exactly what they ask clients not to send.
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
- ...(entry.headers ?? preset?.headers) === void 0 ? {} : { headers: entry.headers ?? preset?.headers },
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 = channel.reasoningEfforts === void 0 ? void 0 : Object.fromEntries(Object.entries(channel.reasoningEfforts).filter(([, value]) => value !== null));
627
- const hasThinking = thinkingLevelMap !== void 0 && Object.keys(thinkingLevelMap).length > 0;
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: textAndImages(message.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
- /** Map harness content blocks to pi-ai text/image blocks. */
735
- function textAndImages(content) {
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") out.push({
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: typed.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?.reasoningEfforts ?? {}).filter(([, wire]) => wire !== null).map(([id]) => id);
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. A single shared literal satisfied
1129
- * the letter of "send the header" while defeating its purpose (routing and prompt-cache
1130
- * affinity), which is why the previous preset's fixed value produced 400s. The harness
1131
- * already stamps every request with `sessionId`; that is the correct source.
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 events = models.streamSimple(model, toPiContext(options), streamOptions);
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
- * Requirement 2 above: a generic SDK name is exactly what they ask clients not to send.
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.7",
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": {