dsh-any-deepseek 0.0.10 โ 0.0.12
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 +10 -0
- package/lib/client.js +52 -0
- package/lib/http.d.ts +34 -0
- package/lib/index.js +252 -28
- package/lib/presets.d.ts +16 -0
- package/lib/usage-ledger.d.ts +35 -0
- package/package.json +1 -1
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,6 +793,16 @@ 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
808
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: row.label ?? row.key }),
|
|
@@ -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,25 @@ export declare function probeChannel(channel: ResolvedChannel, key: string, sess
|
|
|
83
98
|
message?: string;
|
|
84
99
|
responded?: string;
|
|
85
100
|
}>;
|
|
101
|
+
/**
|
|
102
|
+
* The body's `error` value, when it is actually an error.
|
|
103
|
+
*
|
|
104
|
+
* Returns `undefined` for a missing field AND for an explicit `null` โ both mean "no error".
|
|
105
|
+
* A non-null value (object or string) is returned as JSON text for the panel to show.
|
|
106
|
+
* @param text - the raw response body.
|
|
107
|
+
* @returns the error as text, or undefined when the body reports no error.
|
|
108
|
+
*/
|
|
109
|
+
export declare function errorFieldValue(text: string): string | undefined;
|
|
110
|
+
/**
|
|
111
|
+
* What a successful probe response says the relay called its model.
|
|
112
|
+
*
|
|
113
|
+
* Three protocols, three response shapes โ the old reader understood only completions
|
|
114
|
+
* (`choices[0].message.content`), so a Responses or Anthropic 200 came back with an empty verdict
|
|
115
|
+
* and the panel showed a blank success. A verdict nobody can read is not a verdict.
|
|
116
|
+
* @param text - the raw response body.
|
|
117
|
+
* @returns the reported model name, or the first output text; empty when neither is present.
|
|
118
|
+
*/
|
|
119
|
+
export declare function reportedName(text: string): string;
|
|
86
120
|
/**
|
|
87
121
|
* Register the panel's routes.
|
|
88
122
|
* @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,7 +304,7 @@ const CHANNEL_PRESETS = {
|
|
|
286
304
|
},
|
|
287
305
|
"deepseek-official": {
|
|
288
306
|
displayName: "DeepSeek Official",
|
|
289
|
-
api: "openai-
|
|
307
|
+
api: "openai-responses",
|
|
290
308
|
baseURL: "https://api.deepseek.com",
|
|
291
309
|
model: "deepseek-flash",
|
|
292
310
|
...V41_FLASH_CAPS
|
|
@@ -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:
|
|
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
|
-
}))
|
|
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: 256,
|
|
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
|
|
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
|
-
|
|
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;
|
|
@@ -1725,23 +1890,17 @@ async function probeChannel(channel, key, sessionId, signal) {
|
|
|
1725
1890
|
code: probeCode(response.status, text),
|
|
1726
1891
|
message: text.slice(0, 400)
|
|
1727
1892
|
};
|
|
1728
|
-
|
|
1893
|
+
const bodyError = errorFieldValue(text);
|
|
1894
|
+
if (bodyError !== void 0) return {
|
|
1729
1895
|
ok: false,
|
|
1730
1896
|
ms,
|
|
1731
1897
|
code: "SERVER",
|
|
1732
|
-
message:
|
|
1898
|
+
message: bodyError.slice(0, 400)
|
|
1733
1899
|
};
|
|
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
1900
|
return {
|
|
1742
1901
|
ok: true,
|
|
1743
1902
|
ms,
|
|
1744
|
-
responded
|
|
1903
|
+
responded: reportedName(text)
|
|
1745
1904
|
};
|
|
1746
1905
|
} catch (error) {
|
|
1747
1906
|
return {
|
|
@@ -1752,6 +1911,61 @@ async function probeChannel(channel, key, sessionId, signal) {
|
|
|
1752
1911
|
};
|
|
1753
1912
|
}
|
|
1754
1913
|
}
|
|
1914
|
+
/**
|
|
1915
|
+
* The body's `error` value, when it is actually an error.
|
|
1916
|
+
*
|
|
1917
|
+
* Returns `undefined` for a missing field AND for an explicit `null` โ both mean "no error".
|
|
1918
|
+
* A non-null value (object or string) is returned as JSON text for the panel to show.
|
|
1919
|
+
* @param text - the raw response body.
|
|
1920
|
+
* @returns the error as text, or undefined when the body reports no error.
|
|
1921
|
+
*/
|
|
1922
|
+
function errorFieldValue(text) {
|
|
1923
|
+
let parsed;
|
|
1924
|
+
try {
|
|
1925
|
+
parsed = JSON.parse(text);
|
|
1926
|
+
} catch {
|
|
1927
|
+
return text.includes("\"error\"") ? text : void 0;
|
|
1928
|
+
}
|
|
1929
|
+
const candidate = parsed?.error;
|
|
1930
|
+
if (candidate === void 0 || candidate === null) return void 0;
|
|
1931
|
+
return typeof candidate === "string" ? candidate : JSON.stringify(candidate);
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* What a successful probe response says the relay called its model.
|
|
1935
|
+
*
|
|
1936
|
+
* Three protocols, three response shapes โ the old reader understood only completions
|
|
1937
|
+
* (`choices[0].message.content`), so a Responses or Anthropic 200 came back with an empty verdict
|
|
1938
|
+
* and the panel showed a blank success. A verdict nobody can read is not a verdict.
|
|
1939
|
+
* @param text - the raw response body.
|
|
1940
|
+
* @returns the reported model name, or the first output text; empty when neither is present.
|
|
1941
|
+
*/
|
|
1942
|
+
function reportedName(text) {
|
|
1943
|
+
let parsed;
|
|
1944
|
+
try {
|
|
1945
|
+
parsed = JSON.parse(text);
|
|
1946
|
+
} catch {
|
|
1947
|
+
return "";
|
|
1948
|
+
}
|
|
1949
|
+
const asString = (value) => typeof value === "string" ? value : "";
|
|
1950
|
+
const model = asString(parsed.model);
|
|
1951
|
+
if (model !== "") return model;
|
|
1952
|
+
const output = parsed.output;
|
|
1953
|
+
if (Array.isArray(output)) for (const item of output) {
|
|
1954
|
+
const content = item.content;
|
|
1955
|
+
if (!Array.isArray(content)) continue;
|
|
1956
|
+
for (const part of content) {
|
|
1957
|
+
const value = asString(part.text);
|
|
1958
|
+
if (value !== "") return value;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
const choices = parsed.choices;
|
|
1962
|
+
if (Array.isArray(choices)) {
|
|
1963
|
+
const first = choices[0];
|
|
1964
|
+
const value = asString(first?.message?.content);
|
|
1965
|
+
if (value !== "") return value;
|
|
1966
|
+
}
|
|
1967
|
+
return "";
|
|
1968
|
+
}
|
|
1755
1969
|
/** Map an HTTP status from a probe onto the same codes the runtime uses. */
|
|
1756
1970
|
function probeCode(status, body) {
|
|
1757
1971
|
if (status === 401 || status === 403) return "AUTH";
|
|
@@ -1837,8 +2051,11 @@ function registerStatsRoutes(ctx, webServer, deps) {
|
|
|
1837
2051
|
const labels = new Map(deps.channels().map((channel) => [channel.id, channel.label]));
|
|
1838
2052
|
const named = rows.map((row) => ({
|
|
1839
2053
|
...row,
|
|
1840
|
-
label: group === "day" ? row.key : labels.get(row.key) ?? row.key
|
|
2054
|
+
label: group === "day" ? row.key : labels.get(row.key) ?? row.key,
|
|
2055
|
+
tokensPerSecond: outputTokensPerSecond(row.aggregate),
|
|
2056
|
+
firstTokenMs: meanFirstTokenMs(row.aggregate)
|
|
1841
2057
|
}));
|
|
2058
|
+
const totalAggregate = totalOf(rows);
|
|
1842
2059
|
json(res, 200, {
|
|
1843
2060
|
range: {
|
|
1844
2061
|
from: from?.toISOString() ?? null,
|
|
@@ -1846,8 +2063,15 @@ function registerStatsRoutes(ctx, webServer, deps) {
|
|
|
1846
2063
|
group
|
|
1847
2064
|
},
|
|
1848
2065
|
rows: named,
|
|
1849
|
-
total:
|
|
1850
|
-
|
|
2066
|
+
total: totalAggregate,
|
|
2067
|
+
/**
|
|
2068
|
+
* The footer's speed figures, computed from the TOTAL aggregate rather than by
|
|
2069
|
+
* averaging the row values: a ratio of sums is the only one where a quiet channel
|
|
2070
|
+
* cannot outweigh a chatty one. Same functions, one definition.
|
|
2071
|
+
*/
|
|
2072
|
+
totalSpeed: outputTokensPerSecond(totalAggregate),
|
|
2073
|
+
totalFirstTokenMs: meanFirstTokenMs(totalAggregate),
|
|
2074
|
+
note: "input is uncached input only; billable input = input + cacheRead + cacheWrite; tokensPerSecond counts generation time only (total minus time-to-first-token)"
|
|
1851
2075
|
});
|
|
1852
2076
|
}
|
|
1853
2077
|
},
|
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. */
|
package/lib/usage-ledger.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.0.12",
|
|
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": {
|