pi-clinepass 0.1.0 → 0.1.2
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/CHANGELOG.md +32 -0
- package/LICENSE +21 -21
- package/README.md +122 -117
- package/package.json +6 -4
- package/src/auth.ts +375 -363
- package/src/calibrate.ts +773 -0
- package/src/catalog.ts +85 -28
- package/src/errors.ts +114 -112
- package/src/headers.ts +83 -80
- package/src/index.ts +234 -21
- package/src/pricing.ts +204 -0
- package/src/settings.ts +56 -49
- package/src/thinking.ts +104 -0
- package/src/usage.ts +895 -743
- package/src/views.ts +397 -0
- package/src/workos.ts +245 -231
package/src/calibrate.ts
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Price calibration: measure the real billing rates Cline's gateway charges
|
|
3
|
+
* for each paid model, replacing the static catalog when it drifts.
|
|
4
|
+
*
|
|
5
|
+
* Probe design (adaptive 2→3 turns per model, single corpus, identical
|
|
6
|
+
* requests fired through the exact same pi-ai path real chats use):
|
|
7
|
+
* T1 aCold corpus A (~5k tok) maxTokens 32 — cold: seeds the cache and
|
|
8
|
+
* provides the input-rate sample
|
|
9
|
+
* T2 aWarmBig corpus A maxTokens 1000 — warm attempt, big output:
|
|
10
|
+
* THE output-measurement turn
|
|
11
|
+
* T3 aWarmTiny corpus A maxTokens 30 — warm tiny, fired only when
|
|
12
|
+
* T2 engaged the cache
|
|
13
|
+
*
|
|
14
|
+
* Derivation (integer micro-units of $1e-8; $/1M = mu/100):
|
|
15
|
+
* T2 engaged (cachedTokens ≥ 1000): output = (K2−K3)/(C2−C3) — T2 and T3 are
|
|
16
|
+
* identical requests differing only in output length, so subtracting their
|
|
17
|
+
* bills cancels input and cache completely, leaving pure output cost.
|
|
18
|
+
* input = (K1 − C1·output)/P (the cold turn, corrected by the now-known
|
|
19
|
+
* output). cacheRead = (K3 − (P−R3)·input − C3·output)/R3.
|
|
20
|
+
* T2 missed (cachedTokens < 1000): T2 is a second full-price sample — input
|
|
21
|
+
* and output come from the cold pair (T1,T2); cacheRead is inherited from
|
|
22
|
+
* the current effective price (cache is the cheapest component; a stale
|
|
23
|
+
* cacheRead barely moves real cost, and its error is suppressed by the
|
|
24
|
+
* large solve determinant).
|
|
25
|
+
*
|
|
26
|
+
* Failure policy — never leave errors to the user: each turn retries once,
|
|
27
|
+
* value-level anomalies re-run the warm pair once, anything still broken keeps
|
|
28
|
+
* the model's previous prices with an explicit report line. A global
|
|
29
|
+
* median-ratio tripwire (0.05x-20x) catches billing-unit changes. The corpus
|
|
30
|
+
* is a deterministic fictional document (no PII, single prefix).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
34
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
35
|
+
import type { ClinePassModel } from "./catalog.js";
|
|
36
|
+
import { PROVIDER_NAME, fetchUsageRecords, getActiveToken, modelsMatch, type UsageOptions, type UsageRecord } from "./usage.js";
|
|
37
|
+
import { DEFAULT_API_BASE } from "./workos.js";
|
|
38
|
+
import type { CalibratedRates, CalibrationFile } from "./pricing.js";
|
|
39
|
+
|
|
40
|
+
export type ProbeModel = ClinePassModel & {
|
|
41
|
+
api: "openai-completions";
|
|
42
|
+
provider: string;
|
|
43
|
+
baseUrl: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ─── Probe configuration ───────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const PROBE_INSTRUCTION =
|
|
49
|
+
"From the document above, summarize the most important figures, trends, and " +
|
|
50
|
+
"open risks in one detailed paragraph. Use only figures that appear in the " +
|
|
51
|
+
"document; do not invent numbers.";
|
|
52
|
+
|
|
53
|
+
const TURN_TIMEOUT_MS = 90_000;
|
|
54
|
+
/** Poll attempts for the server to flush the turn's usage record. */
|
|
55
|
+
const FLUSH_POLLS = 8;
|
|
56
|
+
const FLUSH_POLL_MS = 500;
|
|
57
|
+
const MIN_DET = 200_000;
|
|
58
|
+
/** cachedTokens at/above this on the warm turn = cache engaged. */
|
|
59
|
+
const MIN_CACHED = 1_000;
|
|
60
|
+
/** Minimum completion-token gap between the big and tiny warm turns. */
|
|
61
|
+
const MIN_WARM_DIFF = 150;
|
|
62
|
+
/** Rates within this fraction of the previous price count as "unchanged". */
|
|
63
|
+
const UNCHANGED_TOLERANCE = 0.005;
|
|
64
|
+
|
|
65
|
+
type TurnKey = "aCold" | "aWarmBig" | "aWarmTiny";
|
|
66
|
+
|
|
67
|
+
interface TurnSpec {
|
|
68
|
+
key: TurnKey;
|
|
69
|
+
maxTokens: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const TURN_A_COLD: TurnSpec = { key: "aCold", maxTokens: 32 };
|
|
73
|
+
const TURN_A_WARM_BIG: TurnSpec = { key: "aWarmBig", maxTokens: 1000 };
|
|
74
|
+
const TURN_A_WARM_TINY: TurnSpec = { key: "aWarmTiny", maxTokens: 30 };
|
|
75
|
+
/** View label per turn. */
|
|
76
|
+
const TURN_LABEL: Record<TurnKey, string> = { aCold: "T1", aWarmBig: "T2", aWarmTiny: "T3" };
|
|
77
|
+
|
|
78
|
+
// ─── Deterministic synthetic corpus (single prefix, no PII) ───────────────
|
|
79
|
+
|
|
80
|
+
function mulberry32(seed: number): () => number {
|
|
81
|
+
let a = seed >>> 0;
|
|
82
|
+
return () => {
|
|
83
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
84
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
85
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
86
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const int = (r: () => number, lo: number, hi: number): number => lo + Math.floor(r() * (hi - lo + 1));
|
|
91
|
+
const pick = <T,>(arr: readonly T[], r: () => number): T => arr[Math.floor(r() * arr.length)]!;
|
|
92
|
+
const num = (v: number): string => v.toLocaleString("en-US");
|
|
93
|
+
const moneyK = (r: () => number, lo: number, hi: number): string =>
|
|
94
|
+
(lo + r() * (hi - lo)).toLocaleString("en-US", { maximumFractionDigits: 1 });
|
|
95
|
+
|
|
96
|
+
const PRODUCTS = ["Atlas", "Borealis", "Cinder", "Ember", "Gantry", "Helix", "Ionos", "Juniper", "Kestrel", "Lumen", "Onyx", "Pylon", "Quill", "Sable", "Tundra", "Vesper", "Wren", "Zephyr"] as const;
|
|
97
|
+
const REGIONS = ["us-east", "us-west", "eu-central", "eu-north", "ap-south", "ap-northeast", "sa-east"] as const;
|
|
98
|
+
const TEAMS = ["Platform", "Billing", "Growth", "Infrastructure", "Security", "Data", "Developer Experience", "Customer Reliability"] as const;
|
|
99
|
+
const CAUSES = ["a connection-pool leak", "a config drift in the edge tier", "an expired signing certificate", "a bad migration lock", "a retry storm from a partner integration"] as const;
|
|
100
|
+
const VENDORS = ["Northlake Logistics", "Copperfield Analytics", "Bluepeak Hosting", "Ravensworth Legal", "Stonewell Facilities", "Ironvale Supply"] as const;
|
|
101
|
+
const FEATURES = ["SSO enforcement", "audit exports", "usage-based invoicing", "regional data residency", "webhook replay", "granular RBAC"] as const;
|
|
102
|
+
const THEMES = ["invoice clarity", "API key rotation", "dashboard latency", "notification noise", "mobile parity"] as const;
|
|
103
|
+
const SPENDKIND = ["compute", "observability", "third-party licenses", "data transfer"] as const;
|
|
104
|
+
|
|
105
|
+
const BUILDERS: readonly ((r: () => number) => string)[] = [
|
|
106
|
+
(r) => {
|
|
107
|
+
const pct = int(r, 3, 34);
|
|
108
|
+
return `- ${pick(PRODUCTS, r)}: ${num(int(r, 180, 2400))} support tickets resolved this week (${pct}% ${r() < 0.5 ? "better" : "worse"} than plan); median first response ${num(int(r, 8, 220))} minutes; queue depth ${(r() * 4 + 0.4).toFixed(1)}h at close, concentrated in ${pick(REGIONS, r)}.`;
|
|
109
|
+
},
|
|
110
|
+
(r) =>
|
|
111
|
+
`- ${pick(PRODUCTS, r)} net revenue $${moneyK(r, 40, 980)}k (${int(r, 1, 28)}% ${r() < 0.6 ? "up" : "down"} QoQ); expansion revenue $${moneyK(r, 5, 90)}k; logo churn ${int(r, 0, 9)} accounts with gross retention at ${int(r, 86, 99)}%.`,
|
|
112
|
+
(r) =>
|
|
113
|
+
`- Incident review: ${int(r, 6, 240)}-minute degradation affecting ${num(int(r, 120, 90000))} sessions; root cause ${pick(CAUSES, r)}; mitigated by rollback plus a feature-flag freeze; follow-up owned by ${pick(TEAMS, r)} with a ${int(r, 2, 21)}-day fix window.`,
|
|
114
|
+
(r) =>
|
|
115
|
+
`- Policy change: expense reports above $${num(int(r, 200, 5000))} now require ${int(r, 2, 5)} approvals; reimbursement SLA ${int(r, 3, 15)} business days; receipts older than ${int(r, 30, 180)} days are auto-declined unless a manager override is attached.`,
|
|
116
|
+
(r) =>
|
|
117
|
+
`- Vendor: ${pick(VENDORS, r)} renewed at $${moneyK(r, 8, 400)}k/year (${int(r, -12, 18)}% vs prior term); contract includes a ${int(r, 30, 180)}-day termination notice and an uptime credit of ${int(r, 2, 15)}% per missed month.`,
|
|
118
|
+
(r) =>
|
|
119
|
+
`- Hiring: ${pick(TEAMS, r)} opened ${int(r, 1, 7)} ${pick(["senior", "staff", "mid-level"], r)} roles; pipeline shows ${num(int(r, 12, 240))} applicants, ${int(r, 3, 40)} screening calls, ${int(r, 1, 9)} onsite loops; target start within ${int(r, 3, 12)} weeks.`,
|
|
120
|
+
(r) =>
|
|
121
|
+
`- Roadmap: ${pick(PRODUCTS, r)} ships ${pick(FEATURES, r)} in ${pick(["Q1", "Q2", "Q3", "Q4"], r)}; engineering estimate ${int(r, 2, 26)} engineer-weeks behind a ${pick(["beta", "GA", "dark-launch"], r)} flag; dependencies limited to internal tooling.`,
|
|
122
|
+
(r) =>
|
|
123
|
+
`- Voice of customer: ${num(int(r, 20, 900))} survey responses; top theme (${int(r, 18, 55)}%) is ${pick(THEMES, r)}; NPS ${int(r, 21, 72)} with detractors concentrated in accounts older than ${int(r, 1, 5)} years.`,
|
|
124
|
+
(r) =>
|
|
125
|
+
`- Cost review: ${pick(SPENDKIND, r)} spend $${moneyK(r, 12, 320)}k MTD, forecast $${moneyK(r, 40, 900)}k EOM; ${int(r, 5, 40)}% of the spend tied to ${pick(["dev clusters", "log ingestion", "egress", "idle replicas"], r)} is flagged for rightsizing.`,
|
|
126
|
+
(r) =>
|
|
127
|
+
`- Compliance: SOC 2 evidence collection ${int(r, 20, 99)}% complete with ${int(r, 0, 12)} open findings (${int(r, 0, 4)} high); access reviews for ${pick(TEAMS, r)} due in ${int(r, 2, 30)} days; audit log retention verified for the trailing ${int(r, 6, 24)} months.`,
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
let corpusCache: string | undefined;
|
|
131
|
+
|
|
132
|
+
function probeCorpus(): string {
|
|
133
|
+
corpusCache ??= (() => {
|
|
134
|
+
const r = mulberry32(0xa1101);
|
|
135
|
+
const parts: string[] = [
|
|
136
|
+
"HELIOS DYNAMICS — OPERATIONS WIKI (INTERNAL)",
|
|
137
|
+
"Helios Dynamics builds developer tooling for mid-market engineering teams. This wiki is the single source of truth for weekly operations, product performance, vendor commitments, and policy changes. Figures are point-in-time snapshots captured by the operations analytics pipeline; percentages compare against the trailing four-week baseline unless stated otherwise. Conflicts between this document and the dashboards should be treated as a stale-cache signal and reported through a data ticket.",
|
|
138
|
+
];
|
|
139
|
+
let week = 1;
|
|
140
|
+
// ~6,000 chars of this number-dense content measures ≈5,000 tokens on the
|
|
141
|
+
// gateway (verified: dense figures tokenize at ~0.83 tokens/char).
|
|
142
|
+
while (parts.join("\n\n").length < 6_000) {
|
|
143
|
+
const lines: string[] = [`## Weekly operations digest — week ${week}`];
|
|
144
|
+
const count = int(r, 5, 8);
|
|
145
|
+
for (let i = 0; i < count; i++) lines.push(pick(BUILDERS, r)(r));
|
|
146
|
+
parts.push(lines.join("\n"));
|
|
147
|
+
week++;
|
|
148
|
+
}
|
|
149
|
+
return parts.join("\n\n");
|
|
150
|
+
})();
|
|
151
|
+
return corpusCache;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Rate derivation (pure) ────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/** One probe turn's server-truth measurement; costMu is integer micro-$ (1e-8). */
|
|
157
|
+
export interface TurnMeasure {
|
|
158
|
+
costMu: number;
|
|
159
|
+
prompt: number;
|
|
160
|
+
cached: number;
|
|
161
|
+
completion: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface ProbeSet {
|
|
165
|
+
aCold: TurnMeasure;
|
|
166
|
+
aWarmBig: TurnMeasure;
|
|
167
|
+
/** Present only when the warm turn engaged the cache. */
|
|
168
|
+
aWarmTiny?: TurnMeasure;
|
|
169
|
+
/** Inherited cacheRead in micro-$ per token (previous effective price) —
|
|
170
|
+
* used to correct a partially-cached miss so in/out stay clean. */
|
|
171
|
+
crRefMu?: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface DerivedRates {
|
|
175
|
+
/** $/1M tokens. */
|
|
176
|
+
inputPerM: number;
|
|
177
|
+
outputPerM: number;
|
|
178
|
+
/** undefined → cache never engaged; inherit previous cacheRead. */
|
|
179
|
+
cacheReadPerM?: number;
|
|
180
|
+
cacheEngaged: boolean;
|
|
181
|
+
/** Value-level anomaly that one warm-pair refresh may fix. */
|
|
182
|
+
needsWarmRefresh: boolean;
|
|
183
|
+
notes: string[];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function toMeasure(rec: UsageRecord): TurnMeasure {
|
|
187
|
+
return {
|
|
188
|
+
costMu: Math.round(rec.costUsd * 1e8),
|
|
189
|
+
prompt: rec.promptTokens,
|
|
190
|
+
cached: rec.cachedTokens,
|
|
191
|
+
completion: rec.completionTokens,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function deriveRates(ps: ProbeSet): DerivedRates {
|
|
196
|
+
const notes: string[] = [];
|
|
197
|
+
let needsWarmRefresh = false;
|
|
198
|
+
const markRefresh = (): void => {
|
|
199
|
+
needsWarmRefresh = true;
|
|
200
|
+
};
|
|
201
|
+
const engaged = ps.aWarmBig.cached >= MIN_CACHED;
|
|
202
|
+
|
|
203
|
+
// ── Cache miss: T2 is a second full-price sample → cold pair solve ──────
|
|
204
|
+
if (!engaged) {
|
|
205
|
+
const u2 = ps.aWarmBig.prompt - ps.aWarmBig.cached;
|
|
206
|
+
const crRefMu = ps.crRefMu ?? 0;
|
|
207
|
+
// Correct T2's bill for the (small) cached portion using the inherited rate.
|
|
208
|
+
const k2 = ps.aWarmBig.costMu - ps.aWarmBig.cached * crRefMu;
|
|
209
|
+
const det = ps.aCold.prompt * ps.aWarmBig.completion - u2 * ps.aCold.completion;
|
|
210
|
+
if (!Number.isFinite(det) || Math.abs(det) < MIN_DET) {
|
|
211
|
+
return {
|
|
212
|
+
inputPerM: 0,
|
|
213
|
+
outputPerM: 0,
|
|
214
|
+
cacheEngaged: false,
|
|
215
|
+
needsWarmRefresh: false,
|
|
216
|
+
notes: [`cold-pair conditioning too weak (completion gap ${ps.aWarmBig.completion - ps.aCold.completion} tokens)`],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const inMu = (ps.aCold.costMu * ps.aWarmBig.completion - k2 * ps.aCold.completion) / det;
|
|
220
|
+
const outMu = (ps.aCold.prompt * k2 - u2 * ps.aCold.costMu) / det;
|
|
221
|
+
if (!(inMu > 0) || !(outMu > 0)) {
|
|
222
|
+
return {
|
|
223
|
+
inputPerM: 0,
|
|
224
|
+
outputPerM: 0,
|
|
225
|
+
cacheEngaged: false,
|
|
226
|
+
needsWarmRefresh: false,
|
|
227
|
+
notes: ["derived input or output rate is not positive (billing anomaly)"],
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
notes.push(`cache did not engage (cachedTokens ${ps.aWarmBig.cached})`);
|
|
231
|
+
return {
|
|
232
|
+
inputPerM: inMu / 100,
|
|
233
|
+
outputPerM: outMu / 100,
|
|
234
|
+
cacheEngaged: false,
|
|
235
|
+
needsWarmRefresh: false,
|
|
236
|
+
notes,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Cache engaged: output from the warm pair (T2 big vs T3 tiny) ────────
|
|
241
|
+
if (!ps.aWarmTiny) {
|
|
242
|
+
return {
|
|
243
|
+
inputPerM: 0,
|
|
244
|
+
outputPerM: 0,
|
|
245
|
+
cacheEngaged: true,
|
|
246
|
+
needsWarmRefresh: true,
|
|
247
|
+
notes: ["cache engaged but the tiny warm turn is missing"],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Output: identical requests differing only in completion length —
|
|
252
|
+
// subtracting the bills cancels input and cache entirely.
|
|
253
|
+
const dC = ps.aWarmBig.completion - ps.aWarmTiny.completion;
|
|
254
|
+
if (Math.abs(dC) < MIN_WARM_DIFF) {
|
|
255
|
+
return {
|
|
256
|
+
inputPerM: 0,
|
|
257
|
+
outputPerM: 0,
|
|
258
|
+
cacheEngaged: true,
|
|
259
|
+
needsWarmRefresh: true,
|
|
260
|
+
notes: [`warm output gap too small for derivation (${dC} tokens)`],
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const outMu = (ps.aWarmBig.costMu - ps.aWarmTiny.costMu) / dC;
|
|
264
|
+
if (!(outMu > 0)) {
|
|
265
|
+
return {
|
|
266
|
+
inputPerM: 0,
|
|
267
|
+
outputPerM: 0,
|
|
268
|
+
cacheEngaged: true,
|
|
269
|
+
needsWarmRefresh: true,
|
|
270
|
+
notes: ["derived output rate is not positive (billing anomaly)"],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Identity: the warm pair must be the same request as each other (the
|
|
275
|
+
// gateway may report cold vs warm prompt tokens differently — that is its
|
|
276
|
+
// normal behavior; T1 needs no cross-check, its own P is used directly).
|
|
277
|
+
if (Math.abs(ps.aWarmBig.prompt - ps.aWarmTiny.prompt) > Math.max(20, ps.aWarmTiny.prompt * 0.01)) {
|
|
278
|
+
notes.push(`warm pair prompts diverged (${ps.aWarmBig.prompt} vs ${ps.aWarmTiny.prompt})`);
|
|
279
|
+
markRefresh();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Identity: identical requests → the gateway reports the same cached amount.
|
|
283
|
+
if (Math.abs(ps.aWarmBig.cached - ps.aWarmTiny.cached) > Math.max(128, ps.aWarmTiny.cached * 0.02)) {
|
|
284
|
+
notes.push(`cached tokens differ between warm turns (${ps.aWarmBig.cached} vs ${ps.aWarmTiny.cached})`);
|
|
285
|
+
markRefresh();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// input from the cold turn — its output fragment is now a KNOWN quantity.
|
|
289
|
+
const inMu = (ps.aCold.costMu - ps.aCold.completion * outMu) / ps.aCold.prompt;
|
|
290
|
+
if (!(inMu > 0)) {
|
|
291
|
+
return {
|
|
292
|
+
inputPerM: 0,
|
|
293
|
+
outputPerM: 0,
|
|
294
|
+
cacheEngaged: true,
|
|
295
|
+
needsWarmRefresh: false,
|
|
296
|
+
notes: ["derived input rate is not positive (billing anomaly)"],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// cacheRead from the tiny warm turn (smallest cancellation error).
|
|
301
|
+
const crMu =
|
|
302
|
+
(ps.aWarmTiny.costMu - (ps.aWarmTiny.prompt - ps.aWarmTiny.cached) * inMu - ps.aWarmTiny.completion * outMu) /
|
|
303
|
+
ps.aWarmTiny.cached;
|
|
304
|
+
if (!(crMu > 0)) {
|
|
305
|
+
notes.push("derived cacheRead rate is not positive");
|
|
306
|
+
markRefresh();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
inputPerM: inMu / 100,
|
|
311
|
+
outputPerM: outMu / 100,
|
|
312
|
+
cacheReadPerM: crMu > 0 ? crMu / 100 : undefined,
|
|
313
|
+
cacheEngaged: true,
|
|
314
|
+
needsWarmRefresh,
|
|
315
|
+
notes,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── Orchestration ─────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
const EFFORT_ORDER = ["off", "minimal", "low"] as const;
|
|
322
|
+
type LowEffort = (typeof EFFORT_ORDER)[number];
|
|
323
|
+
|
|
324
|
+
/** Lowest thinking effort the model supports (mandatory models cannot go off). */
|
|
325
|
+
export function lowestEffort(model: ClinePassModel): LowEffort {
|
|
326
|
+
for (const level of EFFORT_ORDER) {
|
|
327
|
+
if (typeof model.thinkingLevelMap[level] === "string") return level;
|
|
328
|
+
}
|
|
329
|
+
return "off";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export interface CompleteArgs {
|
|
333
|
+
model: ProbeModel;
|
|
334
|
+
systemPrompt: string;
|
|
335
|
+
userMessage: string;
|
|
336
|
+
maxTokens: number;
|
|
337
|
+
effort: LowEffort;
|
|
338
|
+
apiKey: string;
|
|
339
|
+
signal: AbortSignal;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
type CompleteResult = { stopReason?: string; errorMessage?: string; responseId?: string };
|
|
343
|
+
|
|
344
|
+
export interface CalibrationDeps {
|
|
345
|
+
complete?: (args: CompleteArgs) => Promise<CompleteResult>;
|
|
346
|
+
/** Newest-first usage records for this account. */
|
|
347
|
+
fetchRecords?: () => Promise<UsageRecord[] | undefined>;
|
|
348
|
+
sleep?: (ms: number) => Promise<void>;
|
|
349
|
+
now?: () => number;
|
|
350
|
+
signal?: AbortSignal;
|
|
351
|
+
concurrency?: number;
|
|
352
|
+
usageOptions?: UsageOptions;
|
|
353
|
+
onProgress?: (progress: CalProgress) => void;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const defaultComplete = async (args: CompleteArgs): Promise<CompleteResult> => {
|
|
357
|
+
const msg = await completeSimple(
|
|
358
|
+
args.model as Model<"openai-completions">,
|
|
359
|
+
{
|
|
360
|
+
systemPrompt: args.systemPrompt,
|
|
361
|
+
messages: [{ role: "user", content: args.userMessage, timestamp: Date.now() }],
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
apiKey: args.apiKey,
|
|
365
|
+
maxTokens: args.maxTokens,
|
|
366
|
+
// pi's ThinkingLevel has no "off": undefined lets thinkingLevelMap.off ("none") apply.
|
|
367
|
+
reasoning: args.effort === "off" ? undefined : args.effort,
|
|
368
|
+
signal: args.signal,
|
|
369
|
+
},
|
|
370
|
+
);
|
|
371
|
+
// responseId is the stream chunk id — the exact /usages generationId.
|
|
372
|
+
return { stopReason: msg.stopReason, errorMessage: msg.errorMessage, responseId: msg.responseId };
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
interface Rates {
|
|
376
|
+
input: number;
|
|
377
|
+
output: number;
|
|
378
|
+
cacheRead: number;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export interface CalibrationModelResult {
|
|
382
|
+
id: string;
|
|
383
|
+
status: "applied" | "unchanged" | "failed";
|
|
384
|
+
before?: Rates;
|
|
385
|
+
after?: Rates;
|
|
386
|
+
/** Failure reason; empty for successful models. */
|
|
387
|
+
notes: string[];
|
|
388
|
+
spentUsd: number;
|
|
389
|
+
cacheEngaged?: boolean;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export interface CalibrationRunResult {
|
|
393
|
+
results: CalibrationModelResult[];
|
|
394
|
+
spentUsd: number;
|
|
395
|
+
applied: boolean;
|
|
396
|
+
anomaly?: string;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Live per-model state for the calibration view. */
|
|
400
|
+
export interface CalRow {
|
|
401
|
+
id: string;
|
|
402
|
+
name: string;
|
|
403
|
+
state: "queued" | "running" | "done" | "failed";
|
|
404
|
+
/** T1/T2/T3 while running; repriced %/unchanged/failure reason when finished. */
|
|
405
|
+
detail?: string;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export interface CalProgress {
|
|
409
|
+
done: number;
|
|
410
|
+
total: number;
|
|
411
|
+
spentUsd: number;
|
|
412
|
+
rows: readonly CalRow[];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Errors that mean every further probe would fail too (dead login,
|
|
416
|
+
* exhausted quota) — the whole run must stop instead of burning retries per
|
|
417
|
+
* model. The same-error-twice rule below catches whatever wording we did not
|
|
418
|
+
* anticipate: if two different models fail identically, the environment is
|
|
419
|
+
* broken, not the model. */
|
|
420
|
+
export class CalibrationFatalError extends Error {}
|
|
421
|
+
|
|
422
|
+
const FATAL_ERROR_PATTERN = /401|402|403|429|quota|credit|insufficient|usage limit|limit exceeded/i;
|
|
423
|
+
export function isFatalErrorMessage(message: string): boolean {
|
|
424
|
+
return FATAL_ERROR_PATTERN.test(message);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function createdAtMs(rec: UsageRecord): number {
|
|
428
|
+
const ms = Date.parse(rec.createdAt);
|
|
429
|
+
return Number.isNaN(ms) ? 0 : ms;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Run one probe turn; retries the whole cycle (request + record wait) once.
|
|
433
|
+
* Returns the turn's billing record, or the failure reason as a string. */
|
|
434
|
+
async function runTurn(
|
|
435
|
+
model: ProbeModel,
|
|
436
|
+
turn: TurnSpec,
|
|
437
|
+
apiKey: string,
|
|
438
|
+
usedIds: Set<string>,
|
|
439
|
+
deps: Required<Pick<CalibrationDeps, "complete" | "fetchRecords" | "sleep" | "now">>,
|
|
440
|
+
signal: AbortSignal | undefined,
|
|
441
|
+
): Promise<UsageRecord | string> {
|
|
442
|
+
let lastError = "no billing record";
|
|
443
|
+
for (let cycle = 0; cycle < 2; cycle++) {
|
|
444
|
+
if (signal?.aborted) return "cancelled";
|
|
445
|
+
const turnStart = deps.now();
|
|
446
|
+
let responseId: string | undefined;
|
|
447
|
+
try {
|
|
448
|
+
const msg = await deps.complete({
|
|
449
|
+
model,
|
|
450
|
+
systemPrompt: probeCorpus(),
|
|
451
|
+
userMessage: PROBE_INSTRUCTION,
|
|
452
|
+
maxTokens: turn.maxTokens,
|
|
453
|
+
effort: lowestEffort(model),
|
|
454
|
+
apiKey,
|
|
455
|
+
signal: AbortSignal.timeout(TURN_TIMEOUT_MS),
|
|
456
|
+
});
|
|
457
|
+
if (msg.stopReason === "error") throw new Error(msg.errorMessage ?? "provider error");
|
|
458
|
+
responseId = msg.responseId;
|
|
459
|
+
} catch (err) {
|
|
460
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
461
|
+
await deps.sleep(1500);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
// Wait for the server to flush this turn's billing record.
|
|
465
|
+
for (let i = 0; i < FLUSH_POLLS; i++) {
|
|
466
|
+
await deps.sleep(FLUSH_POLL_MS);
|
|
467
|
+
if (signal?.aborted) return "cancelled";
|
|
468
|
+
const records = (await deps.fetchRecords()) ?? [];
|
|
469
|
+
// Exact correlation: the stream chunk id is the /usages generationId
|
|
470
|
+
// (verified against the live API). Immune to concurrent same-model
|
|
471
|
+
// activity from other sessions.
|
|
472
|
+
const match = responseId
|
|
473
|
+
? records.find((rec) => rec.generationId === responseId)
|
|
474
|
+
: undefined;
|
|
475
|
+
if (match) return match;
|
|
476
|
+
// Fallback heuristic when the stream id never arrived.
|
|
477
|
+
const guess = records.find(
|
|
478
|
+
(rec) =>
|
|
479
|
+
rec.completionTokens > 0 &&
|
|
480
|
+
modelsMatch(rec.model, model.id) &&
|
|
481
|
+
!usedIds.has(rec.id) &&
|
|
482
|
+
createdAtMs(rec) >= turnStart - 2000,
|
|
483
|
+
);
|
|
484
|
+
if (guess) return guess;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return lastError;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function calibrateModel(
|
|
491
|
+
model: ClinePassModel,
|
|
492
|
+
apiKey: string,
|
|
493
|
+
deps: Required<Pick<CalibrationDeps, "complete" | "fetchRecords" | "sleep" | "now">>,
|
|
494
|
+
signal: AbortSignal | undefined,
|
|
495
|
+
onTurn?: (label: string) => void,
|
|
496
|
+
): Promise<CalibrationModelResult> {
|
|
497
|
+
const probe: ProbeModel = {
|
|
498
|
+
...model,
|
|
499
|
+
input: [...model.input],
|
|
500
|
+
api: "openai-completions",
|
|
501
|
+
provider: PROVIDER_NAME,
|
|
502
|
+
baseUrl: `${DEFAULT_API_BASE}/api/v1`,
|
|
503
|
+
};
|
|
504
|
+
const usedIds = new Set<string>();
|
|
505
|
+
const records = new Map<TurnKey, UsageRecord>();
|
|
506
|
+
let spent = 0;
|
|
507
|
+
const before: Rates = { input: model.cost.input, output: model.cost.output, cacheRead: model.cost.cacheRead };
|
|
508
|
+
const fail = (note: string): CalibrationModelResult =>
|
|
509
|
+
({ id: model.id, status: "failed", before, notes: [note], spentUsd: spent });
|
|
510
|
+
// A fatal error (dead login / exhausted quota) must stop the whole run.
|
|
511
|
+
const gate = (err: string): string => {
|
|
512
|
+
if (isFatalErrorMessage(err)) throw new CalibrationFatalError(err);
|
|
513
|
+
return err;
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const runSpec = async (spec: TurnSpec): Promise<string | undefined> => {
|
|
517
|
+
onTurn?.(TURN_LABEL[spec.key]);
|
|
518
|
+
const rec = await runTurn(probe, spec, apiKey, usedIds, deps, signal);
|
|
519
|
+
if (typeof rec === "string") return rec;
|
|
520
|
+
usedIds.add(rec.id);
|
|
521
|
+
records.set(spec.key, rec);
|
|
522
|
+
spent += rec.costUsd;
|
|
523
|
+
return undefined;
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// T1 always (cold sample + cache seed). T2 always (the output turn).
|
|
527
|
+
let err = await runSpec(TURN_A_COLD);
|
|
528
|
+
if (err) return fail(`aCold probe failed: ${gate(err)}`);
|
|
529
|
+
err = await runSpec(TURN_A_WARM_BIG);
|
|
530
|
+
if (err) return fail(`aWarmBig probe failed: ${gate(err)}`);
|
|
531
|
+
|
|
532
|
+
const crRefMu = before.cacheRead * 100; // $/1M → micro-$ per token
|
|
533
|
+
let derived = deriveRates({
|
|
534
|
+
aCold: toMeasure(records.get("aCold")!),
|
|
535
|
+
aWarmBig: toMeasure(records.get("aWarmBig")!),
|
|
536
|
+
crRefMu,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// Cache engaged → one more warm turn (tiny) splits output from cacheRead.
|
|
540
|
+
if (derived.cacheEngaged) {
|
|
541
|
+
err = await runSpec(TURN_A_WARM_TINY);
|
|
542
|
+
if (err) return fail(`aWarmTiny probe failed: ${gate(err)}`);
|
|
543
|
+
derived = deriveRates({
|
|
544
|
+
aCold: toMeasure(records.get("aCold")!),
|
|
545
|
+
aWarmBig: toMeasure(records.get("aWarmBig")!),
|
|
546
|
+
aWarmTiny: toMeasure(records.get("aWarmTiny")!),
|
|
547
|
+
crRefMu,
|
|
548
|
+
});
|
|
549
|
+
if (derived.needsWarmRefresh) {
|
|
550
|
+
// One warm-pair refresh for value-level anomalies.
|
|
551
|
+
for (const spec of [TURN_A_WARM_BIG, TURN_A_WARM_TINY]) {
|
|
552
|
+
err = await runSpec(spec);
|
|
553
|
+
if (err) return fail(`${spec.key} refresh failed: ${gate(err)}`);
|
|
554
|
+
}
|
|
555
|
+
derived = deriveRates({
|
|
556
|
+
aCold: toMeasure(records.get("aCold")!),
|
|
557
|
+
aWarmBig: toMeasure(records.get("aWarmBig")!),
|
|
558
|
+
aWarmTiny: toMeasure(records.get("aWarmTiny")!),
|
|
559
|
+
crRefMu,
|
|
560
|
+
});
|
|
561
|
+
if (derived.needsWarmRefresh) {
|
|
562
|
+
return {
|
|
563
|
+
id: model.id,
|
|
564
|
+
status: "failed",
|
|
565
|
+
before,
|
|
566
|
+
notes: derived.notes,
|
|
567
|
+
spentUsd: spent,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const cacheRead = derived.cacheReadPerM !== undefined ? derived.cacheReadPerM : before.cacheRead;
|
|
574
|
+
const after: Rates = { input: derived.inputPerM, output: derived.outputPerM, cacheRead };
|
|
575
|
+
const changed = (["input", "output", "cacheRead"] as const).some(
|
|
576
|
+
(k) => Math.abs(after[k] - before[k]) > Math.max(before[k] * UNCHANGED_TOLERANCE, 1e-9),
|
|
577
|
+
);
|
|
578
|
+
return {
|
|
579
|
+
id: model.id,
|
|
580
|
+
status: changed ? "applied" : "unchanged",
|
|
581
|
+
before,
|
|
582
|
+
after,
|
|
583
|
+
notes: [],
|
|
584
|
+
spentUsd: spent,
|
|
585
|
+
cacheEngaged: derived.cacheEngaged,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Calibrate the given paid models. Concurrent across models (bounded pool),
|
|
591
|
+
* strictly sequential within a model. Never throws for a single model's
|
|
592
|
+
* failure — failures degrade to "kept previous prices" results.
|
|
593
|
+
*/
|
|
594
|
+
export async function runCalibration(
|
|
595
|
+
models: readonly ClinePassModel[],
|
|
596
|
+
deps: CalibrationDeps = {},
|
|
597
|
+
): Promise<CalibrationRunResult> {
|
|
598
|
+
const apiKey = await getActiveToken(deps.usageOptions ?? {});
|
|
599
|
+
if (!apiKey) throw new Error("ClinePass calibration requires login (pi /login).");
|
|
600
|
+
|
|
601
|
+
const bound = {
|
|
602
|
+
complete: deps.complete ?? defaultComplete,
|
|
603
|
+
fetchRecords: deps.fetchRecords ?? (() => fetchUsageRecords(deps.usageOptions ?? {}, 10)),
|
|
604
|
+
sleep: deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
605
|
+
now: deps.now ?? Date.now,
|
|
606
|
+
};
|
|
607
|
+
const signal = deps.signal;
|
|
608
|
+
const concurrency = Math.max(1, Math.min(deps.concurrency ?? 3, models.length));
|
|
609
|
+
const results: CalibrationModelResult[] = [];
|
|
610
|
+
const total = models.length;
|
|
611
|
+
let done = 0;
|
|
612
|
+
let next = 0;
|
|
613
|
+
|
|
614
|
+
const rows: CalRow[] = models.map((m) => ({ id: m.id, name: m.name, state: "queued" as const }));
|
|
615
|
+
let spentRunning = 0;
|
|
616
|
+
const emit = (): void => {
|
|
617
|
+
deps.onProgress?.({ done, total, spentUsd: spentRunning, rows: rows.map((r) => ({ ...r })) });
|
|
618
|
+
};
|
|
619
|
+
let fatal: string | undefined;
|
|
620
|
+
let lastFailMsg: string | undefined;
|
|
621
|
+
|
|
622
|
+
const worker = async (): Promise<void> => {
|
|
623
|
+
while (next < models.length) {
|
|
624
|
+
if (signal?.aborted || fatal !== undefined) return;
|
|
625
|
+
const model = models[next++]!;
|
|
626
|
+
const row = rows.find((r) => r.id === model.id)!;
|
|
627
|
+
row.state = "running";
|
|
628
|
+
emit();
|
|
629
|
+
let result: CalibrationModelResult;
|
|
630
|
+
try {
|
|
631
|
+
result = await calibrateModel(model, apiKey, bound, signal, (label) => {
|
|
632
|
+
row.detail = label;
|
|
633
|
+
emit();
|
|
634
|
+
});
|
|
635
|
+
} catch (err) {
|
|
636
|
+
result = {
|
|
637
|
+
id: model.id,
|
|
638
|
+
status: "failed",
|
|
639
|
+
before: { input: model.cost.input, output: model.cost.output, cacheRead: model.cost.cacheRead },
|
|
640
|
+
notes: [err instanceof Error ? err.message : String(err)],
|
|
641
|
+
spentUsd: 0,
|
|
642
|
+
};
|
|
643
|
+
if (err instanceof CalibrationFatalError) fatal = err.message;
|
|
644
|
+
}
|
|
645
|
+
results.push(result);
|
|
646
|
+
done++;
|
|
647
|
+
spentRunning += result.spentUsd;
|
|
648
|
+
if (result.status === "failed") {
|
|
649
|
+
row.state = "failed";
|
|
650
|
+
row.detail = result.notes[0];
|
|
651
|
+
// Two different models failing identically = broken environment:
|
|
652
|
+
// stop the run instead of replaying the failure down the queue.
|
|
653
|
+
const msg = result.notes[0] ?? "";
|
|
654
|
+
if (msg && msg === lastFailMsg) fatal = msg;
|
|
655
|
+
lastFailMsg = msg;
|
|
656
|
+
} else {
|
|
657
|
+
row.state = "done";
|
|
658
|
+
lastFailMsg = undefined;
|
|
659
|
+
if (result.status === "applied" && result.before && result.after) {
|
|
660
|
+
const pct = Math.round((result.after.input / result.before.input - 1) * 100);
|
|
661
|
+
row.detail = `repriced ${pct >= 0 ? "+" : ""}${pct}%`;
|
|
662
|
+
} else {
|
|
663
|
+
row.detail = "unchanged";
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
emit();
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
670
|
+
|
|
671
|
+
results.sort((a, b) => a.id.localeCompare(b.id));
|
|
672
|
+
const spentUsd = results.reduce((sum, r) => sum + r.spentUsd, 0);
|
|
673
|
+
|
|
674
|
+
// Global scale tripwire: internal checks are blind to a uniform unit change
|
|
675
|
+
// (everything stays self-consistent), the median across models is not.
|
|
676
|
+
const ratios = results
|
|
677
|
+
.filter((r) => r.after && r.before && r.before.input > 0)
|
|
678
|
+
.map((r) => r.after!.input / r.before!.input)
|
|
679
|
+
.sort((a, b) => a - b);
|
|
680
|
+
const median = ratios.length > 0 ? ratios[Math.floor(ratios.length / 2)]! : 1;
|
|
681
|
+
const applied = median >= 0.05 && median <= 20;
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
results,
|
|
685
|
+
spentUsd,
|
|
686
|
+
applied,
|
|
687
|
+
anomaly: applied ? undefined : `derived prices are ${median.toFixed(2)}x the current ones on median — possible billing-unit change; nothing applied`,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ─── Report + persistence ─────────────────────────────────────────────────
|
|
692
|
+
|
|
693
|
+
const shortName = (id: string): string => id.replace("cline-pass/", "").replace(/-code$/, "");
|
|
694
|
+
|
|
695
|
+
function pctChange(before: number, after: number): string {
|
|
696
|
+
if (before <= 0) return "?";
|
|
697
|
+
const delta = ((after - before) / before) * 100;
|
|
698
|
+
return `${delta >= 0 ? "+" : ""}${delta.toFixed(1)}%`;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function deltaLine(r: CalibrationModelResult): string {
|
|
702
|
+
const parts: string[] = [];
|
|
703
|
+
for (const [label, key, decimals] of [
|
|
704
|
+
["input", "input", 2],
|
|
705
|
+
["output", "output", 2],
|
|
706
|
+
["cache", "cacheRead", 4],
|
|
707
|
+
] as const) {
|
|
708
|
+
const b = r.before![key];
|
|
709
|
+
const a = r.after![key];
|
|
710
|
+
if (Math.abs(a - b) > Math.max(b * UNCHANGED_TOLERANCE, 1e-9)) {
|
|
711
|
+
const fmt = (v: number): string => `$${v < 1 ? v.toFixed(decimals) : v.toFixed(2)}/M`;
|
|
712
|
+
parts.push(`${label} ${fmt(b)} → ${fmt(a)} (${pctChange(b, a)})`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return parts.length > 0 ? parts.join(" · ") : "no change";
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export function formatCalibrationReport(result: CalibrationRunResult, cancelled = false): string {
|
|
719
|
+
const lines: string[] = [];
|
|
720
|
+
lines.push(
|
|
721
|
+
`ClinePass calibration ${cancelled ? "cancelled" : result.applied ? "complete" : "aborted (anomaly)"} — spent $${result.spentUsd.toFixed(4)}`,
|
|
722
|
+
);
|
|
723
|
+
if (result.anomaly) {
|
|
724
|
+
lines.push(result.anomaly);
|
|
725
|
+
lines.push("Prices remain unchanged.");
|
|
726
|
+
return lines.join("\n");
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const changed = result.results.filter((r) => r.status === "applied");
|
|
730
|
+
const unchanged = result.results.filter((r) => r.status === "unchanged");
|
|
731
|
+
const failed = result.results.filter((r) => r.status === "failed");
|
|
732
|
+
|
|
733
|
+
if (changed.length === 0 && failed.length === 0) {
|
|
734
|
+
lines.push("All prices unchanged — measured within 0.5% of current values.");
|
|
735
|
+
} else {
|
|
736
|
+
if (changed.length > 0) {
|
|
737
|
+
lines.push(`Price changes (${changed.length}):`);
|
|
738
|
+
for (const r of changed) lines.push(` ${shortName(r.id).padEnd(20)} ${deltaLine(r)}`);
|
|
739
|
+
}
|
|
740
|
+
if (unchanged.length > 0) {
|
|
741
|
+
lines.push(`Unchanged (${unchanged.length}): ${unchanged.map((r) => shortName(r.id)).join(", ")}`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
for (const r of failed) {
|
|
745
|
+
lines.push(`Failed (${shortName(r.id)}): ${r.notes.join("; ")} — kept previous prices`);
|
|
746
|
+
}
|
|
747
|
+
for (const r of result.results.filter((x) => x.cacheEngaged === false && x.after)) {
|
|
748
|
+
const cr = r.after!.cacheRead;
|
|
749
|
+
lines.push(`Cache not measured (${shortName(r.id)}): cacheRead kept at $${cr < 1 ? cr.toFixed(4) : cr.toFixed(2)}/M`);
|
|
750
|
+
}
|
|
751
|
+
return lines.join("\n");
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Build the calibration file from a run (failed models are not written). */
|
|
755
|
+
export function buildCalibrationFile(
|
|
756
|
+
result: CalibrationRunResult,
|
|
757
|
+
calibratedAt = new Date().toISOString(),
|
|
758
|
+
catalogVersion?: string,
|
|
759
|
+
): CalibrationFile {
|
|
760
|
+
const models: Record<string, CalibratedRates> = {};
|
|
761
|
+
for (const r of result.results) {
|
|
762
|
+
if (r.status === "failed" || !r.after) continue;
|
|
763
|
+
models[r.id] = {
|
|
764
|
+
input: r.after.input,
|
|
765
|
+
output: r.after.output,
|
|
766
|
+
cacheRead: r.after.cacheRead,
|
|
767
|
+
calibratedAt,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
// Keep the release stamp — a calibration write without it would look like
|
|
771
|
+
// a foreign release on the next session and get wiped by the sync.
|
|
772
|
+
return { version: 1, catalogVersion, calibratedAt, models };
|
|
773
|
+
}
|