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 +10 -0
- package/lib/client.js +53 -1
- package/lib/http.d.ts +25 -0
- package/lib/index.js +250 -27
- 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,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-
|
|
307
|
+
api: "openai-responses",
|
|
290
308
|
baseURL: "https://api.deepseek.com",
|
|
291
|
-
model: "deepseek-
|
|
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:
|
|
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: 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
|
|
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;
|
|
@@ -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:
|
|
1831
|
-
|
|
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. */
|
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.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": {
|