dsh-session-fee 0.0.0-stage → 0.4.3
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/LICENSE +21 -0
- package/PRICES.md +79 -0
- package/README.md +205 -2
- package/client.js +379 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +9 -0
- package/index.js +87 -0
- package/lib/catalog.js +228 -0
- package/lib/config.js +68 -0
- package/lib/pricing.js +405 -0
- package/lib/projection.js +295 -0
- package/lib/reload.js +68 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +64 -4
- package/prices.example.json +71 -0
- package/prices.json +213 -0
package/client.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-session-cost",
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require("react");
|
|
5
|
+
const ReactDOM = require("react-dom");
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
const NS = "session-cost";
|
|
8
|
+
const CURRENCY_SYMBOLS = {
|
|
9
|
+
CNY: "¥",
|
|
10
|
+
USD: "$",
|
|
11
|
+
EUR: "€",
|
|
12
|
+
GBP: "£",
|
|
13
|
+
JPY: "¥",
|
|
14
|
+
KRW: "₩",
|
|
15
|
+
HKD: "HK$",
|
|
16
|
+
TWD: "NT$"
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const zh = {
|
|
20
|
+
"cost.aria": "Tokens 成本(按市价):{cost},点击查看明细",
|
|
21
|
+
"cost.title": "Tokens 成本(按市价)",
|
|
22
|
+
"cost.own": "主会话",
|
|
23
|
+
"cost.route": "{route}({names})",
|
|
24
|
+
"cost.sep": "、",
|
|
25
|
+
"cost.unknownRoute": "未知模型",
|
|
26
|
+
"cost.unknown": "{count} 次未定价,需在 prices.json 为 {route} 配价格",
|
|
27
|
+
"cost.subagents.pill": "({amounts})",
|
|
28
|
+
"cost.loading": "读取中",
|
|
29
|
+
"cost.unavailable": "无数据",
|
|
30
|
+
"cost.none": "未调用模型"
|
|
31
|
+
};
|
|
32
|
+
const en = {
|
|
33
|
+
"cost.aria": "Token cost (market price): {cost}. Click for details",
|
|
34
|
+
"cost.title": "Token cost (market price)",
|
|
35
|
+
"cost.own": "Main session",
|
|
36
|
+
"cost.route": "{route} ({names})",
|
|
37
|
+
"cost.sep": ", ",
|
|
38
|
+
"cost.unknownRoute": "Unknown model",
|
|
39
|
+
"cost.unknown": "{count} unpriced: add {route} to prices.json",
|
|
40
|
+
"cost.subagents.pill": "({amounts})",
|
|
41
|
+
"cost.loading": "loading",
|
|
42
|
+
"cost.unavailable": "no data",
|
|
43
|
+
"cost.none": "no model calls"
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const css = ".dshsc_anchor{min-width:0;font-size:calc(var(--dsh-content-font-size-secondary,13px) - 1px);line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px));display:inline-flex}.dshsc_pill{box-sizing:border-box;border-radius:var(--dsw-radius-sm);max-width:min(460px,42vw);color:var(--dsw-alias-label-tertiary);font:inherit;font-variant-numeric:tabular-nums;line-height:inherit;white-space:nowrap;background:0 0;border:none;cursor:pointer;align-items:center;gap:6px;padding:1px 8px;display:inline-flex}.dshsc_pill:hover,.dshsc_pill[aria-expanded=true]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}.dshsc_pill svg{flex:none;width:14px;height:14px}.dshsc_label{text-overflow:ellipsis;min-width:0;overflow:hidden}[data-composer-dock]>span{order:1}[data-composer-dock] [data-composer-stat=cost]{order:2}.dshsc_panel{z-index:1100;box-sizing:border-box;border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu);width:max-content;min-width:min(300px,100vw - 24px);max-width:min(440px,100vw - 24px);max-height:min(70vh,560px);overflow:auto;backdrop-filter:var(--dsw-menu-backdrop-filter);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-secondary);cursor:default;border:0;padding:16px;font-size:12px;line-height:18px;position:fixed}.dshsc_title{color:var(--dsw-alias-label-primary);justify-content:space-between;gap:16px;margin-bottom:8px;font-weight:500;display:flex}.dshsc_titleLabel{align-items:center;gap:6px;min-width:0;display:inline-flex}.dshsc_titleLabel svg{flex:none;width:14px;height:14px}.dshsc_titleValue{font-variant-numeric:tabular-nums;text-align:right}.dshsc_rule{border-top:.5px solid var(--dsw-alias-border-l2);margin:10px 0}.dshsc_rule:first-child{margin-top:0}.dshsc_details{color:var(--dsw-alias-label-tertiary);grid-template-columns:minmax(76px,auto) minmax(0,1fr);gap:6px 16px;margin:0;display:grid}.dshsc_details dt,.dshsc_details dd{min-width:0;margin:0}.dshsc_details dt{overflow-wrap:anywhere}.dshsc_details dd{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.dshsc_meta{grid-column:1/-1;overflow-wrap:anywhere;margin-top:-4px;color:var(--dsw-alias-label-tertiary);opacity:.85}.dshsc_details .dshsc_route{margin-top:-4px;color:var(--dsw-alias-label-tertiary);opacity:.85}.dshsc_details .dshsc_spaced{margin-top:6px}.dshsc_strong{color:var(--dsw-alias-label-secondary)}";
|
|
47
|
+
|
|
48
|
+
function decimal(amount) {
|
|
49
|
+
const maximumFractionDigits = amount > 0 && amount < 0.01 ? 6 : 4;
|
|
50
|
+
return amount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function money(currency, amount) {
|
|
54
|
+
return `${CURRENCY_SYMBOLS[currency] ?? `${currency} `}${decimal(amount)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function routeName(segment, t) {
|
|
58
|
+
if (segment.provider === "" && segment.model === "") return t("cost.unknownRoute");
|
|
59
|
+
return `${segment.provider}/${segment.model}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Add one cost view's amounts into a currency → amount map (insertion order kept). */
|
|
63
|
+
function addAmounts(sums, value) {
|
|
64
|
+
for (const segment of value?.segments ?? []) {
|
|
65
|
+
for (const entry of segment.amounts) sums.set(entry.currency, (sums.get(entry.currency) ?? 0) + entry.amount);
|
|
66
|
+
}
|
|
67
|
+
return sums;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sumsText = (sums) => [...sums].map(([currency, amount]) => money(currency, amount)).join(" + ");
|
|
71
|
+
const hasUnknown = (value) => (value?.segments ?? []).some((segment) => segment.unknownCalls > 0);
|
|
72
|
+
/** Amounts of a cost view per currency, plus "--" when some calls are unpriced. */
|
|
73
|
+
const viewText = (value) => [sumsText(addAmounts(new Map(), value)), hasUnknown(value) ? "--" : ""].filter((part) => part !== "").join(" + ");
|
|
74
|
+
|
|
75
|
+
/** "provider/model(price names)" for one model run. */
|
|
76
|
+
function routeLabel(segment, t) {
|
|
77
|
+
const route = routeName(segment, t);
|
|
78
|
+
return segment.rules.length === 0 ? route : t("cost.route", { route, names: segment.rules.join(t("cost.sep")) });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Own segments in call order: one amount per model run, "--" where a run has unpriced calls. */
|
|
82
|
+
function ownLabel(value) {
|
|
83
|
+
const parts = [];
|
|
84
|
+
for (const segment of value?.segments ?? []) {
|
|
85
|
+
for (const amount of segment.amounts) parts.push(money(amount.currency, amount.amount));
|
|
86
|
+
if (segment.unknownCalls > 0) parts.push("--");
|
|
87
|
+
}
|
|
88
|
+
return parts.join(" + ");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Walk the subagent tree below `rootId` (depth-first, catalog order) using the
|
|
93
|
+
* client's per-session projection snapshots. External children are leaves
|
|
94
|
+
* without a local log, so they are skipped.
|
|
95
|
+
*/
|
|
96
|
+
function subagentTree(rootId, projectionsBySession) {
|
|
97
|
+
const nodes = [];
|
|
98
|
+
const seen = new Set([rootId]);
|
|
99
|
+
const visit = (parentId, depth, top) => {
|
|
100
|
+
const entries = projectionsBySession[parentId]?.values?.subagentCatalog;
|
|
101
|
+
if (!Array.isArray(entries)) return;
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (entry === null || typeof entry !== "object" || typeof entry.id !== "string" || entry.mode === "external" || seen.has(entry.id)) continue;
|
|
104
|
+
seen.add(entry.id);
|
|
105
|
+
const snapshot = projectionsBySession[entry.id];
|
|
106
|
+
const node = {
|
|
107
|
+
id: entry.id,
|
|
108
|
+
label: typeof entry.label === "string" && entry.label !== "" ? entry.label : entry.id,
|
|
109
|
+
depth,
|
|
110
|
+
createdAt: typeof entry.createdAt === "number" ? entry.createdAt : void 0,
|
|
111
|
+
top: top ?? entry.id,
|
|
112
|
+
state: snapshot?.state,
|
|
113
|
+
cost: snapshot?.values?.sessionCost
|
|
114
|
+
};
|
|
115
|
+
nodes.push(node);
|
|
116
|
+
visit(entry.id, depth + 1, node.top);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
visit(rootId, 1, void 0);
|
|
120
|
+
const sums = new Map();
|
|
121
|
+
// One group per direct child (its whole subtree), placed by the child's spawn time.
|
|
122
|
+
const groups = new Map();
|
|
123
|
+
let unknown = false;
|
|
124
|
+
let pending = 0;
|
|
125
|
+
for (const node of nodes) {
|
|
126
|
+
if (!groups.has(node.top)) groups.set(node.top, { createdAt: node.createdAt, sums: new Map() });
|
|
127
|
+
if (node.cost === void 0) {
|
|
128
|
+
pending += 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
addAmounts(sums, node.cost);
|
|
132
|
+
addAmounts(groups.get(node.top).sums, node.cost);
|
|
133
|
+
if (hasUnknown(node.cost)) unknown = true;
|
|
134
|
+
}
|
|
135
|
+
return { nodes, sums, groups: [...groups.values()], unknown, pending };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The own segment a subagent group joins: the run active at spawn time, else the nearest run in that currency. */
|
|
139
|
+
function targetSegment(runs, createdAt, currency) {
|
|
140
|
+
if (runs.length === 0) return null;
|
|
141
|
+
let active = 0;
|
|
142
|
+
if (createdAt !== void 0) runs.forEach((run, index) => {
|
|
143
|
+
if (run.firstTime <= createdAt) active = index;
|
|
144
|
+
});
|
|
145
|
+
for (let distance = 0; distance < runs.length; distance += 1) {
|
|
146
|
+
for (const index of distance === 0 ? [active] : [active - distance, active + distance]) {
|
|
147
|
+
if (runs[index]?.sums.has(currency)) return runs[index];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Pill text. Outside the parentheses is the whole spend, one amount per own
|
|
155
|
+
* model run joined by " + " (a subagent's cost is added to the run it was
|
|
156
|
+
* spawned in); inside is the part of it spent by subagents, e.g.
|
|
157
|
+
* "¥137.04 (¥30.62)" = ¥137.04 in total, of which ¥30.62 by subagents.
|
|
158
|
+
* Inside the parentheses " + " only separates currencies.
|
|
159
|
+
*/
|
|
160
|
+
function pillText(own, tree, t) {
|
|
161
|
+
if (tree.nodes.length === 0) return ownLabel(own);
|
|
162
|
+
const runs = (own?.segments ?? []).map((segment) => ({
|
|
163
|
+
firstTime: segment.firstTime,
|
|
164
|
+
sums: addAmounts(new Map(), { segments: [segment] }),
|
|
165
|
+
unknown: segment.unknownCalls > 0
|
|
166
|
+
}));
|
|
167
|
+
const extra = new Map();
|
|
168
|
+
for (const group of tree.groups) {
|
|
169
|
+
for (const [currency, amount] of group.sums) {
|
|
170
|
+
const target = targetSegment(runs, group.createdAt, currency);
|
|
171
|
+
const sums = target === null ? extra : target.sums;
|
|
172
|
+
sums.set(currency, (sums.get(currency) ?? 0) + amount);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const outside = [];
|
|
176
|
+
for (const run of runs) {
|
|
177
|
+
for (const [currency, amount] of run.sums) outside.push(money(currency, amount));
|
|
178
|
+
if (run.unknown) outside.push("--");
|
|
179
|
+
}
|
|
180
|
+
for (const [currency, amount] of extra) outside.push(money(currency, amount));
|
|
181
|
+
const inside = [...tree.sums].map(([currency, amount]) => money(currency, amount));
|
|
182
|
+
if (tree.unknown) inside.push("--");
|
|
183
|
+
if (inside.length === 0 && tree.pending > 0) inside.push("…");
|
|
184
|
+
if (inside.length === 0) return outside.join(" + ");
|
|
185
|
+
return [outside.join(" + "), t("cost.subagents.pill", { amounts: inside.join(" + ") })].filter((part) => part !== "").join(" ");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Rows for the details panel: the main session, then each subagent
|
|
190
|
+
* (indented by depth). An entry is its name and amount, then its model
|
|
191
|
+
* run as "provider/model(price names)"; runs show their own amounts only
|
|
192
|
+
* when the entry switched models. Kinds: entry, route, meta, rule.
|
|
193
|
+
*/
|
|
194
|
+
function dialogRows(own, tree, t) {
|
|
195
|
+
const rows = [];
|
|
196
|
+
const entry = (label, value, indent, spaced) => {
|
|
197
|
+
const segments = value?.segments ?? [];
|
|
198
|
+
rows.push({ kind: "entry", label: indent + label, value: segments.length === 0 ? t("cost.none") : viewText(value), spaced });
|
|
199
|
+
for (const segment of segments) {
|
|
200
|
+
rows.push({ kind: "route", label: indent + routeLabel(segment, t), value: segments.length > 1 ? viewText({ segments: [segment] }) : "" });
|
|
201
|
+
if (segment.unknownCalls > 0) rows.push({ kind: "meta", label: indent + t("cost.unknown", { count: segment.unknownCalls, route: routeName(segment, t) }) });
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
entry(t("cost.own"), own, "", false);
|
|
205
|
+
if (tree.nodes.length > 0) rows.push({ kind: "rule" });
|
|
206
|
+
tree.nodes.forEach((node, index) => {
|
|
207
|
+
const indent = "\u2003".repeat(node.depth - 1);
|
|
208
|
+
if (node.cost !== void 0) return entry(node.label, node.cost, indent, index > 0);
|
|
209
|
+
const loading = node.state === "loading" || node.state === "idle" || node.state === void 0;
|
|
210
|
+
rows.push({ kind: "entry", label: indent + node.label, value: t(loading ? "cost.loading" : "cost.unavailable"), spaced: index > 0 });
|
|
211
|
+
});
|
|
212
|
+
return rows;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Panel title value: everything spent, per currency (own runs plus subagents). */
|
|
216
|
+
function grandTotal(own, tree) {
|
|
217
|
+
const all = addAmounts(new Map(), own);
|
|
218
|
+
for (const [currency, amount] of tree.sums) all.set(currency, (all.get(currency) ?? 0) + amount);
|
|
219
|
+
return sumsText(all);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A receipt (bill) with a torn edge, drawn on DSH's own icon grid (16px, 1px stroke, currentColor). */
|
|
223
|
+
function CostIcon() {
|
|
224
|
+
const line = { stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round" };
|
|
225
|
+
return h("svg", { viewBox: "0 0 16 16", fill: "none", strokeWidth: 1, "aria-hidden": true },
|
|
226
|
+
h("path", { d: "M3.5 1.75H12.5V14.25L11 13.25L9.5 14.25L8 13.25L6.5 14.25L5 13.25L3.5 14.25Z", ...line }),
|
|
227
|
+
h("path", { d: "M5.75 5H10.25", ...line }),
|
|
228
|
+
h("path", { d: "M5.75 7.5H10.25", ...line }),
|
|
229
|
+
h("path", { d: "M5.75 10H8.5", ...line }));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const PANEL_GAP = 8;
|
|
233
|
+
const PANEL_MARGIN = 12;
|
|
234
|
+
|
|
235
|
+
/** Open state, placement above the trigger (clamped to the viewport), and outside-click / Escape to close. */
|
|
236
|
+
function useDialog() {
|
|
237
|
+
const [open, setOpen] = React.useState(false);
|
|
238
|
+
const [pos, setPos] = React.useState(null);
|
|
239
|
+
const rootRef = React.useRef(null);
|
|
240
|
+
const panelRef = React.useRef(null);
|
|
241
|
+
React.useLayoutEffect(() => {
|
|
242
|
+
if (!open) {
|
|
243
|
+
setPos(null);
|
|
244
|
+
return void 0;
|
|
245
|
+
}
|
|
246
|
+
const place = () => {
|
|
247
|
+
const anchor = rootRef.current?.getBoundingClientRect();
|
|
248
|
+
const panel = panelRef.current;
|
|
249
|
+
if (anchor === void 0 || panel === null) return;
|
|
250
|
+
const width = panel.offsetWidth;
|
|
251
|
+
const height = panel.offsetHeight;
|
|
252
|
+
const left = Math.min(Math.max(PANEL_MARGIN, anchor.left), window.innerWidth - width - PANEL_MARGIN);
|
|
253
|
+
const above = anchor.top - PANEL_GAP - height;
|
|
254
|
+
const top = above >= PANEL_MARGIN ? above : Math.min(anchor.bottom + PANEL_GAP, window.innerHeight - height - PANEL_MARGIN);
|
|
255
|
+
setPos({ left: Math.max(PANEL_MARGIN, left), top: Math.max(PANEL_MARGIN, top) });
|
|
256
|
+
};
|
|
257
|
+
place();
|
|
258
|
+
window.addEventListener("resize", place);
|
|
259
|
+
window.addEventListener("scroll", place, true);
|
|
260
|
+
return () => {
|
|
261
|
+
window.removeEventListener("resize", place);
|
|
262
|
+
window.removeEventListener("scroll", place, true);
|
|
263
|
+
};
|
|
264
|
+
}, [open]);
|
|
265
|
+
React.useEffect(() => {
|
|
266
|
+
if (!open) return void 0;
|
|
267
|
+
const inside = (target) => target instanceof Node && (rootRef.current?.contains(target) === true || panelRef.current?.contains(target) === true);
|
|
268
|
+
const onKeyDown = (event) => {
|
|
269
|
+
if (event.key === "Escape") setOpen(false);
|
|
270
|
+
};
|
|
271
|
+
const onPointerDown = (event) => {
|
|
272
|
+
if (!inside(event.target)) setOpen(false);
|
|
273
|
+
};
|
|
274
|
+
document.addEventListener("keydown", onKeyDown);
|
|
275
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
276
|
+
return () => {
|
|
277
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
278
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
279
|
+
};
|
|
280
|
+
}, [open]);
|
|
281
|
+
return { open, setOpen, rootRef, panelRef, pos };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function CostPanel({ own, tree, total, panelRef, pos, t }) {
|
|
285
|
+
const rows = dialogRows(own, tree, t);
|
|
286
|
+
const children = [];
|
|
287
|
+
rows.forEach((row, index) => {
|
|
288
|
+
if (row.kind === "rule") {
|
|
289
|
+
children.push(h("div", { key: index, className: "dshsc_rule", style: { gridColumn: "1 / -1", margin: "4px 0" }, "aria-hidden": true }));
|
|
290
|
+
} else if (row.kind === "meta" || row.kind === "route" && row.value === "") {
|
|
291
|
+
children.push(h("div", { key: index, className: "dshsc_meta" }, row.label));
|
|
292
|
+
} else {
|
|
293
|
+
const className = row.kind === "route" ? "dshsc_route" : row.spaced ? "dshsc_strong dshsc_spaced" : "dshsc_strong";
|
|
294
|
+
children.push(h("dt", { key: `${index}t`, className }, row.label), h("dd", { key: `${index}d`, className }, row.value));
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
return h("div", {
|
|
298
|
+
ref: panelRef,
|
|
299
|
+
className: "dshsc_panel",
|
|
300
|
+
role: "dialog",
|
|
301
|
+
"aria-label": t("cost.title"),
|
|
302
|
+
style: pos ?? { visibility: "hidden", left: 0, top: 0 }
|
|
303
|
+
},
|
|
304
|
+
h("div", { className: "dshsc_title" },
|
|
305
|
+
h("span", { className: "dshsc_titleLabel" }, h(CostIcon), t("cost.title")),
|
|
306
|
+
h("span", { className: "dshsc_titleValue" }, total)),
|
|
307
|
+
h("div", { className: "dshsc_rule", "aria-hidden": true }),
|
|
308
|
+
h("dl", { className: "dshsc_details" }, children));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const EMPTY = {};
|
|
312
|
+
const noSessions = () => EMPTY;
|
|
313
|
+
const selectProjections = (state) => state?.projectionsBySession ?? EMPTY;
|
|
314
|
+
|
|
315
|
+
const CostPill = React.memo(function CostPill({ sessionId, useProjection, useSessions, refreshProjections, t }) {
|
|
316
|
+
const own = useProjection("sessionCost");
|
|
317
|
+
const projections = (useSessions ?? noSessions)(selectProjections);
|
|
318
|
+
const tree = React.useMemo(() => subagentTree(sessionId, projections ?? EMPTY), [sessionId, projections]);
|
|
319
|
+
const requested = React.useRef(new Set());
|
|
320
|
+
React.useEffect(() => {
|
|
321
|
+
if (typeof refreshProjections !== "function") return;
|
|
322
|
+
for (const node of tree.nodes) {
|
|
323
|
+
// Cold children are read once; running children then stream live frames.
|
|
324
|
+
if (node.cost !== void 0 || node.state === "loading" || node.state === "ready" || requested.current.has(node.id)) continue;
|
|
325
|
+
requested.current.add(node.id);
|
|
326
|
+
Promise.resolve().then(() => refreshProjections(node.id)).catch(() => {});
|
|
327
|
+
}
|
|
328
|
+
}, [tree, refreshProjections]);
|
|
329
|
+
const dialog = useDialog();
|
|
330
|
+
const text = pillText(own, tree, t);
|
|
331
|
+
if (text === "") return null;
|
|
332
|
+
return h("span", {
|
|
333
|
+
ref: dialog.rootRef,
|
|
334
|
+
className: "dshsc_anchor",
|
|
335
|
+
"data-composer-stat": "cost"
|
|
336
|
+
}, h("button", {
|
|
337
|
+
type: "button",
|
|
338
|
+
className: "dshsc_pill",
|
|
339
|
+
"aria-haspopup": "dialog",
|
|
340
|
+
"aria-expanded": dialog.open,
|
|
341
|
+
"aria-label": t("cost.aria", { cost: text }),
|
|
342
|
+
onClick: () => dialog.setOpen(!dialog.open)
|
|
343
|
+
}, h(CostIcon), h("span", { className: "dshsc_label" }, text)),
|
|
344
|
+
dialog.open && typeof document !== "undefined"
|
|
345
|
+
? ReactDOM.createPortal(h(CostPanel, { own, tree, total: grandTotal(own, tree), panelRef: dialog.panelRef, pos: dialog.pos, t }), document.body)
|
|
346
|
+
: null);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const testHook = globalThis.__DSH_SESSION_COST_TEST__;
|
|
350
|
+
if (testHook !== void 0) Object.assign(testHook, { subagentTree, pillText, dialogRows, grandTotal, ownLabel, zh, en });
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
inject: ["slots", "locale", "sessions"],
|
|
354
|
+
apply(ctx) {
|
|
355
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-session-cost: dictionaries");
|
|
356
|
+
ctx.effect(() => {
|
|
357
|
+
if (typeof document === "undefined") return void 0;
|
|
358
|
+
const style = document.createElement("style");
|
|
359
|
+
style.dataset.plugin = "dsh-session-cost";
|
|
360
|
+
style.textContent = css;
|
|
361
|
+
document.head.appendChild(style);
|
|
362
|
+
return () => style.remove();
|
|
363
|
+
}, "dsh-session-cost: styles");
|
|
364
|
+
const actions = () => ({
|
|
365
|
+
refreshProjections(sessionId) {
|
|
366
|
+
return ctx.sessions.refreshProjections(sessionId);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
|
|
370
|
+
name: "conversation.composer.dock",
|
|
371
|
+
id: "cost",
|
|
372
|
+
order: 2,
|
|
373
|
+
locale: NS,
|
|
374
|
+
inject: actions
|
|
375
|
+
}, CostPill));
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
});
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
|
2
|
+
<circle cx="32" cy="32" r="28" fill="#247BBF"/>
|
|
3
|
+
<g transform="translate(12 12) scale(2.5)" stroke="#FFFFFF" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
|
|
4
|
+
<path d="M3.5 1.75H12.5V14.25L11 13.25L9.5 14.25L8 13.25L6.5 14.25L5 13.25L3.5 14.25Z"/>
|
|
5
|
+
<path d="M5.75 5H10.25"/>
|
|
6
|
+
<path d="M5.75 7.5H10.25"/>
|
|
7
|
+
<path d="M5.75 10H8.5"/>
|
|
8
|
+
</g>
|
|
9
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { loadCatalog } from "./lib/catalog.js";
|
|
6
|
+
import { Config } from "./lib/config.js";
|
|
7
|
+
import { createPricer, normalizePriceBook } from "./lib/pricing.js";
|
|
8
|
+
import { createSessionCostProjection } from "./lib/projection.js";
|
|
9
|
+
import { watchPriceBook } from "./lib/reload.js";
|
|
10
|
+
|
|
11
|
+
export const name = "session-cost";
|
|
12
|
+
export const inject = ["sessionProjections"];
|
|
13
|
+
|
|
14
|
+
export { Config };
|
|
15
|
+
|
|
16
|
+
const packageDirectory = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const { name: packageName, version: packageVersion } = JSON.parse(readFileSync(join(packageDirectory, "package.json"), "utf8"));
|
|
18
|
+
const statusPath = join(process.env.DSH_HOME || join(homedir(), ".dsh"), "plugins", "session-cost", "status.json");
|
|
19
|
+
|
|
20
|
+
function freeze(value) {
|
|
21
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
|
|
22
|
+
for (const child of Object.values(value)) freeze(child);
|
|
23
|
+
return Object.freeze(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadPriceBook(path) {
|
|
27
|
+
const source = readFileSync(path, "utf8");
|
|
28
|
+
let json;
|
|
29
|
+
try {
|
|
30
|
+
json = JSON.parse(source);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
throw new Error(`Unable to parse session-cost price book ${path}: ${error.message}`, { cause: error });
|
|
33
|
+
}
|
|
34
|
+
return freeze(normalizePriceBook(json));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Price book + catalog → one immutable pricing context. */
|
|
38
|
+
export function loadPricing(priceBookPath, config = Config.parse({})) {
|
|
39
|
+
const book = loadPriceBook(priceBookPath);
|
|
40
|
+
const catalog = config.catalog.enabled
|
|
41
|
+
? loadCatalog({ ...config.catalog, baseDirectory: packageDirectory })
|
|
42
|
+
: null;
|
|
43
|
+
const pricer = createPricer({ book, catalog, addReasoningToOutput: config.addReasoningToOutput });
|
|
44
|
+
return {
|
|
45
|
+
book,
|
|
46
|
+
catalog,
|
|
47
|
+
pricer,
|
|
48
|
+
fingerprint: JSON.stringify([book, catalog?.fingerprint ?? null, config.addReasoningToOutput])
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeStatus(status) {
|
|
53
|
+
try {
|
|
54
|
+
mkdirSync(dirname(statusPath), { recursive: true });
|
|
55
|
+
writeFileSync(statusPath, `${JSON.stringify({ plugin: `${packageName}@${packageVersion}`, ...status }, null, 2)}\n`);
|
|
56
|
+
} catch {
|
|
57
|
+
// Diagnostics only.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function apply(ctx, rawConfig) {
|
|
62
|
+
const config = Config.parse(rawConfig ?? {});
|
|
63
|
+
const priceBookPath = isAbsolute(config.priceBook) ? config.priceBook : resolve(packageDirectory, config.priceBook);
|
|
64
|
+
let lastGood = null;
|
|
65
|
+
ctx.effect(() => {
|
|
66
|
+
const reloader = watchPriceBook({
|
|
67
|
+
priceBookPath,
|
|
68
|
+
clientArtifact: resolve(packageDirectory, "client.js"),
|
|
69
|
+
load: (path) => loadPricing(path, config),
|
|
70
|
+
fingerprint: (pricing) => pricing.fingerprint,
|
|
71
|
+
register: (pricing) => ctx.sessionProjections.register(createSessionCostProjection(pricing.pricer)),
|
|
72
|
+
onLoad: (pricing) => {
|
|
73
|
+
lastGood = {
|
|
74
|
+
loadedAt: new Date().toISOString(),
|
|
75
|
+
priceBook: { path: priceBookPath, models: pricing.book.entries.length },
|
|
76
|
+
catalog: pricing.catalog === null ? null : { sources: pricing.catalog.sources, errors: pricing.catalog.errors }
|
|
77
|
+
};
|
|
78
|
+
writeStatus(lastGood);
|
|
79
|
+
},
|
|
80
|
+
onError: (error) => {
|
|
81
|
+
console.error(`[dsh-session-cost] Keeping the last valid price book: ${error.message}`);
|
|
82
|
+
writeStatus({ ...lastGood, lastError: { at: new Date().toISOString(), message: error.message } });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
return () => reloader.close();
|
|
86
|
+
}, "dsh-session-cost: price-book watcher");
|
|
87
|
+
}
|