3a8be55b55
- 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.
884 lines
34 KiB
TypeScript
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>
|
|
);
|
|
}
|