@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/fusion",
3
- "version": "2.17.0",
3
+ "version": "2.17.1",
4
4
  "description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
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 && typeof cost.input === "number"
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 savings = estimateSavings(runtime.usage, costOf(findModel(reg, active.lead)), costOf(findModel(reg, active.sidekick)));
184
- 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())}`;
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
- // Only keep a Fusion status if the session actually runs on that lead.
356
- if (active?.kind === "fusion" && ctx.model && modelKey(ctx.model) !== active.lead) active = undefined;
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 (primary?.cost) {
482
- cols.push(["Input", money(primary.cost.input)]);
483
- cols.push(["Cached input", money(primary.cost.cachedInput)]);
484
- cols.push(["Output", money(primary.cost.output)]);
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 (side?.cost) {
490
- cols.push(["Sidekick input", money(side.cost.input)]);
491
- cols.push(["Sidekick cached input", money(side.cost.cachedInput)]);
492
- cols.push(["Sidekick output", money(side.cost.output)]);
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 colWidth = Math.max(10, Math.min(18, Math.floor((width - 4) / cols.length)));
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 description = `${badges}${badges.length > 0 ? " " : ""}${desc}`;
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 sliderPrice = sliderModel?.cost === undefined ? undefined : blendedPrice(sliderModel.cost);
565
- const marker = sliderPrice === undefined ? undefined : sliderPosition(sliderPrice, this.priceRange.min, this.priceRange.max, sliderCells);
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
  }