feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from Settings → Dashboard to the Claude Code tab's AgentHub sub-tab (ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub. A home that is or contains the OS home is refused (EBADHOME). Settings persistence: dashboard-saved settings go to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin cache an update wipes. Load order: real env > the checkout's .env > that file. The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put. C1: codex-usage.js reads Codex rollout journals into sessions (new column tool) and token_usage, tracked per file in codex_rollouts; forks deduped by total vector, sub-agent threads folded into the root; pricing-sync also prices OpenAI models; PricingSettings with a vendor filter. C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on a click, through a stable forwarder; Codex trust is never written. The hook route creates tool='codex' sessions and folds sub-agent hooks into the root. D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab; Gemini pricing. Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=, clear-data covering the new tables, Codex child-thread events moving no status, hooks owning status only while current, EFOREIGN for a forwarder that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE honoured from .env. Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
This commit is contained in:
@@ -0,0 +1,772 @@
|
||||
/**
|
||||
* @file PricingSettings.tsx
|
||||
* @description Model Pricing — the per-model rates every session's cost is
|
||||
* computed from — as shown in a tool's Settings → AgentHub sub-tab: 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 router 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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { 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 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 — for Claude — a reminder that prices must be edited manually
|
||||
* when Anthropic publishes new rates; another vendor's rows come from
|
||||
* LiteLLM's price list, so its tab says that instead. 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(claude ? "pricing.tooltip.manualUpdates" : "pricing.tooltip.synced")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug mb-2.5">
|
||||
{t(claude ? "pricing.tooltip.manualUpdatesBody" : "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";
|
||||
|
||||
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 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" }: 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);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [actionResult, setActionResult] = useState<{
|
||||
key: string;
|
||||
message: string;
|
||||
isError: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.pricing.list();
|
||||
setPricing(res.pricing);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [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);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedSave"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRule = async (pattern: string) => {
|
||||
try {
|
||||
await api.pricing.delete(pattern);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedDelete"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPricing = async () => {
|
||||
setResetting(true);
|
||||
setActionResult(null);
|
||||
setConfirmReset(false);
|
||||
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,
|
||||
});
|
||||
await load();
|
||||
} 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-4 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-4 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-4 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-4 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-4 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-4 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-4 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-4 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-4 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-4 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" />
|
||||
{t("pricing.title")}
|
||||
<PricingInfoTooltip vendor={vendor} />
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mt-0.5">{t("pricing.description")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hidden > 0 && (
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-fg-secondary cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAll}
|
||||
onChange={(e) => setShowAll(e.target.checked)}
|
||||
className="accent-current"
|
||||
/>
|
||||
{t("pricing.showAll", { count: hidden })}
|
||||
</label>
|
||||
)}
|
||||
{allowReset && (
|
||||
<button
|
||||
onClick={() => (confirmReset ? handleResetPricing() : setConfirmReset(true))}
|
||||
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"])}
|
||||
|
||||
<div className="card overflow-x-auto mt-4">
|
||||
<table className="w-full min-w-[1000px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("pricing.pattern")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("common:cost.model")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("common:token.input")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("common:token.output")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("common:token.cacheRead")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.cacheWrite5m")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.cacheWrite1h")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.fastInput")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.fastOutput")}
|
||||
</th>
|
||||
<th className="w-24 px-4 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.length === 0 && !adding && (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-6 text-center text-xs text-fg-muted">
|
||||
{t(`pricing.empty.${vendor}`)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{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} className="hover:bg-surface-4 transition-colors group">
|
||||
<td className="px-4 py-3 text-sm font-mono text-fg-secondary">
|
||||
{rule.model_pattern}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary">
|
||||
{rule.display_name}
|
||||
{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-4 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-4 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-4 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-4 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-4 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-4 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-4 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-4 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-blue-500 hover:bg-blue-600/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>
|
||||
|
||||
{lastUpdated && (
|
||||
<p className="text-xs text-fg-muted mt-3">
|
||||
{t("pricing.lastUpdated")}
|
||||
{formatTimestamp(lastUpdated)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user