@tiens.nguyen/gu-cli 1.0.782 → 1.0.784
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/client-expert-run.mjs +2 -2
- package/gu-cli.mjs +9 -3
- package/gu-repl.mjs +504 -250
- package/model-prices.mjs +484 -0
- package/package.json +2 -1
- package/preset-apply.mjs +2 -2
- package/preset-editor.mjs +90 -12
- package/presets-transfer.mjs +5 -5
- package/step-tokens.mjs +45 -0
- package/terminal-layout.mjs +14 -0
- package/terminal-viewport.mjs +65 -0
- package/turn-stop.mjs +17 -0
package/model-prices.mjs
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MODEL PRICES AND WHAT A TURN COST (#323).
|
|
3
|
+
*
|
|
4
|
+
* The user: "estimate cost of USD after each turn" — from each provider's price per 1 million INPUT
|
|
5
|
+
* tokens (output ignored for now, but stored so turning it on needs no migration), with the cheaper
|
|
6
|
+
* CACHED-input price for the part of each step the provider served from its prompt cache.
|
|
7
|
+
*
|
|
8
|
+
* HOW MUCH WAS CACHED. The agent reads the provider's own count when it reports one (OpenAI
|
|
9
|
+
* usage.prompt_tokens_details.cached_tokens, Anthropic cache_read_input_tokens, Kimi cached_tokens
|
|
10
|
+
* — see _cached_tokens_from_usage in gonext_agent_chat.py) and the cost then uses it: "$0.04".
|
|
11
|
+
* When nothing is reported, gu ASSUMES 90% of every step after the first was cached — what the
|
|
12
|
+
* user sees on the Kimi and Claude consoles for gu's turns — and says so: "~$0.04".
|
|
13
|
+
*
|
|
14
|
+
* Prices live in the user's own Mongo (one row per server + model, via the local API); this file is
|
|
15
|
+
* pure — the table it ships (PRICE_TABLE) is what an empty Mongo is seeded with, and what the Deploy
|
|
16
|
+
* app's "Prices" button exports for the admin to publish.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const PRICE_TABLE_VERSION = "1.0";
|
|
20
|
+
/** Of a step after the first, the share assumed served from the cache when the provider says nothing. */
|
|
21
|
+
export const ASSUMED_CACHED_SHARE = 0.9;
|
|
22
|
+
export const MAX_PRICE_ROWS = 500;
|
|
23
|
+
const MAX_PRICE = 1000; // USD per 1M tokens — anything above is a typo (per-token, per-1k…)
|
|
24
|
+
|
|
25
|
+
const ANTHROPIC = "https://platform.claude.com/docs/en/about-claude/pricing";
|
|
26
|
+
const OPENAI = "https://developers.openai.com/api/docs/pricing";
|
|
27
|
+
const KIMI = "https://platform.kimi.ai/docs/pricing/chat";
|
|
28
|
+
const OLLAMA = "https://ollama.com/pricing";
|
|
29
|
+
const CHECKED = "2026-10-01";
|
|
30
|
+
|
|
31
|
+
/** [host, model, input, cachedInput, output, cacheWrite?] — USD per 1M tokens. */
|
|
32
|
+
const ROWS = [
|
|
33
|
+
// Anthropic — cache write (5 minutes) is 1.25× input; gu does not price writes yet (noted per row).
|
|
34
|
+
["api.anthropic.com", "claude-fable-5-1", 10, 0.25, 50, 12.5],
|
|
35
|
+
["api.anthropic.com", "claude-opus-5-5", 4, 0.2, 20, 5],
|
|
36
|
+
["api.anthropic.com", "claude-opus-5", 5, 0.5, 25, 6.25],
|
|
37
|
+
["api.anthropic.com", "claude-opus-4-8", 5, 0.5, 25, 6.25],
|
|
38
|
+
["api.anthropic.com", "claude-sonnet-5-5", 2, 0.2, 10, 2.5],
|
|
39
|
+
["api.anthropic.com", "claude-sonnet-5", 2, 0.2, 10, 2.5],
|
|
40
|
+
["api.anthropic.com", "claude-sonnet-4-6", 3, 0.3, 15, 3.75],
|
|
41
|
+
["api.anthropic.com", "claude-sonnet-4-5", 3, 0.3, 15, 3.75],
|
|
42
|
+
["api.anthropic.com", "claude-haiku-4-5", 1, 0.1, 5, 1.25],
|
|
43
|
+
// OpenAI (standard tier)
|
|
44
|
+
["api.openai.com", "gpt-6-astra", 10, 1, 50],
|
|
45
|
+
["api.openai.com", "gpt-6.1-sol", 2, 0.1, 10],
|
|
46
|
+
["api.openai.com", "gpt-6-sol", 2, 0.2, 10],
|
|
47
|
+
["api.openai.com", "gpt-6-luna", 0.1, 0.01, 0.5],
|
|
48
|
+
["api.openai.com", "gpt-5.6-sol", 4, 0.4, 20],
|
|
49
|
+
["api.openai.com", "gpt-5.6-terra", 2, 0.2, 12],
|
|
50
|
+
["api.openai.com", "gpt-5.6-luna", 0.2, 0.02, 1.2],
|
|
51
|
+
["api.openai.com", "gpt-5.5", 5, 0.5, 30],
|
|
52
|
+
["api.openai.com", "gpt-5.4", 2.5, 0.25, 15],
|
|
53
|
+
["api.openai.com", "gpt-5.3-codex", 1.75, 0.175, 14],
|
|
54
|
+
["api.openai.com", "gpt-5.2", 1.75, 0.175, 14],
|
|
55
|
+
["api.openai.com", "gpt-5.1", 1.25, 0.125, 10],
|
|
56
|
+
["api.openai.com", "gpt-5", 1.25, 0.125, 10],
|
|
57
|
+
["api.openai.com", "gpt-4.1", 2, 0.5, 8],
|
|
58
|
+
["api.openai.com", "gpt-4o", 2.5, 1.25, 10],
|
|
59
|
+
// Moonshot / Kimi
|
|
60
|
+
["api.moonshot.ai", "kimi-k3", 3, 0.3, 15, 3], // cache write (5 min) = the input price
|
|
61
|
+
["api.moonshot.ai", "kimi-k2.7-code", 0.95, 0.19, 4],
|
|
62
|
+
["api.moonshot.ai", "kimi-k2.7-code-highspeed", 1.9, 0.38, 8],
|
|
63
|
+
["api.moonshot.ai", "kimi-k2.6", 0.95, 0.16, 4],
|
|
64
|
+
// Ollama cloud — pay-as-you-go credits (peak price; off-peak is about half)
|
|
65
|
+
["ollama.com", "kimi-k3", 3, 0.3, 15],
|
|
66
|
+
["ollama.com", "kimi-k2.7-code", 0.95, 0.19, 4],
|
|
67
|
+
["ollama.com", "kimi-k2.6", 0.95, 0.16, 4],
|
|
68
|
+
["ollama.com", "glm-5.3", 1.4, 0.26, 4.4],
|
|
69
|
+
["ollama.com", "glm-5.3-flash", 0.15, 0.03, 0.5],
|
|
70
|
+
["ollama.com", "glm-5.2", 1.4, 0.26, 4.4],
|
|
71
|
+
["ollama.com", "deepseek-v4-pro", 1.32, 0.044, 3.96],
|
|
72
|
+
["ollama.com", "deepseek-v4.1-flash", 0.3, 0.006, 1.2],
|
|
73
|
+
["ollama.com", "minimax-m3", 0.6, 0.12, 2.4],
|
|
74
|
+
["ollama.com", "minimax-m2.7", 0.3, 0.06, 1.2],
|
|
75
|
+
["ollama.com", "gpt-oss:120b", 0.15, 0.014, 0.6],
|
|
76
|
+
["ollama.com", "gpt-oss:20b", 0.07, 0.035, 0.3],
|
|
77
|
+
["ollama.com", "gemma4", 0.14, 0.05, 0.4],
|
|
78
|
+
["ollama.com", "mistral-large-3", 0.5, null, 1.5],
|
|
79
|
+
["ollama.com", "nemotron-3-nano", 0.06, null, 0.24],
|
|
80
|
+
["ollama.com", "nemotron-3-super", 0.015, 0.015, 0.6],
|
|
81
|
+
["ollama.com", "nemotron-3-ultra", 0.1, 0.1, 3],
|
|
82
|
+
];
|
|
83
|
+
const PROVIDER = { "api.anthropic.com": "Anthropic", "api.openai.com": "OpenAI", "api.moonshot.ai": "Moonshot (Kimi)", "ollama.com": "Ollama cloud" };
|
|
84
|
+
const SOURCE = { "api.anthropic.com": ANTHROPIC, "api.openai.com": OPENAI, "api.moonshot.ai": KIMI, "ollama.com": OLLAMA };
|
|
85
|
+
|
|
86
|
+
/** The table gu ships — what an empty Mongo is seeded with and what the Deploy app exports. */
|
|
87
|
+
export const PRICE_TABLE = Object.freeze({
|
|
88
|
+
gu: "prices",
|
|
89
|
+
version: PRICE_TABLE_VERSION,
|
|
90
|
+
publishedAt: CHECKED,
|
|
91
|
+
currency: "USD",
|
|
92
|
+
unit: "per 1M tokens",
|
|
93
|
+
note: "input = uncached prompt tokens; cachedInput = prompt tokens served from the provider's cache; output is stored but not yet used. Anthropic cache writes (cacheWrite) are not priced yet. Ollama cloud: peak prices — off-peak is about half.",
|
|
94
|
+
prices: ROWS.map(([host, model, input, cachedInput, output, cacheWrite]) => ({
|
|
95
|
+
provider: PROVIDER[host],
|
|
96
|
+
host,
|
|
97
|
+
model,
|
|
98
|
+
input,
|
|
99
|
+
cachedInput,
|
|
100
|
+
output,
|
|
101
|
+
...(cacheWrite != null ? { cacheWrite } : {}),
|
|
102
|
+
// Used only when the provider does not report its cached count: Kimi and OpenAI cache prompt
|
|
103
|
+
// prefixes automatically; Anthropic only when the request asks (gu's Claude path does); Ollama
|
|
104
|
+
// cloud lists cache prices. No cache price → no discount, so no share.
|
|
105
|
+
...(cachedInput != null ? { assumedCacheShare: ASSUMED_CACHED_SHARE } : {}),
|
|
106
|
+
source: SOURCE[host],
|
|
107
|
+
checkedAt: CHECKED,
|
|
108
|
+
})),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------------------------
|
|
112
|
+
// keys and lookup
|
|
113
|
+
// ---------------------------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/** A server URL (or bare host) → its host: "https://api.moonshot.ai/v1/" → "api.moonshot.ai". */
|
|
116
|
+
export function hostOf(urlOrHost) {
|
|
117
|
+
const raw = String(urlOrHost ?? "").trim();
|
|
118
|
+
if (!raw) return "";
|
|
119
|
+
try {
|
|
120
|
+
return new URL(raw.includes("://") ? raw : `https://${raw}`).host.toLowerCase();
|
|
121
|
+
} catch {
|
|
122
|
+
return raw.replace(/^[a-z]+:\/\//i, "").replace(/\/.*$/, "").toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One row's identity: host + model, lower-cased. */
|
|
127
|
+
export const priceKey = (host, model) => `${hostOf(host)}|${String(model ?? "").trim().toLowerCase()}`;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The price for a server + model, or null. Exact first; then without a dated suffix
|
|
131
|
+
* ("claude-haiku-4-5-20251001" → "claude-haiku-4-5"); then without an Ollama tag ("kimi-k3:cloud",
|
|
132
|
+
* "kimi-k3:latest" → "kimi-k3"). Never a different host — the same model costs differently there.
|
|
133
|
+
*/
|
|
134
|
+
export function findPrice(rows, url, model) {
|
|
135
|
+
const host = hostOf(url);
|
|
136
|
+
const m = String(model ?? "").trim().toLowerCase();
|
|
137
|
+
if (!host || !m || !Array.isArray(rows)) return null;
|
|
138
|
+
const byKey = new Map(rows.map((r) => [priceKey(r.host, r.model), r]));
|
|
139
|
+
const tries = [m, m.replace(/-\d{8}$/, ""), m.replace(/:(cloud|latest)$/, "")];
|
|
140
|
+
for (const t of tries) {
|
|
141
|
+
const r = byKey.get(`${host}|${t}`);
|
|
142
|
+
if (r) return r;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------------------------
|
|
148
|
+
// what a turn cost
|
|
149
|
+
// ---------------------------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The input cost of one turn.
|
|
153
|
+
* input prompt tokens sent, summed over every step of the turn
|
|
154
|
+
* cached prompt tokens the provider served from its cache (summed), when it reported them
|
|
155
|
+
* cacheReported whether the provider reported a cached count at all
|
|
156
|
+
* firstIn the first step's prompt tokens (nothing is cached on the first step of a turn)
|
|
157
|
+
* → { usd, estimated, cachedTokens } — or null when there is no price (never a misleading $0).
|
|
158
|
+
*/
|
|
159
|
+
export function turnCost({ input = 0, cached = 0, cacheReported = false, firstIn = 0, price, assumedShare } = {}) {
|
|
160
|
+
if (!price || !Number.isFinite(price.input)) return null;
|
|
161
|
+
// The row's own assumption wins (providers cache differently), else the 90% default.
|
|
162
|
+
const share = Number.isFinite(assumedShare) ? assumedShare
|
|
163
|
+
: Number.isFinite(price.assumedCacheShare) ? price.assumedCacheShare
|
|
164
|
+
: ASSUMED_CACHED_SHARE;
|
|
165
|
+
const sent = Math.max(0, Number(input) || 0);
|
|
166
|
+
let cachedTokens;
|
|
167
|
+
let estimated = false;
|
|
168
|
+
if (cacheReported) {
|
|
169
|
+
cachedTokens = Math.min(sent, Math.max(0, Number(cached) || 0));
|
|
170
|
+
} else {
|
|
171
|
+
cachedTokens = Math.round(Math.min(1, Math.max(0, share)) * Math.max(0, sent - Math.max(0, Number(firstIn) || 0)));
|
|
172
|
+
estimated = cachedTokens > 0;
|
|
173
|
+
}
|
|
174
|
+
const cachedRate = Number.isFinite(price.cachedInput) ? price.cachedInput : price.input; // no cache price → no discount
|
|
175
|
+
const usd = ((sent - cachedTokens) * price.input + cachedTokens * cachedRate) / 1e6;
|
|
176
|
+
return { usd, estimated, cachedTokens };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** "$1.23", "$0.123", "$0.0042"; "~" in front when the cached share was assumed. */
|
|
180
|
+
export function formatUsd(usd, { estimated = false } = {}) {
|
|
181
|
+
if (!Number.isFinite(usd)) return "";
|
|
182
|
+
const v = Math.max(0, usd);
|
|
183
|
+
const s = v >= 1 ? v.toFixed(2) : v >= 0.01 ? v.toFixed(3) : v > 0 ? v.toFixed(4) : "0";
|
|
184
|
+
return `${estimated ? "~" : ""}$${s}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------------------------
|
|
188
|
+
// checking a price table (the published one, before it is applied)
|
|
189
|
+
// ---------------------------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
const ROW_FIELDS = new Set(["provider", "host", "url", "model", "input", "cachedInput", "output", "cacheWrite", "assumedCacheShare", "source", "checkedAt", "remove"]);
|
|
192
|
+
const price = (v) => v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= MAX_PRICE);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Check a price table (the shape PRICE_TABLE has; a bare array of rows is accepted too).
|
|
196
|
+
* → { ok, version, rows, removes, warnings } | { ok: false, problem }.
|
|
197
|
+
* A row with "remove": true deletes that server + model — nothing is ever deleted otherwise.
|
|
198
|
+
*/
|
|
199
|
+
export function validatePriceTable(input) {
|
|
200
|
+
let doc = input;
|
|
201
|
+
if (typeof input === "string") {
|
|
202
|
+
const src = input.replace(/^[ \t]*\/\/.*$/gm, "");
|
|
203
|
+
if (!src.trim()) return { ok: false, empty: true, problem: "nothing was pasted." };
|
|
204
|
+
try {
|
|
205
|
+
doc = JSON.parse(src);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return { ok: false, problem: `that is not valid JSON — ${String(e?.message ?? e).replace(/\s*in JSON at position \d+.*$/, "")}.` };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const list = Array.isArray(doc) ? doc : doc?.prices;
|
|
211
|
+
if (!Array.isArray(list)) return { ok: false, problem: 'expected { "prices": [ … ] } or a list of rows.' };
|
|
212
|
+
if (list.length === 0) return { ok: false, problem: "the table has no rows." };
|
|
213
|
+
if (list.length > MAX_PRICE_ROWS) return { ok: false, problem: `${list.length} rows — at most ${MAX_PRICE_ROWS}.` };
|
|
214
|
+
const version = !Array.isArray(doc) && doc.version != null ? String(doc.version).slice(0, 40) : "";
|
|
215
|
+
const rows = [];
|
|
216
|
+
const removes = [];
|
|
217
|
+
const warnings = [];
|
|
218
|
+
const seen = new Set();
|
|
219
|
+
for (let i = 0; i < list.length; i++) {
|
|
220
|
+
const r = list[i];
|
|
221
|
+
const at = `row ${i + 1}`;
|
|
222
|
+
if (!r || typeof r !== "object" || Array.isArray(r)) return { ok: false, problem: `${at} is not an object.` };
|
|
223
|
+
const host = hostOf(r.host ?? r.url);
|
|
224
|
+
const model = typeof r.model === "string" ? r.model.trim() : "";
|
|
225
|
+
if (!host) return { ok: false, problem: `${at} needs "host" (or "url") — the server it is priced on.` };
|
|
226
|
+
if (!model || model.length > 120) return { ok: false, problem: `${at} needs "model" (at most 120 characters).` };
|
|
227
|
+
const key = priceKey(host, model);
|
|
228
|
+
if (seen.has(key)) return { ok: false, problem: `${at}: ${host} ${model} appears twice.` };
|
|
229
|
+
seen.add(key);
|
|
230
|
+
const extra = Object.keys(r).filter((k) => !ROW_FIELDS.has(k));
|
|
231
|
+
if (extra.length) warnings.push(`${at} (${model}): ignored ${extra.map((k) => `"${k}"`).join(", ")}`);
|
|
232
|
+
if (r.remove === true) {
|
|
233
|
+
removes.push({ host, model });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!(typeof r.input === "number" && price(r.input))) {
|
|
237
|
+
return { ok: false, problem: `${at} (${model}): "input" must be a number of USD per 1M tokens, 0 to ${MAX_PRICE}.` };
|
|
238
|
+
}
|
|
239
|
+
for (const f of ["cachedInput", "output", "cacheWrite"]) {
|
|
240
|
+
if (r[f] !== undefined && !price(r[f])) return { ok: false, problem: `${at} (${model}): "${f}" must be a number 0 to ${MAX_PRICE}, or null.` };
|
|
241
|
+
}
|
|
242
|
+
if (r.assumedCacheShare !== undefined && !(typeof r.assumedCacheShare === "number" && r.assumedCacheShare >= 0 && r.assumedCacheShare <= 1)) {
|
|
243
|
+
return { ok: false, problem: `${at} (${model}): "assumedCacheShare" must be a number from 0 to 1 (0.9 = 90%).` };
|
|
244
|
+
}
|
|
245
|
+
if (typeof r.cachedInput === "number" && r.cachedInput > r.input) warnings.push(`${at} (${model}): cachedInput is MORE than input — check it`);
|
|
246
|
+
rows.push({
|
|
247
|
+
...(typeof r.provider === "string" && r.provider.trim() ? { provider: r.provider.trim().slice(0, 60) } : {}),
|
|
248
|
+
host,
|
|
249
|
+
model,
|
|
250
|
+
input: r.input,
|
|
251
|
+
cachedInput: r.cachedInput ?? null,
|
|
252
|
+
output: r.output ?? null,
|
|
253
|
+
...(typeof r.cacheWrite === "number" ? { cacheWrite: r.cacheWrite } : {}),
|
|
254
|
+
...(typeof r.assumedCacheShare === "number" ? { assumedCacheShare: r.assumedCacheShare } : {}),
|
|
255
|
+
...(typeof r.source === "string" && /^https?:\/\//.test(r.source) ? { source: r.source.slice(0, 300) } : {}),
|
|
256
|
+
...(typeof r.checkedAt === "string" && !Number.isNaN(Date.parse(r.checkedAt)) ? { checkedAt: r.checkedAt.slice(0, 30) } : {}),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return { ok: true, version, rows, removes, warnings };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const COMPARED = ["input", "cachedInput", "output", "cacheWrite", "assumedCacheShare", "provider"];
|
|
263
|
+
|
|
264
|
+
/** What applying `incoming` to `current` would do. → { added, changed:[{row, was, fields}], unchanged, removed, missingRemoves } */
|
|
265
|
+
export function diffPrices(current, incoming, removes = []) {
|
|
266
|
+
const now = new Map((current ?? []).map((r) => [priceKey(r.host, r.model), r]));
|
|
267
|
+
const added = [];
|
|
268
|
+
const changed = [];
|
|
269
|
+
let unchanged = 0;
|
|
270
|
+
for (const r of incoming) {
|
|
271
|
+
const was = now.get(priceKey(r.host, r.model));
|
|
272
|
+
if (!was) {
|
|
273
|
+
added.push(r);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const fields = COMPARED.filter((f) => (r[f] ?? null) !== (was[f] ?? null) && !(f === "provider" && r[f] == null));
|
|
277
|
+
if (fields.length) changed.push({ row: r, was, fields });
|
|
278
|
+
else unchanged++;
|
|
279
|
+
}
|
|
280
|
+
const removed = removes.filter((x) => now.has(priceKey(x.host, x.model)));
|
|
281
|
+
const missingRemoves = removes.filter((x) => !now.has(priceKey(x.host, x.model)));
|
|
282
|
+
return { added, changed, unchanged, removed, missingRemoves };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The diff as lines, for the "apply? [y/N]" question. */
|
|
286
|
+
export function diffLines(d) {
|
|
287
|
+
const p = (v) => (v == null ? "—" : `$${v}`);
|
|
288
|
+
const out = [];
|
|
289
|
+
for (const r of d.added) out.push(`+ ${r.host} ${r.model} in ${p(r.input)} · cached ${p(r.cachedInput)}`);
|
|
290
|
+
const pct = (v) => (v == null ? "—" : `${Math.round(v * 100)}%`);
|
|
291
|
+
const show = (f, v) => (f === "assumedCacheShare" ? pct(v) : p(v));
|
|
292
|
+
for (const c of d.changed) out.push(`~ ${c.row.host} ${c.row.model} ${c.fields.map((f) => (f === "provider" ? `provider → ${c.row.provider}` : `${f} ${show(f, c.was[f])} → ${show(f, c.row[f])}`)).join(", ")}`);
|
|
293
|
+
for (const r of d.removed) out.push(`- ${r.host} ${r.model} (removed)`);
|
|
294
|
+
out.push(`${d.added.length} new · ${d.changed.length} changed · ${d.unchanged} unchanged${d.removed.length ? ` · ${d.removed.length} removed` : ""}`);
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------------------------
|
|
299
|
+
// /price
|
|
300
|
+
// ---------------------------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The table for /price, grouped by server, newest-first nowhere — alphabetical, so a model is easy
|
|
304
|
+
* to find. `current` = { url, model } of the coder in use: its row is marked "▸".
|
|
305
|
+
* Columns: model · input · cached · output (USD per 1M). Fits `width` (80 on a server over SSH).
|
|
306
|
+
*/
|
|
307
|
+
export function priceTableLines(rows, { width = 80, current = null, version = "" } = {}) {
|
|
308
|
+
const cur = current ? findPrice(rows, current.url, current.model) : null;
|
|
309
|
+
const money = (v) => (v == null ? "—" : `$${Number(v) < 0.1 && v !== 0 ? Number(v).toString() : Number(v).toFixed(2)}`);
|
|
310
|
+
const numW = 9;
|
|
311
|
+
const modelW = Math.max(12, Math.min(34, width - 4 - 2 - numW * 3));
|
|
312
|
+
const cut = (s, w) => (s.length > w ? s.slice(0, w - 1) + "…" : s.padEnd(w));
|
|
313
|
+
const out = [`USD per 1M tokens${version ? ` · table v${version}` : ""}`];
|
|
314
|
+
out.push(` ${"model".padEnd(modelW)}${"input".padStart(numW)}${"cached".padStart(numW)}${"output".padStart(numW)}`);
|
|
315
|
+
const hosts = [...new Set(rows.map((r) => r.host))].sort();
|
|
316
|
+
for (const h of hosts) {
|
|
317
|
+
const group = rows.filter((r) => r.host === h).sort((a, b) => a.model.localeCompare(b.model));
|
|
318
|
+
out.push(`${h}${group[0]?.provider ? ` (${group[0].provider})` : ""}`);
|
|
319
|
+
for (const r of group) {
|
|
320
|
+
out.push(`${r === cur ? "▸ " : " "}${cut(r.model, modelW)}${money(r.input).padStart(numW)}${money(r.cachedInput).padStart(numW)}${money(r.output).padStart(numW)}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------------------------
|
|
327
|
+
// THE ASSUMED-CACHE-SHARE TABLE (#323 follow-up) — its own table so the admin can review and
|
|
328
|
+
// publish it on its own (Deploy app → "Cache shares" → Update to API), and users can read it with
|
|
329
|
+
// /prices. Made FROM the price table (the rows that have a cache price), so the two cannot
|
|
330
|
+
// disagree.
|
|
331
|
+
// ---------------------------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
/** Why each provider's share is what it is — shown beside the number, so it can be reviewed. */
|
|
334
|
+
export const CACHE_SHARE_WHY = Object.freeze({
|
|
335
|
+
"api.moonshot.ai": "automatic prefix caching (Kimi reports cached_tokens)",
|
|
336
|
+
"api.openai.com": "automatic prefix caching for prompts ≥ 1024 tokens (reports cached_tokens)",
|
|
337
|
+
"api.anthropic.com": "cached only when the request asks — gu's Claude path does (reports cache reads)",
|
|
338
|
+
"ollama.com": "lists cache prices; whether it reports a count is not yet verified",
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
export const CACHE_SHARE_TABLE = Object.freeze({
|
|
342
|
+
gu: "cache-shares",
|
|
343
|
+
version: PRICE_TABLE_VERSION,
|
|
344
|
+
publishedAt: PRICE_TABLE.publishedAt,
|
|
345
|
+
note: "The share of a step's prompt (after a turn's first step) assumed served from the provider's cache, used ONLY when the provider does not report its own cached count. 0.9 = 90%.",
|
|
346
|
+
shares: PRICE_TABLE.prices
|
|
347
|
+
.filter((r) => r.cachedInput != null)
|
|
348
|
+
.map((r) => ({ provider: r.provider, host: r.host, model: r.model, assumedCacheShare: r.assumedCacheShare ?? ASSUMED_CACHED_SHARE, why: CACHE_SHARE_WHY[r.host] ?? "" })),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
/** Check a cache-share table → { ok, version, shares } | { ok: false, problem }. */
|
|
352
|
+
export function validateCacheShareTable(input) {
|
|
353
|
+
let doc = input;
|
|
354
|
+
if (typeof input === "string") {
|
|
355
|
+
try {
|
|
356
|
+
doc = JSON.parse(input.replace(/^[ \t]*\/\/.*$/gm, ""));
|
|
357
|
+
} catch (e) {
|
|
358
|
+
return { ok: false, problem: `that is not valid JSON — ${String(e?.message ?? e).replace(/\s*in JSON at position \d+.*$/, "")}.` };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const list = Array.isArray(doc) ? doc : doc?.shares;
|
|
362
|
+
if (!Array.isArray(list) || list.length === 0) return { ok: false, problem: 'expected { "shares": [ … ] } with at least one row.' };
|
|
363
|
+
if (list.length > MAX_PRICE_ROWS) return { ok: false, problem: `${list.length} rows — at most ${MAX_PRICE_ROWS}.` };
|
|
364
|
+
const seen = new Set();
|
|
365
|
+
const shares = [];
|
|
366
|
+
for (const [i, r] of list.entries()) {
|
|
367
|
+
const at = `row ${i + 1}`;
|
|
368
|
+
const host = hostOf(r?.host ?? r?.url);
|
|
369
|
+
const model = typeof r?.model === "string" ? r.model.trim() : "";
|
|
370
|
+
if (!host || !model) return { ok: false, problem: `${at} needs "host" and "model".` };
|
|
371
|
+
if (!(typeof r.assumedCacheShare === "number" && r.assumedCacheShare >= 0 && r.assumedCacheShare <= 1)) {
|
|
372
|
+
return { ok: false, problem: `${at} (${model}): "assumedCacheShare" must be a number from 0 to 1 (0.9 = 90%).` };
|
|
373
|
+
}
|
|
374
|
+
const key = priceKey(host, model);
|
|
375
|
+
if (seen.has(key)) return { ok: false, problem: `${at}: ${host} ${model} appears twice.` };
|
|
376
|
+
seen.add(key);
|
|
377
|
+
shares.push({
|
|
378
|
+
...(typeof r.provider === "string" && r.provider.trim() ? { provider: r.provider.trim().slice(0, 60) } : {}),
|
|
379
|
+
host,
|
|
380
|
+
model,
|
|
381
|
+
assumedCacheShare: r.assumedCacheShare,
|
|
382
|
+
...(typeof r.why === "string" && r.why.trim() ? { why: r.why.trim().slice(0, 200) } : {}),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
const version = !Array.isArray(doc) && doc.version != null ? String(doc.version).slice(0, 40) : "";
|
|
386
|
+
return { ok: true, version, shares };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The cache table in /prices: the share each model is assumed to cache — THIS ACCOUNT's (its price rows, what its
|
|
391
|
+
* estimates use) beside the one gu PUBLISHED (when the API has one), marking where they differ.
|
|
392
|
+
* Fits `width`; the coder in use is marked "▸".
|
|
393
|
+
*/
|
|
394
|
+
export function cacheShareLines({ yours = [], published = null, width = 80, current = null } = {}) {
|
|
395
|
+
const pub = new Map((published?.shares ?? []).map((s) => [priceKey(s.host, s.model), s.assumedCacheShare]));
|
|
396
|
+
const mine = (yours ?? []).filter((r) => r.cachedInput != null || pub.has(priceKey(r.host, r.model)));
|
|
397
|
+
const cur = current ? findPrice(mine, current.url, current.model) : null;
|
|
398
|
+
const pct = (v) => (Number.isFinite(v) ? `${Math.round(v * 100)}%` : "—");
|
|
399
|
+
const colW = 8;
|
|
400
|
+
const modelW = Math.max(12, Math.min(34, width - 4 - 2 - colW * 2 - 2));
|
|
401
|
+
const cut = (s, w) => (s.length > w ? s.slice(0, w - 1) + "…" : s.padEnd(w));
|
|
402
|
+
const out = [];
|
|
403
|
+
out.push(` ${"model".padEnd(modelW)}${"yours".padStart(colW)}${published ? "gu".padStart(colW) : ""}`);
|
|
404
|
+
const keys = new Set([...mine.map((r) => priceKey(r.host, r.model)), ...pub.keys()]);
|
|
405
|
+
const rows = [...keys].map((k) => {
|
|
406
|
+
const r = mine.find((x) => priceKey(x.host, x.model) === k);
|
|
407
|
+
const s = published?.shares?.find((x) => priceKey(x.host, x.model) === k);
|
|
408
|
+
return { host: r?.host ?? s.host, model: r?.model ?? s.model, yours: r ? (Number.isFinite(r.assumedCacheShare) ? r.assumedCacheShare : ASSUMED_CACHED_SHARE) : undefined, gu: pub.get(k), row: r };
|
|
409
|
+
});
|
|
410
|
+
for (const h of [...new Set(rows.map((r) => r.host))].sort()) {
|
|
411
|
+
out.push(h);
|
|
412
|
+
for (const r of rows.filter((x) => x.host === h).sort((a, b) => a.model.localeCompare(b.model))) {
|
|
413
|
+
const differs = published && r.gu !== undefined && r.yours !== undefined && Math.abs(r.gu - r.yours) > 1e-9;
|
|
414
|
+
out.push(`${r.row && r.row === cur ? "▸ " : " "}${cut(r.model, modelW)}${pct(r.yours).padStart(colW)}${published ? pct(r.gu).padStart(colW) : ""}${differs ? " *" : ""}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------------------------
|
|
421
|
+
// "NEW PRICES UPDATED <date>" (#323): gu checks the hosted API's GET /api/get-price-and-cache-table
|
|
422
|
+
// (the newest tables the admin published from the Deploy app); when they are newer than what this
|
|
423
|
+
// account last applied, the notice row offers them, and a click writes both into the user's Mongo.
|
|
424
|
+
// ---------------------------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
/** Is the published stamp newer than the one this account applied? ("" applied = never.) */
|
|
427
|
+
export function hasNewerPublished(appliedAt, publishedAt) {
|
|
428
|
+
const p = Date.parse(publishedAt ?? "");
|
|
429
|
+
if (!Number.isFinite(p)) return false;
|
|
430
|
+
const a = Date.parse(appliedAt ?? "");
|
|
431
|
+
return !Number.isFinite(a) || p > a;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The notice: "new prices updated 1 Oct 2026". */
|
|
435
|
+
export function priceUpdateLabel(publishedAt) {
|
|
436
|
+
const d = new Date(publishedAt);
|
|
437
|
+
if (Number.isNaN(d.getTime())) return "new prices updated";
|
|
438
|
+
const m = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][d.getUTCMonth()];
|
|
439
|
+
return `new prices updated ${d.getUTCDate()} ${m} ${d.getUTCFullYear()}`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The published tables onto this account's rows → what to write.
|
|
444
|
+
* · the published PRICE table's rows replace the same server + model here (others are kept —
|
|
445
|
+
* a self-hosted server priced by hand stays);
|
|
446
|
+
* · the published CACHE table's share is set on each matching row (published price rows and the
|
|
447
|
+
* account's own);
|
|
448
|
+
* · nothing is deleted.
|
|
449
|
+
* → { ok, rows: the rows that change (to PUT), diff, version, publishedAt } | { ok: false, problem }
|
|
450
|
+
*/
|
|
451
|
+
export function mergePublishedTables(current, cloud) {
|
|
452
|
+
const pub = cloud ?? {};
|
|
453
|
+
const priceRows = [];
|
|
454
|
+
if (pub.prices) {
|
|
455
|
+
const c = validatePriceTable(pub.prices);
|
|
456
|
+
if (!c.ok) return { ok: false, problem: `the published price table is not usable — ${c.problem}` };
|
|
457
|
+
priceRows.push(...c.rows);
|
|
458
|
+
}
|
|
459
|
+
let shares = [];
|
|
460
|
+
if (pub.cacheShares) {
|
|
461
|
+
const c = validateCacheShareTable(pub.cacheShares);
|
|
462
|
+
if (!c.ok) return { ok: false, problem: `the published cache table is not usable — ${c.problem}` };
|
|
463
|
+
shares = c.shares;
|
|
464
|
+
}
|
|
465
|
+
if (!priceRows.length && !shares.length) return { ok: false, problem: "nothing has been published yet." };
|
|
466
|
+
const share = new Map(shares.map((s) => [priceKey(s.host, s.model), s.assumedCacheShare]));
|
|
467
|
+
const byKey = new Map((current ?? []).map((r) => [priceKey(r.host, r.model), { ...r }]));
|
|
468
|
+
for (const r of priceRows) {
|
|
469
|
+
const was = byKey.get(priceKey(r.host, r.model));
|
|
470
|
+
// A published price row without its own share keeps the one this account has, until the cache
|
|
471
|
+
// table says otherwise.
|
|
472
|
+
byKey.set(priceKey(r.host, r.model), { ...r, ...(r.assumedCacheShare == null && was?.assumedCacheShare != null ? { assumedCacheShare: was.assumedCacheShare } : {}) });
|
|
473
|
+
}
|
|
474
|
+
for (const [k, row] of byKey) if (share.has(k) && row.cachedInput != null) row.assumedCacheShare = share.get(k);
|
|
475
|
+
const merged = [...byKey.values()];
|
|
476
|
+
const diff = diffPrices(current ?? [], merged);
|
|
477
|
+
return {
|
|
478
|
+
ok: true,
|
|
479
|
+
rows: [...diff.added, ...diff.changed.map((c) => c.row)],
|
|
480
|
+
diff,
|
|
481
|
+
version: pub.prices?.version || pub.cacheShares?.version || "",
|
|
482
|
+
publishedAt: pub.publishedAt || [pub.prices?.publishedAt, pub.cacheShares?.publishedAt].filter(Boolean).sort().at(-1) || "",
|
|
483
|
+
};
|
|
484
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.784",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"image-capable-coders.json",
|
|
70
70
|
"preset-editor.mjs",
|
|
71
71
|
"preset-apply.mjs",
|
|
72
|
+
"model-prices.mjs",
|
|
72
73
|
"device-login.mjs",
|
|
73
74
|
"daemon-control.mjs",
|
|
74
75
|
"log-policy.mjs",
|
package/preset-apply.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* here the REAL code runs in tests against a fake API that records every call and can be told to
|
|
5
5
|
* fail — which is how "loading a preset cannot break the workspace" is checked rather than hoped.
|
|
6
6
|
*
|
|
7
|
-
* Used for /
|
|
7
|
+
* Used for /use-presets (a version-2 preset) and /load-settings — one path for all.
|
|
8
8
|
*
|
|
9
9
|
* api = {
|
|
10
10
|
* settings(): Promise<object|null> GET /api/worker/settings
|
|
@@ -101,7 +101,7 @@ export async function applyTransfer(value, { api, io, fromLine = "", carriesSecr
|
|
|
101
101
|
}
|
|
102
102
|
out.backup = b.file;
|
|
103
103
|
say(`backup of your current setup: ${b.file}`);
|
|
104
|
-
say(`to undo: /
|
|
104
|
+
say(`to undo: /use-presets ${b.file}`);
|
|
105
105
|
}
|
|
106
106
|
const patch = toSettingsPatch(value, current);
|
|
107
107
|
const saved = Object.keys(patch).length ? await api.saveSettings(patch) : true;
|