pi-sdk-web 0.5.1 → 0.5.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/dist/server.js +18 -21
- package/dist/static/app.js +10 -0
- package/dist/ui-context.js +41 -49
- package/dist/usage-render.js +10 -120
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -270,6 +270,19 @@ export class PiWebServer {
|
|
|
270
270
|
if (Object.keys(extStatus).length > 0) {
|
|
271
271
|
this.sendJson(ws, { type: "ext_status", data: extStatus });
|
|
272
272
|
}
|
|
273
|
+
// Current widgets (setWidget may have fired before this client connected,
|
|
274
|
+
// e.g. magic-context's todos overlay) - replay as individual requests so
|
|
275
|
+
// the browser's extension_ui_request handler renders them.
|
|
276
|
+
for (const [key, w] of Object.entries(this.uiContext.getWidgetSnapshot())) {
|
|
277
|
+
this.sendJson(ws, {
|
|
278
|
+
type: "extension_ui_request",
|
|
279
|
+
id: crypto.randomUUID(),
|
|
280
|
+
method: "setWidget",
|
|
281
|
+
widgetKey: key,
|
|
282
|
+
widgetLines: w.lines,
|
|
283
|
+
widgetPlacement: w.placement,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
273
286
|
}
|
|
274
287
|
sendJson(ws, obj) {
|
|
275
288
|
if (ws.readyState === WebSocket.OPEN) {
|
|
@@ -792,31 +805,15 @@ export class PiWebServer {
|
|
|
792
805
|
// (appendEntry -> modal), same as before.
|
|
793
806
|
const cmdPath = (cmd.sourceInfo && cmd.sourceInfo.path) || "";
|
|
794
807
|
const customOnly = /pi-magic-context|magic-context/.test(cmdPath);
|
|
795
|
-
const ui = this.uiContext;
|
|
796
|
-
const originalSink = ui.sink;
|
|
797
808
|
try {
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
if (req && req.method === "notify") {
|
|
804
|
-
// Tag command-time notifies with the command name as title so the
|
|
805
|
-
// browser renders them as a titled modal (colored, Markdown).
|
|
806
|
-
originalSink({ ...obj, title: `/${name}` });
|
|
807
|
-
}
|
|
808
|
-
else {
|
|
809
|
-
originalSink(obj);
|
|
810
|
-
}
|
|
811
|
-
};
|
|
809
|
+
// No sink wrapping: notify() broadcasts go through as-is (no title),
|
|
810
|
+
// so the frontend renders them as persistent status lines - the same
|
|
811
|
+
// as TUI's notify -> showStatus (a dim line appended to the chat),
|
|
812
|
+
// whether or not they come from a command. Command *results* (custom
|
|
813
|
+
// entries) still become titled modals via the captured entries below.
|
|
812
814
|
await cmd.handler(args, this.buildCommandContext(customOnly ? false : true));
|
|
813
815
|
}
|
|
814
|
-
catch (e) {
|
|
815
|
-
ui.sink = originalSink;
|
|
816
|
-
throw e;
|
|
817
|
-
}
|
|
818
816
|
finally {
|
|
819
|
-
ui.sink = originalSink;
|
|
820
817
|
unsubscribe();
|
|
821
818
|
}
|
|
822
819
|
// Show command output as a modal (TUI-like). The session entry remains
|
package/dist/static/app.js
CHANGED
|
@@ -1944,8 +1944,18 @@ class PiWebClient {
|
|
|
1944
1944
|
}
|
|
1945
1945
|
|
|
1946
1946
|
appendNotifyLine(message, type) {
|
|
1947
|
+
// TUI's showStatus reuses the LAST status line: consecutive notifies
|
|
1948
|
+
// update it in place (a sticky status); a new line starts only when
|
|
1949
|
+
// something else (a message) was inserted in between. Mirror that here.
|
|
1950
|
+
const last = this.contentEl.lastElementChild;
|
|
1951
|
+
if (last && last.classList.contains('notify-line') && last.dataset.notifyType === (type || '')) {
|
|
1952
|
+
last.innerHTML = ansiToHtml(String(message));
|
|
1953
|
+
this.scrollToBottom();
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1947
1956
|
const div = document.createElement('div');
|
|
1948
1957
|
div.className = 'notify-line' + (type ? ` notify-${type}` : '');
|
|
1958
|
+
div.dataset.notifyType = type || '';
|
|
1949
1959
|
div.innerHTML = ansiToHtml(String(message));
|
|
1950
1960
|
this.contentEl.appendChild(div);
|
|
1951
1961
|
this.scrollToBottom();
|
package/dist/ui-context.js
CHANGED
|
@@ -66,6 +66,13 @@ export class WebUIContext {
|
|
|
66
66
|
webTheme = createWebTheme();
|
|
67
67
|
/** Latest setStatus values per key, so late-connecting browsers get current state */
|
|
68
68
|
statusMap = new Map();
|
|
69
|
+
/** Latest setWidget lines per key (persistent widgets, e.g. TodoOverlay):
|
|
70
|
+
* replayed to late-connecting browsers like setStatus snapshots. */
|
|
71
|
+
widgetMap = new Map();
|
|
72
|
+
/** Current widget snapshot (key -> lines) for new connections. */
|
|
73
|
+
getWidgetSnapshot() {
|
|
74
|
+
return Object.fromEntries(this.widgetMap);
|
|
75
|
+
}
|
|
69
76
|
constructor(sink) {
|
|
70
77
|
this.sink = sink;
|
|
71
78
|
// Pi's ExtensionRunner wraps the ui context with `{...ui}` (a shallow
|
|
@@ -106,6 +113,7 @@ export class WebUIContext {
|
|
|
106
113
|
}
|
|
107
114
|
this.pending.clear();
|
|
108
115
|
this.statusMap.clear();
|
|
116
|
+
this.widgetMap.clear();
|
|
109
117
|
}
|
|
110
118
|
/** Handle a browser `extension_ui_response` message. */
|
|
111
119
|
respond(id, response) {
|
|
@@ -177,14 +185,41 @@ export class WebUIContext {
|
|
|
177
185
|
this.sink({ type: "extension_ui_request", id: crypto.randomUUID(), method: "setTitle", title });
|
|
178
186
|
}
|
|
179
187
|
setWidget(key, content, options) {
|
|
188
|
+
let lines;
|
|
189
|
+
if (Array.isArray(content)) {
|
|
190
|
+
lines = content.map((l) => String(l));
|
|
191
|
+
}
|
|
192
|
+
else if (typeof content === "function") {
|
|
193
|
+
// Some extensions (e.g. magic-context's TodoOverlay) pass a TUI
|
|
194
|
+
// component factory instead of string lines. Web has no TUI renderer,
|
|
195
|
+
// but the factory yields a render(width) -> string[] that we can
|
|
196
|
+
// evaluate with a no-op tui stub and broadcast as text lines - the
|
|
197
|
+
// browser renders them (with ANSI colors) in the widgets panel.
|
|
198
|
+
try {
|
|
199
|
+
const stubTui = { requestRender: () => { }, invalidate: () => { } };
|
|
200
|
+
const component = content(stubTui, this.webTheme);
|
|
201
|
+
const render = component && component.render;
|
|
202
|
+
if (typeof render === "function") {
|
|
203
|
+
const width = 78;
|
|
204
|
+
const out = render(width);
|
|
205
|
+
if (Array.isArray(out))
|
|
206
|
+
lines = out.map((l) => String(l));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// fall through with no lines - widget renders empty
|
|
211
|
+
}
|
|
212
|
+
}
|
|
180
213
|
this.sink({
|
|
181
214
|
type: "extension_ui_request",
|
|
182
215
|
id: crypto.randomUUID(),
|
|
183
216
|
method: "setWidget",
|
|
184
217
|
widgetKey: key,
|
|
185
|
-
widgetLines:
|
|
218
|
+
widgetLines: lines,
|
|
186
219
|
widgetPlacement: options?.placement,
|
|
187
220
|
});
|
|
221
|
+
// Store for late-connecting browsers (replayed on WS connect).
|
|
222
|
+
this.widgetMap.set(key, { lines, placement: options?.placement });
|
|
188
223
|
}
|
|
189
224
|
// ------------------------------------------------------------------
|
|
190
225
|
// Terminal-specific features: no-op in web mode
|
|
@@ -200,54 +235,11 @@ export class WebUIContext {
|
|
|
200
235
|
setHeader() { }
|
|
201
236
|
custom() {
|
|
202
237
|
// Pi's custom() shows an extension-drawn TUI component. Web has no TUI
|
|
203
|
-
// renderer, so the component can't be
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
// (loader -> done(value)) have their result read from the extension's
|
|
209
|
-
// own cache file afterwards when needed (see usage-render.ts). So
|
|
210
|
-
// custom() never blocks the command.
|
|
211
|
-
// Extensions that branch on hasUI before custom (magic-context
|
|
212
|
-
// ctx-status) are routed to their text fallback by executeCommand
|
|
213
|
-
// (hasUI:false) and never reach this.
|
|
214
|
-
try {
|
|
215
|
-
// Arguments are (factory, options) at runtime; the declared
|
|
216
|
-
// signature stays interface-compatible, read them dynamically.
|
|
217
|
-
const args = arguments;
|
|
218
|
-
const factory = args[0];
|
|
219
|
-
const options = args[1];
|
|
220
|
-
if (typeof factory === "function") {
|
|
221
|
-
// No-op TUI stub: enough surface for factories that need a render
|
|
222
|
-
// handle; rendering itself is never performed on Web.
|
|
223
|
-
const stubTui = {
|
|
224
|
-
requestRender: () => { },
|
|
225
|
-
invalidate: () => { },
|
|
226
|
-
setFocus: () => { },
|
|
227
|
-
getWidth: () => 100,
|
|
228
|
-
};
|
|
229
|
-
// Theme stub: color helpers degrade to plain text.
|
|
230
|
-
const stubTheme = {
|
|
231
|
-
fg: (_k, s) => s,
|
|
232
|
-
bold: (s) => s,
|
|
233
|
-
dim: (s) => s,
|
|
234
|
-
get theme() {
|
|
235
|
-
return undefined;
|
|
236
|
-
},
|
|
237
|
-
};
|
|
238
|
-
const component = factory(stubTui, stubTheme, {}, () => { });
|
|
239
|
-
// Component built and its logic ran (data collection started);
|
|
240
|
-
// NOT disposed - async work inside may still be running and own
|
|
241
|
-
// its resources. settle immediately.
|
|
242
|
-
void component;
|
|
243
|
-
}
|
|
244
|
-
if (options && typeof options.onHandle === "function") {
|
|
245
|
-
options.onHandle({ setHidden: () => { }, focus: () => { } });
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
catch {
|
|
249
|
-
// factory threw - ignore, still settle
|
|
250
|
-
}
|
|
238
|
+
// renderer, so the component can't be displayed - same headless stub as
|
|
239
|
+
// Pi's RPC mode (rpc-mode.ts: "Custom UI not supported in RPC mode"):
|
|
240
|
+
// settle immediately so commands awaiting the panel don't hang. This is
|
|
241
|
+
// now purely internal: /usage (which used to collect data inside the
|
|
242
|
+
// factory) collects natively from Pi session files instead.
|
|
251
243
|
return Promise.resolve(undefined);
|
|
252
244
|
}
|
|
253
245
|
pasteToEditor() { }
|
package/dist/usage-render.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* (5 time tabs x provider/model x metrics + insights + global hourly
|
|
15
15
|
* series + tab windows); the frontend renders.
|
|
16
16
|
*/
|
|
17
|
-
import { existsSync, readdirSync, readFileSync
|
|
17
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
const SESSIONS_DIRS = [
|
|
@@ -73,11 +73,10 @@ function tsOf(messageTimestamp, entryTimestamp) {
|
|
|
73
73
|
}
|
|
74
74
|
return 0;
|
|
75
75
|
}
|
|
76
|
-
function auxMessage(usage, ts
|
|
76
|
+
function auxMessage(usage, ts) {
|
|
77
77
|
return {
|
|
78
78
|
provider: "aux",
|
|
79
79
|
model: "auxiliary",
|
|
80
|
-
thinkingLevel: undefined,
|
|
81
80
|
cost: usage.cost,
|
|
82
81
|
input: usage.input,
|
|
83
82
|
output: usage.output,
|
|
@@ -85,7 +84,6 @@ function auxMessage(usage, ts, sourceId) {
|
|
|
85
84
|
cacheWrite: usage.cacheWrite,
|
|
86
85
|
timestamp: ts,
|
|
87
86
|
reasoning: usage.reasoning,
|
|
88
|
-
afterCompaction: false,
|
|
89
87
|
source: "auxiliary",
|
|
90
88
|
};
|
|
91
89
|
}
|
|
@@ -98,7 +96,6 @@ function parseSessionFile(path) {
|
|
|
98
96
|
let sessionId = "";
|
|
99
97
|
let cwd = "";
|
|
100
98
|
let parentSession = "";
|
|
101
|
-
let thinkingLevel;
|
|
102
99
|
let compactionPending = false;
|
|
103
100
|
const messages = [];
|
|
104
101
|
let content;
|
|
@@ -129,21 +126,19 @@ function parseSessionFile(path) {
|
|
|
129
126
|
break;
|
|
130
127
|
}
|
|
131
128
|
case "thinking_level_change": {
|
|
132
|
-
if (typeof entry.thinkingLevel === "string")
|
|
133
|
-
thinkingLevel = entry.thinkingLevel;
|
|
134
129
|
break;
|
|
135
130
|
}
|
|
136
131
|
case "compaction": {
|
|
137
132
|
const usage = parseUsageAmount(entry.usage);
|
|
138
133
|
if (usage)
|
|
139
|
-
messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp)
|
|
134
|
+
messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp)));
|
|
140
135
|
compactionPending = true;
|
|
141
136
|
break;
|
|
142
137
|
}
|
|
143
138
|
case "branch_summary": {
|
|
144
139
|
const usage = parseUsageAmount(entry.usage);
|
|
145
140
|
if (usage)
|
|
146
|
-
messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp)
|
|
141
|
+
messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp)));
|
|
147
142
|
break;
|
|
148
143
|
}
|
|
149
144
|
case "message": {
|
|
@@ -156,7 +151,6 @@ function parseSessionFile(path) {
|
|
|
156
151
|
messages.push({
|
|
157
152
|
provider: msg.provider,
|
|
158
153
|
model: msg.model,
|
|
159
|
-
thinkingLevel,
|
|
160
154
|
cost: usage.cost,
|
|
161
155
|
input: usage.input,
|
|
162
156
|
output: usage.output,
|
|
@@ -164,7 +158,6 @@ function parseSessionFile(path) {
|
|
|
164
158
|
cacheWrite: usage.cacheWrite,
|
|
165
159
|
timestamp: tsOf(msg.timestamp, entry.timestamp),
|
|
166
160
|
reasoning: usage.reasoning,
|
|
167
|
-
afterCompaction: compactionPending,
|
|
168
161
|
source: "assistant",
|
|
169
162
|
});
|
|
170
163
|
compactionPending = false;
|
|
@@ -193,111 +186,6 @@ export function collectUsage() {
|
|
|
193
186
|
}
|
|
194
187
|
return out;
|
|
195
188
|
}
|
|
196
|
-
/** A coarse freshness stamp: sum of session-file mtimes, for cheap invalidation. */
|
|
197
|
-
export function sessionsStamp() {
|
|
198
|
-
const root = sessionsDir();
|
|
199
|
-
if (!root)
|
|
200
|
-
return 0;
|
|
201
|
-
const files = [];
|
|
202
|
-
collectSessionFiles(root, files);
|
|
203
|
-
let sum = 0;
|
|
204
|
-
for (const f of files) {
|
|
205
|
-
try {
|
|
206
|
-
sum += statSync(f).mtimeMs;
|
|
207
|
-
}
|
|
208
|
-
catch {
|
|
209
|
-
// ignore
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return sum;
|
|
213
|
-
}
|
|
214
|
-
function startOfDay(ts) {
|
|
215
|
-
const d = new Date(ts);
|
|
216
|
-
d.setHours(0, 0, 0, 0);
|
|
217
|
-
return d.getTime();
|
|
218
|
-
}
|
|
219
|
-
function startOfWeek(ts) {
|
|
220
|
-
const d = new Date(ts);
|
|
221
|
-
const day = d.getDay() === 0 ? 6 : d.getDay() - 1;
|
|
222
|
-
d.setHours(0, 0, 0, 0);
|
|
223
|
-
d.setDate(d.getDate() - day);
|
|
224
|
-
return d.getTime();
|
|
225
|
-
}
|
|
226
|
-
function emptyTotals() {
|
|
227
|
-
return { cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, messages: 0, sessions: new Set() };
|
|
228
|
-
}
|
|
229
|
-
function fmt(n) {
|
|
230
|
-
return n >= 1e9
|
|
231
|
-
? `${(n / 1e9).toFixed(2)}B`
|
|
232
|
-
: n >= 1e6
|
|
233
|
-
? `${(n / 1e6).toFixed(2)}M`
|
|
234
|
-
: n >= 1e3
|
|
235
|
-
? `${(n / 1e3).toFixed(1)}K`
|
|
236
|
-
: `${Math.round(n)}`;
|
|
237
|
-
}
|
|
238
|
-
function fmtCost(n) {
|
|
239
|
-
return `$${n.toFixed(n >= 1 ? 2 : 4)}`;
|
|
240
|
-
}
|
|
241
|
-
/** Render a Markdown usage summary (today / this week / all time). */
|
|
242
|
-
export function renderUsageSummary() {
|
|
243
|
-
const files = collectUsage();
|
|
244
|
-
if (files.length === 0) {
|
|
245
|
-
return "## Usage\n\nNo usage data found yet.";
|
|
246
|
-
}
|
|
247
|
-
const now = Date.now();
|
|
248
|
-
const todayStart = startOfDay(now);
|
|
249
|
-
const weekStart = startOfWeek(now);
|
|
250
|
-
const today = emptyTotals();
|
|
251
|
-
const week = emptyTotals();
|
|
252
|
-
const all = emptyTotals();
|
|
253
|
-
const byModel = new Map();
|
|
254
|
-
for (const file of files) {
|
|
255
|
-
for (const m of file.messages) {
|
|
256
|
-
const t = m.timestamp;
|
|
257
|
-
const targets = [all];
|
|
258
|
-
if (t >= todayStart)
|
|
259
|
-
targets.push(today);
|
|
260
|
-
if (t >= weekStart)
|
|
261
|
-
targets.push(week);
|
|
262
|
-
for (const target of targets) {
|
|
263
|
-
target.cost += m.cost;
|
|
264
|
-
target.input += m.input;
|
|
265
|
-
target.output += m.output;
|
|
266
|
-
target.cacheRead += m.cacheRead;
|
|
267
|
-
target.cacheWrite += m.cacheWrite;
|
|
268
|
-
target.messages += 1;
|
|
269
|
-
target.sessions.add(file.sessionId);
|
|
270
|
-
}
|
|
271
|
-
const key = `${m.provider}/${m.model}`;
|
|
272
|
-
const byModelEntry = byModel.get(key) ?? emptyTotals();
|
|
273
|
-
byModelEntry.cost += m.cost;
|
|
274
|
-
byModelEntry.input += m.input;
|
|
275
|
-
byModelEntry.output += m.output;
|
|
276
|
-
byModelEntry.cacheRead += m.cacheRead;
|
|
277
|
-
byModelEntry.cacheWrite += m.cacheWrite;
|
|
278
|
-
byModelEntry.messages += 1;
|
|
279
|
-
byModel.set(key, byModelEntry);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
const row = (label, t) => `| ${label} | ${fmt(t.input)} | ${fmt(t.output)} | ${fmt(t.cacheRead)} | ${fmt(t.cacheWrite)} | $${t.cost.toFixed(4)} | ${t.messages} | ${t.sessions.size} |`;
|
|
283
|
-
const lines = [
|
|
284
|
-
"## Usage",
|
|
285
|
-
"",
|
|
286
|
-
"| Period | Input | Output | Cache R | Cache W | Cost | Msgs | Sessions |",
|
|
287
|
-
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
288
|
-
row("Today", today),
|
|
289
|
-
row("This week", week),
|
|
290
|
-
row("All time", all),
|
|
291
|
-
];
|
|
292
|
-
if (byModel.size > 0) {
|
|
293
|
-
const sorted = [...byModel.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, 10);
|
|
294
|
-
lines.push("", "### By model (all time)", "", "| Model | Input | Output | Cost | Msgs |", "| --- | --- | --- | --- | --- |");
|
|
295
|
-
for (const [key, t] of sorted) {
|
|
296
|
-
lines.push(`| ${key} | ${fmt(t.input)} | ${fmt(t.output)} | $${t.cost.toFixed(4)} | ${t.messages} |`);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
return lines.join("\n");
|
|
300
|
-
}
|
|
301
189
|
const TAB_KEYS = ["today", "thisWeek", "lastWeek", "last30Days", "allTime"];
|
|
302
190
|
function emptyTokens() {
|
|
303
191
|
return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
@@ -310,7 +198,7 @@ function addTokens(a, b) {
|
|
|
310
198
|
a.cacheWrite += b.cacheWrite;
|
|
311
199
|
}
|
|
312
200
|
/** Format a cost value the way the extension does (0 -> "-"). */
|
|
313
|
-
|
|
201
|
+
function formatUsageCost(cost) {
|
|
314
202
|
if (cost === 0)
|
|
315
203
|
return "-";
|
|
316
204
|
if (cost < 0.01)
|
|
@@ -324,7 +212,7 @@ export function formatUsageCost(cost) {
|
|
|
324
212
|
return `$${Math.round(cost)}`;
|
|
325
213
|
}
|
|
326
214
|
/** Format a token count compactly (12.3K, 4.5M, ...). */
|
|
327
|
-
|
|
215
|
+
function formatUsageTokens(n) {
|
|
328
216
|
if (n >= 1e9)
|
|
329
217
|
return `${(n / 1e9).toFixed(2)}B`;
|
|
330
218
|
if (n >= 1e6)
|
|
@@ -436,7 +324,7 @@ export function buildUsageData(sessionId) {
|
|
|
436
324
|
}
|
|
437
325
|
}
|
|
438
326
|
}
|
|
439
|
-
const payload = { tabs: {}, hourly: [], tabWindow: {}, collectedAt:
|
|
327
|
+
const payload = { tabs: {}, hourly: [], tabWindow: {}, collectedAt: Date.now() };
|
|
440
328
|
// Graph x-axis windows per tab: [start, end]. today: midnight->now;
|
|
441
329
|
// thisWeek: monday->now; lastWeek: monday->next Monday; last30Days:
|
|
442
330
|
// start->now; allTime: 0 -> now (frontend clips to first data).
|
|
@@ -494,7 +382,9 @@ export function buildUsageData(sessionId) {
|
|
|
494
382
|
insights.push({
|
|
495
383
|
kind: "alarm",
|
|
496
384
|
stat: formatUsageCost(topModel.cost),
|
|
497
|
-
|
|
385
|
+
// Model names can repeat across providers (e.g. deepseek-v4-
|
|
386
|
+
// flash on several providers) - show provider/model.
|
|
387
|
+
headline: `${top.name}/${topModel.name} is the costliest model (${((topModel.cost / totalCost) * 100).toFixed(0)}% of total)`,
|
|
498
388
|
advice: "Check whether its output quality justifies the price.",
|
|
499
389
|
});
|
|
500
390
|
}
|