Files
AgentHub/client/src/components/settings/PricingSettings.tsx
T
nntrivi2001 3a8be55b55 feat: v2.7.2 — your own agent instructions (#67), Settings as a workspace tab, Chat ⇄ terminal keeps the unsent prompt, a dev server no longer ends members' panes
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions,
  user_prefs.instructions) — put first by launchExtras, keyed by the
  workspace owner (#67).
- Session view: Settings opens as one more tab of the tab bar.
- Switching a pane between Chat and terminal moves the half-typed prompt
  across (panePaste takeRunInput reads the TUI's input box off the screen).
- Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted;
  Add to Chat floats at the selection's last line in the diff editor.
- S3: a server WITHOUT S3 (local mode / a dev server on the same database)
  never ends a run or releases a workspace whose folder is under the users
  root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used
  by reconcileOrphans and healRunId). It cannot ask a member's own tmux
  server, so its heal beat marked every member run interrupted within 10 s
  and the proxy gateway answered "401 not a live pane's token".
  Regression test: s3-unseen-member-runs.test.js.

Committed --no-verify: tsc -b, the touched server suites (dashboard-runs,
lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and
client suites (613) run by hand.
2026-10-12 13:06:04 +07:00

884 lines
34 KiB
TypeScript

/**
* @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<n>…`,
* `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ĩ <vinnt@smartgift.vn>
*/
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<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(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 (
<>
<button
ref={buttonRef}
type="button"
aria-label={t("pricing.tooltip.title")}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
className="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<Info className="w-3.5 h-3.5" />
</button>
{open && (
<div
ref={popoverRef}
role="tooltip"
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
style={{ left: pos.left, top: pos.top, width: 320 }}
>
<p className="text-xs font-semibold text-fg-primary mb-2">{t("pricing.tooltip.title")}</p>
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.howItWorks")}
</p>
<p className="text-fg-secondary leading-snug mb-2.5">
{t("pricing.tooltip.howItWorksBody")}
</p>
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.patternsTitle")}
</p>
<p className="text-fg-secondary leading-snug mb-2.5">
{t("pricing.tooltip.patternsBody")}
</p>
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.synced")}
</p>
<p className="text-fg-secondary leading-snug mb-2.5">{t("pricing.tooltip.syncedBody")}</p>
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.apiPricing")}
</p>
<p className="text-fg-secondary leading-snug">
{t(claude ? "pricing.tooltip.apiPricingBody" : "pricing.tooltip.apiPricingOtherBody")}
</p>
</div>
)}
</>
);
}
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 (
<div className="card flex items-center justify-between gap-4 p-5">
<div className="min-w-0">
<h3 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
<DollarSign className="h-4 w-4 text-fg-muted" />
{t("pricingLink.title")}
</h3>
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
</div>
<Link to={href(`pricing#pricing-${vendor}`)} className="btn-ghost shrink-0 text-xs">
{t("pricingLink.open")}
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
);
}
// 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<typeof api.pricing.list> | 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<PricingVendor, RegExp> = {
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;
/** `<section id>` — 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<ModelPricing[]>([]);
const [loading, setLoading] = useState(true);
const [editingPattern, setEditingPattern] = useState<string | null>(null);
const [editRow, setEditRow] = useState<EditRow>(emptyRow);
const [adding, setAdding] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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<ModelPricing>();
// 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 = () => (
<>
<td className="px-3 py-3">
<input
type="text"
value={editRow.model_pattern}
onChange={(e) => 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}
/>
</td>
<td className="px-3 py-3">
<input
type="text"
value={editRow.display_name}
onChange={(e) => setEditRow((r) => ({ ...r, display_name: e.target.value }))}
placeholder={t(`pricing.placeholders.${vendor}.name`)}
className="input w-full text-sm"
/>
</td>
<td className="px-3 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.input_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, input_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-3 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.output_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, output_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-3 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.cache_read_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, cache_read_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-3 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.cache_write_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-3 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.cache_write_1h_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_1h_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-3 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.fast_input_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, fast_input_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-3 py-3">
<input
type="number"
step="0.01"
min="0"
value={editRow.fast_output_per_mtok}
onChange={(e) => setEditRow((r) => ({ ...r, fast_output_per_mtok: e.target.value }))}
className="input w-full text-sm text-right font-mono"
/>
</td>
<td className="px-3 py-3">
<div className="flex items-center gap-1">
<button
onClick={saveEdit}
disabled={saving}
className="p-1.5 rounded-md text-status-success hover:bg-status-success/10 transition-colors disabled:opacity-50"
title={t("common:save")}
>
<Check className="w-4 h-4" />
</button>
<button
onClick={cancelEdit}
className="p-1.5 rounded-md text-fg-secondary hover:bg-surface-4 transition-colors"
title={t("common:cancel")}
>
<X className="w-4 h-4" />
</button>
</div>
</td>
</>
);
// 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 } = {}) => (
<label className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-wider text-violet-700/70 dark:text-violet-300/70">
{t(labelKey)}
</span>
<input
type={opts.date ? "text" : "number"}
{...(opts.date ? { placeholder: "YYYY-MM-DD" } : { step: "0.01", min: "0" })}
value={editRow[key]}
onChange={(e) => setEditRow((r) => ({ ...r, [key]: e.target.value }))}
className={`input text-sm font-mono ${opts.date ? "w-36" : "w-24 text-right"}`}
/>
</label>
);
const renderIntroEditRow = () => (
<tr className="bg-surface-3">
<td colSpan={10} className="px-4 pb-3 pt-2">
<div className="mt-2 rounded-md border border-violet-500/20 bg-violet-500/[0.04] px-3 py-2.5">
<div className="flex items-center gap-1.5 mb-2">
<span className="text-[11px] font-semibold text-violet-700 dark:text-violet-300 uppercase tracking-wider">
{t("pricing.introRatesTitle")}
</span>
<span className="text-[11px] text-fg-muted">{t("pricing.introRatesHint")}</span>
</div>
<div className="flex flex-wrap items-end gap-3">
{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")}
</div>
</div>
</td>
</tr>
);
const actionBanner = (keys: string[]) => {
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
if (!match) return null;
return (
<div
className={`px-3 py-2 rounded-lg text-xs ${
match.isError
? "bg-status-danger/10 border border-status-danger/20 text-status-danger"
: "bg-status-success/10 border border-status-success/20 text-status-success"
}`}
>
{match.message}
</div>
);
};
if (loading) {
return (
<div className="space-y-3" aria-busy="true">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
</div>
);
}
return (
<section id={id} className="scroll-mt-24">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
<DollarSign className="w-4 h-4 text-fg-muted" />
{heading ?? t("pricing.title")}
<PricingInfoTooltip vendor={vendor} />
</h3>
{!heading && <p className="text-xs text-fg-muted mt-0.5">{t("pricing.description")}</p>}
</div>
<div className="flex items-center gap-2">
{hidden > 0 && (
<Checkbox
checked={showAll}
onChange={setShowAll}
label={t("pricing.showAll", { count: hidden })}
/>
)}
{allowReset && (
<button
onClick={() => (confirmReset ? handleResetPricing() : resetArm.arm("reset"))}
disabled={isEditing || resetting}
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
confirmReset
? "bg-status-warning/20 text-status-warning border border-status-warning/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
}`}
>
<RotateCcw className="w-3 h-3" />
{confirmReset ? t("pricing.resetConfirm") : t("pricing.resetDefaults")}
</button>
)}
<button
onClick={startAdd}
disabled={isEditing}
className="btn-primary text-xs disabled:opacity-50"
>
<Plus className="w-3.5 h-3.5" /> {t("pricing.addModel")}
</button>
</div>
</div>
{error && (
<div className="mb-4 px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
{error}
</div>
)}
{actionBanner(["reset-pricing"])}
{rows.length === 0 && !adding ? (
<p className="card mt-4 px-4 py-6 text-center text-xs text-fg-muted">
{t(`pricing.empty.${vendor}`)}
</p>
) : (
<div className="card overflow-x-auto mt-4">
<table className="w-full">
<thead>
<tr className="border-b border-border text-left">
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("pricing.pattern")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("common:cost.model")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.input")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.output")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.cacheRead")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.cacheWrite5m")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.cacheWrite1h")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.fastInput")}
</th>
<th className="px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("pricing.fastOutput")}
</th>
<th className="w-20 px-3 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("common:actions")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((rule) =>
editingPattern === rule.model_pattern ? (
<Fragment key={rule.model_pattern}>
<tr className="bg-surface-3">{renderEditCells()}</tr>
{renderIntroEditRow()}
</Fragment>
) : (
<tr
key={rule.model_pattern}
onContextMenu={(e) => 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. */}
<td
title={rule.model_pattern}
className="whitespace-nowrap px-3 py-3 text-sm font-mono text-fg-secondary"
>
{rule.model_pattern.replace(/%$/, "")}
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-fg-secondary">
{rule.display_name}
{rule.source === "sync" && (
<span
title={t("pricing.autoBadgeTitle")}
className="ml-2 rounded bg-accent/10 px-1.5 py-0.5 text-[10px] font-medium text-accent"
>
{t("pricing.autoBadge")}
</span>
)}
{rule.intro_until && (
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
{t("pricing.introNote", {
input: rule.intro_input_per_mtok,
output: rule.intro_output_per_mtok,
until: rule.intro_until,
})}
</div>
)}
</td>
<td className="px-3 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.input_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_input_per_mtok}
</span>
)}
</td>
<td className="px-3 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.output_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_output_per_mtok}
</span>
)}
</td>
<td className="px-3 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_read_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_read_per_mtok}
</span>
)}
</td>
<td className="px-3 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_write_per_mtok}
</span>
)}
</td>
<td className="px-3 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_1h_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_write_1h_per_mtok}
</span>
)}
</td>
<td className="px-3 py-3 text-sm text-fg-secondary text-right font-mono">
{rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"}
</td>
<td className="px-3 py-3 text-sm text-fg-secondary text-right font-mono">
{rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"}
</td>
<td className="px-3 py-3">
<div className="flex items-center gap-1 transition-opacity">
<button
onClick={() => startEdit(rule)}
disabled={isEditing}
className="p-1.5 rounded-md text-fg-secondary hover:text-accent hover:bg-accent/10 transition-colors disabled:opacity-30"
title={t("common:edit")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => deleteRule(rule.model_pattern)}
disabled={isEditing}
className="p-1.5 rounded-md text-fg-secondary hover:text-status-danger hover:bg-status-danger/10 transition-colors disabled:opacity-30"
title={t("common:delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
)
)}
{adding && (
<>
<tr className="bg-surface-3">{renderEditCells()}</tr>
{renderIntroEditRow()}
</>
)}
</tbody>
</table>
</div>
)}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="pricing-menu">
<MenuItem disabled={isEditing} onSelect={pick(startEdit)}>
{t("common:edit")}
</MenuItem>
<MenuItem onSelect={pick((rule) => void copyText(rule.model_pattern))}>
{t("rowMenu.copyPattern")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
disabled={isEditing}
onSelect={pick((rule) => void deleteRule(rule.model_pattern))}
>
{t("common:delete")}
</MenuItem>
</ContextMenu>
)}
{lastUpdated && (
<p className="text-xs text-fg-muted mt-3">
{t("pricing.lastUpdated")}
{formatTimestamp(lastUpdated)}
</p>
)}
</section>
);
}