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,30 @@
|
||||
/**
|
||||
* @file SessionToolBadge — names the AI coding CLI that produced a session
|
||||
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
|
||||
* list and session header look as before for every Claude row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
|
||||
/** A chip with the tool's name, or nothing for `claude` / an unset tool. */
|
||||
export function SessionToolBadge({ tool }: { tool?: string | null }) {
|
||||
if (!tool || tool === "claude") return null;
|
||||
return <ToolChip tool={tool} />;
|
||||
}
|
||||
|
||||
// Split out so the catalog hook only mounts for a non-Claude session.
|
||||
function ToolChip({ tool }: { tool: string }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const { label } = useAiTools();
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center text-[10px] font-semibold text-violet-700 dark:text-violet-300 bg-violet-500/10 border border-violet-500/25 px-1.5 py-0.5 rounded-full"
|
||||
title={t("toolBadgeTitle", { tool: label(tool) })}
|
||||
>
|
||||
{label(tool)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file SessionToolBadge.test.tsx
|
||||
* @description The session tool chip: nothing for a Claude Code session (so
|
||||
* every hook row renders as before), the tool's name for a Codex one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionToolBadge } from "../SessionToolBadge";
|
||||
|
||||
vi.mock("../../lib/api", () => ({ api: { aiTools: { list: () => new Promise(() => {}) } } }));
|
||||
|
||||
describe("SessionToolBadge", () => {
|
||||
it("renders nothing for a Claude or unset tool", () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<SessionToolBadge tool="claude" />
|
||||
<SessionToolBadge tool={undefined} />
|
||||
</>
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("names a Codex session's tool", () => {
|
||||
render(<SessionToolBadge tool="codex" />);
|
||||
expect(screen.getByText("Codex")).toHaveAttribute("title", "Recorded by Codex");
|
||||
});
|
||||
});
|
||||
@@ -893,7 +893,10 @@ function SessionPicker({
|
||||
if (sessions !== null && fetchedForCwd.current === cwd) return;
|
||||
fetchedForCwd.current = cwd;
|
||||
api.sessions
|
||||
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd })
|
||||
// `claude --resume` can only resume a Claude session; a Codex lane at the
|
||||
// same directory records its own sessions there too. Filtered by the
|
||||
// server, before its limit.
|
||||
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd, tool: "claude" })
|
||||
.then((r) => setSessions(r.sessions))
|
||||
.catch(() => setSessions([]));
|
||||
}, [open, cwd, sessions]);
|
||||
|
||||
@@ -236,6 +236,27 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
||||
);
|
||||
});
|
||||
|
||||
it("never offers a Codex session, which `claude --resume` cannot resume", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
sessions: [
|
||||
{ id: "sess-claude", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
} as never);
|
||||
|
||||
renderSetup({ laneCwd: "/Users/tester/lane-a" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
|
||||
await screen.findByText("/Users/tester/lane-a");
|
||||
// Filtered by the server, before its limit: a busy Codex lane at the same
|
||||
// directory cannot push Claude sessions off the page.
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" }));
|
||||
});
|
||||
|
||||
it("starts the resume immediately when a session is picked", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @file AntigravityHooksSettings.tsx
|
||||
* @description Settings → Antigravity → AgentHub → Antigravity hooks: whether
|
||||
* AgentHub's named hook is in `~/.gemini/config/hooks.json` (and not switched
|
||||
* off there), per event, with Install/Reinstall, Remove and Refresh. Nothing is
|
||||
* written until the user clicks; agy asks for no trust, and has no permission
|
||||
* hook — the note says both (#40 part D).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { AntigravityHooksStatus } from "../../lib/types";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
|
||||
const CHIP = "inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border";
|
||||
|
||||
/**
|
||||
* The Antigravity hooks card.
|
||||
* @returns A section with id `antigravity-hooks` for deep links.
|
||||
*/
|
||||
export function AntigravityHooksSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<AntigravityHooksStatus | null>(null);
|
||||
const [busy, setBusy] = useState<"install" | "remove" | "get" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.antigravityHooks
|
||||
.get()
|
||||
.then(setStatus)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, []);
|
||||
|
||||
const run = async (kind: "install" | "remove" | "get") => {
|
||||
setBusy(kind);
|
||||
setError(null);
|
||||
try {
|
||||
setStatus(await api.settings.antigravityHooks[kind]());
|
||||
} catch (err) {
|
||||
setError(
|
||||
t("antigravityHooks.failed", { message: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const anyInstalled = !!status && Object.values(status.events).some((e) => e.installed);
|
||||
const chip = !status?.installed ? (
|
||||
<span className={`${CHIP} text-fg-secondary bg-surface-2 border-border`}>
|
||||
<XCircle className="w-3.5 h-3.5" /> {t("antigravityHooks.notInstalled")}
|
||||
</span>
|
||||
) : status.enabled ? (
|
||||
<span className={`${CHIP} text-status-success bg-status-success/10 border-status-success/20`}>
|
||||
<CheckCircle className="w-3.5 h-3.5" /> {t("antigravityHooks.installed")}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`${CHIP} text-status-warning bg-status-warning/10 border-status-warning/20`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> {t("antigravityHooks.disabled")}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<section id="antigravity-hooks" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Plug className="w-4 h-4 text-fg-muted" />
|
||||
{t("antigravityHooks.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("antigravityHooks.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-4">
|
||||
{!status && !error ? (
|
||||
<div className="space-y-2" aria-busy="true">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status && !status.supported ? (
|
||||
<p className="text-xs text-fg-muted">{t("antigravityHooks.unsupported")}</p>
|
||||
) : null}
|
||||
|
||||
{status && status.supported ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
{chip}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => run("get")}
|
||||
disabled={busy !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "get" ? "animate-spin" : ""}`} />
|
||||
{t("antigravityHooks.refresh")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => run("install")}
|
||||
disabled={busy !== null || !status.readable}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{busy === "install" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{status.installed
|
||||
? t("antigravityHooks.reinstall")
|
||||
: t("antigravityHooks.install")}
|
||||
</button>
|
||||
{anyInstalled ? (
|
||||
<button
|
||||
onClick={() => run("remove")}
|
||||
disabled={busy !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{busy === "remove" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("antigravityHooks.remove")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!status.readable ? (
|
||||
<p className="text-xs text-status-danger">
|
||||
{t("antigravityHooks.unreadable", { path: status.path, error: status.error ?? "" })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-fg-secondary">{t("antigravityHooks.note")}</p>
|
||||
|
||||
{anyInstalled ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{Object.entries(status.events).map(([event, e]) => {
|
||||
const label = e.installed
|
||||
? t("antigravityHooks.eventInstalled")
|
||||
: t("antigravityHooks.eventMissing");
|
||||
return (
|
||||
<div
|
||||
key={event}
|
||||
title={label}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
|
||||
>
|
||||
{e.installed ? (
|
||||
<CheckCircle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-success flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<XCircle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-danger flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-fg-secondary truncate">{event}</span>
|
||||
<span className="sr-only">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{status.path}</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="px-3 py-2 rounded-lg text-xs bg-status-danger/10 border border-status-danger/20 text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @file ClaudeAgentHubSettings.tsx
|
||||
* @description Settings → Claude Code → AgentHub: the dashboard's own settings
|
||||
* that belong to Claude Code — Model Pricing for Claude models (the shared
|
||||
* `PricingSettings` table, with Reset Defaults), the AgentHub hooks installed
|
||||
* into Claude Code with a Reinstall button, and the CLAUDE_HOME the server
|
||||
* reads transcripts and config from. Moved here from Settings → Dashboard
|
||||
* (#40 part B).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw, Plug, AlertTriangle, RotateCcw, CheckCircle, XCircle } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { HomeDirSetting } from "./HomeDirSetting";
|
||||
import { PricingSettings } from "./PricingSettings";
|
||||
|
||||
type HooksInfo = { installed: boolean; path: string; hooks: Record<string, boolean> };
|
||||
|
||||
/**
|
||||
* Claude Code's AgentHub settings: pricing, hook status and CLAUDE_HOME.
|
||||
* @param onHomeChanged - called after CLAUDE_HOME was saved (the page re-reads
|
||||
* the config it lists, which lives under that home).
|
||||
* @returns The three sections, each with its own id.
|
||||
*/
|
||||
export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () => void } = {}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hooks, setHooks] = useState<HooksInfo | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [actionResult, setActionResult] = useState<{
|
||||
key: string;
|
||||
message: string;
|
||||
isError: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const infoRes = await api.settings.info();
|
||||
setHooks(infoRes.hooks);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Hooks can be (re)installed from a terminal while this tab is open; keep the
|
||||
// badge current the way the old Settings page did, every 10 s.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
api.settings
|
||||
.info()
|
||||
.then((r) => setHooks(r.hooks))
|
||||
.catch(() => {});
|
||||
}, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionResult) return;
|
||||
const timeout = setTimeout(() => setActionResult(null), 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [actionResult]);
|
||||
|
||||
const runAction = async (key: string, fn: () => Promise<string>) => {
|
||||
setActionLoading(key);
|
||||
setActionResult(null);
|
||||
try {
|
||||
const message = await fn();
|
||||
setActionResult({ key, message, isError: false });
|
||||
await load();
|
||||
} catch (err) {
|
||||
setActionResult({
|
||||
key,
|
||||
message: t("messages.actionFailed", {
|
||||
message: err instanceof Error ? err.message : t("messages.unknownError"),
|
||||
}),
|
||||
isError: true,
|
||||
});
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReinstallHooks = () =>
|
||||
runAction("hooks", async () => {
|
||||
const res = await api.settings.reinstallHooks();
|
||||
return res.ok ? t("hooks.success") : t("hooks.failed");
|
||||
});
|
||||
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
<PricingSettings vendor="claude" allowReset />
|
||||
{error && (
|
||||
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── HOOK CONFIGURATION ─── */}
|
||||
<section id="hooks" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Plug className="w-4 h-4 text-fg-muted" />
|
||||
{t("hooks.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("hooks.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{hooks?.installed ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 border border-status-success/20 px-2.5 py-1 rounded-full">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> {t("hooks.allInstalled")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-warning bg-status-warning/10 border border-status-warning/20 px-2.5 py-1 rounded-full">
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> {t("hooks.incomplete")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReinstallHooks}
|
||||
disabled={actionLoading !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "hooks" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("hooks.reinstall")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{actionBanner(["hooks"])}
|
||||
|
||||
{hooks && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
||||
{Object.entries(hooks.hooks).map(([hook, active]) => (
|
||||
<div
|
||||
key={hook}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
|
||||
>
|
||||
{active ? (
|
||||
<CheckCircle className="w-3 h-3 text-status-success flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="w-3 h-3 text-status-danger flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-fg-secondary truncate">{hook}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HomeDirSetting
|
||||
id="claude-home"
|
||||
title={t("claudeHome.title")}
|
||||
description={t("claudeHome.description")}
|
||||
note={t("cursorPathsNote")}
|
||||
placeholder={t("claudeHome.placeholder")}
|
||||
load={() =>
|
||||
api.settings.claudeHome
|
||||
.get()
|
||||
.then((r) => ({ value: r.claude_home, envOwned: !!r.env_owned }))
|
||||
}
|
||||
save={(p) =>
|
||||
api.settings.claudeHome.set(p).then((r) => {
|
||||
void load();
|
||||
onHomeChanged?.();
|
||||
const missing = r.routers?.missing ?? [];
|
||||
return {
|
||||
value: r.claude_home,
|
||||
envOwned: !!r.env_owned,
|
||||
warning: missing.length
|
||||
? t("claudeHome.routersMissing", { lanes: missing.join(", ") })
|
||||
: null,
|
||||
};
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @file CodexHooksSettings.tsx
|
||||
* @description Settings → Codex → AgentHub → Codex hooks: whether AgentHub's
|
||||
* hooks are in `$CODEX_HOME/hooks.json` and trusted by Codex, per event, with
|
||||
* Install/Reinstall and Remove. Nothing is written until the user clicks;
|
||||
* Codex itself asks the user to trust the hooks before it runs them, and this
|
||||
* card says so while they wait, with a Refresh to re-read once they have
|
||||
* (#40 part C2).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { CodexHooksStatus } from "../../lib/types";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
|
||||
const CHIP = "inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border";
|
||||
|
||||
/**
|
||||
* The Codex hooks card.
|
||||
* @returns A section with id `codex-hooks` for deep links.
|
||||
*/
|
||||
export function CodexHooksSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<CodexHooksStatus | null>(null);
|
||||
const [busy, setBusy] = useState<"install" | "remove" | "get" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.codexHooks
|
||||
.get()
|
||||
.then(setStatus)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, []);
|
||||
|
||||
const run = async (kind: "install" | "remove" | "get") => {
|
||||
setBusy(kind);
|
||||
setError(null);
|
||||
try {
|
||||
setStatus(await api.settings.codexHooks[kind]());
|
||||
} catch (err) {
|
||||
setError(
|
||||
t("codexHooks.failed", { message: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Some of ours present but not all (an event added since, or one taken out
|
||||
// by hand): still show them, and still offer Remove.
|
||||
const anyInstalled = !!status && Object.values(status.events).some((e) => e.installed);
|
||||
const eventLabel = (e: { installed: boolean; trusted: boolean }) =>
|
||||
!e.installed
|
||||
? t("codexHooks.eventMissing")
|
||||
: e.trusted
|
||||
? t("codexHooks.eventTrusted")
|
||||
: t("codexHooks.eventUntrusted");
|
||||
|
||||
const chip = !status?.installed ? (
|
||||
<span className={`${CHIP} text-fg-secondary bg-surface-2 border-border`}>
|
||||
<XCircle className="w-3.5 h-3.5" /> {t("codexHooks.notInstalled")}
|
||||
</span>
|
||||
) : status.trusted ? (
|
||||
<span className={`${CHIP} text-status-success bg-status-success/10 border-status-success/20`}>
|
||||
<CheckCircle className="w-3.5 h-3.5" /> {t("codexHooks.trusted")}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`${CHIP} text-status-warning bg-status-warning/10 border-status-warning/20`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> {t("codexHooks.untrusted")}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<section id="codex-hooks" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Plug className="w-4 h-4 text-fg-muted" />
|
||||
{t("codexHooks.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("codexHooks.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-4">
|
||||
{!status && !error ? (
|
||||
<div className="space-y-2" aria-busy="true">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status && !status.supported ? (
|
||||
<p className="text-xs text-fg-muted">{t("codexHooks.unsupported")}</p>
|
||||
) : null}
|
||||
|
||||
{status && status.supported ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
{chip}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => run("get")}
|
||||
disabled={busy !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "get" ? "animate-spin" : ""}`} />
|
||||
{t("codexHooks.refresh")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => run("install")}
|
||||
disabled={busy !== null || !status.readable}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{busy === "install" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{status.installed ? t("codexHooks.reinstall") : t("codexHooks.install")}
|
||||
</button>
|
||||
{anyInstalled ? (
|
||||
<button
|
||||
onClick={() => run("remove")}
|
||||
disabled={busy !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{busy === "remove" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("codexHooks.remove")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!status.readable ? (
|
||||
<p className="text-xs text-status-danger">
|
||||
{t("codexHooks.unreadable", { path: status.path, error: status.error ?? "" })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{status.installed && !status.trusted ? (
|
||||
<p className="text-xs text-fg-secondary">{t("codexHooks.trustNote")}</p>
|
||||
) : null}
|
||||
|
||||
{anyInstalled ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{Object.entries(status.events).map(([event, e]) => (
|
||||
<div
|
||||
key={event}
|
||||
title={eventLabel(e)}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
|
||||
>
|
||||
{!e.installed ? (
|
||||
<XCircle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-danger flex-shrink-0"
|
||||
/>
|
||||
) : e.trusted ? (
|
||||
<CheckCircle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-success flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<AlertTriangle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-warning flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-fg-secondary truncate">{event}</span>
|
||||
<span className="sr-only">{eventLabel(e)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{status.path}</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="px-3 py-2 rounded-lg text-xs bg-status-danger/10 border border-status-danger/20 text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @file HomeDirSetting.tsx
|
||||
* @description One editable home directory (CLAUDE_HOME in Claude Code →
|
||||
* AgentHub, CODEX_HOME in Codex → AgentHub): a path field with Save, the
|
||||
* server's refusal inline, the value in effect, and a warning when the real
|
||||
* environment sets the variable (the environment wins at the next start, so a
|
||||
* saved value would not survive a restart). The server validates and persists;
|
||||
* this component only loads, submits and reports.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, FolderOpen } from "lucide-react";
|
||||
|
||||
export interface HomeDirValue {
|
||||
value: string;
|
||||
envOwned: boolean;
|
||||
/** Shown after a save that succeeded only in part (e.g. a lane not updated). */
|
||||
warning?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** `<section id>` — a deep-link target. */
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Optional second line under the description. */
|
||||
note?: string;
|
||||
placeholder: string;
|
||||
load: () => Promise<HomeDirValue>;
|
||||
save: (path: string) => Promise<HomeDirValue>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An editable home directory with its own load/save cycle.
|
||||
* @returns The section, or its skeleton-free shell while loading.
|
||||
*/
|
||||
export function HomeDirSetting({ id, title, description, note, placeholder, load, save }: Props) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [current, setCurrent] = useState<HomeDirValue | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
load()
|
||||
.then((v) => {
|
||||
if (!live) return;
|
||||
setCurrent(v);
|
||||
setInput(v.value);
|
||||
})
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// `load` is a stable api call per mount; re-running on identity would loop.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!current || input === current.value) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await save(input);
|
||||
setCurrent(next);
|
||||
setInput(next.value);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("homeDir.saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section id={id} className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<FolderOpen className="w-4 h-4 text-fg-muted" />
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-1">{description}</p>
|
||||
{note && <p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{note}</p>}
|
||||
|
||||
<div className={`card p-5 space-y-4 ${note ? "" : "mt-3"}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
aria-label={title}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !current || input === current.value}
|
||||
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-status-danger">{error}</p>}
|
||||
{current && (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("homeDir.current")} <code className="text-fg-secondary">{current.value}</code>
|
||||
</p>
|
||||
)}
|
||||
{current?.envOwned && (
|
||||
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||
{t("homeDir.envOwned")}
|
||||
</p>
|
||||
)}
|
||||
{current?.warning && (
|
||||
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||
{current.warning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file AntigravityHooksSettings.test.tsx
|
||||
* @description Settings → Antigravity → Antigravity hooks: not installed until
|
||||
* the user clicks Install, then "Installed" with the note that agy asks no
|
||||
* trust and has no permission hook; a hook switched off in hooks.json is
|
||||
* reported; Refresh re-reads; Remove; a server refusal shown inline.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { AntigravityHooksSettings } from "../AntigravityHooksSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { AntigravityHooksStatus } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { settings: { antigravityHooks: { get: vi.fn(), install: vi.fn(), remove: vi.fn() } } },
|
||||
};
|
||||
});
|
||||
|
||||
function status(installed: boolean, enabled = installed): AntigravityHooksStatus {
|
||||
return {
|
||||
supported: true,
|
||||
readable: true,
|
||||
path: "/home/u/.gemini/config/hooks.json",
|
||||
script: "/home/u/.gemini/config/agenthub-hook.sh",
|
||||
installed,
|
||||
enabled,
|
||||
events: Object.fromEntries(
|
||||
["PreInvocation", "PostToolUse", "Stop"].map((e) => [e, { installed }])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const hooks = api.settings.antigravityHooks;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(hooks.get).mockReset().mockResolvedValue(status(false));
|
||||
vi.mocked(hooks.install).mockReset().mockResolvedValue(status(true));
|
||||
vi.mocked(hooks.remove).mockReset().mockResolvedValue(status(false));
|
||||
});
|
||||
|
||||
describe("AntigravityHooksSettings", () => {
|
||||
it("installs on a click, and says agy has no permission hook", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AntigravityHooksSettings />);
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Remove" })).toBeNull();
|
||||
expect(screen.getByText(/no hook for a permission prompt/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||
expect(hooks.install).toHaveBeenCalled();
|
||||
expect(await screen.findByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||
expect(screen.getByText("PreInvocation")).toBeInTheDocument();
|
||||
expect(screen.queryByText("PreToolUse")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a hook switched off, re-reads on Refresh, and removes", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(hooks.get)
|
||||
.mockResolvedValueOnce(status(true, false))
|
||||
.mockResolvedValueOnce(status(true, true));
|
||||
render(<AntigravityHooksSettings />);
|
||||
expect(await screen.findByText(/switched off/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(await screen.findByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/switched off/)).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
expect(hooks.remove).toHaveBeenCalled();
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a server refusal inline", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(hooks.install).mockRejectedValue(new Error("hooks.json is not a JSON object"));
|
||||
render(<AntigravityHooksSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Install" }));
|
||||
expect(await screen.findByText(/is not a JSON object/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @file ClaudeAgentHubSettings.test.tsx
|
||||
* @description Settings → Claude Code → AgentHub: the pricing table, the hook
|
||||
* status with Reinstall, and CLAUDE_HOME — the three sections moved out of
|
||||
* Settings → Dashboard (#40 part B) — still load and act as they did there.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { ModelPricing } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
|
||||
settings: {
|
||||
info: vi.fn(),
|
||||
reinstallHooks: vi.fn(),
|
||||
resetPricing: vi.fn(),
|
||||
claudeHome: { get: vi.fn(), set: vi.fn() },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const RULE = {
|
||||
model_pattern: "claude-opus-5-5%",
|
||||
display_name: "Claude Opus 5.5",
|
||||
input_per_mtok: 4,
|
||||
output_per_mtok: 20,
|
||||
cache_read_per_mtok: 0.2,
|
||||
cache_write_per_mtok: 5,
|
||||
cache_write_1h_per_mtok: 8,
|
||||
fast_input_per_mtok: 8,
|
||||
fast_output_per_mtok: 40,
|
||||
updated_at: "2026-09-28 10:00:00",
|
||||
} as ModelPricing;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.pricing.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ pricing: [RULE] });
|
||||
vi.mocked(api.settings.info)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
hooks: {
|
||||
installed: true,
|
||||
path: "/home/u/.claude/settings.json",
|
||||
hooks: { SessionStart: true, Stop: true },
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof api.settings.info>>);
|
||||
vi.mocked(api.settings.claudeHome.get)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ claude_home: "/home/u/.claude" });
|
||||
vi.mocked(api.settings.claudeHome.set)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ok: true, claude_home: "/srv/claude", routers: { missing: [] } });
|
||||
vi.mocked(api.settings.resetPricing).mockReset();
|
||||
vi.mocked(api.settings.reinstallHooks)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ok: true, hooks: { installed: true, hooks: {} } });
|
||||
});
|
||||
|
||||
describe("ClaudeAgentHubSettings", () => {
|
||||
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
|
||||
const { container } = render(<ClaudeAgentHubSettings />);
|
||||
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
||||
}
|
||||
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||
expect(within(hooks).getByText("SessionStart")).toBeInTheDocument();
|
||||
expect(within(hooks).getByText("/home/u/.claude/settings.json")).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue("/home/u/.claude")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves a new CLAUDE_HOME and reinstalls hooks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<ClaudeAgentHubSettings />);
|
||||
const input = await screen.findByDisplayValue("/home/u/.claude");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/srv/claude");
|
||||
const home = container.querySelector("section#claude-home") as HTMLElement;
|
||||
await user.click(within(home).getByRole("button"));
|
||||
expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude");
|
||||
|
||||
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||
await user.click(within(hooks).getByRole("button"));
|
||||
expect(api.settings.reinstallHooks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-reads hook status and tells the page after a CLAUDE_HOME save, naming lanes left without their router", async () => {
|
||||
vi.mocked(api.settings.claudeHome.set).mockResolvedValue({
|
||||
ok: true,
|
||||
claude_home: "/srv/claude",
|
||||
routers: { missing: ["7 (api): my-router"] },
|
||||
});
|
||||
const onHomeChanged = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<ClaudeAgentHubSettings onHomeChanged={onHomeChanged} />);
|
||||
const input = await screen.findByDisplayValue("/home/u/.claude");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/srv/claude");
|
||||
const home = container.querySelector("section#claude-home") as HTMLElement;
|
||||
const reads = vi.mocked(api.settings.info).mock.calls.length;
|
||||
await user.click(within(home).getByRole("button"));
|
||||
expect(await within(home).findByText(/7 \(api\): my-router/)).toBeInTheDocument();
|
||||
expect(onHomeChanged).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
|
||||
});
|
||||
|
||||
it("resets pricing only on the second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.resetPricing).mockResolvedValue({ ok: true, pricing: [RULE] });
|
||||
render(<ClaudeAgentHubSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Reset Defaults" }));
|
||||
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText("Reset to 1 default rules")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @file CodexHooksSettings.test.tsx
|
||||
* @description Settings → Codex → AgentHub → Codex hooks: not installed until
|
||||
* the user clicks Install, then "waiting for your trust" with the note on how
|
||||
* Codex asks for it, "trusted" once Codex's config says so, Remove, and a
|
||||
* server refusal shown inline.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { CodexHooksSettings } from "../CodexHooksSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { CodexHooksStatus } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { settings: { codexHooks: { get: vi.fn(), install: vi.fn(), remove: vi.fn() } } },
|
||||
};
|
||||
});
|
||||
|
||||
const EVENTS = ["SessionStart", "PreToolUse", "PermissionRequest", "Stop"];
|
||||
function status(installed: boolean, trusted: boolean): CodexHooksStatus {
|
||||
return {
|
||||
supported: true,
|
||||
readable: true,
|
||||
path: "/home/u/.codex/hooks.json",
|
||||
script: "/home/u/.codex/agenthub-hook.sh",
|
||||
installed,
|
||||
trusted: installed && trusted,
|
||||
events: Object.fromEntries(
|
||||
EVENTS.map((e) => [e, { installed, trusted: installed && trusted }])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.settings.codexHooks.get).mockReset().mockResolvedValue(status(false, false));
|
||||
vi.mocked(api.settings.codexHooks.install).mockReset().mockResolvedValue(status(true, false));
|
||||
vi.mocked(api.settings.codexHooks.remove).mockReset().mockResolvedValue(status(false, false));
|
||||
});
|
||||
|
||||
describe("CodexHooksSettings", () => {
|
||||
it("installs on a click, then says Codex still has to trust them", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Remove" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||
expect(api.settings.codexHooks.install).toHaveBeenCalled();
|
||||
expect(await screen.findByText(/waiting for your trust/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Trust all and continue/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows trusted hooks, and removes them", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.codexHooks.get).mockResolvedValue(status(true, true));
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByText("Installed and trusted")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Trust all and continue/)).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
expect(api.settings.codexHooks.remove).toHaveBeenCalled();
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-reads the status on Refresh, e.g. once the user trusted the hooks in Codex", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.codexHooks.get)
|
||||
.mockResolvedValueOnce(status(true, false))
|
||||
.mockResolvedValueOnce(status(true, true));
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByText(/waiting for your trust/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(await screen.findByText("Installed and trusted")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still offers Remove when only some of the hooks are there", async () => {
|
||||
const partial = status(true, false);
|
||||
partial.installed = false;
|
||||
partial.events.Stop = { installed: false, trusted: false };
|
||||
vi.mocked(api.settings.codexHooks.get).mockResolvedValue(partial);
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByRole("button", { name: "Remove" })).toBeInTheDocument();
|
||||
// The chip, plus the missing event's screen-reader label.
|
||||
expect(screen.getAllByText("Not installed")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows a server refusal inline", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.codexHooks.install).mockRejectedValue(
|
||||
new Error("/home/u/.codex/hooks.json is not a JSON object; fix or move it first")
|
||||
);
|
||||
render(<CodexHooksSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Install" }));
|
||||
expect(await screen.findByText(/is not a JSON object/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file HomeDirSetting.test.tsx
|
||||
* @description The shared editable home directory: loads the value in effect,
|
||||
* Save is disabled until the path changes, a server refusal shows inline and
|
||||
* changes nothing, an env-owned variable warns that a save will not survive a
|
||||
* restart, and a partial-success warning from `save` is shown.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { HomeDirSetting, type HomeDirValue } from "../HomeDirSetting";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
function setup(load: HomeDirValue, save: (p: string) => Promise<HomeDirValue>) {
|
||||
const saveFn = vi.fn(save);
|
||||
const view = render(
|
||||
<HomeDirSetting
|
||||
id="x-home"
|
||||
title="X_HOME"
|
||||
description="desc"
|
||||
placeholder="/path"
|
||||
load={() => Promise.resolve(load)}
|
||||
save={saveFn}
|
||||
/>
|
||||
);
|
||||
return { saveFn, ...view };
|
||||
}
|
||||
|
||||
describe("HomeDirSetting", () => {
|
||||
it("saves a changed path and shows the value now in effect", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { saveFn, container } = setup({ value: "/a", envOwned: false }, (p) =>
|
||||
Promise.resolve({ value: p, envOwned: false })
|
||||
);
|
||||
const input = await screen.findByDisplayValue("/a");
|
||||
const button = screen.getByRole("button", { name: "Save" });
|
||||
expect(button).toBeDisabled();
|
||||
expect(container.querySelector("section#x-home")).not.toBeNull();
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, "/b");
|
||||
await user.click(button);
|
||||
expect(saveFn).toHaveBeenCalledWith("/b");
|
||||
expect(await screen.findByText("/b", { selector: "code" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/set by the environment/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a refusal inline and keeps the old value", async () => {
|
||||
const user = userEvent.setup();
|
||||
setup({ value: "/a", envOwned: false }, () =>
|
||||
Promise.reject(new Error("Too broad for X_HOME: / contains your home directory"))
|
||||
);
|
||||
const input = await screen.findByDisplayValue("/a");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText(/Too broad for X_HOME/)).toBeInTheDocument();
|
||||
expect(screen.getByText("/a", { selector: "code" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns when the environment owns the variable, and shows a save's warning", async () => {
|
||||
const user = userEvent.setup();
|
||||
setup({ value: "/a", envOwned: true }, (p) =>
|
||||
Promise.resolve({ value: p, envOwned: true, warning: "lane 3 not updated" })
|
||||
);
|
||||
expect(await screen.findByText(/set by the environment/)).toBeInTheDocument();
|
||||
const input = screen.getByDisplayValue("/a");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/b");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText("lane 3 not updated")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file PricingSettings.test.tsx
|
||||
* @description The shared Model Pricing table: each tool's tab shows its own
|
||||
* vendor's rows, **Show all models** lists the rest, a row added outside the
|
||||
* filter turns that switch on instead of vanishing, and Reset Defaults is
|
||||
* offered only where asked for (the Claude tab).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { PricingSettings } from "../PricingSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { ModelPricing } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
|
||||
settings: { resetPricing: vi.fn() },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const rule = (model_pattern: string, display_name: string) =>
|
||||
({
|
||||
model_pattern,
|
||||
display_name,
|
||||
input_per_mtok: 1,
|
||||
output_per_mtok: 2,
|
||||
cache_read_per_mtok: 0.1,
|
||||
cache_write_per_mtok: 0,
|
||||
cache_write_1h_per_mtok: 0,
|
||||
fast_input_per_mtok: 0,
|
||||
fast_output_per_mtok: 0,
|
||||
updated_at: "2026-09-29 10:00:00",
|
||||
}) as ModelPricing;
|
||||
|
||||
const ROWS = [rule("claude-opus-5-5%", "Claude Opus 5.5"), rule("gpt-5.6-sol%", "GPT-5.6 Sol")];
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: ROWS });
|
||||
vi.mocked(api.pricing.upsert)
|
||||
.mockReset()
|
||||
.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("PricingSettings", () => {
|
||||
it("shows the vendor's rows, and the rest behind Show all models", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
expect(await screen.findByText("gpt-5.6-sol%")).toBeInTheDocument();
|
||||
expect(screen.queryByText("claude-opus-5-5%")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Reset Defaults/i })).toBeNull();
|
||||
await user.click(screen.getByRole("checkbox", { name: /Show all models \(1 more\)/ }));
|
||||
expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says where OpenAI prices come from when there are none", async () => {
|
||||
vi.mocked(api.pricing.list).mockResolvedValue({ pricing: [ROWS[0]!] });
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
expect(await screen.findByText(/added automatically from LiteLLM/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("turns Show all on when a row outside the filter is added", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
await screen.findByText("gpt-5.6-sol%");
|
||||
await user.click(screen.getByRole("button", { name: /Add Model/i }));
|
||||
const [pattern, name] = screen.getAllByRole("textbox");
|
||||
await user.type(pattern!, "deepseek-v4%");
|
||||
await user.type(name!, "DeepSeek V4");
|
||||
vi.mocked(api.pricing.list).mockResolvedValue({
|
||||
pricing: [...ROWS, rule("deepseek-v4%", "DeepSeek V4")],
|
||||
});
|
||||
await user.click(screen.getByTitle(/save/i));
|
||||
expect(await screen.findByText("deepseek-v4%")).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: /Show all models/ })).toBeChecked();
|
||||
});
|
||||
|
||||
it("offers Reset Defaults on the Claude tab, confirmed by a second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.resetPricing).mockReset().mockResolvedValue({ ok: true, pricing: ROWS });
|
||||
render(<PricingSettings vendor="claude" allowReset />);
|
||||
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||
expect(screen.queryByText("gpt-5.6-sol%")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /Reset Defaults/i }));
|
||||
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: /confirm/i }));
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledWith("claude");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user