@pi-unipi/fusion 2.17.0 → 2.17.1
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/README.md +3 -0
- package/package.json +1 -1
- package/src/index.ts +35 -14
- package/src/picker.ts +25 -13
- package/src/preset.ts +18 -0
package/README.md
CHANGED
|
@@ -117,11 +117,14 @@ savings above `$0.005`.
|
|
|
117
117
|
"default": {"lead": "provider/lead", "sidekick": "provider/sidekick"},
|
|
118
118
|
"effort": {"provider/sidekick": "high"},
|
|
119
119
|
"badges": {"provider/sidekick": "new"},
|
|
120
|
+
"prices": {"provider/sidekick": {"input": 0.2, "cachedInput": 0.02, "output": 1.2}},
|
|
120
121
|
"recent": ["provider/lead"],
|
|
121
122
|
"active": {"kind": "fusion", "lead": "provider/lead", "sidekick": "provider/sidekick"}
|
|
122
123
|
}
|
|
123
124
|
```
|
|
124
125
|
|
|
126
|
+
`prices` is an optional manual override for models whose provider reports no pricing.
|
|
127
|
+
|
|
125
128
|
## Status
|
|
126
129
|
|
|
127
130
|
- [x] Preset store, project layering, curation UI, autocomplete boost
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { homedir } from "node:os";
|
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import {
|
|
23
23
|
effortLabel,
|
|
24
|
+
globalPresetPath,
|
|
24
25
|
isEffortLevel,
|
|
25
26
|
loadPreset,
|
|
26
27
|
modelKey,
|
|
@@ -60,20 +61,18 @@ function findModel(reg: Registry | undefined, key: string): Model<Api> | undefin
|
|
|
60
61
|
return modelBykey.get(key);
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
function costOf(m: Model<Api> | undefined): PickerModel["cost"] {
|
|
64
|
-
const cost = m?.cost;
|
|
65
|
-
return cost &&
|
|
66
|
-
? { input: cost.input, cachedInput: cost.cacheRead ?? 0, output: cost.output }
|
|
67
|
-
: undefined;
|
|
64
|
+
function costOf(m: Model<Api> | undefined, override?: PickerModel["cost"]): PickerModel["cost"] {
|
|
65
|
+
const cost = override ?? (m?.cost && typeof m.cost.input === "number" ? { input: m.cost.input, cachedInput: m.cost.cacheRead ?? 0, output: m.cost.output } : undefined);
|
|
66
|
+
return cost && (cost.input > 0 || cost.cachedInput > 0 || cost.output > 0) ? cost : undefined;
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
function toPickerModel(m: Model<Api>, badge?: FusionPreset["badges"][string]): PickerModel {
|
|
69
|
+
function toPickerModel(m: Model<Api>, badge?: FusionPreset["badges"][string], override?: PickerModel["cost"]): PickerModel {
|
|
71
70
|
return {
|
|
72
71
|
key: modelKey(m),
|
|
73
72
|
name: m.name || m.id,
|
|
74
73
|
provider: m.provider,
|
|
75
74
|
badge,
|
|
76
|
-
cost: costOf(m),
|
|
75
|
+
cost: costOf(m, override),
|
|
77
76
|
reasoning: Boolean(m.reasoning),
|
|
78
77
|
};
|
|
79
78
|
}
|
|
@@ -130,9 +129,10 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
130
129
|
function statusSavings(ctx: ExtensionContext): number | undefined {
|
|
131
130
|
if (active?.kind !== "fusion" || runtime === undefined) return undefined;
|
|
132
131
|
const reg = registryOf(ctx);
|
|
132
|
+
const preset = loadPreset(ctx.cwd ?? process.cwd()).preset;
|
|
133
133
|
const lead = findModel(reg, active.lead);
|
|
134
134
|
const side = findModel(reg, active.sidekick);
|
|
135
|
-
return estimateSavings(runtime.usage, costOf(lead), costOf(side)).savedUsd;
|
|
135
|
+
return estimateSavings(runtime.usage, costOf(lead, preset.prices[active.lead]), costOf(side, preset.prices[active.sidekick])).savedUsd;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
function publishStatus(ctx: ExtensionContext): void {
|
|
@@ -180,8 +180,14 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
180
180
|
function savingsStats(ctx: ExtensionContext): string {
|
|
181
181
|
if (active?.kind !== "fusion" || runtime === undefined) return "Fusion is not active — pick a Fusion pair with /unipi:model.";
|
|
182
182
|
const reg = registryOf(ctx);
|
|
183
|
-
const
|
|
184
|
-
|
|
183
|
+
const preset = loadPreset(ctx.cwd ?? process.cwd()).preset;
|
|
184
|
+
const leadCost = costOf(findModel(reg, active.lead), preset.prices[active.lead]);
|
|
185
|
+
const sidekickCost = costOf(findModel(reg, active.sidekick), preset.prices[active.sidekick]);
|
|
186
|
+
const savings = estimateSavings(runtime.usage, leadCost, sidekickCost);
|
|
187
|
+
const pricing = leadCost === undefined && sidekickCost === undefined
|
|
188
|
+
? '\nPricing unavailable from provider — set "prices" in ~/.unipi/config/fusion/preset.json to estimate savings.'
|
|
189
|
+
: "";
|
|
190
|
+
return `Sidekick tokens: in ${String(runtime.usage.input)} · out ${String(runtime.usage.output)} · cached ${String(runtime.usage.cacheRead)} · cache write ${String(runtime.usage.cacheWrite)}\nSidekick cost: $${savings.sidekickUsd.toFixed(2)} · at lead prices: $${savings.atLeadUsd.toFixed(2)} · saved: $${savings.savedUsd.toFixed(2)}\nHandoffs: ${String(runtime.reports.size)} · runtime alive: ${String(runtime.isAlive())} · busy: ${String(runtime.isBusy())}${pricing}`;
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
registerFusionTools(pi, {
|
|
@@ -267,7 +273,7 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
267
273
|
const cwd = ctx.cwd ?? process.cwd();
|
|
268
274
|
const loaded = loadPreset(cwd);
|
|
269
275
|
const preset = loaded.preset;
|
|
270
|
-
const models = reg.getAvailable().map((m) => toPickerModel(m, preset.badges[modelKey(m)]));
|
|
276
|
+
const models = reg.getAvailable().map((m) => toPickerModel(m, preset.badges[modelKey(m)], preset.prices[modelKey(m)]));
|
|
271
277
|
if (models.length === 0) {
|
|
272
278
|
ctx.ui.notify("No models available. Use /login to add a provider.", "warning");
|
|
273
279
|
return;
|
|
@@ -347,13 +353,26 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
347
353
|
},
|
|
348
354
|
});
|
|
349
355
|
|
|
350
|
-
pi.on("session_start", (_e, ctx) => {
|
|
356
|
+
pi.on("session_start", async (_e, ctx) => {
|
|
351
357
|
stopRuntime();
|
|
352
358
|
nudged = false;
|
|
353
359
|
modelBykey.clear();
|
|
354
360
|
active = loadPreset(ctx.cwd ?? process.cwd()).preset.active;
|
|
355
|
-
|
|
356
|
-
|
|
361
|
+
if (active?.kind === "fusion" && (!ctx.model || modelKey(ctx.model) !== active.lead)) {
|
|
362
|
+
const leadKey = active.lead;
|
|
363
|
+
const lead = findModel(registryOf(ctx), leadKey);
|
|
364
|
+
const restored = lead !== undefined && await pi.setModel(lead);
|
|
365
|
+
if (restored) {
|
|
366
|
+
try {
|
|
367
|
+
pi.setThinkingLevel(active.leadEffort ?? "medium");
|
|
368
|
+
} catch {
|
|
369
|
+
/* provider may not support thinking */
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
active = undefined;
|
|
373
|
+
if (ctx.hasUI) ctx.ui.notify(`Fusion lead ${leadKey} unavailable — Fusion off`, "warning");
|
|
374
|
+
}
|
|
375
|
+
}
|
|
357
376
|
publishStatus(ctx);
|
|
358
377
|
if (ctx.hasUI) ctx.ui.addAutocompleteProvider(createModelBoostProvider);
|
|
359
378
|
});
|
|
@@ -369,6 +388,8 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
369
388
|
if (active?.kind === "fusion" && modelKey(event.model) !== active.lead) {
|
|
370
389
|
stopRuntime();
|
|
371
390
|
active = { kind: "single", model: modelKey(event.model) };
|
|
391
|
+
const loaded = loadPreset(ctx.cwd ?? process.cwd());
|
|
392
|
+
saveRuntimeState(globalPresetPath(), { effort: loaded.preset.effort, recent: loaded.preset.recent, active });
|
|
372
393
|
publishStatus(ctx);
|
|
373
394
|
}
|
|
374
395
|
});
|
package/src/picker.ts
CHANGED
|
@@ -128,6 +128,10 @@ function money(perMillion: number): string {
|
|
|
128
128
|
return `$${rounded.replace(/\.0+$/u, "").replace(/(\.\d)0$/u, "$1")} / 1M`;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
function hasPricing(cost: PickerModel["cost"]): cost is NonNullable<PickerModel["cost"]> {
|
|
132
|
+
return cost !== undefined && (cost.input > 0 || cost.cachedInput > 0 || cost.output > 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
131
135
|
function pad(text: string, width: number): string {
|
|
132
136
|
const w = visibleWidth(text);
|
|
133
137
|
return w >= width ? text : text + " ".repeat(width - w);
|
|
@@ -161,7 +165,7 @@ export class ModelPicker {
|
|
|
161
165
|
this.onRenderRequest = options.onRenderRequest;
|
|
162
166
|
this.visibleRows = options.visibleRows ?? DEFAULT_VISIBLE_ROWS;
|
|
163
167
|
this.modelsByKey = new Map(options.state.models.map((m) => [m.key, m]));
|
|
164
|
-
const prices = options.state.models.map((m) => (m.cost ? blendedPrice(m.cost) : undefined)).filter((p): p is number => p !== undefined);
|
|
168
|
+
const prices = options.state.models.map((m) => (hasPricing(m.cost) ? blendedPrice(m.cost) : undefined)).filter((p): p is number => p !== undefined && p > 0);
|
|
165
169
|
this.priceRange = { min: prices.length > 0 ? Math.min(...prices) : 0, max: prices.length > 0 ? Math.max(...prices) : 0 };
|
|
166
170
|
this.effort = { ...options.state.effort };
|
|
167
171
|
const active = options.state.active;
|
|
@@ -477,24 +481,27 @@ export class ModelPicker {
|
|
|
477
481
|
const primaryKey = row.kind === "fusion" ? this.lead : row.key;
|
|
478
482
|
const primary = primaryKey === undefined ? undefined : this.modelsByKey.get(primaryKey);
|
|
479
483
|
const side = row.kind === "fusion" && this.sidekick !== undefined ? this.modelsByKey.get(this.sidekick) : undefined;
|
|
484
|
+
const primaryCost = primary?.cost;
|
|
485
|
+
const sideCost = side?.cost;
|
|
480
486
|
const cols: Array<[string, string]> = [];
|
|
481
|
-
if (
|
|
482
|
-
cols.push(["Input", money(
|
|
483
|
-
cols.push(["Cached input", money(
|
|
484
|
-
cols.push(["Output", money(
|
|
487
|
+
if (hasPricing(primaryCost)) {
|
|
488
|
+
cols.push(["Input", money(primaryCost.input)]);
|
|
489
|
+
cols.push(["Cached input", money(primaryCost.cachedInput)]);
|
|
490
|
+
cols.push(["Output", money(primaryCost.output)]);
|
|
485
491
|
} else {
|
|
486
492
|
cols.push(["Input", "—"], ["Cached input", "—"], ["Output", "—"]);
|
|
487
493
|
}
|
|
488
494
|
if (row.kind === "fusion") {
|
|
489
|
-
if (
|
|
490
|
-
cols.push(["Sidekick input", money(
|
|
491
|
-
cols.push(["Sidekick cached input", money(
|
|
492
|
-
cols.push(["Sidekick output", money(
|
|
495
|
+
if (hasPricing(sideCost)) {
|
|
496
|
+
cols.push(["Sidekick input", money(sideCost.input)]);
|
|
497
|
+
cols.push(["Sidekick cached input", money(sideCost.cachedInput)]);
|
|
498
|
+
cols.push(["Sidekick output", money(sideCost.output)]);
|
|
493
499
|
} else {
|
|
494
500
|
cols.push(["Sidekick input", "—"], ["Sidekick cached input", "—"], ["Sidekick output", "—"]);
|
|
495
501
|
}
|
|
496
502
|
}
|
|
497
|
-
const
|
|
503
|
+
const need = Math.max(...cols.map(([h, v]) => Math.max(visibleWidth(h), visibleWidth(v)))) + 3;
|
|
504
|
+
const colWidth = Math.max(10, Math.min(need, Math.floor((width - 4) / cols.length)));
|
|
498
505
|
const head = cols.map(([h]) => pad(t.fg("dim", h), colWidth)).join("");
|
|
499
506
|
const vals = cols.map(([, v]) => pad(t.fg("text", v), colWidth)).join("");
|
|
500
507
|
const desc =
|
|
@@ -506,7 +513,11 @@ export class ModelPicker {
|
|
|
506
513
|
const badges = this.state.models.some((m) => m.badge !== undefined)
|
|
507
514
|
? `${t.fg("success", "✱")} ${t.fg("dim", "New")} ${t.fg("accent", "✱")} ${t.fg("dim", "Promotion")} ${t.fg("warning", "✱")} ${t.fg("dim", "Beta")} ${t.fg("dim", "·")}`
|
|
508
515
|
: "";
|
|
509
|
-
const
|
|
516
|
+
const noPricing = row.kind === "fusion"
|
|
517
|
+
? !hasPricing(primaryCost) || !hasPricing(sideCost)
|
|
518
|
+
: !hasPricing(primaryCost);
|
|
519
|
+
const pricing = noPricing ? t.fg("dim", " · no pricing data from provider") : "";
|
|
520
|
+
const description = `${badges}${badges.length > 0 ? " " : ""}${desc}${pricing}`;
|
|
510
521
|
return [truncateToWidth(` ${head}`, width - 1), truncateToWidth(` ${vals}`, width - 1), truncateToWidth(` ${description}`, width - 1)];
|
|
511
522
|
}
|
|
512
523
|
|
|
@@ -561,8 +572,9 @@ export class ModelPicker {
|
|
|
561
572
|
const sliderCells = Math.min(48, Math.max(1, width - 6));
|
|
562
573
|
const sliderKey = row?.kind === "fusion" ? this.lead : row?.key;
|
|
563
574
|
const sliderModel = sliderKey === undefined ? undefined : this.modelsByKey.get(sliderKey);
|
|
564
|
-
const
|
|
565
|
-
const
|
|
575
|
+
const sliderCost = sliderModel?.cost;
|
|
576
|
+
const sliderPrice = hasPricing(sliderCost) ? blendedPrice(sliderCost) : undefined;
|
|
577
|
+
const marker = this.priceRange.max <= 0 || sliderPrice === undefined ? undefined : sliderPosition(sliderPrice, this.priceRange.min, this.priceRange.max, sliderCells);
|
|
566
578
|
lines.push(truncateToWidth(` ${renderSlider(sliderCells, marker)}`, width - 1));
|
|
567
579
|
lines.push(...this.renderPricePanel(row, width));
|
|
568
580
|
lines.push("");
|
package/src/preset.ts
CHANGED
|
@@ -61,6 +61,8 @@ export interface FusionPreset {
|
|
|
61
61
|
recent: ModelKey[];
|
|
62
62
|
/** Optional hand-curated model badge metadata. */
|
|
63
63
|
badges: Record<ModelKey, FusionBadge>;
|
|
64
|
+
/** Manual pricing overrides for providers that report no pricing. */
|
|
65
|
+
prices: Record<ModelKey, { input: number; cachedInput: number; output: number }>;
|
|
64
66
|
/** What the user last confirmed in the picker. */
|
|
65
67
|
active?: ActiveSelection | undefined;
|
|
66
68
|
}
|
|
@@ -74,6 +76,7 @@ export function emptyPreset(): FusionPreset {
|
|
|
74
76
|
effort: {},
|
|
75
77
|
recent: [],
|
|
76
78
|
badges: {},
|
|
79
|
+
prices: {},
|
|
77
80
|
};
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -136,6 +139,20 @@ export function parsePreset(raw: unknown): Partial<FusionPreset> {
|
|
|
136
139
|
}
|
|
137
140
|
out.badges = badges;
|
|
138
141
|
}
|
|
142
|
+
if (typeof r["prices"] === "object" && r["prices"] !== null) {
|
|
143
|
+
const prices: FusionPreset["prices"] = {};
|
|
144
|
+
for (const [k, v] of Object.entries(r["prices"] as Record<string, unknown>)) {
|
|
145
|
+
if (typeof v !== "object" || v === null) continue;
|
|
146
|
+
const price = v as Record<string, unknown>;
|
|
147
|
+
const input = price["input"];
|
|
148
|
+
const cachedInput = price["cachedInput"];
|
|
149
|
+
const output = price["output"];
|
|
150
|
+
if ([input, cachedInput, output].every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0)) {
|
|
151
|
+
prices[k] = { input: input as number, cachedInput: cachedInput as number, output: output as number };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
out.prices = prices;
|
|
155
|
+
}
|
|
139
156
|
const active = r["active"];
|
|
140
157
|
if (typeof active === "object" && active !== null) {
|
|
141
158
|
const a = active as Record<string, unknown>;
|
|
@@ -167,6 +184,7 @@ export function mergePresets(base: FusionPreset, over: Partial<FusionPreset>): F
|
|
|
167
184
|
effort: { ...base.effort, ...(over.effort ?? {}) },
|
|
168
185
|
recent: over.recent ?? base.recent,
|
|
169
186
|
badges: { ...base.badges, ...(over.badges ?? {}) },
|
|
187
|
+
prices: { ...base.prices, ...(over.prices ?? {}) },
|
|
170
188
|
active: over.active ?? base.active,
|
|
171
189
|
};
|
|
172
190
|
}
|