// 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) => (
{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 */}
{tools.map((t, i) => { const sl = _SCALE_LABEL[t.scaling] || _SCALE_LABEL.fixed; return ( ); })}
Tool Scala con $/giorno $/chiamata chiamate/g ticker
{(t.tool || "?").toUpperCase()} {sl.t} {_costUsd(t.daily_avg)} {_costUsd(t.per_call)} {t.calls_per_day} {t.syms}
{/* What-if */}
Simulatore "e se…"
Ticker in watchlist {nTick}{nTick !== wl ? (ora {wl}) : ""}
setNTick(parseInt(e.target.value))} style={sliderStyle} />
Run Tool3/4 ogni {interval}′ · ~{cycles(interval)} cicli/g{interval !== curInt ? ` (ora ${curInt}′)` : ""}
setIv(parseInt(e.target.value))} style={sliderStyle} />
{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 ( ); } 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.
{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 || ""}
))}
)}
); } 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 }))} />
{msg === "ok" && ✓ salvato} {msg && msg !== "ok" && {msg}} {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}
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
{recent.map((r, i) => ( ))}
Ora API Modello Tool Op. Ticker Token in/out Costo
{_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;