// Costi — costi delle chiamate API a pagamento (Anthropic + Perplexity).
// Legge /api/costs (aggregati per provider/mese/tool/modello + ultime chiamate).
// Importi in USD dal backend; mostriamo anche € (× eur_per_usd).
const _PROVIDER_COLOR = { anthropic: "#d39a6a", perplexity: "#6ea8fe" };
const _PROVIDER_LABEL = { anthropic: "Anthropic (Claude)", perplexity: "Perplexity" };
function _costUsd(v) {
const n = Number(v) || 0;
return "$" + (Math.abs(n) < 1 ? n.toFixed(4) : n.toFixed(2));
}
function _costEur(v, rate) {
const n = (Number(v) || 0) * (Number(rate) || 0.92);
return "€" + (Math.abs(n) < 1 ? n.toFixed(4) : n.toFixed(2));
}
function _costTs(iso) {
if (!iso) return "—";
const m = String(iso).match(/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return String(iso);
const [, , mo, d, hh, mm] = m;
return `${d}/${mo} ${hh}:${mm}`;
}
// Barre orizzontali (per provider / per tool)
function CostHBars({ rows, rate, colorFor }) {
const max = Math.max(...rows.map(r => r.usd || 0), 0.000001);
return (
{rows.map((r, i) => {
const w = Math.max(2, Math.round((r.usd || 0) / max * 100));
const col = colorFor ? colorFor(r) : "#6ea8fe";
return (
{r.label}
{_costUsd(r.usd)}
{_costEur(r.usd, rate)}
{r.calls} ch.
);
})}
);
}
// Barre verticali per mese (stack anthropic + perplexity)
function CostMonthChart({ months, rate }) {
const data = [...months].reverse(); // backend DESC → cronologico
if (!data.length) return Ancora nessun dato mensile.
;
const max = Math.max(...data.map(m => m.usd || 0), 0.000001);
const H = 160, barW = Math.max(18, Math.min(48, Math.floor(560 / data.length) - 8));
return (
{data.map((m, i) => {
const a = m.anthropic_usd || 0, p = m.perplexity_usd || 0;
const hA = Math.round(a / max * H), hP = Math.round(p / max * H);
return (
{_costUsd(m.usd)}
{m.month?.slice(5)}/{m.month?.slice(2, 4)}
);
})}
);
}
function _CostCard({ title, totals, rate, nPayers }) {
const usd = totals?.usd || 0;
return (
{title}
{_costUsd(usd)}
{_costEur(usd, rate)} · {totals?.calls || 0} chiamate
{nPayers > 0 && (
{_costUsd(usd / nPayers)} / pagante ({nPayers})
)}
);
}
// ── Analisi & proiezioni (capacity planning) ────────────────────────────────
const _SCALE_LABEL = {
"ticker+interval": { t: "↑ticker · ↑frequenza", c: "#d39a6a" },
"ticker": { t: "↑ticker", c: "#e0b341" },
"fixed": { t: "fisso", c: "#7a8aa0" },
};
function CostProjection({ p, rate }) {
// Hooks SEMPRE chiamati per primi (Rules of Hooks): il guard viene dopo.
const tools = (p && p.by_tool) || [];
const wl = Math.max(1, (p && p.watchlist_size) || 1);
const curInt = (p && p.intraday_interval_min) || 30;
const mkt = (p && p.market_minutes) || 780;
const tdpm = (p && p.trading_days_per_month) || 21;
const [nTick, setNTick] = useState(wl);
const [interval, setIv] = useState(curInt);
if (!p || !tools.length) return null;
// Modello lineare di primo ordine: ogni tool scala con ticker e/o cadenza.
const estDaily = (nt, iv) => tools.reduce((s, t) => {
let v = t.daily_avg || 0;
const sc = t.scaling || "fixed";
if (sc.includes("ticker")) v *= nt / wl;
if (sc.includes("interval")) v *= curInt / Math.max(1, iv);
return s + v;
}, 0);
const baseDaily = estDaily(wl, curInt);
const projDaily = estDaily(nTick, interval);
const projMonthly = projDaily * tdpm;
const deltaDaily = projDaily - baseDaily;
// costo per ticker/giorno = solo tool che scalano con la watchlist
const tickerDaily = tools.filter(t => (t.scaling || "").includes("ticker")).reduce((s, t) => s + (t.daily_avg || 0), 0);
const perTicker = tickerDaily / wl;
const cycles = iv => Math.max(1, Math.round(mkt / Math.max(1, iv)));
const deltaCol = deltaDaily > 0.0005 ? "var(--red)" : deltaDaily < -0.0005 ? "var(--green)" : "var(--text-muted)";
const sign = v => (v >= 0 ? "+" : "−");
const fmtD = v => sign(v) + _costUsd(Math.abs(v));
const statBox = (title, big, sub) => (
);
const sliderStyle = { width: "100%", accentColor: "var(--accent)" };
return (
Analisi & proiezioni
Stime di primo ordine dalle medie reali ({p.days_window || 0}g di dati). I costi sono guidati dagli eventi news (le chiamate Claude sono filtrate "a evento"), quindi variano molto giorno per giorno — guarda il range.
{statBox("Spesa media / giorno", _costUsd(p.daily_avg), `range ${_costUsd(p.daily_min)}–${_costUsd(p.daily_max)}`)}
{statBox("Stima / mese", _costUsd((p.daily_avg || 0) * tdpm), `${_costEur((p.daily_avg || 0) * tdpm, rate)} · ≈${tdpm}g mercato`)}
{statBox("Ticker watchlist", String(wl), `${_costUsd(perTicker)}/ticker/giorno`)}
{statBox("Cadenza Tool4", `ogni ${curInt}′`, `~${cycles(curInt)} cicli/giorno`)}
{/* Dettaglio per tool */}
Tool
Scala con
$/giorno
$/chiamata
chiamate/g
ticker
{tools.map((t, i) => {
const sl = _SCALE_LABEL[t.scaling] || _SCALE_LABEL.fixed;
return (
{(t.tool || "?").toUpperCase()}
{sl.t}
{_costUsd(t.daily_avg)}
{_costUsd(t.per_call)}
{t.calls_per_day}
{t.syms}
);
})}
{/* What-if */}
Simulatore "e se…"
{statBox("Stima / giorno", _costUsd(projDaily), _costEur(projDaily, rate))}
{statBox("Stima / mese", _costUsd(projMonthly), `${_costEur(projMonthly, rate)} · ≈${tdpm}g`)}
Δ vs adesso
{fmtD(deltaDaily)}/g
{fmtD(deltaDaily * tdpm)}/mese
+1 ticker ≈ {fmtD(estDaily(wl + 1, curInt) - baseDaily)}/g ({fmtD((estDaily(wl + 1, curInt) - baseDaily) * tdpm)}/mese)
· da {curInt}′ → 25′ ≈ {fmtD(estDaily(wl, 25) - baseDaily)}/g
);
}
// Campo numerico stabile (definito a livello di modulo per non perdere il focus a ogni keystroke)
function _NumField({ label, value, onChange, step }) {
return (
{label}
onChange(e.target.value)}
style={{ width: 94, padding: "5px 7px", background: "var(--surface2)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text)" }} />
);
}
function CreditCard({ c, rate }) {
const has = c && c.amount != null;
const rem = has ? (c.remaining || 0) : null;
const frac = has && c.amount > 0 ? Math.max(0, rem / c.amount) : 0;
const col = !has ? "var(--text-faint)" : frac > 0.25 ? "var(--green)" : frac > 0.1 ? "var(--amber)" : "var(--red)";
return (
Credito {_PROVIDER_LABEL[c.provider] || c.provider}
{!has ? (
Non impostato — vedi Impostazioni ⚙
) : (
<>
{_costUsd(rem)}
su {_costUsd(c.amount)} · speso {_costUsd(c.spent_since)}{c.set_at ? ` dal ${_costTs(c.set_at)}` : ""}
{c.n_recharges > 0 && (
incl. {c.n_recharges} {c.n_recharges === 1 ? "ricarica" : "ricariche"} (+{_costUsd(c.topups)})
)}
>
)}
);
}
// Ledger ricariche credito: aggiungi top-up storicizzati + elenco con eliminazione.
function RechargePanel({ data, onChanged }) {
const [provider, setProvider] = useState("anthropic");
const [amount, setAmount] = useState("");
const [date, setDate] = useState("");
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState(null);
const recharges = [];
(data?.credits || []).forEach(c => (c.recharges || []).forEach(r => recharges.push(r)));
recharges.sort((a, b) => String(b.timestamp).localeCompare(String(a.timestamp)));
const inputStyle = { padding: "5px 7px", background: "var(--surface2)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text)", fontSize: 12.5 };
const add = async () => {
const amt = parseFloat(amount);
if (!(amt > 0)) { setMsg("Importo non valido"); setTimeout(() => setMsg(null), 3000); return; }
setBusy(true); setMsg(null);
try {
const r = await fetch("/api/costs/recharge", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, amount: amt, date: date || undefined, note: note || undefined }),
}).then(r => r.json());
if (r.ok) { setAmount(""); setNote(""); setDate(""); setMsg("ok"); onChanged && onChanged(); }
else setMsg(r.error || "errore");
} catch (e) { setMsg(String(e)); }
finally { setBusy(false); setTimeout(() => setMsg(null), 3000); }
};
const del = async (id) => {
try {
const r = await fetch("/api/costs/recharge/delete", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
}).then(r => r.json());
if (r.ok) onChanged && onChanged();
} catch (e) { /* ignore */ }
};
return (
Ricariche credito
Registra ogni acquisto di credito: si somma al saldo di riferimento e aumenta il credito disponibile mostrato sopra.
Provider
setProvider(e.target.value)} style={{ ...inputStyle, width: 150 }}>
Anthropic (Claude)
Perplexity
Importo $
setAmount(e.target.value)} style={{ ...inputStyle, width: 100 }} />
Data (opz.)
setDate(e.target.value)} style={{ ...inputStyle, width: 150 }} />
Nota (opz.)
setNote(e.target.value)} placeholder="es. ricarica mensile" style={{ ...inputStyle, width: "100%" }} />
{busy ? "…" : "Aggiungi"}
{msg === "ok" && ✓ }
{msg && msg !== "ok" && {msg} }
{recharges.length === 0 ? (
Nessuna ricarica registrata.
) : (
{recharges.map(r => (
{_costTs(r.timestamp)}
{r.provider}
+{_costUsd(r.amount_usd)}
{r.note || ""}
del(r.id)} title="Elimina ricarica" style={{ padding: "2px 9px", color: "var(--red)" }}>×
))}
)}
);
}
function CostSettings({ data, onSaved }) {
const [form, setForm] = useState(null);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState(null);
const [recomp, setRecomp] = useState(null);
const [recomping, setRecomping] = useState(false);
// Inizializza il form SOLO al mount (il pannello è montato all'apertura): così i
// refresh dati ogni 60s non sovrascrivono le modifiche in corso dell'utente.
useEffect(() => {
if (!data) return;
// c.anchor = saldo manuale grezzo (NON c.amount, che è il pool ancora+ricariche)
const cr = {}; (data.credits || []).forEach(c => { cr[c.provider] = c.anchor; });
setForm({
eur_per_usd: data.eur_per_usd ?? 0.92,
haiku: { ...(data.pricing?.haiku || {}) },
sonnet: { ...(data.pricing?.sonnet || {}) },
sonar: { ...(data.pricing?.sonar || {}) },
credit_anthropic: cr.anthropic ?? "",
credit_perplexity: cr.perplexity ?? "",
});
}, []);
if (!form) return null;
const setP = (grp, key, v) => setForm(f => ({ ...f, [grp]: { ...f[grp], [key]: v } }));
const save = async () => {
setSaving(true); setMsg(null);
const body = {
eur_per_usd: parseFloat(form.eur_per_usd) || 0,
pricing: {
haiku: ["in", "out", "cache_write", "cache_read"].reduce((o, k) => { o[k] = parseFloat(form.haiku[k]) || 0; return o; }, {}),
sonnet: ["in", "out", "cache_write", "cache_read"].reduce((o, k) => { o[k] = parseFloat(form.sonnet[k]) || 0; return o; }, {}),
sonar: ["in", "out", "request_fee"].reduce((o, k) => { o[k] = parseFloat(form.sonar[k]) || 0; return o; }, {}),
},
credits: {},
};
if (form.credit_anthropic !== "" && form.credit_anthropic != null) body.credits.anthropic = parseFloat(form.credit_anthropic);
if (form.credit_perplexity !== "" && form.credit_perplexity != null) body.credits.perplexity = parseFloat(form.credit_perplexity);
try {
const r = await fetch("/api/costs/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).then(r => r.json());
if (r.ok) { setMsg("ok"); onSaved && onSaved(); } else setMsg(r.error || "errore");
} catch (e) { setMsg(String(e)); }
finally { setSaving(false); setTimeout(() => setMsg(null), 3000); }
};
const recompute = async () => {
setRecomping(true); setRecomp(null);
try {
const r = await fetch("/api/costs/recompute", { method: "POST" }).then(r => r.json());
if (r.ok) { setRecomp(`✓ ${r.rows} righe · $${(r.before || 0).toFixed(2)} → $${(r.after || 0).toFixed(2)}`); onSaved && onSaved(); }
else setRecomp(r.error || "errore");
} catch (e) { setRecomp(String(e)); }
finally { setRecomping(false); setTimeout(() => setRecomp(null), 7000); }
};
return (
Impostazioni costi
Credito disponibile (USD)
<_NumField label="Anthropic" step="0.5" value={form.credit_anthropic} onChange={v => setForm(f => ({ ...f, credit_anthropic: v }))} />
<_NumField label="Perplexity" step="0.5" value={form.credit_perplexity} onChange={v => setForm(f => ({ ...f, credit_perplexity: v }))} />
Saldo di riferimento: da quel momento viene scalato a ogni chiamata. Le ricariche (sezione in fondo alla pagina) si sommano sopra a questo.
Claude Haiku ($/1M token)
<_NumField label="input" value={form.haiku.in} onChange={v => setP("haiku", "in", v)} />
<_NumField label="output" value={form.haiku.out} onChange={v => setP("haiku", "out", v)} />
<_NumField label="cache write" value={form.haiku.cache_write} onChange={v => setP("haiku", "cache_write", v)} />
<_NumField label="cache read" value={form.haiku.cache_read} onChange={v => setP("haiku", "cache_read", v)} />
Claude Sonnet ($/1M token) · Portfolio Manager
<_NumField label="input" value={form.sonnet.in} onChange={v => setP("sonnet", "in", v)} />
<_NumField label="output" value={form.sonnet.out} onChange={v => setP("sonnet", "out", v)} />
<_NumField label="cache write" value={form.sonnet.cache_write} onChange={v => setP("sonnet", "cache_write", v)} />
<_NumField label="cache read" value={form.sonnet.cache_read} onChange={v => setP("sonnet", "cache_read", v)} />
Perplexity Sonar
<_NumField label="input /1M" value={form.sonar.in} onChange={v => setP("sonar", "in", v)} />
<_NumField label="output /1M" value={form.sonar.out} onChange={v => setP("sonar", "out", v)} />
<_NumField label="fee/richiesta" step="0.001" value={form.sonar.request_fee} onChange={v => setP("sonar", "request_fee", v)} />
Cambio
<_NumField label="€ per 1 $" value={form.eur_per_usd} onChange={v => setForm(f => ({ ...f, eur_per_usd: v }))} />
{saving ? "Salvo…" : "Salva impostazioni"}
{msg === "ok" && ✓ salvato }
{msg && msg !== "ok" && {msg} }
{recomping ? "Ricalcolo…" : "↻ Ricalcola costi storici"}
{recomp && {recomp} }
);
}
function CostsView() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showSettings, setShowSettings] = useState(false);
const load = async () => {
try {
const d = await fetch("/api/costs").then(r => r.json());
setData(d); setError(d?.error || null);
} catch (e) { setError(String(e)); }
finally { setLoading(false); }
};
useEffect(() => {
load();
const t = setInterval(load, 60 * 1000);
return () => clearInterval(t);
}, []);
const rate = data?.eur_per_usd || 0.92;
const totals = data?.totals || {};
const nPayers = data?.n_payers || 0;
const credits = data?.credits || [];
const provRows = (data?.by_provider || []).map(r => ({ label: _PROVIDER_LABEL[r.provider] || r.provider, usd: r.usd, calls: r.calls, provider: r.provider }));
const toolRows = (data?.by_tool || []).map(r => ({ label: (r.tool || "?").toUpperCase(), usd: r.usd, calls: r.calls }));
const recent = data?.recent || [];
const hasData = (totals?.all?.calls || 0) > 0;
return (
Costi API
tasso €/$ {rate}
setShowSettings(s => !s)}
style={showSettings ? { color: "var(--accent)" } : {}}>
Impostazioni
Aggiorna
Costo delle chiamate a pagamento del daemon (Claude di Tool4/Tool9 + Perplexity). Calcolato dai token di ogni risposta × prezzo del modello. Il conteggio parte da quando è stato attivato il tracking.
{loading && !data &&
Caricamento…
}
{error &&
Errore: {error}
}
{data && showSettings &&
}
{data && !hasData && (
Nessuna chiamata registrata ancora. La tabella si popola appena Tool4 o Tool9 fanno una chiamata
ad Anthropic o Perplexity (in orario di mercato, se abilitati). Intanto puoi impostare crediti e prezzi da Impostazioni ⚙ .
)}
{data && hasData && <>
{/* Card riepilogo */}
<_CostCard title="Questo mese" totals={totals.month} rate={rate} nPayers={nPayers} />
<_CostCard title="Ultimi 30 giorni" totals={totals.last30} rate={rate} nPayers={nPayers} />
<_CostCard title="Totale" totals={totals.all} rate={rate} nPayers={nPayers} />
{/* Credito residuo per provider */}
{credits.length > 0 && (
{credits.map(c => )}
)}
{/* Analisi & proiezioni (capacity planning) */}
{/* Costo per API */}
Costo per API
_PROVIDER_COLOR[r.provider] || "#6ea8fe"} />
{/* Costo per mese */}
Costo per mese
■ Anthropic
■ Perplexity
{/* Costo per tool */}
Costo per tool
"#5fd0ac"} />
{/* Ultime chiamate */}
Ultime chiamate
Ora
API
Modello
Tool
Op.
Ticker
Token in/out
Costo
{recent.map((r, i) => (
{_costTs(r.timestamp)}
{r.provider}
{(r.model || "—").replace("claude-", "")}
{(r.tool || "—").toUpperCase()}
{r.operation || "—"}
{r.symbol || "—"}
{(r.input_tokens || 0)}/{(r.output_tokens || 0)}
{r.status === "error" ? "—" : _costUsd(r.cost_usd)}
))}
>}
{/* Ricariche credito — sempre visibile, sotto le ultime chiamate */}
{data &&
}
);
}
window.CostsView = CostsView;