dsh-any-deepseek 0.0.9 โ†’ 0.0.11

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,6 +27,7 @@ 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 { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy';
30
31
  import { type ChannelModality } from './presets.js';
31
32
  import type { ResolvedChannel } from './channels.js';
32
33
  import type { LedgerEntry } from './usage-ledger.js';
@@ -163,6 +164,15 @@ export declare function requestHeadersFor(channel: ResolvedChannel, sessionId: u
163
164
  * @returns a pi-ai chat model describing that relay's view of the model.
164
165
  */
165
166
  export declare function buildPiModel(channel: ResolvedChannel): Model<Api>;
167
+ /** The shape `createProvider` accepts per protocol (an object with `stream`/`streamSimple`). */
168
+ type ApiStreams = ReturnType<typeof openAICompletionsApi>;
169
+ /**
170
+ * Build the per-protocol implementation map for the pi-ai provider.
171
+ *
172
+ * Every supported protocol is constructed eagerly because the factories are lazy internally
173
+ * (they load the SDK on first use), so this costs one closure each, not one SDK download.
174
+ */
175
+ export declare function apiImplementations(): Record<string, ApiStreams>;
166
176
  /** One resolved image, keyed by attachment id. */
167
177
  export type ImageBytes = Map<string, {
168
178
  data: string;
package/lib/client.js CHANGED
@@ -115,6 +115,32 @@ window.__ModuleLoader__.load({
115
115
  if (value < 1e6) return `${(value / 1e3).toFixed(value < 1e4 ? 1 : 0)}K`;
116
116
  return `${(value / 1e6).toFixed(value < 1e7 ? 2 : 1)}M`;
117
117
  }
118
+ /**
119
+ * Format a throughput figure, or an em dash when it is unmeasurable.
120
+ *
121
+ * ๐Ÿ”ด `null` is NOT zero: a group with no first-token measurement has no speed, and printing `0`
122
+ * would claim the relay produced nothing โ€” a different and much worse statement. The em dash
123
+ * says "not measured", which is the truth.
124
+ * @param value - tokens per second, or null/undefined.
125
+ * @returns e.g. `42 tok/s`, or `โ€”`.
126
+ */
127
+ function formatSpeed(value) {
128
+ if (value === null || value === void 0 || !Number.isFinite(value) || value <= 0) return "โ€”";
129
+ return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} tok/s`;
130
+ }
131
+ /**
132
+ * Format a duration in ms compactly.
133
+ * @param value - milliseconds, or null/undefined.
134
+ * @returns e.g. `820ms` / `1.4s` / `2m 05s`, or `โ€”`.
135
+ */
136
+ function formatDuration(value) {
137
+ if (value === null || value === void 0 || !Number.isFinite(value) || value < 0) return "โ€”";
138
+ if (value < 1e3) return `${Math.round(value)}ms`;
139
+ const seconds = value / 1e3;
140
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
141
+ const minutes = Math.floor(seconds / 60);
142
+ return `${minutes}m ${String(Math.round(seconds - minutes * 60)).padStart(2, "0")}s`;
143
+ }
118
144
  //#endregion
119
145
  //#region \0ad-css:src/client/SettingsPage
120
146
  const css = "/* Panel styles.\n *\n * ๐Ÿ”ด There are NO `--dsw-alias-*` design tokens on this host (measured 2026-10-21: the document\n * declares 29 custom properties and not one is a colour). This file used to reference a dozen of\n * them, every one falling back to a hardcoded LIGHT value (`rgba(0,0,0,0.12)` and friends) โ€” so\n * on the dark theme the borders and dividers were effectively invisible.\n *\n * โ‡’ Derive everything from `currentColor`, which the host sets per theme. `color-mix` gives the\n * low-contrast borders and fills that a token would have provided, and it follows the theme\n * automatically instead of assuming one palette.\n */\n\n.ad-page {\n display: flex;\n flex-direction: column;\n gap: 20px;\n padding: 4px 2px 12px;\n}\n\n/* One place to change the two theme-derived values the whole panel uses. */\n.ad-page {\n --ad-line: color-mix(in srgb, currentColor 18%, transparent);\n --ad-line-strong: color-mix(in srgb, currentColor 32%, transparent);\n --ad-fill-subtle: color-mix(in srgb, currentColor 7%, transparent);\n /* The current row's fill: visible against both themes because it is derived from the text\n colour the host already picked, not from an assumed palette. */\n --ad-fill-current: color-mix(in srgb, currentColor 11%, transparent);\n --ad-danger: #e5534b;\n}\n\n.ad-head {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.ad-title {\n margin: 0;\n font-size: 15px;\n font-weight: 600;\n}\n\n.ad-intro {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n opacity: 0.72;\n}\n\n.ad-error {\n border: 1px solid var(--ad-danger);\n border-radius: 6px;\n padding: 8px 10px;\n font-size: 12px;\n}\n\n.ad-section {\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n\n.ad-sectionTitle {\n margin: 0;\n font-size: 12px;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n opacity: 0.6;\n}\n\n.ad-rows {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.ad-row {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 12px;\n border: 1px solid var(--ad-line);\n border-radius: 8px;\n padding: 10px 12px;\n /*\n * The left edge is reserved on EVERY row so the current one does not shift its content when\n * selected โ€” a row that reindents on click makes the list feel like it moved.\n */\n border-left: 3px solid transparent;\n}\n\n/*\n * ๐Ÿ”ด The current channel is marked three ways, not one.\n *\n * It used to be a small badge plus a barely-different border, and the owner could not tell at a\n * glance which relay was live โ€” glanceability is the whole point of a single-choice list. So:\n * a colour bar down the left edge, a stronger border, and a tinted fill. All derived from\n * `currentColor`, because this host ships no colour tokens (measured: 29 custom properties, none\n * of them a colour), so any hardcoded value would assume the wrong theme.\n */\n.ad-row-current {\n border-color: var(--ad-line-strong);\n border-left-color: currentColor;\n background: var(--ad-fill-current);\n}\n\n/* The ๅฝ“ๅ‰ badge keeps its place as the textual confirmation, for when colour is not enough. */\n.ad-row-current .ad-badge {\n border-color: currentColor;\n opacity: 1;\n font-weight: 600;\n}\n\n/* A rename button must not look like the row's own selection affordance. */\n.ad-renameBtn {\n margin-left: auto;\n flex: none;\n}\n\n.ad-rowMain {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 0;\n}\n\n.ad-rowTitleLine {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 13px;\n font-weight: 500;\n}\n\n.ad-rowTitle {\n white-space: nowrap;\n}\n\n.ad-badge {\n font-size: 10px;\n padding: 1px 6px;\n border-radius: 999px;\n border: 1px solid currentColor;\n opacity: 0.8;\n}\n\n.ad-warn {\n color: #b7791f;\n cursor: help;\n}\n\n.ad-rowMeta {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n opacity: 0.66;\n min-width: 0;\n}\n\n.ad-mono {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n word-break: break-all;\n}\n\n.ad-sep {\n opacity: 0.5;\n}\n\n.ad-rowActions {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n}\n\n.ad-btn {\n font-size: 11px;\n padding: 4px 10px;\n border-radius: 6px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n\n.ad-btn:disabled {\n opacity: 0.45;\n cursor: default;\n}\n\n.ad-btn-quiet {\n opacity: 0.8;\n}\n\n.ad-probe {\n font-size: 11px;\n padding-top: 2px;\n}\n\n.ad-probe-ok {\n color: #2f855a;\n}\n\n.ad-probe-bad {\n color: #c53030;\n word-break: break-word;\n}\n\n.ad-hint,\n.ad-empty,\n.ad-note {\n margin: 0;\n font-size: 11px;\n line-height: 1.6;\n opacity: 0.66;\n}\n\n.ad-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n flex-shrink: 0;\n display: inline-block;\n}\n\n.ad-dot-on {\n background: #2f855a;\n}\n\n.ad-dot-off {\n border: 1px solid currentColor;\n opacity: 0.5;\n}\n\n.ad-dot-unknown {\n background: linear-gradient(90deg, currentColor 50%, transparent 50%);\n opacity: 0.5;\n}\n\n/* The channel row is a radio \"pick\" zone: the whole left side is clickable, and the radio sits\n at the start of the line so \"which one is live\" is readable down a single column. */\n.ad-rowPick {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n flex: 1;\n min-width: 0;\n cursor: pointer;\n}\n\n.ad-rowPick input[type='radio'] {\n flex: none;\n /* Optically centred against the first text line rather than the row box. */\n margin-top: 3px;\n accent-color: var(--ad-line-strong);\n cursor: pointer;\n}\n\n/* The switch is gone: selection is the only axis (see `channels.ts`). Keeping the rules would\n invite someone to bring the second axis back. */\n\n.ad-tabs {\n display: flex;\n gap: 6px;\n}\n\n.ad-tab {\n font-size: 11px;\n padding: 3px 10px;\n border-radius: 999px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n\n.ad-tab-on {\n border-color: var(--ad-line-strong);\n font-weight: 600;\n}\n\n.ad-tab-refresh {\n margin-left: auto;\n opacity: 0.75;\n}\n\n.ad-table {\n width: 100%;\n border-collapse: collapse;\n font-size: 11px;\n}\n\n.ad-table th,\n.ad-table td {\n padding: 5px 8px;\n border-bottom: 1px solid var(--ad-line);\n text-align: left;\n}\n\n.ad-table th {\n font-weight: 500;\n opacity: 0.6;\n}\n\n.ad-num {\n text-align: right;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n\n.ad-total td {\n font-weight: 600;\n border-bottom: none;\n}\n\n/* โ”€โ”€ add-channel form โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */\n\n.ad-addForm {\n display: flex;\n flex-direction: column;\n gap: 8px;\n border: 1px dashed var(--ad-line);\n border-radius: 8px;\n padding: 10px 12px;\n}\n\n.ad-field {\n display: flex;\n flex-direction: column;\n gap: 3px;\n font-size: 11px;\n}\n\n.ad-field > span {\n opacity: 0.7;\n}\n\n.ad-input {\n font-size: 11px;\n padding: 4px 8px;\n border-radius: 6px;\n border: 1px solid var(--ad-line);\n background: transparent;\n color: inherit;\n font-family: inherit;\n}\n\n/*\n * ๐Ÿ”ด A native `<select>` cannot be themed by leaving the background transparent.\n *\n * The popup list is drawn by the browser, not by the closed control's box: the options inherit\n * nothing from it, so `background: transparent` made them render in the platform default (light)\n * palette against this dark theme โ€” reported by the owner and measured on the real host.\n *\n * ๐Ÿ”ด SECOND correction (measured 2026-10-21): this file used `var(--dsw-alias-color-*, #fallback)`\n * throughout, and **none of those variables exist**. The host declares only 29 custom properties\n * in total and not one is a colour token, so every `var()` here was falling back โ€” and a fallback\n * that happens to be set inside a shorthand can be silently dropped (the select's background\n * computed to `rgba(0,0,0,0)`, i.e. still transparent, which is exactly the bug).\n *\n * โ‡’ Use only values this file controls, derived from `currentColor` (the host sets a light text\n * colour on both themes) plus explicit translucent layers. No host token is assumed.\n */\nselect.ad-input {\n /* Tells the engine which palette to use for the popup. This part DID work. */\n color-scheme: dark light;\n /*\n * Paint the closed box with a layer that follows the surrounding text colour, so it matches\n * whichever theme is active instead of hardcoding a dark value into a light theme.\n */\n background-color: color-mix(in srgb, currentColor 8%, transparent);\n color: inherit;\n cursor: pointer;\n padding-right: 22px;\n /* Room for the chevron drawn below, so text never runs under it. */\n appearance: none;\n background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),\n linear-gradient(135deg, currentColor 50%, transparent 50%);\n background-position: calc(100% - 12px) calc(50% + 1px), calc(100% - 8px) calc(50% + 1px);\n background-size: 4px 4px, 4px 4px;\n background-repeat: no-repeat;\n}\n\nselect.ad-input:hover:not(:disabled) {\n background-color: color-mix(in srgb, currentColor 14%, transparent);\n}\n\nselect.ad-input:focus-visible {\n outline: 2px solid currentColor;\n outline-offset: 1px;\n}\n\n/*\n * The popup itself follows `color-scheme`; these keep the option rows explicit for engines that\n * honour per-option colours. `color-scheme: dark` above is what actually stops the light-on-dark\n * list, because the popup is native chrome rather than a styled element.\n */\nselect.ad-input option {\n background-color: Canvas;\n color: CanvasText;\n}\n\n.ad-fieldError {\n margin: 0;\n font-size: 11px;\n color: #c53030;\n}\n\n/* โ”€โ”€ key row โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */\n\n/*\n * The key line states a FACT and offers the fix beside it: \"configured / not configured\",\n * the reference name the secret is filed under, and one text button that opens the input.\n *\n * The value is never shown โ€” the harness credential seam answers \"configured?\" and refuses to\n * answer anything else, so there is nothing to render even if we wanted to.\n */\n.ad-keyLine {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 6px;\n font-size: 11px;\n padding-top: 2px;\n}\n\n.ad-keyOn {\n color: #2f855a;\n}\n\n.ad-keyOff {\n color: #b7791f;\n}\n\n/* The reference name, not a secret: monospace so it lines up and misreads as a name, not a value. */\n.ad-keyName {\n font-size: 10px;\n padding: 1px 5px;\n border-radius: 4px;\n background: var(--ad-fill-subtle);\n opacity: 0.8;\n}\n\n/* A text-shaped action. Styled as a link because it opens an inline editor, not a dialog. */\n.ad-link {\n font-size: 11px;\n padding: 0;\n border: none;\n background: none;\n color: inherit;\n text-decoration: underline;\n text-underline-offset: 2px;\n opacity: 0.75;\n cursor: pointer;\n}\n\n.ad-link:hover {\n opacity: 1;\n}\n\n/* The inline key editor: one masked field and one button, on the same line as the fact above. */\n.ad-keyForm {\n display: flex;\n align-items: center;\n gap: 6px;\n padding-top: 4px;\n}\n\n.ad-keyForm .ad-input {\n flex: 1;\n min-width: 0;\n}\n\n/* A destructive action is quiet until hovered: it should be findable, not inviting. */\n.ad-btn-danger {\n opacity: 0.6;\n}\n\n.ad-btn-danger:hover:not(:disabled) {\n opacity: 1;\n border-color: var(--ad-danger);\n color: var(--ad-danger);\n}\n";
@@ -767,9 +793,19 @@ window.__ModuleLoader__.load({
767
793
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
768
794
  className: "ad-num",
769
795
  children: "ๅˆ่ฎก"
796
+ }),
797
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
798
+ className: "ad-num",
799
+ title: "็”Ÿๆˆ้˜ถๆฎต้€Ÿๅบฆ๏ผˆไธๅซ้ฆ–ๅญ—็ญ‰ๅพ…๏ผ‰",
800
+ children: "้€Ÿๅบฆ"
801
+ }),
802
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
803
+ className: "ad-num",
804
+ title: "้ฆ–ๅญ—ๅนณๅ‡็ญ‰ๅพ…ๆ—ถ้—ด",
805
+ children: "้ฆ–ๅญ—"
770
806
  })
771
807
  ] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tbody", { children: [stats.rows.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
772
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: row.key }),
808
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: row.label ?? row.key }),
773
809
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
774
810
  className: "ad-num",
775
811
  children: row.aggregate.requests
@@ -797,6 +833,14 @@ window.__ModuleLoader__.load({
797
833
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
798
834
  className: "ad-num",
799
835
  children: formatTokens(row.aggregate.total)
836
+ }),
837
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
838
+ className: "ad-num",
839
+ children: formatSpeed(row.tokensPerSecond)
840
+ }),
841
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
842
+ className: "ad-num",
843
+ children: formatDuration(row.firstTokenMs)
800
844
  })
801
845
  ] }, row.key)), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", {
802
846
  className: "ad-total",
@@ -829,6 +873,14 @@ window.__ModuleLoader__.load({
829
873
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
830
874
  className: "ad-num",
831
875
  children: formatTokens(stats.total.total)
876
+ }),
877
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
878
+ className: "ad-num",
879
+ children: formatSpeed(stats.totalSpeed)
880
+ }),
881
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
882
+ className: "ad-num",
883
+ children: formatDuration(stats.totalFirstTokenMs)
832
884
  })
833
885
  ]
834
886
  })] })]
package/lib/http.d.ts CHANGED
@@ -59,6 +59,21 @@ export interface WebServerLike {
59
59
  handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
60
60
  }) => unknown;
61
61
  }
62
+ /**
63
+ * The URL path and body a probe uses for one protocol.
64
+ *
65
+ * ๐Ÿ”ด **The probe used to hardcode `/chat/completions`.** With the Responses API now selectable
66
+ * per channel (owner directive 2026-10-22: ใ€Œไผ˜ๅ…ˆๆ”ฏๆŒๅนถไฝฟ็”จ responses apiใ€), that would have
67
+ * probed the WRONG endpoint for every Responses channel and reported a verdict about a protocol
68
+ * the channel does not use. "A probe must send what a real request sends" already cost one round
69
+ * over headers; the rule applies unchanged to the path.
70
+ * @param channel - the channel being probed.
71
+ * @returns the path suffix and request body for its protocol.
72
+ */
73
+ export declare function probeRequest(channel: ResolvedChannel): {
74
+ path: string;
75
+ body: unknown;
76
+ };
62
77
  /**
63
78
  * Probe one channel with a minimal real request.
64
79
  *
@@ -83,6 +98,16 @@ export declare function probeChannel(channel: ResolvedChannel, key: string, sess
83
98
  message?: string;
84
99
  responded?: string;
85
100
  }>;
101
+ /**
102
+ * What a successful probe response says the relay called its model.
103
+ *
104
+ * Three protocols, three response shapes โ€” the old reader understood only completions
105
+ * (`choices[0].message.content`), so a Responses or Anthropic 200 came back with an empty verdict
106
+ * and the panel showed a blank success. A verdict nobody can read is not a verdict.
107
+ * @param text - the raw response body.
108
+ * @returns the reported model name, or the first output text; empty when neither is present.
109
+ */
110
+ export declare function reportedName(text: string): string;
86
111
  /**
87
112
  * Register the panel's routes.
88
113
  * @param ctx - plugin context (only used to bind the disposer when the host offers one).
package/lib/index.js CHANGED
@@ -5,6 +5,8 @@ import "@deepseek-ai/dsh-credentials";
5
5
  import { createHash } from "node:crypto";
6
6
  import { createModels, createProvider } from "@earendil-works/pi-ai";
7
7
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
8
+ import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
9
+ import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
8
10
  import { appendFile, mkdir, readFile, readdir } from "node:fs/promises";
9
11
  //#region src/config.ts
10
12
  /**
@@ -250,6 +252,22 @@ const OPENCODE_SESSION_FALLBACK = "dsh-any-deepseek";
250
252
  *
251
253
  * The id is what a channel entry stores (`preset: opencode-go`), so it is part of the user's
252
254
  * configuration surface: renaming one breaks saved channels. Add, do not rename.
255
+ *
256
+ * ## Why every preset prefers the Responses API
257
+ *
258
+ * Owner directive (2026-10-22): ใ€ŒๅฏนไบŽๅ„ๅฎถๆŸฅ้˜…่ต„ๆ–™๏ผŒไผ˜ๅ…ˆๆ”ฏๆŒๅนถไฝฟ็”จ responses api(่ฟ™ไธชapiๆ›ดๅ…ˆ่ฟ›)ใ€.
259
+ * Responses is the newer surface: it carries reasoning items as first-class output, reports
260
+ * `output_tokens_details.reasoning_tokens`, and supports server-side state/tool primitives the
261
+ * completions shape cannot express.
262
+ *
263
+ * ๐Ÿ”ด **But "prefer" is not "assume".** A relay that answers `/responses` with 404 would break
264
+ * every request if this were a blanket switch, so the rule is: a preset declares Responses ONLY
265
+ * where the endpoint is documented to serve it; everything else keeps completions, which is the
266
+ * lowest common denominator every relay speaks. `api` stays a per-channel field, so a user can
267
+ * override any of them in the panel without waiting for a release.
268
+ *
269
+ * DeepSeek's own docs confirm `/responses` with `deepseek-flash`, including `input_image` โ€” so
270
+ * the official preset is the first to move and is the reference case.
253
271
  */
254
272
  const CHANNEL_PRESETS = {
255
273
  "opencode-go": {
@@ -286,9 +304,9 @@ const CHANNEL_PRESETS = {
286
304
  },
287
305
  "deepseek-official": {
288
306
  displayName: "DeepSeek Official",
289
- api: "openai-completions",
307
+ api: "openai-responses",
290
308
  baseURL: "https://api.deepseek.com",
291
- model: "deepseek-v4.1-flash",
309
+ model: "deepseek-flash",
292
310
  ...V41_FLASH_CAPS
293
311
  }
294
312
  };
@@ -784,6 +802,38 @@ function buildPiModel(channel) {
784
802
  };
785
803
  }
786
804
  /**
805
+ * The transport implementation for one wire protocol.
806
+ *
807
+ * ๐Ÿ”ด **This table is the fix for a silent hole.** The provider was built with
808
+ * `api: openAICompletionsApi()` โ€” a SINGLE implementation. `createProvider` treats a single
809
+ * implementation as "serves every model" (`models.js:529-532`: `single = input.api &&
810
+ * typeof input.api.stream === 'function'`), so `model.api` was never consulted and every channel
811
+ * went out over the completions wire **no matter what its `api` field said**. A channel
812
+ * configured for `openai-responses` therefore produced completions requests: the setting was
813
+ * accepted, shown in the panel, and silently ignored. Same class as the other defects this
814
+ * plugin has paid for โ€” accepted, rendered, and not in effect.
815
+ *
816
+ * Passing a MAP instead makes `createProvider` dispatch on `model.api` (`models.js:555`
817
+ * `apiFor`), and a model naming an unimplemented protocol fails loudly with
818
+ * `has no API implementation for "<api>"` rather than silently using the wrong one.
819
+ */
820
+ const API_FACTORIES = {
821
+ "openai-completions": openAICompletionsApi,
822
+ "openai-responses": openAIResponsesApi,
823
+ "anthropic-messages": anthropicMessagesApi
824
+ };
825
+ /**
826
+ * Build the per-protocol implementation map for the pi-ai provider.
827
+ *
828
+ * Every supported protocol is constructed eagerly because the factories are lazy internally
829
+ * (they load the SDK on first use), so this costs one closure each, not one SDK download.
830
+ */
831
+ function apiImplementations() {
832
+ const table = {};
833
+ for (const [protocol, factory] of Object.entries(API_FACTORIES)) table[protocol] = factory();
834
+ return table;
835
+ }
836
+ /**
787
837
  * Collect the bytes for every image in a request.
788
838
  *
789
839
  * ๐Ÿ”ด A separate pass, because the conversion itself is synchronous and the store is not. This is
@@ -1272,6 +1322,20 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
1272
1322
  usage: null
1273
1323
  };
1274
1324
  let recorded = false;
1325
+ /**
1326
+ * Time to the first streamed block, in ms from request start.
1327
+ *
1328
+ * ๐Ÿ”ด Owner request (2026-10-22): ใ€Œ่ƒฝๅŠ ไธŠ token ้€Ÿๅบฆ็ปŸ่ฎกๅ—ใ€. Throughput cannot be recovered
1329
+ * from the totals alone โ€” `output / totalMs` folds in queueing and time-to-first-token, so a
1330
+ * relay with slow first-token but fast generation looks identical to a uniformly slow one.
1331
+ * Recording the split makes the two visible separately, and both numbers are already
1332
+ * measurable at this boundary: the first `block-start` is the moment generation began.
1333
+ *
1334
+ * Guards that matter: it is measured ONCE (first block only), and only for a call that got
1335
+ * that far โ€” an error before any block leaves it undefined rather than reporting 0, which
1336
+ * would read as "instant first token".
1337
+ */
1338
+ let firstTokenMs;
1275
1339
  const finish = async () => {
1276
1340
  if (recorded || channel === void 0) return;
1277
1341
  recorded = true;
@@ -1281,6 +1345,7 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
1281
1345
  model: channel.model,
1282
1346
  ...options.sessionId === void 0 ? {} : { sessionId: String(options.sessionId) },
1283
1347
  ...options.purpose === void 0 ? {} : { purpose: options.purpose },
1348
+ ...firstTokenMs === void 0 ? {} : { firstTokenMs },
1284
1349
  ok: outcome.ok,
1285
1350
  ms: Math.max(0, Math.round(this.deps.elapsed() - startedAt)),
1286
1351
  usage: outcome.usage === null ? null : {
@@ -1331,7 +1396,7 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
1331
1396
  resolve: async () => ({ auth: { apiKey: key } })
1332
1397
  } },
1333
1398
  models: [model],
1334
- api: openAICompletionsApi()
1399
+ api: apiImplementations()
1335
1400
  }));
1336
1401
  /**
1337
1402
  * Per-request headers.
@@ -1363,7 +1428,10 @@ var AnyDeepSeekAdapter = class extends LlmAdapter {
1363
1428
  const events = models.streamSimple(model, toPiContext(options, images), streamOptions);
1364
1429
  for await (const chunk of toStreamChunks(events, (observed) => {
1365
1430
  outcome = observed;
1366
- })) yield chunk;
1431
+ })) {
1432
+ if (firstTokenMs === void 0 && chunk.type === "block-start") firstTokenMs = Math.max(0, Math.round(this.deps.elapsed() - startedAt));
1433
+ yield chunk;
1434
+ }
1367
1435
  } catch (error) {
1368
1436
  const message = error instanceof Error ? error.message : String(error);
1369
1437
  outcome = {
@@ -1433,6 +1501,33 @@ function failureChunk(failure) {
1433
1501
  * exposes the sum explicitly, because a table that showed only `input` would read as if input
1434
1502
  * were being under-reported.
1435
1503
  */
1504
+ /**
1505
+ * Output tokens per second generated, or null when unmeasurable.
1506
+ *
1507
+ * ๐Ÿ”ด **Why not `output / total` (owner request 2026-10-22: ใ€Œ่ƒฝๅŠ ไธŠ token ้€Ÿๅบฆ็ปŸ่ฎกๅ—ใ€).**
1508
+ * One ratio collapses queueing and generation together, so a relay that thinks for 3s and then
1509
+ * streams fast looks identical to a uniformly slow one โ€” and those two call for different fixes
1510
+ * (switch relay vs. shrink the prompt). The split is measured per request and this divides only
1511
+ * by the generating half.
1512
+ *
1513
+ * Averaged as a RATIO OF SUMS rather than a mean of per-request speeds: a mean lets one
1514
+ * five-token reply dominate, while the sum ratio answers "across this group, how fast did tokens
1515
+ * actually come out".
1516
+ * @param aggregate - one group's figures.
1517
+ * @returns tokens per second, or null when nothing was timed or nothing was generated.
1518
+ */
1519
+ function outputTokensPerSecond(aggregate) {
1520
+ if (aggregate.generationMs <= 0 || aggregate.timedOutput <= 0) return null;
1521
+ return aggregate.timedOutput * 1e3 / aggregate.generationMs;
1522
+ }
1523
+ /**
1524
+ * Mean time to first token, in ms, or null when nothing was timed.
1525
+ * @param aggregate - one group's figures.
1526
+ */
1527
+ function meanFirstTokenMs(aggregate) {
1528
+ if (aggregate.timed <= 0) return null;
1529
+ return aggregate.ttftMs / aggregate.timed;
1530
+ }
1436
1531
  /** Empty aggregate, used both as an accumulator seed and as a zero row. */
1437
1532
  function emptyAggregate() {
1438
1533
  return {
@@ -1445,7 +1540,11 @@ function emptyAggregate() {
1445
1540
  cacheWrite: 0,
1446
1541
  reasoning: 0,
1447
1542
  billableInput: 0,
1448
- total: 0
1543
+ total: 0,
1544
+ timed: 0,
1545
+ ttftMs: 0,
1546
+ generationMs: 0,
1547
+ timedOutput: 0
1449
1548
  };
1450
1549
  }
1451
1550
  /** Fold one entry into an aggregate accumulator (mutates and returns it, for cheap loops). */
@@ -1464,8 +1563,29 @@ function addToAggregate(into, entry) {
1464
1563
  into.reasoning += usage.reasoning;
1465
1564
  into.billableInput += usage.input + usage.cacheRead + usage.cacheWrite;
1466
1565
  into.total += usage.input + usage.cacheRead + usage.cacheWrite + usage.output;
1566
+ recordSpeed(into, entry);
1467
1567
  return into;
1468
1568
  }
1569
+ /**
1570
+ * Fold one entry's speed measurement into the accumulators.
1571
+ *
1572
+ * Extraction is a bare subtraction, which can go negative when a relay reports a `ms` shorter
1573
+ * than its first token (clock granularity, or a resume counted differently). A negative
1574
+ * generation time would poison the ratio, so such an entry contributes to `timed`/`ttftMs` but
1575
+ * not to `generationMs`/`timedOutput` โ€” the honest reading is "we measured a start, not a rate".
1576
+ * @param into - the group accumulator.
1577
+ * @param entry - one finished request.
1578
+ */
1579
+ function recordSpeed(into, entry) {
1580
+ const firstTokenMs = entry.firstTokenMs;
1581
+ if (firstTokenMs === void 0) return;
1582
+ into.timed += 1;
1583
+ into.ttftMs += firstTokenMs;
1584
+ const generationMs = entry.ms - firstTokenMs;
1585
+ if (generationMs <= 0 || entry.usage === null) return;
1586
+ into.generationMs += generationMs;
1587
+ into.timedOutput += entry.usage.output;
1588
+ }
1469
1589
  /** `YYYY-MM` for a timestamp, in the timestamp's own offset. */
1470
1590
  function monthKey(at) {
1471
1591
  return `${at.getFullYear()}-${`${at.getMonth() + 1}`.padStart(2, "0")}`;
@@ -1641,6 +1761,10 @@ function totalOf(rows) {
1641
1761
  total.reasoning += row.aggregate.reasoning;
1642
1762
  total.billableInput += row.aggregate.billableInput;
1643
1763
  total.total += row.aggregate.total;
1764
+ total.timed += row.aggregate.timed;
1765
+ total.ttftMs += row.aggregate.ttftMs;
1766
+ total.generationMs += row.aggregate.generationMs;
1767
+ total.timedOutput += row.aggregate.timedOutput;
1644
1768
  }
1645
1769
  return total;
1646
1770
  }
@@ -1678,6 +1802,51 @@ function queryDate(value) {
1678
1802
  return Number.isNaN(parsed.getTime()) ? void 0 : parsed;
1679
1803
  }
1680
1804
  /**
1805
+ * The URL path and body a probe uses for one protocol.
1806
+ *
1807
+ * ๐Ÿ”ด **The probe used to hardcode `/chat/completions`.** With the Responses API now selectable
1808
+ * per channel (owner directive 2026-10-22: ใ€Œไผ˜ๅ…ˆๆ”ฏๆŒๅนถไฝฟ็”จ responses apiใ€), that would have
1809
+ * probed the WRONG endpoint for every Responses channel and reported a verdict about a protocol
1810
+ * the channel does not use. "A probe must send what a real request sends" already cost one round
1811
+ * over headers; the rule applies unchanged to the path.
1812
+ * @param channel - the channel being probed.
1813
+ * @returns the path suffix and request body for its protocol.
1814
+ */
1815
+ function probeRequest(channel) {
1816
+ if (channel.api === "openai-responses") return {
1817
+ path: "/responses",
1818
+ body: {
1819
+ model: channel.model,
1820
+ input: "ping",
1821
+ max_output_tokens: 16,
1822
+ stream: false
1823
+ }
1824
+ };
1825
+ if (channel.api === "anthropic-messages") return {
1826
+ path: "/messages",
1827
+ body: {
1828
+ model: channel.model,
1829
+ max_tokens: 16,
1830
+ messages: [{
1831
+ role: "user",
1832
+ content: "ping"
1833
+ }]
1834
+ }
1835
+ };
1836
+ return {
1837
+ path: "/chat/completions",
1838
+ body: {
1839
+ model: channel.model,
1840
+ messages: [{
1841
+ role: "user",
1842
+ content: "ping"
1843
+ }],
1844
+ max_tokens: 1,
1845
+ stream: false
1846
+ }
1847
+ };
1848
+ }
1849
+ /**
1681
1850
  * Probe one channel with a minimal real request.
1682
1851
  *
1683
1852
  * This spends tokens, which is why it is an explicit route rather than something the panel does
@@ -1696,25 +1865,21 @@ function queryDate(value) {
1696
1865
  */
1697
1866
  async function probeChannel(channel, key, sessionId, signal) {
1698
1867
  const started = Date.now();
1699
- const url = `${channel.baseURL.replace(/\/+$/, "")}/chat/completions`;
1868
+ const request = probeRequest(channel);
1869
+ const url = `${channel.baseURL.replace(/\/+$/, "")}${request.path}`;
1700
1870
  try {
1701
1871
  const response = await fetch(url, {
1702
1872
  method: "POST",
1703
1873
  headers: {
1704
1874
  "content-type": "application/json",
1705
- authorization: `Bearer ${key}`,
1875
+ ...channel.api === "anthropic-messages" ? {
1876
+ "x-api-key": key,
1877
+ "anthropic-version": "2023-06-01"
1878
+ } : { authorization: `Bearer ${key}` },
1706
1879
  ...channel.headers,
1707
1880
  ...requestHeadersFor(channel, sessionId)
1708
1881
  },
1709
- body: JSON.stringify({
1710
- model: channel.model,
1711
- messages: [{
1712
- role: "user",
1713
- content: "ping"
1714
- }],
1715
- max_tokens: 1,
1716
- stream: false
1717
- }),
1882
+ body: JSON.stringify(request.body),
1718
1883
  ...signal === void 0 ? {} : { signal }
1719
1884
  });
1720
1885
  const ms = Date.now() - started;
@@ -1731,17 +1896,10 @@ async function probeChannel(channel, key, sessionId, signal) {
1731
1896
  code: "SERVER",
1732
1897
  message: text.slice(0, 400)
1733
1898
  };
1734
- let responded = "";
1735
- try {
1736
- const parsed = JSON.parse(text);
1737
- responded = typeof parsed.model === "string" ? parsed.model : typeof parsed.choices?.[0]?.message?.content === "string" ? parsed.choices[0].message.content : "";
1738
- } catch {
1739
- responded = "";
1740
- }
1741
1899
  return {
1742
1900
  ok: true,
1743
1901
  ms,
1744
- responded
1902
+ responded: reportedName(text)
1745
1903
  };
1746
1904
  } catch (error) {
1747
1905
  return {
@@ -1752,6 +1910,42 @@ async function probeChannel(channel, key, sessionId, signal) {
1752
1910
  };
1753
1911
  }
1754
1912
  }
1913
+ /**
1914
+ * What a successful probe response says the relay called its model.
1915
+ *
1916
+ * Three protocols, three response shapes โ€” the old reader understood only completions
1917
+ * (`choices[0].message.content`), so a Responses or Anthropic 200 came back with an empty verdict
1918
+ * and the panel showed a blank success. A verdict nobody can read is not a verdict.
1919
+ * @param text - the raw response body.
1920
+ * @returns the reported model name, or the first output text; empty when neither is present.
1921
+ */
1922
+ function reportedName(text) {
1923
+ let parsed;
1924
+ try {
1925
+ parsed = JSON.parse(text);
1926
+ } catch {
1927
+ return "";
1928
+ }
1929
+ const asString = (value) => typeof value === "string" ? value : "";
1930
+ const model = asString(parsed.model);
1931
+ if (model !== "") return model;
1932
+ const output = parsed.output;
1933
+ if (Array.isArray(output)) for (const item of output) {
1934
+ const content = item.content;
1935
+ if (!Array.isArray(content)) continue;
1936
+ for (const part of content) {
1937
+ const value = asString(part.text);
1938
+ if (value !== "") return value;
1939
+ }
1940
+ }
1941
+ const choices = parsed.choices;
1942
+ if (Array.isArray(choices)) {
1943
+ const first = choices[0];
1944
+ const value = asString(first?.message?.content);
1945
+ if (value !== "") return value;
1946
+ }
1947
+ return "";
1948
+ }
1755
1949
  /** Map an HTTP status from a probe onto the same codes the runtime uses. */
1756
1950
  function probeCode(status, body) {
1757
1951
  if (status === 401 || status === 403) return "AUTH";
@@ -1820,15 +2014,44 @@ function registerStatsRoutes(ctx, webServer, deps) {
1820
2014
  const group = url.searchParams.get("group") ?? "channel";
1821
2015
  const entries = await readLedgerRange(deps.ledgerDir(), from, to);
1822
2016
  const rows = group === "day" ? summarize(entries, (entry) => dayKey(new Date(entry.at))) : summarize(entries, (entry) => entry.channel);
2017
+ /**
2018
+ * ๐Ÿ”ด Label each row with the channel's DISPLAY NAME, not its id.
2019
+ *
2020
+ * Owner report (2026-10-22): ใ€Œ็ปŸ่ฎก่ฆๆŒ‰ๆ˜พ็คบๅ็งฐๆฅใ€. The ledger keys rows by the channel
2021
+ * id because that is the channel's identity โ€” it must not change when a user renames a
2022
+ * channel (decision 37: renaming touches `label`, never `id`). But the id is an
2023
+ * implementation handle, and showing it makes the table disagree with the list above it,
2024
+ * where the same channel appears under its display name.
2025
+ *
2026
+ * Resolution is done here, at read time, from the LIVE channel list โ€” so a rename is
2027
+ * reflected immediately in historical rows too, with no ledger rewrite. A row whose
2028
+ * channel no longer exists keeps its raw id: that usage is real and must stay visible,
2029
+ * and inventing a name for a channel that is gone would be worse than the id.
2030
+ */
2031
+ const labels = new Map(deps.channels().map((channel) => [channel.id, channel.label]));
2032
+ const named = rows.map((row) => ({
2033
+ ...row,
2034
+ label: group === "day" ? row.key : labels.get(row.key) ?? row.key,
2035
+ tokensPerSecond: outputTokensPerSecond(row.aggregate),
2036
+ firstTokenMs: meanFirstTokenMs(row.aggregate)
2037
+ }));
2038
+ const totalAggregate = totalOf(rows);
1823
2039
  json(res, 200, {
1824
2040
  range: {
1825
2041
  from: from?.toISOString() ?? null,
1826
2042
  to: to?.toISOString() ?? null,
1827
2043
  group
1828
2044
  },
1829
- rows,
1830
- total: totalOf(rows),
1831
- note: "input is uncached input only; billable input = input + cacheRead + cacheWrite"
2045
+ rows: named,
2046
+ total: totalAggregate,
2047
+ /**
2048
+ * The footer's speed figures, computed from the TOTAL aggregate rather than by
2049
+ * averaging the row values: a ratio of sums is the only one where a quiet channel
2050
+ * cannot outweigh a chatty one. Same functions, one definition.
2051
+ */
2052
+ totalSpeed: outputTokensPerSecond(totalAggregate),
2053
+ totalFirstTokenMs: meanFirstTokenMs(totalAggregate),
2054
+ note: "input is uncached input only; billable input = input + cacheRead + cacheWrite; tokensPerSecond counts generation time only (total minus time-to-first-token)"
1832
2055
  });
1833
2056
  }
1834
2057
  },
package/lib/presets.d.ts CHANGED
@@ -145,6 +145,22 @@ export declare const OPENCODE_SESSION_FALLBACK = "dsh-any-deepseek";
145
145
  *
146
146
  * The id is what a channel entry stores (`preset: opencode-go`), so it is part of the user's
147
147
  * configuration surface: renaming one breaks saved channels. Add, do not rename.
148
+ *
149
+ * ## Why every preset prefers the Responses API
150
+ *
151
+ * Owner directive (2026-10-22): ใ€ŒๅฏนไบŽๅ„ๅฎถๆŸฅ้˜…่ต„ๆ–™๏ผŒไผ˜ๅ…ˆๆ”ฏๆŒๅนถไฝฟ็”จ responses api(่ฟ™ไธชapiๆ›ดๅ…ˆ่ฟ›)ใ€.
152
+ * Responses is the newer surface: it carries reasoning items as first-class output, reports
153
+ * `output_tokens_details.reasoning_tokens`, and supports server-side state/tool primitives the
154
+ * completions shape cannot express.
155
+ *
156
+ * ๐Ÿ”ด **But "prefer" is not "assume".** A relay that answers `/responses` with 404 would break
157
+ * every request if this were a blanket switch, so the rule is: a preset declares Responses ONLY
158
+ * where the endpoint is documented to serve it; everything else keeps completions, which is the
159
+ * lowest common denominator every relay speaks. `api` stays a per-channel field, so a user can
160
+ * override any of them in the panel without waiting for a release.
161
+ *
162
+ * DeepSeek's own docs confirm `/responses` with `deepseek-flash`, including `input_image` โ€” so
163
+ * the official preset is the first to move and is the reference case.
148
164
  */
149
165
  export declare const CHANNEL_PRESETS: Readonly<Record<string, ChannelPreset>>;
150
166
  /** Every preset id, in table order โ€” the panel's picker source. */
@@ -71,7 +71,42 @@ export interface LedgerAggregate {
71
71
  billableInput: number;
72
72
  /** `billableInput + output`. */
73
73
  total: number;
74
+ /**
75
+ * Requests carrying a first-token measurement โ€” the denominator for both speed figures.
76
+ *
77
+ * Kept separate from `requests` on purpose: not every request has one (an error before the
78
+ * first block has no first token, and ledger lines written before this field existed do not
79
+ * either). Dividing by `requests` would silently understate every average.
80
+ */
81
+ timed: number;
82
+ /** Sum of observed time-to-first-token, over `timed` requests only. */
83
+ ttftMs: number;
84
+ /** Sum of generation time (`ms - firstTokenMs`), over `timed` requests only. */
85
+ generationMs: number;
86
+ /** Sum of `output` over `timed` requests โ€” the numerator for generation throughput. */
87
+ timedOutput: number;
74
88
  }
89
+ /**
90
+ * Output tokens per second generated, or null when unmeasurable.
91
+ *
92
+ * ๐Ÿ”ด **Why not `output / total` (owner request 2026-10-22: ใ€Œ่ƒฝๅŠ ไธŠ token ้€Ÿๅบฆ็ปŸ่ฎกๅ—ใ€).**
93
+ * One ratio collapses queueing and generation together, so a relay that thinks for 3s and then
94
+ * streams fast looks identical to a uniformly slow one โ€” and those two call for different fixes
95
+ * (switch relay vs. shrink the prompt). The split is measured per request and this divides only
96
+ * by the generating half.
97
+ *
98
+ * Averaged as a RATIO OF SUMS rather than a mean of per-request speeds: a mean lets one
99
+ * five-token reply dominate, while the sum ratio answers "across this group, how fast did tokens
100
+ * actually come out".
101
+ * @param aggregate - one group's figures.
102
+ * @returns tokens per second, or null when nothing was timed or nothing was generated.
103
+ */
104
+ export declare function outputTokensPerSecond(aggregate: LedgerAggregate): number | null;
105
+ /**
106
+ * Mean time to first token, in ms, or null when nothing was timed.
107
+ * @param aggregate - one group's figures.
108
+ */
109
+ export declare function meanFirstTokenMs(aggregate: LedgerAggregate): number | null;
75
110
  /** Aggregation result for one grouping. */
76
111
  export interface LedgerSummary {
77
112
  /** Group key (channel id, or `YYYY-MM-DD`). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-any-deepseek",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
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": {