/** * @file PricingSettings.tsx * @description Model Pricing — the per-model rates every session's cost is * computed from — as shown on a tool's Settings tab (Overview for Claude Code * and Codex, the Antigravity tab itself): Claude * Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o…`, * `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a proxy model can serve either tool), so a * **Show all models** switch lists every row, and a row added outside the * filter turns it on rather than vanishing. Reset Defaults reseeds Claude's * rates, so only the Claude tab offers it; the server re-fetches any row the * automatic sync owns (OpenAI's, Gemini's) straight after. A right-click on a * rule row opens its Edit / Delete (plus Copy model pattern) as a menu. A * delete asks first (the sync never re-adds a deleted pattern). Every mounted * table shares ONE list request and reloads together after any change, so the * Pricing page's per-vendor tables never go stale against each other. * @author Nguyễn Ngọc Trí Vĩ */ import { Checkbox } from "../Checkbox"; import { useEffect, useState, useCallback, useRef, Fragment } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { useSettingsHref } from "../../lib/settingsLink"; import { ArrowRight, DollarSign, Plus, Pencil, Trash2, Check, X, RotateCcw, Info, } from "lucide-react"; import { api } from "../../lib/api"; import { eventBus } from "../../lib/eventBus"; import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents"; import { getCurrentLocale } from "../../lib/format"; import { Skeleton } from "../Skeleton"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { confirmDialog } from "../../lib/confirm"; import { copyText } from "../../lib/clipboard"; import { useArmed } from "../../hooks/useArmed"; import type { ModelPricing, WSMessage } from "../../lib/types"; interface EditRow { model_pattern: string; display_name: string; input_per_mtok: string; output_per_mtok: string; cache_read_per_mtok: string; cache_write_per_mtok: string; cache_write_1h_per_mtok: string; fast_input_per_mtok: string; fast_output_per_mtok: string; // Time-limited introductory rates. intro_until empty ⇒ no promo (the intro_* // values are ignored). Generic: any model can carry a promo window. intro_until: string; intro_input_per_mtok: string; intro_output_per_mtok: string; intro_cache_read_per_mtok: string; intro_cache_write_per_mtok: string; intro_cache_write_1h_per_mtok: string; } const emptyRow: EditRow = { model_pattern: "", display_name: "", input_per_mtok: "0", output_per_mtok: "0", cache_read_per_mtok: "0", cache_write_per_mtok: "0", cache_write_1h_per_mtok: "0", fast_input_per_mtok: "0", fast_output_per_mtok: "0", intro_until: "", intro_input_per_mtok: "0", intro_output_per_mtok: "0", intro_cache_read_per_mtok: "0", intro_cache_write_per_mtok: "0", intro_cache_write_1h_per_mtok: "0", }; function formatTimestamp(iso: string): string { const normalized = /[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso) ? iso : iso.replace(" ", "T") + "Z"; const d = new Date(normalized); return d.toLocaleString(getCurrentLocale(), { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } /** * Info popover for the Model Pricing section. Hover or focus the icon to see a * three-section explanation: how prices are applied, how pattern matching * works, and where the rates come from (LiteLLM's price list, kept up to date * daily for the rows marked Auto). All copy is i18n-driven * (settings.pricing.tooltip.*). * * The popover is fixed-positioned and clamped to the viewport so it never * gets clipped by the sidebar or screen edges, mirroring the pattern used by * the Workflows stat tooltips. */ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) { const claude = vendor === "claude"; const { t } = useTranslation("settings"); const [open, setOpen] = useState(false); const buttonRef = useRef(null); const popoverRef = useRef(null); const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 }); const positionPopover = useCallback(() => { const btn = buttonRef.current; const pop = popoverRef.current; if (!btn) return; const r = btn.getBoundingClientRect(); const w = pop?.offsetWidth ?? 320; const h = pop?.offsetHeight ?? 240; const margin = 8; let left = r.right - w; // right-align with the icon if (left < margin) left = margin; if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin; let top = r.bottom + 8; if (top + h > window.innerHeight - margin) { top = Math.max(margin, r.top - h - 8); } setPos({ left, top }); }, []); useEffect(() => { if (!open) return; positionPopover(); const onScroll = () => positionPopover(); const onResize = () => positionPopover(); window.addEventListener("scroll", onScroll, true); window.addEventListener("resize", onResize); const raf = requestAnimationFrame(positionPopover); return () => { cancelAnimationFrame(raf); window.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onResize); }; }, [open, positionPopover]); return ( <> {open && (

{t("pricing.tooltip.title")}

{t("pricing.tooltip.howItWorks")}

{t("pricing.tooltip.howItWorksBody")}

{t("pricing.tooltip.patternsTitle")}

{t("pricing.tooltip.patternsBody")}

{t("pricing.tooltip.synced")}

{t("pricing.tooltip.syncedBody")}

{t("pricing.tooltip.apiPricing")}

{t(claude ? "pricing.tooltip.apiPricingBody" : "pricing.tooltip.apiPricingOtherBody")}

)} ); } export type PricingVendor = "claude" | "openai" | "google"; /** * A tool page's pointer to its vendor's table on the one Pricing page — the * table itself lives only there, so a price is never shown (or edited) twice. */ export function PricingLink({ vendor }: { vendor: PricingVendor }) { const { t } = useTranslation("settings"); const href = useSettingsHref(); return (

{t("pricingLink.title")}

{t("pricingLink.description")}

{t("pricingLink.open")}
); } // Every mounted table's `load`: a change in one reloads them all, and loads // fired together share one request. const tables = new Set<() => void>(); let inflight: ReturnType | null = null; function listPricing() { inflight ??= api.pricing.list().finally(() => { inflight = null; }); return inflight; } function reloadAllTables() { for (const load of tables) load(); } const VENDOR_PATTERN: Record = { claude: /^claude-/, openai: /^(gpt-|o\d|codex-)/, google: /^gemini-/, }; interface Props { vendor: PricingVendor; /** Offer Reset Defaults (it reseeds Claude's rates and resets only `claude-…` rows). */ allowReset?: boolean; /** `
` — a deep-link target. */ id?: string; /** The Pricing page's per-vendor heading; its long description is shown once, by the page. */ heading?: string; } /** * The pricing table filtered to one vendor's rows. * @returns The section, or a skeleton while the rules load. */ export function PricingSettings({ vendor, allowReset = false, id = "pricing", heading }: Props) { const { t } = useTranslation("settings"); const [pricing, setPricing] = useState([]); const [loading, setLoading] = useState(true); const [editingPattern, setEditingPattern] = useState(null); const [editRow, setEditRow] = useState(emptyRow); const [adding, setAdding] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [showAll, setShowAll] = useState(false); const [resetting, setResetting] = useState(false); // Reset Defaults is a two-click that disarms itself after 3 s. const resetArm = useArmed(); const confirmReset = resetArm.isArmed("reset"); const ctx = useContextMenu(); // A menu item: close the menu, then act on the rule it was opened on. const pick = (fn: (rule: ModelPricing) => unknown) => () => { const rule = ctx.menu?.item; ctx.close(); if (rule) fn(rule); }; const [actionResult, setActionResult] = useState<{ key: string; message: string; isError: boolean; } | null>(null); const load = useCallback(async () => { try { const res = await listPricing(); setPricing(res.pricing); setError(null); } catch (err) { setError(err instanceof Error ? err.message : t("messages.failedLoad")); } finally { setLoading(false); } }, [t]); useEffect(() => { load(); const reload = () => void load(); tables.add(reload); return () => { tables.delete(reload); }; }, [load]); // A remote source can bring its own pricing rules; reload when one syncs. useEffect(() => { return eventBus.subscribe((msg: WSMessage) => { if (isRemoteDataRefreshMessage(msg)) load(); }); }, [load]); useEffect(() => { if (!actionResult) return; const timeout = setTimeout(() => setActionResult(null), 5000); return () => clearTimeout(timeout); }, [actionResult]); const ours = (pattern: string) => VENDOR_PATTERN[vendor].test(pattern); const rows = showAll ? pricing : pricing.filter((p) => ours(p.model_pattern)); const hidden = pricing.length - pricing.filter((p) => ours(p.model_pattern)).length; const startEdit = (rule: ModelPricing) => { setAdding(false); setEditingPattern(rule.model_pattern); setEditRow({ model_pattern: rule.model_pattern, display_name: rule.display_name, input_per_mtok: String(rule.input_per_mtok), output_per_mtok: String(rule.output_per_mtok), cache_read_per_mtok: String(rule.cache_read_per_mtok), cache_write_per_mtok: String(rule.cache_write_per_mtok), cache_write_1h_per_mtok: String(rule.cache_write_1h_per_mtok), fast_input_per_mtok: String(rule.fast_input_per_mtok), fast_output_per_mtok: String(rule.fast_output_per_mtok), intro_until: rule.intro_until ?? "", intro_input_per_mtok: String(rule.intro_input_per_mtok ?? 0), intro_output_per_mtok: String(rule.intro_output_per_mtok ?? 0), intro_cache_read_per_mtok: String(rule.intro_cache_read_per_mtok ?? 0), intro_cache_write_per_mtok: String(rule.intro_cache_write_per_mtok ?? 0), intro_cache_write_1h_per_mtok: String(rule.intro_cache_write_1h_per_mtok ?? 0), }); }; const startAdd = () => { setEditingPattern(null); setAdding(true); setEditRow({ ...emptyRow }); }; const cancelEdit = () => { setEditingPattern(null); setAdding(false); setError(null); }; const saveEdit = async () => { if (!editRow.model_pattern.trim() || !editRow.display_name.trim()) { setError(t("pricing.validationRequired")); return; } const introUntil = editRow.intro_until.trim(); if (introUntil && !/^\d{4}-\d{2}-\d{2}$/.test(introUntil)) { setError(t("pricing.introUntilInvalid")); return; } setSaving(true); setError(null); try { await api.pricing.upsert({ model_pattern: editRow.model_pattern.trim(), display_name: editRow.display_name.trim(), input_per_mtok: parseFloat(editRow.input_per_mtok) || 0, output_per_mtok: parseFloat(editRow.output_per_mtok) || 0, cache_read_per_mtok: parseFloat(editRow.cache_read_per_mtok) || 0, cache_write_per_mtok: parseFloat(editRow.cache_write_per_mtok) || 0, cache_write_1h_per_mtok: parseFloat(editRow.cache_write_1h_per_mtok) || 0, fast_input_per_mtok: parseFloat(editRow.fast_input_per_mtok) || 0, fast_output_per_mtok: parseFloat(editRow.fast_output_per_mtok) || 0, // Always send the intro block so the UI is authoritative for it: an // empty date clears the promo, a valid date persists the intro rates. intro_until: introUntil || null, intro_input_per_mtok: parseFloat(editRow.intro_input_per_mtok) || 0, intro_output_per_mtok: parseFloat(editRow.intro_output_per_mtok) || 0, intro_cache_read_per_mtok: parseFloat(editRow.intro_cache_read_per_mtok) || 0, intro_cache_write_per_mtok: parseFloat(editRow.intro_cache_write_per_mtok) || 0, intro_cache_write_1h_per_mtok: parseFloat(editRow.intro_cache_write_1h_per_mtok) || 0, }); if (!ours(editRow.model_pattern.trim())) setShowAll(true); setEditingPattern(null); setAdding(false); reloadAllTables(); } catch (err) { setError(err instanceof Error ? err.message : t("messages.failedSave")); } finally { setSaving(false); } }; const deleteRule = async (pattern: string) => { if ( !(await confirmDialog({ title: t("pricing.deleteConfirmTitle", { pattern }), message: t("pricing.deleteConfirmBody"), confirmLabel: t("common:delete"), })) ) return; try { await api.pricing.delete(pattern); reloadAllTables(); } catch (err) { setError(err instanceof Error ? err.message : t("messages.failedDelete")); } }; const handleResetPricing = async () => { setResetting(true); setActionResult(null); resetArm.disarm(); try { // Only the Claude tab offers the button, and it resets only Claude's rows. const res = await api.settings.resetPricing("claude"); setActionResult({ key: "reset-pricing", message: t("pricing.resetResult", { count: res.pricing.filter((p) => ours(p.model_pattern)).length, }), isError: false, }); reloadAllTables(); } catch (err) { setActionResult({ key: "reset-pricing", message: t("messages.actionFailed", { message: err instanceof Error ? err.message : t("messages.unknownError"), }), isError: true, }); } finally { setResetting(false); } }; const lastUpdated = rows.length > 0 ? rows.reduce( (latest, p) => (p.updated_at > latest ? p.updated_at : latest), rows[0]!.updated_at ) : null; const isEditing = editingPattern !== null || adding; const renderEditCells = () => ( <> setEditRow((r) => ({ ...r, model_pattern: e.target.value }))} placeholder={t(`pricing.placeholders.${vendor}.pattern`)} disabled={editingPattern !== null} className="input w-full text-sm font-mono disabled:opacity-50" autoFocus={adding} /> setEditRow((r) => ({ ...r, display_name: e.target.value }))} placeholder={t(`pricing.placeholders.${vendor}.name`)} className="input w-full text-sm" /> setEditRow((r) => ({ ...r, input_per_mtok: e.target.value }))} className="input w-full text-sm text-right font-mono" /> setEditRow((r) => ({ ...r, output_per_mtok: e.target.value }))} className="input w-full text-sm text-right font-mono" /> setEditRow((r) => ({ ...r, cache_read_per_mtok: e.target.value }))} className="input w-full text-sm text-right font-mono" /> setEditRow((r) => ({ ...r, cache_write_per_mtok: e.target.value }))} className="input w-full text-sm text-right font-mono" /> setEditRow((r) => ({ ...r, cache_write_1h_per_mtok: e.target.value }))} className="input w-full text-sm text-right font-mono" /> setEditRow((r) => ({ ...r, fast_input_per_mtok: e.target.value }))} className="input w-full text-sm text-right font-mono" /> setEditRow((r) => ({ ...r, fast_output_per_mtok: e.target.value }))} className="input w-full text-sm text-right font-mono" />
); // Second edit row: the time-limited introductory-rate block. Rendered under // the standard-rate cells whenever a row is being edited/added. Leaving the // date empty means "no promo" — the rate inputs are then ignored. This is the // ONLY place intro rates are entered, and it works for any model pattern (not // just Sonnet 5), so a future model with a launch promo needs no code change. const introField = (key: keyof EditRow, labelKey: string, opts: { date?: boolean } = {}) => ( ); const renderIntroEditRow = () => (
{t("pricing.introRatesTitle")} {t("pricing.introRatesHint")}
{introField("intro_until", "pricing.introUntil", { date: true })} {introField("intro_input_per_mtok", "common:token.input")} {introField("intro_output_per_mtok", "common:token.output")} {introField("intro_cache_read_per_mtok", "common:token.cacheRead")} {introField("intro_cache_write_per_mtok", "pricing.cacheWrite5m")} {introField("intro_cache_write_1h_per_mtok", "pricing.cacheWrite1h")}
); const actionBanner = (keys: string[]) => { const match = actionResult && keys.includes(actionResult.key) ? actionResult : null; if (!match) return null; return (
{match.message}
); }; if (loading) { return (
); } return (

{heading ?? t("pricing.title")}

{!heading &&

{t("pricing.description")}

}
{hidden > 0 && (
{error && (
{error}
)} {actionBanner(["reset-pricing"])} {rows.length === 0 && !adding ? (

{t(`pricing.empty.${vendor}`)}

) : (
{rows.map((rule) => editingPattern === rule.model_pattern ? ( {renderEditCells()} {renderIntroEditRow()} ) : ( ctx.open(e, rule)} className="hover:bg-surface-4 transition-colors group" > {/* The trailing wildcard is noise in a list of prefixes; the full pattern stays in the tooltip, the editor and Copy. */} ) )} {adding && ( <> {renderEditCells()} {renderIntroEditRow()} )}
{t("pricing.pattern")} {t("common:cost.model")} {t("common:token.input")} {t("common:token.output")} {t("common:token.cacheRead")} {t("pricing.cacheWrite5m")} {t("pricing.cacheWrite1h")} {t("pricing.fastInput")} {t("pricing.fastOutput")} {t("common:actions")}
{rule.model_pattern.replace(/%$/, "")} {rule.display_name} {rule.source === "sync" && ( {t("pricing.autoBadge")} )} {rule.intro_until && (
{t("pricing.introNote", { input: rule.intro_input_per_mtok, output: rule.intro_output_per_mtok, until: rule.intro_until, })}
)}
${rule.input_per_mtok} {rule.intro_until && ( ${rule.intro_input_per_mtok} )} ${rule.output_per_mtok} {rule.intro_until && ( ${rule.intro_output_per_mtok} )} ${rule.cache_read_per_mtok} {rule.intro_until && ( ${rule.intro_cache_read_per_mtok} )} ${rule.cache_write_per_mtok} {rule.intro_until && ( ${rule.intro_cache_write_per_mtok} )} ${rule.cache_write_1h_per_mtok} {rule.intro_until && ( ${rule.intro_cache_write_1h_per_mtok} )} {rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"} {rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"}
)} {ctx.menu && ( {t("common:edit")} void copyText(rule.model_pattern))}> {t("rowMenu.copyPattern")} void deleteRule(rule.model_pattern))} > {t("common:delete")} )} {lastUpdated && (

{t("pricing.lastUpdated")} {formatTimestamp(lastUpdated)}

)}
); }