feat: model lists and prices that follow each AI tool, a Settings tab per tool, live lane status (#40)

Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.

Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.

Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.

Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.

DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
This commit is contained in:
2026-10-01 13:39:17 +07:00
parent c5aba094bd
commit f887c16309
51 changed files with 2343 additions and 203 deletions
+5 -3
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -204,7 +204,7 @@ client/
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates │ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
│ │ ├── Workflows.tsx │ │ ├── Workflows.tsx
│ │ ├── Settings.tsx │ │ ├── Settings.tsx
│ │ ├── AntigravityConfig.tsx # Settings → Antigravity: Gemini pricing + Antigravity hooks │ │ ├── AiToolConfig.tsx # Settings → <tool>: the generic tab of every installed, enabled catalog tool (path, Update link, sessions/cost/lanes, default model, vendor pricing, catalog-declared config files; Antigravity adds its hooks card)
│ │ └── NotFound.tsx │ │ └── NotFound.tsx
│ │ │ │
│ ├── lib/ # Core utilities & business logic │ ├── lib/ # Core utilities & business logic
@@ -53,6 +53,10 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
routerCapable: false, routerCapable: false,
install: null, install: null,
update: null, update: null,
modelFlag: false,
defaultModel: null,
modelVendor: null,
configFiles: 0,
...over, ...over,
}); });
const TOOLS: AiToolsView = { const TOOLS: AiToolsView = {
@@ -152,6 +152,12 @@ export function LaneConsolePane({
); );
const aiTools = useAiTools(); const aiTools = useAiTools();
const toolLabel = aiTools.label(laneTool); const toolLabel = aiTools.label(laneTool);
// A new run starts on the tool's default model (AI Tools settings), and a
// lane switch to another tool drops the previous tool's pick.
const defaultModel = aiTools.byId(laneTool)?.defaultModel ?? "";
useEffect(() => {
setModel(defaultModel);
}, [laneTool, defaultModel]);
useEffect(() => { useEffect(() => {
onHasActiveRunChange?.(handle !== null); onHasActiveRunChange?.(handle !== null);
+51 -13
View File
@@ -43,7 +43,7 @@ import {
RotateCcw, RotateCcw,
Lock, Lock,
} from "lucide-react"; } from "lucide-react";
import { api, RUN_MODEL_CHOICES, RUN_MODEL_CHOICES_CODEX, RUN_EFFORT_CHOICES } from "../../lib/api"; import { api, RUN_MODEL_CHOICES, RUN_EFFORT_CHOICES, type ModelChoice } from "../../lib/api";
import type { import type {
CwdSuggestion, CwdSuggestion,
DashboardRunHistoryItem, DashboardRunHistoryItem,
@@ -409,6 +409,8 @@ export function RunSetup(props: RunSetupProps) {
// Per run, never remembered: the form starts unticked every time it mounts. // Per run, never remembered: the form starts unticked every time it mounts.
const [bypass, setBypass] = useState(false); const [bypass, setBypass] = useState(false);
const canBypass = props.tool !== "claude" && !!entry?.bypass; const canBypass = props.tool !== "claude" && !!entry?.bypass;
// Claude and Codex always take a model; a catalog tool only when its file names the flag.
const hasModel = !catalogTool || !!entry?.modelFlag;
// Keep "resume picked" in sync with the parent. Parent set a resume session // Keep "resume picked" in sync with the parent. Parent set a resume session
// (e.g. user clicked Resume in the runs modal) - flip the radio so the picker // (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
// is shown and the selection is visible. // is shown and the selection is visible.
@@ -441,8 +443,8 @@ export function RunSetup(props: RunSetupProps) {
</div> </div>
)} )}
</Field> </Field>
{/* A catalog tool takes no model, permission or effort flag. */} {/* A catalog tool takes no permission or effort flag, and a model only when it declares one. */}
{!catalogTool && ( {hasModel && (
<Field label={t("fields.model")}> <Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} /> <ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field> </Field>
@@ -532,7 +534,8 @@ export function RunSetup(props: RunSetupProps) {
// Pass the picked session explicitly: the parent's state // Pass the picked session explicitly: the parent's state
// update has not landed yet on this tick, so reading // update has not landed yet on this tick, so reading
// props.resumeSession here would resume nothing. // props.resumeSession here would resume nothing.
if (s && !props.busy) handleStart(props, s, bypass); if (s && !props.busy)
handleStart(props, s, bypass, hasModel, entry?.defaultModel);
}} }}
// Fall back to the typed cwd when no lane is selected — otherwise // Fall back to the typed cwd when no lane is selected — otherwise
// an unfiltered picker lists every session from every repo. // an unfiltered picker lists every session from every repo.
@@ -559,7 +562,9 @@ export function RunSetup(props: RunSetupProps) {
<PromptEditor <PromptEditor
value={props.prompt} value={props.prompt}
onChange={props.onPromptChange} onChange={props.onPromptChange}
onSubmit={() => handleStart(props, undefined, canBypass && bypass)} onSubmit={() =>
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel)
}
placeholder={t("fields.promptPlaceholderTerminal", { tool: aiTools.label(props.tool) })} placeholder={t("fields.promptPlaceholderTerminal", { tool: aiTools.label(props.tool) })}
rows={5} rows={5}
slash={props.tool === "claude"} slash={props.tool === "claude"}
@@ -615,7 +620,9 @@ export function RunSetup(props: RunSetupProps) {
) : null} ) : null}
</div> </div>
<button <button
onClick={() => handleStart(props, undefined, canBypass && bypass)} onClick={() =>
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel)
}
disabled={ disabled={
!props.binaryFound || !props.binaryFound ||
// A resume needs no prompt — the session it continues is the input. // A resume needs no prompt — the session it continues is the input.
@@ -644,7 +651,13 @@ export function RunSetup(props: RunSetupProps) {
/** `session` overrides `props.resumeSession` for the auto-start fired straight /** `session` overrides `props.resumeSession` for the auto-start fired straight
* out of the picker, before the parent's state has caught up. */ * out of the picker, before the parent's state has caught up. */
function handleStart(props: RunSetupProps, session?: Session, bypass?: boolean) { function handleStart(
props: RunSetupProps,
session?: Session,
bypass?: boolean,
hasModel?: boolean,
defaultModel?: string | null
) {
const resume = session ?? props.resumeSession; const resume = session ?? props.resumeSession;
const codex = props.tool === "codex"; const codex = props.tool === "codex";
// Any catalog tool: none of Claude's or Codex's own flags apply. // Any catalog tool: none of Claude's or Codex's own flags apply.
@@ -654,7 +667,12 @@ function handleStart(props: RunSetupProps, session?: Session, bypass?: boolean)
// A resume is pinned to its own session's folder — that's what the locked // A resume is pinned to its own session's folder — that's what the locked
// cwd field shows, so it's what gets sent. // cwd field shows, so it's what gets sent.
cwd: resume?.cwd || props.cwd || undefined, cwd: resume?.cwd || props.cwd || undefined,
model: other ? undefined : props.model || undefined, // A resume keeps the session's own model: the tool's default the form
// starts on is not a pick, so only a model the user changed is sent.
model:
!(other ? hasModel : true) || (resume && props.model === (defaultModel ?? ""))
? undefined
: props.model || undefined,
permissionMode: codex || other ? undefined : props.permissionMode || undefined, permissionMode: codex || other ? undefined : props.permissionMode || undefined,
sandbox: codex ? props.sandbox : undefined, sandbox: codex ? props.sandbox : undefined,
approval: codex ? props.approval : undefined, approval: codex ? props.approval : undefined,
@@ -1029,7 +1047,7 @@ function SessionPicker({
// the "inherit from settings" choice, so use a non-empty marker. // the "inherit from settings" choice, so use a non-empty marker.
const MODEL_CUSTOM = "__custom__"; const MODEL_CUSTOM = "__custom__";
function ModelPicker({ export function ModelPicker({
value, value,
onChange, onChange,
tool, tool,
@@ -1039,11 +1057,31 @@ function ModelPicker({
tool: LaneTool; tool: LaneTool;
}) { }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const choices = tool === "codex" ? RUN_MODEL_CHOICES_CODEX : RUN_MODEL_CHOICES; // The tool's own list (Codex's models cache, Claude's aliases named after
// "Custom" is selected when the value isn't one of our curated IDs. // the newest version, a catalog tool's vendor rows); Claude's static
// aliases only when the server cannot answer.
const [fetched, setFetched] = useState<ModelChoice[] | null>(null);
useEffect(() => {
let live = true;
setFetched(null);
Promise.resolve()
.then(() => api.aiTools.models(tool))
.then((r) => live && setFetched(r.models))
.catch(() => live && setFetched([]));
return () => {
live = false;
};
}, [tool]);
const choices = useMemo<ModelChoice[]>(() => {
const inherit = { id: "", label: "" };
if (fetched?.length) return [inherit, ...fetched];
return tool === "claude" ? RUN_MODEL_CHOICES : [inherit];
}, [fetched, tool]);
// "Custom" is selected when the value isn't one of the listed IDs — judged
// only once the list has arrived, so a listed default never flashes as custom.
const knownIds = useMemo(() => choices.map((c) => c.id), [choices]); const knownIds = useMemo(() => choices.map((c) => c.id), [choices]);
const isCustom = value !== "" && !knownIds.includes(value); const isCustom = fetched !== null && value !== "" && !knownIds.includes(value);
const [showCustom, setShowCustom] = useState(isCustom); const [showCustom, setShowCustom] = useState(false);
// Reuse the shared Select so the Model dropdown renders identically to the // Reuse the shared Select so the Model dropdown renders identically to the
// Permission Mode and Effort dropdowns (Tailwind + lucide popover) instead of // Permission Mode and Effort dropdowns (Tailwind + lucide popover) instead of
@@ -10,6 +10,7 @@ import { MemoryRouter } from "react-router-dom";
import { LaneConsolePane } from "../LaneConsolePane"; import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types"; import type { Lane } from "../../../lib/types";
import { __resetAiTools } from "../../../hooks/useAiTools";
vi.mock("../TerminalView", () => ({ vi.mock("../TerminalView", () => ({
// The exit button stands in for the server's `exit` frame (a double Ctrl+C // The exit button stands in for the server's `exit` frame (a double Ctrl+C
@@ -28,6 +29,10 @@ vi.mock("../../../lib/api", () => ({
action: vi.fn(), action: vi.fn(),
list: vi.fn(), list: vi.fn(),
}, },
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
},
run: { run: {
list: vi.fn().mockResolvedValue({ items: [] }), list: vi.fn().mockResolvedValue({ items: [] }),
history: vi.fn().mockResolvedValue({ items: [] }), history: vi.fn().mockResolvedValue({ items: [] }),
@@ -36,7 +41,6 @@ vi.mock("../../../lib/api", () => ({
}, },
}, },
RUN_MODEL_CHOICES: [], RUN_MODEL_CHOICES: [],
RUN_MODEL_CHOICES_CODEX: [],
RUN_EFFORT_CHOICES: [], RUN_EFFORT_CHOICES: [],
})); }));
@@ -136,6 +140,31 @@ describe("LaneConsolePane", () => {
); );
}); });
it("starts a run on the tool's default model from AI Tools settings", async () => {
__resetAiTools();
(api.aiTools.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
default: "claude",
tools: [{ id: "claude", label: "Claude Code", defaultModel: "sonnet", modelFlag: true }],
});
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({ lane: LANE });
render(<LaneConsolePane {...baseProps()} />);
fireEvent.change(screen.getByPlaceholderText(/type to search/i), {
target: { value: "/workspace/a" },
});
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ model: "sonnet" })
)
);
__resetAiTools();
});
it("returns to the setup form when the pane exits, without a New run click", async () => { it("returns to the setup form when the pane exits, without a New run click", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({ (api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "run-1" }, lane: { ...LANE, run_id: "run-1" },
@@ -29,12 +29,37 @@ vi.mock("../../../lib/api", async (importOriginal) => {
list: r({ list: r({
default: "claude", default: "claude",
tools: [ tools: [
{ id: "claude", label: "Claude Code", resume: false, bypass: false }, {
id: "claude",
label: "Claude Code",
resume: false,
bypass: false,
modelFlag: true,
defaultModel: "sonnet",
},
{ id: "codex", label: "Codex", resume: false, bypass: true }, { id: "codex", label: "Codex", resume: false, bypass: true },
{ id: "antigravity", label: "Antigravity", resume: true, bypass: true }, {
id: "antigravity",
label: "Antigravity",
resume: true,
bypass: true,
modelFlag: true,
},
{ id: "gemini", label: "Gemini", resume: false, bypass: false }, { id: "gemini", label: "Gemini", resume: false, bypass: false },
].map((x) => ({ ...x, installed: true, enabled: true, command: x.id })), ].map((x) => ({ ...x, installed: true, enabled: true, command: x.id })),
}), }),
// Each tool's own model list, as GET /api/ai-tools/:id/models answers it.
models: vi.fn(async (id: string) => {
const lists: Record<string, { id: string; label: string }[]> = {
claude: [
{ id: "sonnet", label: "Sonnet (latest · 5.5)" },
{ id: "claude-sonnet-5-5", label: "Claude Sonnet 5.5" },
],
codex: [{ id: "gpt-6-sol", label: "GPT-6-Sol" }],
antigravity: [{ id: "gemini-3.1-pro", label: "gemini-3.1-pro" }],
};
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
}),
}, },
run: { files: r({ items: [] }) }, run: { files: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) }, sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
@@ -145,11 +170,12 @@ describe("RunSetup — selections report through callbacks", () => {
onlyCalled(spies, "onCwdChange"); onlyCalled(spies, "onCwdChange");
}); });
it("reports the model from the picker, including a custom id", () => { it("reports the model from the picker, including a custom id", async () => {
const { spies } = renderSetup(); const { spies } = renderSetup();
// The picker offers the CLI's "latest" aliases, not pinned ids — that is // The list is the tool's own, fetched from the server — aliases named
// what keeps the list from going stale on every model release. // after the newest version, then pinned versions — never a hardcoded copy.
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet (latest)"); fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
fireEvent.click(await screen.findByText("Sonnet (latest · 5.5)"));
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet"); expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
// "Custom…" is a sentinel, not a model id — it must not be reported as one; // "Custom…" is a sentinel, not a model id — it must not be reported as one;
// the free-text box it reveals is what reports. // the free-text box it reveals is what reports.
@@ -374,6 +400,27 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
}); });
}); });
describe("RunSetup — resume keeps the session's model", () => {
const session = { id: "sess-1", cwd: "/Users/tester" } as never;
it("sends no model when it is still the tool's default", async () => {
const { spies } = renderSetup({ resumeSession: session, model: "sonnet", prompt: "" });
await screen.findByText(i18n.t("run:fields.model"));
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(
expect.objectContaining({ resumeSessionId: "sess-1", model: undefined })
);
});
it("sends a model the user picked", async () => {
const { spies } = renderSetup({ resumeSession: session, model: "opus", prompt: "" });
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(expect.objectContaining({ model: "opus" }));
});
});
describe("RunSetup — per-tool fields", () => { describe("RunSetup — per-tool fields", () => {
it("shows sandbox and approval instead of permission mode for a codex lane", () => { it("shows sandbox and approval instead of permission mode for a codex lane", () => {
renderSetup({ tool: "codex" }); renderSetup({ tool: "codex" });
@@ -457,20 +504,38 @@ describe("RunSetup — / and @ suggestions in the prompt box", () => {
}); });
describe("RunSetup — antigravity lanes", () => { describe("RunSetup — antigravity lanes", () => {
it("names the lane's CLI in the prompt box and drops the flags `agy` lacks", () => { it("names the lane's CLI, offers its model flag and drops the flags `agy` lacks", async () => {
const { spies } = renderSetup({ tool: "antigravity", model: "opus", effort: "high" }); const { spies } = renderSetup({ tool: "antigravity", model: "gemini-3.1-pro", effort: "high" });
expect(screen.getByPlaceholderText("Ask Antigravity anything…")).toBeTruthy(); expect(screen.getByPlaceholderText("Ask Antigravity anything…")).toBeTruthy();
expect(screen.queryByText(i18n.t("run:fields.model"))).toBeNull(); expect(await screen.findByText(i18n.t("run:fields.model"))).toBeTruthy();
expect(screen.queryByText(i18n.t("run:fields.effort"))).toBeNull(); expect(screen.queryByText(i18n.t("run:fields.effort"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:actions.start"))); fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith( expect(spies.onStart).toHaveBeenCalledWith(
expect.objectContaining({ model: undefined, effort: undefined, permissionMode: undefined }) expect.objectContaining({
model: "gemini-3.1-pro",
effort: undefined,
permissionMode: undefined,
})
); );
}); });
it("sends no model for a catalog tool whose CLI has no model flag", async () => {
const { spies } = renderSetup({ tool: "gemini", model: "stale", prompt: "go" });
await screen.findByPlaceholderText("Ask Gemini anything…");
expect(screen.queryByText(i18n.t("run:fields.model"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(expect.objectContaining({ model: undefined }));
});
}); });
describe("RunSetup — codex lanes", () => { describe("RunSetup — codex lanes", () => {
it("offers the models Codex itself lists", async () => {
renderSetup({ tool: "codex" });
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
});
it("starts a fresh codex run instead of resuming the last one", () => { it("starts a fresh codex run instead of resuming the last one", () => {
const { spies } = renderSetup({ tool: "codex" }); const { spies } = renderSetup({ tool: "codex" });
fireEvent.click(screen.getByText(i18n.t("run:actions.start"))); fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
@@ -76,9 +76,8 @@ function formatTimestamp(iso: string): string {
/** /**
* Info popover for the Model Pricing section. Hover or focus the icon to see a * 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 * three-section explanation: how prices are applied, how pattern matching
* works, and — for Claude — a reminder that prices must be edited manually * works, and where the rates come from (LiteLLM's price list, kept up to date
* when Anthropic publishes new rates; another vendor's rows come from * daily for the rows marked Auto). All copy is i18n-driven
* LiteLLM's price list, so its tab says that instead. All copy is i18n-driven
* (settings.pricing.tooltip.*). * (settings.pricing.tooltip.*).
* *
* The popover is fixed-positioned and clamped to the viewport so it never * The popover is fixed-positioned and clamped to the viewport so it never
@@ -165,11 +164,9 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
</p> </p>
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1"> <p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
{t(claude ? "pricing.tooltip.manualUpdates" : "pricing.tooltip.synced")} {t("pricing.tooltip.synced")}
</p>
<p className="text-fg-secondary leading-snug mb-2.5">
{t(claude ? "pricing.tooltip.manualUpdatesBody" : "pricing.tooltip.syncedBody")}
</p> </p>
<p className="text-fg-secondary leading-snug mb-2.5">{t("pricing.tooltip.syncedBody")}</p>
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1"> <p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
{t("pricing.tooltip.apiPricing")} {t("pricing.tooltip.apiPricing")}
@@ -673,6 +670,14 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing" }:
</td> </td>
<td className="px-4 py-3 text-sm text-fg-secondary"> <td className="px-4 py-3 text-sm text-fg-secondary">
{rule.display_name} {rule.display_name}
{rule.source === "sync" && (
<span
title={t("pricing.autoBadgeTitle")}
className="ml-2 rounded bg-accent/10 px-1.5 py-0.5 text-[10px] font-medium text-accent"
>
{t("pricing.autoBadge")}
</span>
)}
{rule.intro_until && ( {rule.intro_until && (
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5"> <div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
{t("pricing.introNote", { {t("pricing.introNote", {
@@ -54,6 +54,10 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
routerCapable: false, routerCapable: false,
install: null, install: null,
update: null, update: null,
modelFlag: false,
defaultModel: null,
modelVendor: null,
configFiles: 0,
...over, ...over,
}); });
const VIEW: AiToolsView = { const VIEW: AiToolsView = {
@@ -257,6 +261,28 @@ describe("SettingsHub — AI tool tabs", () => {
expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull(); expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull();
}); });
it("gives every installed, enabled catalog tool its own tab, and none to the rest", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue({
default: "claude",
tools: [
tool("claude", "Claude Code", { launcher: "claude" }),
tool("gemini", "Gemini"),
tool("qwen-code", "Qwen Code", { enabled: false }),
tool("aider", "Aider", { installed: false }),
],
});
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub");
render(
<MemoryRouter>
<SettingsHub />
</MemoryRouter>
);
expect(await screen.findByRole("tab", { name: /Gemini/ })).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /Qwen Code/ })).toBeNull();
expect(screen.queryByRole("tab", { name: /Aider/ })).toBeNull();
});
it("opens AI Tools as a modal from the tab bar, and closes it on Escape", async () => { it("opens AI Tools as a modal from the tab bar, and closes it on Escape", async () => {
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null })); vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
const { SettingsHub } = await import("../../../pages/SettingsHub"); const { SettingsHub } = await import("../../../pages/SettingsHub");
@@ -61,6 +61,18 @@ describe("PricingSettings", () => {
expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument(); expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument();
}); });
it("marks a row the automatic sync keeps up to date", async () => {
vi.mocked(api.pricing.list).mockResolvedValue({
pricing: [
{ ...rule("gpt-6-sol%", "GPT-6 Sol"), source: "sync" },
{ ...rule("gpt-5.6-sol%", "GPT-5.6 Sol"), source: "user" },
],
});
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-6-sol%");
expect(screen.getAllByText("Auto")).toHaveLength(1);
});
it("says where OpenAI prices come from when there are none", async () => { it("says where OpenAI prices come from when there are none", async () => {
vi.mocked(api.pricing.list).mockResolvedValue({ pricing: [ROWS[0]!] }); vi.mocked(api.pricing.list).mockResolvedValue({ pricing: [ROWS[0]!] });
render(<PricingSettings vendor="openai" />); render(<PricingSettings vendor="openai" />);
+2 -2
View File
@@ -8,7 +8,7 @@
*/ */
import { useEffect, useSyncExternalStore } from "react"; import { useEffect, useSyncExternalStore } from "react";
import { api, type AiTool, type AiToolsView } from "../lib/api"; import { api, type AiTool, type AiToolUpdateBody, type AiToolsView } from "../lib/api";
let view: AiToolsView | null = null; let view: AiToolsView | null = null;
let inflight: Promise<void> | null = null; let inflight: Promise<void> | null = null;
@@ -74,7 +74,7 @@ export function useAiTools() {
label: (id: string) => byId(id)?.label ?? FALLBACK[id] ?? id, label: (id: string) => byId(id)?.label ?? FALLBACK[id] ?? id,
reload: load, reload: load,
refresh: async () => publish(await api.aiTools.refresh()), refresh: async () => publish(await api.aiTools.refresh()),
update: async (id: string, body: { enabled?: boolean; default?: true }) => update: async (id: string, body: AiToolUpdateBody) =>
publish(await api.aiTools.update(id, body)), publish(await api.aiTools.update(id, body)),
}; };
} }
+19 -4
View File
@@ -31,12 +31,10 @@
"howItWorksBody": "When a session reports usage, the dashboard finds the first pricing rule whose pattern matches the model id and multiplies (input tokens / 1M) and (output tokens / 1M) by the rates below to compute cost. Rules are evaluated top-to-bottom - the first match wins.", "howItWorksBody": "When a session reports usage, the dashboard finds the first pricing rule whose pattern matches the model id and multiplies (input tokens / 1M) and (output tokens / 1M) by the rates below to compute cost. Rules are evaluated top-to-bottom - the first match wins.",
"patternsTitle": "Pattern syntax", "patternsTitle": "Pattern syntax",
"patternsBody": "Patterns are SQL-style globs. % is a wildcard that matches any characters. For example: claude-opus-4-7% matches every model id that starts with claude-opus-4-7 (e.g. claude-opus-4-7, claude-opus-4-7-20260101). claude-%-haiku matches any haiku variant. An exact id like claude-sonnet-4-6 only matches itself.", "patternsBody": "Patterns are SQL-style globs. % is a wildcard that matches any characters. For example: claude-opus-4-7% matches every model id that starts with claude-opus-4-7 (e.g. claude-opus-4-7, claude-opus-4-7-20260101). claude-%-haiku matches any haiku variant. An exact id like claude-sonnet-4-6 only matches itself.",
"manualUpdates": "You must update prices manually",
"manualUpdatesBody": "Anthropic publishes pricing changes on their website - there is no live feed. When a new model launches or rates change, edit the matching rule (or add a new one above the catch-all) so newly recorded sessions are costed correctly. Already-stored sessions keep the price that was applied at ingest time.",
"apiPricing": "Reference only - API rates", "apiPricing": "Reference only - API rates",
"apiPricingBody": "These are pay-as-you-go API rates. If you use Claude Code on a subscription plan (Pro, Max, Team, or Enterprise), your actual cost is a flat monthly fee, not per-token - so these figures will usually be higher than what you really pay. Treat the costs here as a usage estimate for reference, not a bill.", "apiPricingBody": "These are pay-as-you-go API rates. If you use Claude Code on a subscription plan (Pro, Max, Team, or Enterprise), your actual cost is a flat monthly fee, not per-token - so these figures will usually be higher than what you really pay. Treat the costs here as a usage estimate for reference, not a bill.",
"synced": "Prices are filled in automatically", "synced": "Prices are filled in automatically",
"syncedBody": "A model a session uses with no rule of its own is priced from LiteLLM's public price list the first time it is seen. That row is never updated afterwards: edit it here when the vendor's rates change. Already-stored sessions keep the price that was applied at ingest time.", "syncedBody": "Every current Claude, OpenAI and Gemini model is priced from LiteLLM's public price list, checked once a day, as is any other model the moment a session uses it. Rows marked Auto follow that list when a rate changes; a row you edit becomes yours and is never touched again, and a row you delete stays deleted. Already-stored sessions keep the price that was applied at ingest time.",
"apiPricingOtherBody": "These are pay-as-you-go API rates. If you use this CLI on a subscription plan, your actual cost is a flat fee, not per-token - so these figures will usually be higher than what you really pay. Treat them as a usage estimate, not a bill." "apiPricingOtherBody": "These are pay-as-you-go API rates. If you use this CLI on a subscription plan, your actual cost is a flat fee, not per-token - so these figures will usually be higher than what you really pay. Treat them as a usage estimate, not a bill."
}, },
"cacheWrite5m": "Cache Write (5m)", "cacheWrite5m": "Cache Write (5m)",
@@ -62,7 +60,9 @@
"pattern": "gemini-3.8-flash%", "pattern": "gemini-3.8-flash%",
"name": "Gemini 3.8 Flash" "name": "Gemini 3.8 Flash"
} }
} },
"autoBadge": "Auto",
"autoBadgeTitle": "Kept at the published rate automatically (LiteLLM's price list, checked daily). Edit it to keep your own rate."
}, },
"claudeHome": { "claudeHome": {
"title": "CLAUDE_HOME", "title": "CLAUDE_HOME",
@@ -624,5 +624,20 @@
"unsupported": "Antigravity hooks from AgentHub need a POSIX shell, so they are not available on Windows.", "unsupported": "Antigravity hooks from AgentHub need a POSIX shell, so they are not available on Windows.",
"unreadable": "AgentHub cannot read {{path}}: {{error}}", "unreadable": "AgentHub cannot read {{path}}: {{error}}",
"failed": "Could not change Antigravity hooks: {{message}}" "failed": "Could not change Antigravity hooks: {{message}}"
},
"toolTab": {
"update": "Update…",
"usage_one": "{{count}} session · ${{cost}}",
"usage_other": "{{count}} sessions · ${{cost}}",
"lanes": "Lanes:",
"defaultModel": "Default model",
"defaultModelHint": "The model a new run in this tool's lanes starts on. The run form can still pick another one.",
"saveDefaultModel": "Save default model",
"config": "Config files",
"configHint": "This tool's own settings files. A save is checked for valid syntax, the old text is backed up in AgentHub's data folder, and nothing is saved if the file changed since you opened it.",
"missing": "This file does not exist yet",
"save": "Save",
"close": "Close",
"saved": "Saved. Backup: {{backup}}"
} }
} }
+19 -4
View File
@@ -31,12 +31,10 @@
"howItWorksBody": "Khi một phiên báo cáo mức dùng, dashboard tìm quy tắc định giá đầu tiên có mẫu khớp với model id, rồi nhân (token đầu vào / 1 triệu) và (token đầu ra / 1 triệu) với các đơn giá bên dưới để tính chi phí. Quy tắc được duyệt từ trên xuống - quy tắc khớp đầu tiên sẽ được dùng.", "howItWorksBody": "Khi một phiên báo cáo mức dùng, dashboard tìm quy tắc định giá đầu tiên có mẫu khớp với model id, rồi nhân (token đầu vào / 1 triệu) và (token đầu ra / 1 triệu) với các đơn giá bên dưới để tính chi phí. Quy tắc được duyệt từ trên xuống - quy tắc khớp đầu tiên sẽ được dùng.",
"patternsTitle": "Cú pháp mẫu", "patternsTitle": "Cú pháp mẫu",
"patternsBody": "Mẫu là dạng glob kiểu SQL. % là ký tự đại diện khớp với bất kỳ chuỗi nào. Ví dụ: claude-opus-4-7% khớp mọi model id bắt đầu bằng claude-opus-4-7 (ví dụ claude-opus-4-7, claude-opus-4-7-20260101). claude-%-haiku khớp mọi biến thể haiku. Một id chính xác như claude-sonnet-4-6 chỉ khớp với chính nó.", "patternsBody": "Mẫu là dạng glob kiểu SQL. % là ký tự đại diện khớp với bất kỳ chuỗi nào. Ví dụ: claude-opus-4-7% khớp mọi model id bắt đầu bằng claude-opus-4-7 (ví dụ claude-opus-4-7, claude-opus-4-7-20260101). claude-%-haiku khớp mọi biến thể haiku. Một id chính xác như claude-sonnet-4-6 chỉ khớp với chính nó.",
"manualUpdates": "Bạn phải cập nhật giá thủ công",
"manualUpdatesBody": "Anthropic công bố thay đổi giá trên website của họ - không có nguồn dữ liệu trực tiếp. Khi có model mới ra mắt hoặc đơn giá thay đổi, hãy chỉnh sửa quy tắc tương ứng (hoặc thêm quy tắc mới ở phía trên quy tắc bao quát) để những phiên ghi nhận sau này được tính phí chính xác. Phiên đã lưu vẫn giữ nguyên giá đã áp dụng tại thời điểm nạp.",
"apiPricing": "Chỉ để tham khảo - đơn giá API", "apiPricing": "Chỉ để tham khảo - đơn giá API",
"apiPricingBody": "Đây là đơn giá API trả theo lượng dùng. Nếu bạn dùng Claude Code theo gói thuê bao (Pro, Max, Team hoặc Enterprise), chi phí thực tế là phí cố định hàng tháng chứ không tính theo token - nên những con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chi phí ở đây là ước tính mức dùng để tham khảo, không phải hóa đơn.", "apiPricingBody": "Đây là đơn giá API trả theo lượng dùng. Nếu bạn dùng Claude Code theo gói thuê bao (Pro, Max, Team hoặc Enterprise), chi phí thực tế là phí cố định hàng tháng chứ không tính theo token - nên những con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chi phí ở đây là ước tính mức dùng để tham khảo, không phải hóa đơn.",
"synced": "Giá được điền tự động", "synced": "Giá được điền tự động",
"syncedBody": "Một model mà phiên dùng nhưng chưa có quy tắc riêng sẽ được lấy giá từ bảng giá công khai của LiteLLM ở lần đầu gặp. Dòng đó không bao giờ tự cập nhật lại: hãy sửa ở đây khi nhà cung cấp đổi giá. Các phiên đã lưu vẫn giữ mức giá áp dụng lúc ghi nhận.", "syncedBody": "Mọi model Claude, OpenAI và Gemini hiện hành được lấy giá từ bảng giá công khai của LiteLLM, kiểm tra mỗi ngày một lần; model khác cũng có giá ngay khi một phiên dùng tới. Dòng có nhãn Tự động sẽ theo bảng giá đó khi giá thay đổi; dòng bạn sửa trở thành của bạn và không bao giờ bị ghi đè, dòng bạn xoá sẽ không quay lại. Phiên đã lưu giữ nguyên giá đã áp dụng lúc ghi nhận.",
"apiPricingOtherBody": "Đây là giá API trả theo mức dùng. Nếu bạn dùng CLI này theo gói thuê bao, chi phí thực là một khoản cố định chứ không tính theo token, nên các con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chúng là ước tính mức dùng, không phải hoá đơn." "apiPricingOtherBody": "Đây là giá API trả theo mức dùng. Nếu bạn dùng CLI này theo gói thuê bao, chi phí thực là một khoản cố định chứ không tính theo token, nên các con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chúng là ước tính mức dùng, không phải hoá đơn."
}, },
"cacheWrite5m": "Ghi bộ nhớ đệm (5m)", "cacheWrite5m": "Ghi bộ nhớ đệm (5m)",
@@ -62,7 +60,9 @@
"pattern": "gemini-3.8-flash%", "pattern": "gemini-3.8-flash%",
"name": "Gemini 3.8 Flash" "name": "Gemini 3.8 Flash"
} }
} },
"autoBadge": "Tự động",
"autoBadgeTitle": "Tự cập nhật theo giá công bố (bảng giá LiteLLM, kiểm tra mỗi ngày). Sửa dòng này để giữ giá của riêng bạn."
}, },
"claudeHome": { "claudeHome": {
"title": "CLAUDE_HOME", "title": "CLAUDE_HOME",
@@ -624,5 +624,20 @@
"unsupported": "Hook của Antigravity từ AgentHub cần một shell POSIX nên không dùng được trên Windows.", "unsupported": "Hook của Antigravity từ AgentHub cần một shell POSIX nên không dùng được trên Windows.",
"unreadable": "AgentHub không đọc được {{path}}: {{error}}", "unreadable": "AgentHub không đọc được {{path}}: {{error}}",
"failed": "Không đổi được hook của Antigravity: {{message}}" "failed": "Không đổi được hook của Antigravity: {{message}}"
},
"toolTab": {
"update": "Cập nhật…",
"usage_one": "{{count}} phiên · ${{cost}}",
"usage_other": "{{count}} phiên · ${{cost}}",
"lanes": "Lane:",
"defaultModel": "Model mặc định",
"defaultModelHint": "Model mà một lần chạy mới trong lane của công cụ này dùng. Ô chọn khi chạy vẫn đổi được.",
"saveDefaultModel": "Lưu model mặc định",
"config": "File cấu hình",
"configHint": "Các file cấu hình của chính công cụ này. Khi lưu, cú pháp được kiểm tra, bản cũ được sao lưu vào thư mục dữ liệu của AgentHub, và sẽ không lưu nếu file đã đổi từ lúc mở.",
"missing": "File này chưa có",
"save": "Lưu",
"close": "Đóng",
"saved": "Đã lưu. Bản sao lưu: {{backup}}"
} }
} }
+53 -15
View File
@@ -1990,12 +1990,31 @@ export const api = {
list: () => request<AiToolsView>("/ai-tools"), list: () => request<AiToolsView>("/ai-tools"),
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */ /** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }), refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
/** PUT /api/ai-tools/:id - enable/disable a tool, or make it the default. */ /** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
update: (id: string, body: { enabled?: boolean; default?: true }) => update: (id: string, body: AiToolUpdateBody) =>
request<AiToolsView>(`/ai-tools/${encodeURIComponent(id)}`, { request<AiToolsView>(`/ai-tools/${encodeURIComponent(id)}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
/** GET /api/ai-tools/:id/models - the models the tool itself offers. */
models: (id: string) => request<AiToolModels>(`/ai-tools/${encodeURIComponent(id)}/models`),
/** GET /api/ai-tools/:id/usage - how many sessions the tool ran, and their cost. */
usage: (id: string) =>
request<{ sessions: number; cost: number }>(`/ai-tools/${encodeURIComponent(id)}/usage`),
/** GET /api/ai-tools/:id/config - the config files the tool's catalog file lists. */
config: (id: string) =>
request<{ files: AiToolConfigFile[] }>(`/ai-tools/${encodeURIComponent(id)}/config`),
/** GET /api/ai-tools/:id/config/:index - one config file's text and sha. */
readConfig: (id: string, index: number) =>
request<{ path: string; format: AiToolConfigFile["format"]; text: string; sha: string }>(
`/ai-tools/${encodeURIComponent(id)}/config/${index}`
),
/** PUT /api/ai-tools/:id/config/:index - save over `baseSha` (409 ESTALE otherwise). */
saveConfig: (id: string, index: number, text: string, baseSha: string) =>
request<{ sha: string; backup: string }>(
`/ai-tools/${encodeURIComponent(id)}/config/${index}`,
{ method: "PUT", body: JSON.stringify({ text, baseSha }) }
),
/** POST /api/ai-tools/:id/install - start the catalog's install job. */ /** POST /api/ai-tools/:id/install - start the catalog's install job. */
install: (id: string) => install: (id: string) =>
request<{ job: AiToolJob }>(`/ai-tools/${encodeURIComponent(id)}/install`, { request<{ job: AiToolJob }>(`/ai-tools/${encodeURIComponent(id)}/install`, {
@@ -3066,6 +3085,36 @@ export interface AiTool {
install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null; install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null;
/** What Update runs; `self` is the tool's own updater with these args. */ /** What Update runs; `self` is the tool's own updater with these args. */
update: { kind: "npm" } | { kind: "script" } | { kind: "self"; args: string[] } | null; update: { kind: "npm" } | { kind: "script" } | { kind: "self"; args: string[] } | null;
/** The CLI takes a model flag (Claude, Codex, or a catalog `model` field). */
modelFlag: boolean;
/** The model a new run starts with; null inherits the CLI's own. */
defaultModel: string | null;
/** Whose priced models the tool offers (its Settings tab's pricing table). */
modelVendor: "claude" | "openai" | "google" | null;
/** How many config files its catalog file lists for the Settings tab. */
configFiles: number;
}
/** One config file a catalog tool's Settings tab may edit. */
export interface AiToolConfigFile {
index: number;
path: string;
format: "json" | "jsonc" | "text";
exists: boolean;
size: number | null;
}
export interface AiToolUpdateBody {
enabled?: boolean;
default?: true;
defaultModel?: string | null;
}
/** GET /api/ai-tools/:id/models - read from the tool's own source. */
export interface AiToolModels {
flag: boolean;
models: ModelChoice[];
defaultModel: string | null;
} }
export interface AiToolsView { export interface AiToolsView {
@@ -3890,9 +3939,8 @@ export const RUN_EFFORT_CHOICES: EffortChoice[] = [
{ id: "max", label: "Max", hint: "All-out - slowest, most tokens" }, { id: "max", label: "Max", hint: "All-out - slowest, most tokens" },
]; ];
// Curated model list. "" means "inherit from settings.json" - no --model flag. // Claude's fallback model list, used only when GET /api/ai-tools/claude/models
// Static UI data for the Run page's model picker; the first entry inherits the // cannot answer. "" means "inherit from settings.json" - no --model flag.
// configured default and the rest map to concrete `--model` values.
export const RUN_MODEL_CHOICES: ModelChoice[] = [ export const RUN_MODEL_CHOICES: ModelChoice[] = [
{ id: "", label: "Inherit from settings", hint: "Use whatever your settings.json model is" }, { id: "", label: "Inherit from settings", hint: "Use whatever your settings.json model is" },
// Aliases, never pinned ids: `claude --model` documents `fable`/`opus`/ // Aliases, never pinned ids: `claude --model` documents `fable`/`opus`/
@@ -3908,16 +3956,6 @@ export const RUN_MODEL_CHOICES: ModelChoice[] = [
{ id: "haiku", label: "Haiku (latest)", hint: "Fastest, lightest" }, { id: "haiku", label: "Haiku (latest)", hint: "Fastest, lightest" },
]; ];
// Curated model list for Codex lanes - same shape as RUN_MODEL_CHOICES, sent
// via `codex -m`. "" inherits Codex's own configured default.
export const RUN_MODEL_CHOICES_CODEX: ModelChoice[] = [
{ id: "", label: "Inherit from settings", hint: "Use whatever your Codex config model is" },
{ id: "gpt-5.6-sol", label: "GPT-5.6 Sol", hint: "Highest capability" },
{ id: "gpt-5.6-terra", label: "GPT-5.6 Terra", hint: "Balanced capability and speed" },
{ id: "gpt-5.6-luna", label: "GPT-5.6 Luna", hint: "Fastest, lightest" },
{ id: "gpt-5.5", label: "GPT-5.5", hint: "Previous generation" },
];
/** Result of a transcript import run - returned by `api.import.rescan`, /** Result of a transcript import run - returned by `api.import.rescan`,
* `scanPath`, and `upload`, and mirrored by the final `import.progress` * `scanPath`, and `upload`, and mirrored by the final `import.progress`
* WebSocket message (`phase: "complete"`). The core counters (`imported`/ * WebSocket message (`phase: "complete"`). The core counters (`imported`/
+5
View File
@@ -1037,6 +1037,11 @@ export interface Analytics {
* applied when a token bucket's `speed` is "fast". * applied when a token bucket's `speed` is "fast".
*/ */
export interface ModelPricing { export interface ModelPricing {
/** Who owns the row: `"sync"` = added by the automatic sync and kept at
* LiteLLM's published rate daily, `"user"` = edited here and never touched
* again, null = from before this field (adopted by the sync only while it
* still equals the list). */
source?: "sync" | "user" | null;
/** SQL LIKE-style pattern (`%` wildcard) matched against a token bucket's /** SQL LIKE-style pattern (`%` wildcard) matched against a token bucket's
* model id; longer/more-specific patterns win ties. Primary key. Examples: * model id; longer/more-specific patterns win ties. Primary key. Examples:
* `"claude-opus-4%"` (specific) beats `"claude-%"` (catch-all). */ * `"claude-opus-4%"` (specific) beats `"claude-%"` (catch-all). */
+267
View File
@@ -0,0 +1,267 @@
/**
* @file AiToolConfig.tsx
* @description Settings → <tool>: the one tab every installed, enabled
* catalog tool without a hand-written tab gets (Claude Code and Codex keep
* theirs). Shows where the CLI lives, its homepage and an Update link into the
* AI Tools modal; how many sessions it ran, their cost, and its lanes; a
* default model (`PUT /api/ai-tools/:id {defaultModel}`) for a tool with a
* model flag; its vendor's pricing table; and the config files its catalog
* file lists, each opened over a sha and saved by the server only if it is
* still that sha, valid, and backed up. Antigravity adds its hooks card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ComponentType } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Cpu, ExternalLink, FileCog, RefreshCw } from "lucide-react";
import { api, type AiToolConfigFile } from "../lib/api";
import type { Lane } from "../lib/types";
import { useAiTools } from "../hooks/useAiTools";
import { AiToolIcon } from "../components/AiToolIcon";
import { ModelPicker } from "../components/run/RunSetup";
import { PricingSettings } from "../components/settings/PricingSettings";
import { AntigravityHooksSettings } from "../components/settings/AntigravityHooksSettings";
/** Cards one tool adds below the generic ones. */
const EXTRAS: Record<string, ComponentType> = { antigravity: AntigravityHooksSettings };
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** The default-model card: the picker, saved on an explicit click. */
function DefaultModel({ toolId, saved }: { toolId: string; saved: string }) {
const { t } = useTranslation("settings");
const aiTools = useAiTools();
const [value, setValue] = useState(saved);
const [error, setError] = useState<string | null>(null);
useEffect(() => setValue(saved), [saved]);
const save = async () => {
setError(null);
try {
await aiTools.update(toolId, { defaultModel: value || null });
} catch (err) {
setError(message(err));
}
};
return (
<div className="space-y-2">
<p className="text-xs text-fg-muted">{t("toolTab.defaultModelHint")}</p>
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-[16rem] flex-1">
<ModelPicker value={value} onChange={setValue} tool={toolId} />
</div>
<button
type="button"
onClick={save}
disabled={value === saved}
className="btn-ghost text-xs disabled:opacity-50"
>
{t("toolTab.saveDefaultModel")}
</button>
</div>
{error && <p className="text-xs text-status-danger">{error}</p>}
</div>
);
}
/** The config-files card: pick a declared file, edit it, save over its sha. */
function ConfigFiles({ toolId }: { toolId: string }) {
const { t } = useTranslation("settings");
const [files, setFiles] = useState<AiToolConfigFile[] | null>(null);
const [open, setOpen] = useState<{ index: number; path: string; sha: string } | null>(null);
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
useEffect(() => {
api.aiTools
.config(toolId)
.then((r) => setFiles(r.files))
.catch((err) => setNote({ ok: false, text: message(err) }));
}, [toolId]);
const openFile = async (f: AiToolConfigFile) => {
setNote(null);
try {
const r = await api.aiTools.readConfig(toolId, f.index);
setOpen({ index: f.index, path: r.path, sha: r.sha });
setText(r.text);
} catch (err) {
setNote({ ok: false, text: message(err) });
}
};
const save = async () => {
if (!open) return;
setBusy(true);
setNote(null);
try {
const r = await api.aiTools.saveConfig(toolId, open.index, text, open.sha);
setOpen({ ...open, sha: r.sha });
setNote({ ok: true, text: t("toolTab.saved", { backup: r.backup }) });
} catch (err) {
setNote({ ok: false, text: message(err) });
} finally {
setBusy(false);
}
};
return (
<div className="space-y-3">
<p className="text-xs text-fg-muted">{t("toolTab.configHint")}</p>
<div className="flex flex-wrap gap-2">
{(files ?? []).map((f) => (
<button
key={f.index}
type="button"
onClick={() => openFile(f)}
disabled={!f.exists}
title={f.exists ? undefined : t("toolTab.missing")}
className={`rounded-md border px-2.5 py-1 font-mono text-[11px] disabled:opacity-50 ${
open?.index === f.index
? "border-accent/50 text-fg-primary"
: "border-border text-fg-secondary hover:bg-surface-3"
}`}
>
{f.path}
</button>
))}
</div>
{open && (
<div className="space-y-2">
<textarea
aria-label={open.path}
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
rows={16}
className="w-full rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] text-fg-primary focus:border-accent/50 focus:outline-none"
/>
<div className="flex gap-2">
<button
type="button"
onClick={save}
disabled={busy}
className="btn-ghost text-xs disabled:opacity-50"
>
{busy && <RefreshCw className="h-3.5 w-3.5 animate-spin" />}
{t("toolTab.save")}
</button>
<button type="button" onClick={() => setOpen(null)} className="btn-ghost text-xs">
{t("toolTab.close")}
</button>
</div>
</div>
)}
{note && (
<p className={`text-xs ${note.ok ? "text-fg-muted" : "text-status-danger"}`}>{note.text}</p>
)}
</div>
);
}
function Card({
title,
Icon,
children,
}: {
title: string;
Icon: ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-fg-secondary">
<Icon className="h-4 w-4 text-fg-muted" />
{title}
</h3>
<div className="card p-5">{children}</div>
</section>
);
}
/** The tab for one catalog tool. */
export function AiToolConfig({ toolId }: { toolId: string }) {
const { t } = useTranslation("settings");
const aiTools = useAiTools();
const tool = aiTools.byId(toolId);
const [usage, setUsage] = useState<{ sessions: number; cost: number } | null>(null);
const [lanes, setLanes] = useState<Lane[]>([]);
useEffect(() => {
api.aiTools
.usage(toolId)
.then(setUsage)
.catch(() => setUsage(null));
api.lanes
.list()
.then((r) => setLanes(r.lanes.filter((l) => l.tool === toolId)))
.catch(() => setLanes([]));
}, [toolId]);
if (!tool) return null;
const Extra = EXTRAS[toolId];
return (
<div className="space-y-8">
<div className="card flex flex-wrap items-start gap-4 p-5">
<AiToolIcon tool={tool} className="h-10 w-10" />
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-base font-semibold text-fg-primary">{tool.label}</h2>
{tool.homepageUrl && (
<a
href={tool.homepageUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 text-xs text-accent hover:underline"
>
<ExternalLink className="h-3 w-3" />
{t("aiTools.homepage")}
</a>
)}
{tool.update && (
<Link to={`/settings?tool=${toolId}#ai-tools`} className="btn-ghost text-xs">
<RefreshCw className="h-3.5 w-3.5" />
{t("toolTab.update")}
</Link>
)}
</div>
<p className="break-all font-mono text-[11px] text-fg-muted">{tool.path}</p>
{usage && (
<p className="text-xs text-fg-secondary">
{t("toolTab.usage", { count: usage.sessions, cost: usage.cost.toFixed(2) })}
</p>
)}
{lanes.length > 0 && (
<p className="flex flex-wrap items-center gap-2 text-xs text-fg-secondary">
{t("toolTab.lanes")}
{lanes.map((l) => (
<Link
key={l.id}
to={`/workspace?lane=${l.id}`}
className="text-accent hover:underline"
>
{l.title || l.cwd}
</Link>
))}
</p>
)}
</div>
</div>
{tool.modelFlag && (
<Card title={t("toolTab.defaultModel")} Icon={Cpu}>
<DefaultModel toolId={toolId} saved={tool.defaultModel ?? ""} />
</Card>
)}
{tool.modelVendor && <PricingSettings vendor={tool.modelVendor} id={`${toolId}-pricing`} />}
{tool.configFiles > 0 && (
<Card title={t("toolTab.config")} Icon={FileCog}>
<ConfigFiles toolId={toolId} />
</Card>
)}
{Extra && <Extra />}
</div>
);
}
-22
View File
@@ -1,22 +0,0 @@
/**
* @file AntigravityConfig.tsx
* @description Settings → Antigravity: AgentHub's own settings for the
* Antigravity CLI (`agy`) — Model Pricing for Gemini models (the shared
* `PricingSettings` table) and AgentHub's Antigravity hooks
* (`AntigravityHooksSettings`). agy has no config surface AgentHub edits, so
* there is no explorer and no sub-tab bar, only these cards (#40 part D).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { PricingSettings } from "../components/settings/PricingSettings";
import { AntigravityHooksSettings } from "../components/settings/AntigravityHooksSettings";
/** The Antigravity settings tab. */
export function AntigravityConfig() {
return (
<div className="space-y-8">
<PricingSettings vendor="google" id="antigravity-pricing" />
<AntigravityHooksSettings />
</div>
);
}
+28 -15
View File
@@ -1,6 +1,6 @@
/** /**
* @file SettingsHub.tsx * @file SettingsHub.tsx
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code, Codex and Antigravity) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. The **AI Tools** button at the bar's right edge opens `AiToolsModal` (also opened by `/settings#ai-tools`, the Add-lane modal's link); a tool's tab is hidden while that tool is disabled there. * @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex have their own; every other installed, enabled catalog tool gets the generic `AiToolConfig` tab, Antigravity's with its hooks card) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param. The **AI Tools** button at the bar's right edge opens `AiToolsModal` (also opened by `/settings#ai-tools`, the Add-lane modal's link); a tool's tab is hidden while that tool is disabled there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useState, type ComponentType } from "react"; import { useCallback, useEffect, useState, type ComponentType } from "react";
@@ -11,6 +11,7 @@ import {
Boxes, Boxes,
KeyRound, KeyRound,
Orbit, Orbit,
Puzzle,
Router, Router,
Settings as SettingsIcon, Settings as SettingsIcon,
SquareTerminal, SquareTerminal,
@@ -19,7 +20,7 @@ import { PageTabs } from "../components/PageTabs";
import { Settings } from "./Settings"; import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig"; import { CcConfig } from "./CcConfig";
import { CodexConfig } from "./CodexConfig"; import { CodexConfig } from "./CodexConfig";
import { AntigravityConfig } from "./AntigravityConfig"; import { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig"; import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig"; import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { useAiTools } from "../hooks/useAiTools"; import { useAiTools } from "../hooks/useAiTools";
@@ -27,9 +28,13 @@ import { AiToolsModal } from "../components/settings/AiToolsModal";
interface ToolTab { interface ToolTab {
id: string; id: string;
labelKey: string; labelKey?: string;
/** A catalog tool's own label, used instead of `labelKey`. */
label?: string;
Icon: ComponentType<{ className?: string }>; Icon: ComponentType<{ className?: string }>;
Panel: ComponentType; Panel?: ComponentType;
/** Set on a generic catalog-tool tab: rendered as `<AiToolConfig toolId>`. */
toolId?: string;
/** The AI Tools catalog id this tab configures; hidden while it is disabled. */ /** The AI Tools catalog id this tab configures; hidden while it is disabled. */
aiTool?: string; aiTool?: string;
} }
@@ -58,13 +63,6 @@ const TOOL_TABS: ToolTab[] = [
Panel: CodexConfig, Panel: CodexConfig,
aiTool: "codex", aiTool: "codex",
}, },
{
id: "antigravity",
labelKey: "tools.antigravity",
Icon: Orbit,
Panel: AntigravityConfig,
aiTool: "antigravity",
},
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig }, { id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
{ {
id: "git-credentials", id: "git-credentials",
@@ -83,9 +81,20 @@ export function SettingsHub() {
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const requested = params.get("tool"); const requested = params.get("tool");
const aiTools = useAiTools(); const aiTools = useAiTools();
const tabs = TOOL_TABS.filter( const fixed = TOOL_TABS.filter(
(tab) => !tab.aiTool || aiTools.byId(tab.aiTool)?.enabled !== false (tab) => !tab.aiTool || aiTools.byId(tab.aiTool)?.enabled !== false
); );
// Every other installed, enabled tool, after Codex; a fixed tab's id wins.
const catalogTabs: ToolTab[] = aiTools.tools
.filter((x) => x.installed && x.enabled && !x.launcher && !fixed.some((f) => f.id === x.id))
.map((x) => ({
id: x.id,
label: x.label,
Icon: x.id === "antigravity" ? Orbit : Puzzle,
toolId: x.id,
}));
const at = fixed.findIndex((tab) => tab.id === "ai-router");
const tabs = [...fixed.slice(0, at), ...catalogTabs, ...fixed.slice(at)];
const active = tabs.find((tab) => tab.id === requested) ?? DASHBOARD_TAB; const active = tabs.find((tab) => tab.id === requested) ?? DASHBOARD_TAB;
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -107,10 +116,10 @@ export function SettingsHub() {
ariaLabel={t("tools.label", "Tools")} ariaLabel={t("tools.label", "Tools")}
activeId={active.id} activeId={active.id}
onSelect={(id) => setParams(id === DASHBOARD_TAB.id ? {} : { tool: id }, { replace: true })} onSelect={(id) => setParams(id === DASHBOARD_TAB.id ? {} : { tool: id }, { replace: true })}
tabs={tabs.map(({ id, labelKey, Icon }) => ({ tabs={tabs.map(({ id, labelKey, label, Icon }) => ({
id, id,
Icon, Icon,
label: t(labelKey), label: label ?? t(labelKey ?? id),
}))} }))}
actions={ actions={
<button <button
@@ -125,7 +134,11 @@ export function SettingsHub() {
} }
/> />
<active.Panel /> {active.toolId ? (
<AiToolConfig key={active.toolId} toolId={active.toolId} />
) : (
active.Panel && <active.Panel />
)}
{aiToolsOpen && <AiToolsModal onClose={closeAiTools} />} {aiToolsOpen && <AiToolsModal onClose={closeAiTools} />}
</div> </div>
); );
@@ -0,0 +1,141 @@
/**
* @file AiToolConfig.test.tsx
* @description The generic per-tool Settings tab: the tool's path, homepage
* and an Update link into the AI Tools modal; its session count, cost and
* lanes; a default-model picker saved through PUT /api/ai-tools/:id; and its
* catalog-declared config files, edited over the sha they were opened at with
* the server's refusal shown.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { api, type AiTool } from "../../lib/api";
import { __resetAiTools } from "../../hooks/useAiTools";
import { AiToolConfig } from "../AiToolConfig";
vi.mock("../../lib/api", () => ({
api: {
aiTools: {
list: vi.fn(),
update: vi.fn(),
models: vi.fn(),
usage: vi.fn(),
config: vi.fn(),
readConfig: vi.fn(),
saveConfig: vi.fn(),
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
},
lanes: { list: vi.fn() },
},
RUN_MODEL_CHOICES: [],
}));
vi.mock("../../components/settings/PricingSettings", () => ({
PricingSettings: ({ vendor }: { vendor: string }) => <div>pricing:{vendor}</div>,
}));
const GEMINI: AiTool = {
id: "gemini",
label: "Gemini",
command: "gemini",
homepageUrl: "https://github.com/google-gemini/gemini-cli",
installed: true,
path: "/usr/bin/gemini",
enabled: true,
source: "builtin",
launcher: null,
prompt: { mode: "flag" },
resume: false,
bypass: true,
routerCapable: false,
install: { kind: "npm", package: "@google/gemini-cli" },
update: { kind: "npm" },
modelFlag: true,
defaultModel: null,
modelVendor: "google",
configFiles: 2,
};
beforeEach(() => {
__resetAiTools();
const view = { default: "claude", tools: [GEMINI] };
vi.mocked(api.aiTools.list)
.mockReset()
.mockResolvedValue(view as never);
vi.mocked(api.aiTools.update)
.mockReset()
.mockResolvedValue(view as never);
vi.mocked(api.aiTools.models)
.mockReset()
.mockResolvedValue({
flag: true,
models: [{ id: "gemini-3.1-pro", label: "gemini-3.1-pro" }],
defaultModel: null,
});
vi.mocked(api.aiTools.usage).mockReset().mockResolvedValue({ sessions: 3, cost: 1.5 });
vi.mocked(api.aiTools.config)
.mockReset()
.mockResolvedValue({
files: [
{ index: 0, path: "/h/.gemini/settings.json", format: "json", exists: true, size: 2 },
{ index: 1, path: "/h/.gemini/GEMINI.md", format: "text", exists: false, size: null },
],
});
vi.mocked(api.aiTools.readConfig)
.mockReset()
.mockResolvedValue({ path: "/h/.gemini/settings.json", format: "json", text: "{}", sha: "s1" });
vi.mocked(api.aiTools.saveConfig).mockReset();
vi.mocked(api.lanes.list)
.mockReset()
.mockResolvedValue({
lanes: [
{ id: 7, tool: "gemini", title: "web", cwd: "/w" },
{ id: 8, tool: "claude", title: "other", cwd: "/o" },
],
} as never);
});
const renderTab = () =>
render(
<MemoryRouter>
<AiToolConfig toolId="gemini" />
</MemoryRouter>
);
describe("AiToolConfig", () => {
it("shows the tool, its usage, its lanes and its vendor's pricing", async () => {
renderTab();
expect(await screen.findByText("/usr/bin/gemini")).toBeInTheDocument();
expect(await screen.findByText(/3 sessions/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "web" })).toHaveAttribute("href", "/workspace?lane=7");
expect(screen.queryByRole("link", { name: "other" })).toBeNull();
expect(screen.getByRole("link", { name: /update/i })).toHaveAttribute(
"href",
"/settings?tool=gemini#ai-tools"
);
expect(screen.getByText("pricing:google")).toBeInTheDocument();
});
it("saves the default model", async () => {
renderTab();
fireEvent.click(await screen.findByText("Inherit from settings"));
fireEvent.click(await screen.findByText("gemini-3.1-pro"));
fireEvent.click(screen.getByRole("button", { name: /save default model/i }));
await waitFor(() =>
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { defaultModel: "gemini-3.1-pro" })
);
});
it("edits a config file over the sha it opened, and shows a refusal", async () => {
vi.mocked(api.aiTools.saveConfig).mockRejectedValueOnce(new Error("not valid JSON"));
renderTab();
fireEvent.click(await screen.findByRole("button", { name: "/h/.gemini/settings.json" }));
expect(screen.getByRole("button", { name: "/h/.gemini/GEMINI.md" })).toBeDisabled();
const box = await screen.findByRole("textbox", { name: "/h/.gemini/settings.json" });
fireEvent.change(box, { target: { value: "{nope" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
expect(await screen.findByText(/not valid JSON/)).toBeInTheDocument();
expect(api.aiTools.saveConfig).toHaveBeenCalledWith("gemini", 0, "{nope", "s1");
});
});
+7 -2
View File
@@ -2101,7 +2101,12 @@ Every history row carries `lane_id` (the lane whose run it belongs to) and `tool
``` ```
GET /api/ai-tools Catalog + detection — { tools: [...], default } GET /api/ai-tools Catalog + detection — { tools: [...], default }
POST /api/ai-tools/refresh Re-read the login shell's PATH, re-detect, same body POST /api/ai-tools/refresh Re-read the login shell's PATH, re-detect, same body
PUT /api/ai-tools/:id Body: { enabled?: boolean, default?: true } — same body PUT /api/ai-tools/:id Body: { enabled?: boolean, default?: true, defaultModel?: string|null } — same body
GET /api/ai-tools/:id/models { flag, models: [{ id, label, hint? }], defaultModel } — the tool's own list
GET /api/ai-tools/:id/usage { sessions, cost } — sessions with tool = id, their tokens priced
GET /api/ai-tools/:id/config { files: [{ index, path, format: "json"|"jsonc"|"text", exists, size }] }
GET /api/ai-tools/:id/config/:i { path, format, text, sha }
PUT /api/ai-tools/:id/config/:i Body: { text, baseSha } → { sha, backup }
POST /api/ai-tools/:id/install Start the catalog's install job → 202 { job } POST /api/ai-tools/:id/install Start the catalog's install job → 202 { job }
POST /api/ai-tools/:id/update Start the catalog's update job → 202 { job } POST /api/ai-tools/:id/update Start the catalog's update job → 202 { job }
POST /api/ai-tools/update-all One job: every npm-managed tool with a newer version + every self updater POST /api/ai-tools/update-all One job: every npm-managed tool with a newer version + every self updater
@@ -2110,7 +2115,7 @@ GET /api/ai-tools/versions { [id]: { npmManaged, installed, latest, u
GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the server; 204 → letter avatar GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the server; 204 → letter avatar
``` ```
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, routerCapable: bool }` — flags only, never the arg arrays. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`). Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, routerCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is not under `npm prefix -g`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. **Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is not under `npm prefix -g`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`.
+7 -1
View File
@@ -394,7 +394,7 @@ CREATE TABLE notifications (
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins. Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes. Any Claude, OpenAI or Gemini model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` / `-YYYY-MM-DD` suffix; OpenAI means `gpt-…`, `o<n>…` or `codex-…`, the models Codex sessions use; Gemini means `gemini-…`, an Antigravity session's, taken whole) has no `<family>%` row of its own gets one, INSERTED from LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub). The list is fetched only when something is missing and cached for 24 h. Existing rows are never updated, so a rate edited in Settings always wins. An entry is taken only from its own vendor (`litellm_provider` `anthropic` / `openai`, and `vertex_ai-language-models` or `gemini` for Gemini); an OpenAI or Gemini row carries no cache-write charge unless the list names one, and no fast rate. Gemini's >200k-token rate is not modelled (the base rate is used). A router's model id (`clinepass/…`) is never priced automatically. `POST /api/settings/reset-pricing` runs a sync right after reseeding (waiting up to 3 s), so the OpenAI and Gemini rows a reset wiped come straight back; with `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) it deletes and reseeds only `claude-…` rows, so other vendors' rows and a custom router row survive. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off. **Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes, and fetches LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub) at most once a day. Each pass (1) INSERTS a row, `source = 'sync'`, for every Claude, OpenAI or Gemini model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` / `-YYYY-MM-DD` suffix; OpenAI means `gpt-…`, `o<n>…` or `codex-…`; Gemini means `gemini-…`, taken whole) has no `<family>%` row of its own, AND for every **current** model of the three vendors before anyone uses it — a list key that is a family itself, `mode` `chat`/`responses`, not past its `deprecation_date`, not an audio/search/TTS/realtime/image/embedding variant, and of the current generation (any `claude-<name>-…`; `gpt-` 5 and up; `gemini-` 3 and up — `MIN_GEN`); (2) UPDATES the rates of its own `sync` rows when the list changes; (3) adopts a row from before the `source` column (`NULL`: a seed, an old sync insert or an old edit) as `sync` only while its rates still equal the list exactly. A row edited through `PUT /api/pricing` becomes `source = 'user'` and is never touched again; a deleted row's pattern goes to `model_pricing_dismissed` and is never added back. An entry is taken only from its own vendor (`litellm_provider` `anthropic` / `openai`, and `vertex_ai-language-models` or `gemini` for Gemini); an OpenAI or Gemini row carries no cache-write charge unless the list names one, and no fast rate. Gemini's >200k-token rate is not modelled (the base rate is used). A router's model id (`clinepass/…`) is never priced automatically. `POST /api/settings/reset-pricing` empties `model_pricing_dismissed` for its scope and runs a sync right after reseeding (waiting up to 3 s), so the OpenAI and Gemini rows a reset wiped come straight back; with `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) it deletes and reseeds only `claude-…` rows, so other vendors' rows and a custom router row survive. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
```sql ```sql
CREATE TABLE model_pricing ( CREATE TABLE model_pricing (
@@ -416,6 +416,7 @@ CREATE TABLE model_pricing (
intro_cache_write_per_mtok REAL NOT NULL DEFAULT 0, intro_cache_write_per_mtok REAL NOT NULL DEFAULT 0,
intro_cache_write_1h_per_mtok REAL NOT NULL DEFAULT 0, intro_cache_write_1h_per_mtok REAL NOT NULL DEFAULT 0,
intro_until TEXT, -- promo cutoff YYYY-MM-DD, or NULL intro_until TEXT, -- promo cutoff YYYY-MM-DD, or NULL
source TEXT, -- 'sync' | 'user' | NULL (from before the column)
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
); );
``` ```
@@ -431,10 +432,13 @@ CREATE TABLE model_pricing (
| `fast_input_per_mtok` / `fast_output_per_mtok` | REAL | NO | Fast-mode premium rates (0 = no premium) | | `fast_input_per_mtok` / `fast_output_per_mtok` | REAL | NO | Fast-mode premium rates (0 = no premium) |
| `intro_*_per_mtok` | REAL | NO | Introductory (promo) rates, mirroring the standard fields | | `intro_*_per_mtok` | REAL | NO | Introductory (promo) rates, mirroring the standard fields |
| `intro_until` | TEXT | YES | Promo cutoff `YYYY-MM-DD`. Usage on/before it uses the intro rates; NULL = no promo. Editable per-rule in Settings | | `intro_until` | TEXT | YES | Promo cutoff `YYYY-MM-DD`. Usage on/before it uses the intro rates; NULL = no promo. Editable per-rule in Settings |
| `source` | TEXT | YES | `sync` = kept at LiteLLM's rate by the automatic sync (shown **Auto** in Settings), `user` = edited in Settings and never touched by the sync, NULL = from before this column (adopted as `sync` only while it equals the list) |
| `updated_at` | TEXT | NO | ISO8601 timestamp of the last edit | | `updated_at` | TEXT | NO | ISO8601 timestamp of the last edit |
Standard rates and intro rates are edited independently: the pricing update path writes intro columns only when the caller sends intro fields, so a standard-rate edit never disturbs a promo (and vice versa). Clearing `intro_until` also zeroes the intro rates. Standard rates and intro rates are edited independently: the pricing update path writes intro columns only when the caller sends intro fields, so a standard-rate edit never disturbs a promo (and vice versa). Clearing `intro_until` also zeroes the intro rates.
`model_pricing_dismissed (model_pattern TEXT PRIMARY KEY)` holds the pattern of every rule deleted through `DELETE /api/pricing/:pattern`, so the sync never adds it back; a reset empties it for the scope it reseeds.
**Example default rules:** **Example default rules:**
| Pattern | Input | Output | Cache read | Fast in/out | | Pattern | Input | Output | Cache read | Fast in/out |
@@ -604,6 +608,8 @@ CREATE INDEX idx_notifications_session_id ON notifications(session_id);
## Migrations ## Migrations
Every migration in `server/db.js` runs at module load as a check-then-act (probe a column, `ALTER` when missing; rebuild a table whose old constraint is still there). Two dashboards may start on one database at the same instant, so the whole section — the legacy-DB copy, `CREATE TABLE IF NOT EXISTS`, every probe and rebuild, the pricing top-up — runs under a cross-process lock: a `BEGIN IMMEDIATE` on the separate file `<db>-migrate.lock` (busy timeout 120 s), committed just before the prepared statements are built. The lock is a separate file because several rebuilds open their own `BEGIN` and switch `foreign_keys` off, which a surrounding transaction on the main database would break. A process that dies holding it releases it with its file handle.
### Schema Versioning ### Schema Versioning
```mermaid ```mermaid
+3 -1
View File
@@ -585,7 +585,9 @@ function sendToServer(hookType, payload) {
> startup and removes it on a clean shutdown. The handler **prunes any > startup and removes it on a clean shutdown. The handler **prunes any
> entry whose PID is no longer alive** and POSTs the hook payload to one > entry whose PID is no longer alive** and POSTs the hook payload to one
> port per **unique SQLite data directory** (lowest port wins when two > port per **unique SQLite data directory** (lowest port wins when two
> instances share `~/.claude/agenthub`). > instances share `~/.claude/agenthub`). The other instance re-broadcasts
> the lanes those hooks moved within `DASHBOARD_LANE_SYNC_MS` (2 s), so its
> browsers see a lane's status without a refresh.
> 3. If neither yields a target, the handler falls back to `4820`. > 3. If neither yields a target, the handler falls back to `4820`.
> >
> Dashboards with **different** databases (e.g. the packaged desktop app using > Dashboards with **different** databases (e.g. the packaged desktop app using
+5 -3
View File
@@ -188,7 +188,7 @@ Every destructive `POST /api/lanes/:id/:action` must echo back an `expect` objec
### Environment variables ### Environment variables
- **`LANES_ROOT`** (default `~/.claude/agenthub-lanes`) — the directory managed worktrees are provisioned under; also the boundary that safety check 2 resolves paths against. - **`LANES_ROOT`** (default `~/.claude/agenthub-lanes`) — the directory managed worktrees are provisioned under; also the boundary that safety check 2 resolves paths against.
- **`LANE_DEAD_SEC`** (default `300`) — seconds of silence before a lane whose stage matches `/watch|poll/i` flips from `active` to `dead` liveness. - **`LANE_DEAD_SEC`** (default `300`) — seconds of silence before a lane whose stage matches `/watch|poll/i` (or whose status is `running`) flips from `active` to `dead` liveness. Silence is counted from the newer of the lane session's last event and the start of the lane's current run (`dashboard_runs.started_at`), so a run started a moment ago is never judged by the previous session's quiet. A lane no hook ever binds a session to (a catalog tool, Codex without its hooks) has no silence to measure and stays `active`.
- **`LANE_BASE_BRANCH`** (default `main`) — the base branch a new managed worktree resolves against when a request omits `base`. An explicit `--base <branch>` (or request body `base`) always wins over this default. - **`LANE_BASE_BRANCH`** (default `main`) — the base branch a new managed worktree resolves against when a request omits `base`. An explicit `--base <branch>` (or request body `base`) always wins over this default.
- **`LANE_BRANCH_PREFIX`** (default `feat/`) — the prefix used to build a new managed worktree's feature branch name (`<prefix><slug>`). - **`LANE_BRANCH_PREFIX`** (default `feat/`) — the prefix used to build a new managed worktree's feature branch name (`<prefix><slug>`).
@@ -1703,7 +1703,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
## Lanes on any AI tool ## Lanes on any AI tool
A lane can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json`, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). The **AI Tools** button at the right of the Settings tab bar opens a dialog (also `/settings#ai-tools`) that lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-lane modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub lanes add --tool <id>` does the same from a terminal. A lane can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json`, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). The **AI Tools** button at the right of the Settings tab bar opens a dialog (also `/settings#ai-tools`) that lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-lane modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub lanes add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the lane tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A catalog file: A catalog file:
@@ -1714,9 +1714,11 @@ A catalog file:
"detect": ["agy"], "detect": ["agy"],
"homepageUrl": "https://antigravity.google/docs/cli/overview", "homepageUrl": "https://antigravity.google/docs/cli/overview",
"prompt": { "mode": "flag", "flag": "-i" }, "prompt": { "mode": "flag", "flag": "-i" },
"model": { "flag": "--model", "vendor": "google" },
"config": [{ "path": "~/.gemini/GEMINI.md" }],
"resume": ["--continue"], "resume": ["--continue"],
"bypass": ["--dangerously-skip-permissions"] "bypass": ["--dangerously-skip-permissions"]
} }
``` ```
`detect` lists bare command names (the first found on PATH is what runs), `requires` other commands that must be present, `args` fixed leading args, `prompt.mode` one of `argv` (optional `separator: "--"`), `flag` (`flag`), or `after-start` (`delayMs`, default 3000 — the prompt is pasted into the pane once the CLI is up). `resume` enables **Resume last session**; `bypass`/`bypassEnv` enable the run form's per-run **Bypass permissions** switch, which is off every time the form opens. Every non-Claude lane has no pipeline, no agents install and no AI Router (native only, except Codex); running/idle comes from the exited-pane heal sweep. `detect` lists bare command names (the first found on PATH is what runs), `requires` other commands that must be present, `args` fixed leading args, `prompt.mode` one of `argv` (optional `separator: "--"`), `flag` (`flag`), or `after-start` (`delayMs`, default 3000 — the prompt is pasted into the pane once the CLI is up). `model.flag` (optional `vendor`: `claude`/`openai`/`google`, whose priced models the picker offers) puts the run form's model before the prompt; `config` (built-in and release files only, like `install`) lists the `~/`-relative files the tool's Settings tab may edit — `format` `json`/`jsonc`/`text`, guessed from the extension, and never a credential-looking name; `resume` enables **Resume last session**; `bypass`/`bypassEnv` enable the run form's per-run **Bypass permissions** switch, which is off every time the form opens. Every non-Claude lane has no pipeline, no agents install and no AI Router (native only, except Codex); running/idle comes from the exited-pane heal sweep.
+8
View File
@@ -1090,6 +1090,14 @@ components:
fast_output_per_mtok: fast_output_per_mtok:
type: number type: number
description: Fast-mode output rate (0 = none) description: Fast-mode output rate (0 = none)
source:
type: string
nullable: true
enum:
- sync
- user
- null
description: sync = kept at LiteLLM's published rate by the automatic sync; user = edited through PUT, never touched by the sync; null = from before this field
updated_at: updated_at:
type: string type: string
format: date-time format: date-time
+7 -1
View File
@@ -537,7 +537,12 @@ Request body shape:
| `DELETE` | `/api/git-credentials/:protocol/:host` | Same-origin guarded delete. `404 ENOTFOUND`. | | `DELETE` | `/api/git-credentials/:protocol/:host` | Same-origin guarded delete. `404 ENOTFOUND`. |
| `GET` | `/api/ai-tools` | The AI Tools catalog (`server/lib/ai-tools.js`: `server/data/ai-tools/*.json` + `DASHBOARD_AI_TOOLS_DIR`) with detection, enabled flag and the effective default. Flags only, never arg arrays. | | `GET` | `/api/ai-tools` | The AI Tools catalog (`server/lib/ai-tools.js`: `server/data/ai-tools/*.json` + `DASHBOARD_AI_TOOLS_DIR`) with detection, enabled flag and the effective default. Flags only, never arg arrays. |
| `POST` | `/api/ai-tools/refresh` | Same-origin guarded: merge the login shell's PATH (`server/lib/shell-path.js`), re-detect. | | `POST` | `/api/ai-tools/refresh` | Same-origin guarded: merge the login shell's PATH (`server/lib/shell-path.js`), re-detect. |
| `PUT` | `/api/ai-tools/:id` | Same-origin guarded `{enabled?, default?: true}`. `404 ENOTOOL`, `400 EBADDEFAULT`/`EBADFIELD`. | | `PUT` | `/api/ai-tools/:id` | Same-origin guarded `{enabled?, default?: true, defaultModel?}` (`""`/`null` clears the default model). `404 ENOTOOL`, `400 EBADDEFAULT`/`EBADFIELD`/`EBADMODEL`. |
| `GET` | `/api/ai-tools/:id/usage` | `{sessions, cost}` for `sessions.tool = id`. `404 ENOTOOL`. |
| `GET` | `/api/ai-tools/:id/config` | The config files the tool's trusted catalog file lists (`server/lib/ai-tool-config.js`), with `exists`/`format`. |
| `GET` | `/api/ai-tools/:id/config/:index` | One file's `{text, sha}`. `404 ENOFILE`/`ENOENT`, `403 ESECRET`, `413 ETOOBIG`, `415 EBINARY`. |
| `PUT` | `/api/ai-tools/:id/config/:index` | Same-origin guarded `{text, baseSha}`: syntax-checked, backed up, atomic. `409 ESTALE`, `400 EBADSYNTAX`, plus the read errors. |
| `GET` | `/api/ai-tools/:id/models` | The tool's own model list (`server/lib/ai-tool-models.js`: Codex's `models_cache.json`, Claude's aliases + priced versions, a catalog tool's vendor rows + the models its sessions used) and its `defaultModel`. `404 ENOTOOL`. |
| `POST` | `/api/ai-tools/:id/install` | Same-origin guarded: start the catalog's install job (`server/lib/ai-tool-jobs.js` — `npm install -g <pkg>@latest` with the login shell's npm, or a vendor script downloaded and run with `bash`). `202 {job}`; `404 ENOTOOL`, `400 ENOINSTALL`, `409 EINSTALLED`/`ENONPM`/`EBUSY`. | | `POST` | `/api/ai-tools/:id/install` | Same-origin guarded: start the catalog's install job (`server/lib/ai-tool-jobs.js` — `npm install -g <pkg>@latest` with the login shell's npm, or a vendor script downloaded and run with `bash`). `202 {job}`; `404 ENOTOOL`, `400 ENOINSTALL`, `409 EINSTALLED`/`ENONPM`/`EBUSY`. |
| `POST` | `/api/ai-tools/:id/update` | Same-origin guarded: start the update job; npm only for an npm-managed tool (`server/lib/ai-tool-versions.js`), else `409 ENOTNPM`. `400 ENOUPDATE`, `409 ENOTINSTALLED`/`EBUSY`. | | `POST` | `/api/ai-tools/:id/update` | Same-origin guarded: start the update job; npm only for an npm-managed tool (`server/lib/ai-tool-versions.js`), else `409 ENOTNPM`. `400 ENOUPDATE`, `409 ENOTINSTALLED`/`EBUSY`. |
| `POST` | `/api/ai-tools/update-all` | Same-origin guarded: one job over every npm-managed tool with a newer version plus every self updater. `400 ENOTHING`. | | `POST` | `/api/ai-tools/update-all` | Same-origin guarded: one job over every npm-managed tool with a newer version plus every self updater. `400 ENOTHING`. |
@@ -1564,6 +1569,7 @@ DASHBOARD_DB_PATH=./data/dashboard.db # SQLite database path
DASHBOARD_AI_TOOLS_DIR= # Extra AI Tools catalog JSON files; an id here replaces the built-in one DASHBOARD_AI_TOOLS_DIR= # Extra AI Tools catalog JSON files; an id here replaces the built-in one
AGENTHUB_SHELL_PATH=1 # 0 = never run the login shell to read its PATH (startup + Refresh) AGENTHUB_SHELL_PATH=1 # 0 = never run the login shell to read its PATH (startup + Refresh)
AGENTHUB_AI_TOOLS_SYNC=1 # 0 = never copy the newest release's AI Tools catalog (server/lib/ai-tools-sync.js, daily) AGENTHUB_AI_TOOLS_SYNC=1 # 0 = never copy the newest release's AI Tools catalog (server/lib/ai-tools-sync.js, daily)
DASHBOARD_LANE_SYNC_MS=2000 # re-broadcast lanes another dashboard on the same DB changed (PRAGMA data_version); 0 = off
# Background services # Background services
DASHBOARD_SESSION_SYNC_MS=30000 # Continuous project-sync poll interval (ms); 0 disables the poll (watcher stays) DASHBOARD_SESSION_SYNC_MS=30000 # Continuous project-sync poll interval (ms); 0 disables the poll (watcher stays)
+201
View File
@@ -0,0 +1,201 @@
/**
* @file Tests for server/lib/ai-tool-config.js and its routes: a tool's
* config files come only from a TRUSTED catalog file's `config` list, only an
* existing regular text file is read or written, a credential-looking name is
* refused, JSON/JSONC is validated before saving, every save is backed up in
* AgentHub's data dir, and a stale `baseSha` is refused.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tool-config-"));
const builtin = path.join(root, "builtin");
const custom = path.join(root, "custom");
const home = path.join(root, "home");
const data = path.join(root, "data");
for (const d of [builtin, custom, home, data]) fs.mkdirSync(d, { recursive: true });
const saved = { home: process.env.HOME, data: process.env.DASHBOARD_DATA_DIR };
process.env.HOME = home;
process.env.DASHBOARD_DATA_DIR = data;
process.env.DASHBOARD_DB_PATH = path.join(root, "test.db");
const aiTools = require("../lib/ai-tools");
const cfg = require("../lib/ai-tool-config");
const write = (dir, doc) => fs.writeFileSync(path.join(dir, `${doc.id}.json`), JSON.stringify(doc));
const tool = (id, config) => ({
id,
label: id,
detect: [id],
prompt: { mode: "argv" },
config,
});
let server;
let base;
before(async () => {
write(
builtin,
tool("gem", [
{ path: "~/.gem/settings.json" },
{ path: "~/.gem/opencode.jsonc" },
{ path: "~/.gem/GEMINI.md" },
{ path: "~/.gem/missing.json" },
])
);
write(builtin, tool("leaky", [{ path: "~/.leaky/oauth_creds.json" }]));
write(builtin, tool("escape", [{ path: "~/../etc/passwd" }]));
write(custom, tool("untrusted", [{ path: "~/.u/settings.json" }]));
aiTools.__setCatalogDirs([builtin, custom]);
fs.mkdirSync(path.join(home, ".gem"));
fs.writeFileSync(path.join(home, ".gem/settings.json"), '{"theme":"dark"}\n', { mode: 0o600 });
fs.writeFileSync(path.join(home, ".gem/opencode.jsonc"), '{\n // c\n "a": 1,\n}\n');
fs.writeFileSync(path.join(home, ".gem/GEMINI.md"), "# rules\n");
const express = require("express");
const app = express();
app.use(express.json({ limit: "2mb" }));
app.use("/api/ai-tools", require("../routes/ai-tools"));
server = http.createServer(app);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${server.address().port}/api/ai-tools`;
});
after(() => {
server?.close();
aiTools.__setCatalogDirs(null);
if (saved.home === undefined) delete process.env.HOME;
else process.env.HOME = saved.home;
if (saved.data === undefined) delete process.env.DASHBOARD_DATA_DIR;
else process.env.DASHBOARD_DATA_DIR = saved.data;
fs.rmSync(root, { recursive: true, force: true });
});
describe("catalog `config` field", () => {
it("is honoured only in a trusted file, and never for a credential or an escaping path", () => {
assert.equal(aiTools.has("gem"), true);
assert.equal(aiTools.has("untrusted"), false);
assert.equal(aiTools.has("leaky"), false);
assert.equal(aiTools.has("escape"), false);
});
});
describe("listConfig / readConfig / writeConfig", () => {
it("lists the declared files with their format and whether they exist", () => {
const files = cfg.listConfig("gem");
assert.deepEqual(
files.map((f) => [f.index, f.format, f.exists]),
[
[0, "json", true],
[1, "jsonc", true],
[2, "text", true],
[3, "json", false],
]
);
assert.equal(files[0].path, path.join(home, ".gem/settings.json"));
});
it("reads an existing file with its sha, and refuses a missing one", () => {
const r = cfg.readConfig("gem", 0);
assert.equal(r.text, '{"theme":"dark"}\n');
assert.match(r.sha, /^[0-9a-f]{40}$/);
assert.throws(() => cfg.readConfig("gem", 3), { code: "ENOENT" });
assert.throws(() => cfg.readConfig("gem", 9), { code: "ENOFILE" });
});
it("refuses invalid JSON and JSONC, accepts JSONC comments and trailing commas", () => {
const { sha } = cfg.readConfig("gem", 0);
assert.throws(() => cfg.writeConfig("gem", 0, "{nope", sha), { code: "EBADSYNTAX" });
const c = cfg.readConfig("gem", 1);
cfg.writeConfig("gem", 1, '{\n // keep\n "a": 2, /* x */\n}\n', c.sha);
assert.throws(() => cfg.writeConfig("gem", 1, '{"a": }', cfg.readConfig("gem", 1).sha), {
code: "EBADSYNTAX",
});
});
it("saves over the sha it was opened at, backs up the old text and keeps the mode", () => {
const { sha } = cfg.readConfig("gem", 0);
const out = cfg.writeConfig("gem", 0, '{"theme":"light"}\n', sha);
const file = path.join(home, ".gem/settings.json");
assert.equal(fs.readFileSync(file, "utf8"), '{"theme":"light"}\n');
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
assert.equal(fs.readFileSync(out.backup, "utf8"), '{"theme":"dark"}\n');
assert.ok(out.backup.startsWith(data));
assert.throws(() => cfg.writeConfig("gem", 0, "{}", sha), { code: "ESTALE" });
});
it("refuses a file that is a symlink to a credential, and a binary file", () => {
const md = path.join(home, ".gem/GEMINI.md");
const secret = path.join(home, ".gem/oauth_creds.json");
fs.writeFileSync(secret, '{"t":1}');
fs.rmSync(md);
fs.symlinkSync(secret, md);
assert.throws(() => cfg.readConfig("gem", 2), { code: "ESECRET" });
fs.rmSync(md);
fs.writeFileSync(md, Buffer.from([0x23, 0x00, 0x01]));
assert.throws(() => cfg.readConfig("gem", 2), { code: "EBINARY" });
fs.writeFileSync(md, "# rules\n");
});
});
describe("routes", () => {
it("lists, reads and saves through /:id/config, answering structured errors", async () => {
const list = await (await fetch(`${base}/gem/config`)).json();
assert.equal(list.files.length, 4);
const r = await (await fetch(`${base}/gem/config/2`)).json();
assert.equal(r.text, "# rules\n");
const put = await fetch(`${base}/gem/config/2`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "# new\n", baseSha: r.sha }),
});
assert.equal(put.status, 200);
const stale = await fetch(`${base}/gem/config/2`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "# x\n", baseSha: r.sha }),
});
assert.equal(stale.status, 409);
assert.equal((await fetch(`${base}/nope/config`)).status, 404);
assert.equal((await fetch(`${base}/gem/config/3`)).status, 404);
});
it("answers a failed write as a JSON 500 and leaves no temp file", async () => {
const dir = path.join(home, ".gem");
const r = await (await fetch(`${base}/gem/config/0`)).json();
fs.chmodSync(dir, 0o500);
try {
const put = await fetch(`${base}/gem/config/0`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: "{}", baseSha: r.sha }),
});
assert.equal(put.status, 500);
assert.equal((await put.json()).error.code, "EACCES");
} finally {
fs.chmodSync(dir, 0o700);
}
assert.deepEqual(
fs.readdirSync(dir).filter((n) => n.includes(".agenthub-")),
[]
);
});
it("reports a tool's session count and cost", async () => {
const { db } = require("../db");
db.prepare(
"INSERT INTO sessions (id, status, started_at, tool) VALUES ('s1', 'completed', ?, 'gem')"
).run(new Date().toISOString());
const u = await (await fetch(`${base}/gem/usage`)).json();
assert.equal(u.sessions, 1);
assert.equal(typeof u.cost, "number");
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* @file Tests for server/lib/ai-tool-models.js and the model plumbing around
* it: each tool's model list comes from that tool's own source (Codex's
* models cache, Claude's aliases named after the newest priced version, a
* catalog tool's vendor rows plus the models its sessions used), a model id is
* validated before it reaches an argv, the catalog `model` field is checked,
* and the per-tool default model persists.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tool-models-"));
const builtin = path.join(root, "builtin");
const codexHome = path.join(root, "codex");
const data = path.join(root, "data");
for (const d of [builtin, codexHome, data]) fs.mkdirSync(d);
const saved = {
data: process.env.DASHBOARD_DATA_DIR,
codex: process.env.CODEX_HOME,
db: process.env.DASHBOARD_DB_PATH,
};
process.env.DASHBOARD_DATA_DIR = data;
process.env.CODEX_HOME = codexHome;
process.env.DASHBOARD_DB_PATH = path.join(root, "test.db");
const { db } = require("../db");
const aiTools = require("../lib/ai-tools");
const { listModels } = require("../lib/ai-tool-models");
const write = (name, doc) => fs.writeFileSync(path.join(builtin, name), JSON.stringify(doc));
before(() => {
write("claude.json", {
id: "claude",
label: "Claude Code",
detect: ["claude"],
launcher: "claude",
});
write("codex.json", { id: "codex", label: "Codex", detect: ["codex"], launcher: "codex" });
write("gemini.json", {
id: "gemini",
label: "Gemini",
detect: ["gemini"],
prompt: { mode: "argv" },
model: { flag: "--model", vendor: "google" },
});
write("plain.json", { id: "plain", label: "Plain", detect: ["plain"], prompt: { mode: "argv" } });
write("badflag.json", {
id: "badflag",
label: "Bad",
detect: ["badflag"],
prompt: { mode: "argv" },
model: { flag: "rm -rf" },
});
aiTools.__setCatalogDirs([builtin, path.join(root, "none")]);
const price = db.prepare(
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok) VALUES (?, ?, 1)"
);
db.prepare("DELETE FROM model_pricing").run();
price.run("claude-sonnet-5-5%", "Claude Sonnet 5.5");
price.run("claude-sonnet-5%", "Claude Sonnet 5");
price.run("claude-opus-5-5%", "Claude Opus 5.5");
price.run("gemini-3.8-flash%", "Gemini 3.8 Flash");
price.run("gpt-6-sol%", "GPT-6 Sol");
db.prepare(
"INSERT INTO sessions (id, status, started_at, tool) VALUES ('g1', 'completed', ?, 'gemini')"
).run(new Date().toISOString());
db.prepare("INSERT INTO token_usage (session_id, model) VALUES ('g1', 'gemini-3.1-pro')").run();
});
after(() => {
aiTools.__setCatalogDirs(null);
for (const [k, env] of [
["data", "DASHBOARD_DATA_DIR"],
["codex", "CODEX_HOME"],
["db", "DASHBOARD_DB_PATH"],
]) {
if (saved[k] === undefined) delete process.env[env];
else process.env[env] = saved[k];
}
fs.rmSync(root, { recursive: true, force: true });
});
describe("listModels", () => {
it("reads Codex's own models cache, listed ones only, and nothing else from it", () => {
fs.writeFileSync(
path.join(codexHome, "models_cache.json"),
JSON.stringify({
identity: { email: "someone@example.com" },
models: [
{ slug: "gpt-5.5", display_name: "GPT-5.5", visibility: "list", priority: 12 },
{ slug: "gpt-hidden", display_name: "Hidden", visibility: "hide", priority: 1 },
{
slug: "gpt-6-sol",
display_name: "GPT-6-Sol",
description: "Top",
visibility: "list",
priority: 2,
},
],
})
);
const out = listModels("codex", db);
assert.equal(out.flag, true);
assert.deepEqual(
out.models.map((m) => m.id),
["gpt-6-sol", "gpt-5.5"]
);
assert.equal(out.models[0].label, "GPT-6-Sol");
assert.equal(out.models[0].hint, "Top");
assert.doesNotMatch(JSON.stringify(out), /someone@example\.com/);
});
it("answers an empty Codex list when the cache is missing or unreadable", () => {
fs.writeFileSync(path.join(codexHome, "models_cache.json"), "{nope");
assert.deepEqual(listModels("codex", db).models, []);
});
it("names Claude's aliases after the newest priced version and lists the versions", () => {
const out = listModels("claude", db);
const sonnet = out.models.find((m) => m.id === "sonnet");
assert.match(sonnet.label, /5\.5/);
const ids = out.models.map((m) => m.id);
assert.ok(ids.includes("claude-sonnet-5-5"));
assert.ok(ids.includes("claude-opus-5-5"));
assert.ok(ids.indexOf("claude-sonnet-5-5") < ids.indexOf("claude-sonnet-5"));
});
it("gives a catalog tool its vendor's priced models plus those its sessions used", () => {
const out = listModels("gemini", db);
assert.equal(out.flag, true);
const ids = out.models.map((m) => m.id);
assert.ok(ids.includes("gemini-3.8-flash"));
assert.ok(ids.includes("gemini-3.1-pro"));
assert.ok(!ids.includes("gpt-6-sol"));
});
it("offers no picker to a tool whose catalog declares no model flag", () => {
assert.deepEqual(listModels("plain", db), { flag: false, models: [] });
assert.equal(aiTools.has("badflag"), false);
});
});
describe("model ids and the default model", () => {
it("accepts real model ids and refuses anything flag- or shell-shaped", () => {
for (const ok of [
"opus",
"opus[1m]",
"claude-sonnet-5-5",
"openai/gpt-5.5",
"gemini-3.1-pro",
]) {
assert.equal(aiTools.validModel(ok), true, ok);
}
for (const bad of [
"-p",
"--dangerously-skip-permissions",
"a b",
"x;rm",
"",
"a".repeat(200),
]) {
assert.equal(aiTools.validModel(bad), false, bad);
}
});
it("persists a default model per tool next to enable/default, and clears it", () => {
aiTools.setDefaultModel("gemini", "gemini-3.8-flash");
aiTools.setEnabled("gemini", false);
aiTools.setEnabled("gemini", true);
assert.equal(
aiTools.view().tools.find((t) => t.id === "gemini").defaultModel,
"gemini-3.8-flash"
);
assert.equal(aiTools.view().tools.find((t) => t.id === "gemini").modelFlag, true);
assert.throws(() => aiTools.setDefaultModel("gemini", "--x"), { code: "EBADMODEL" });
aiTools.setDefaultModel("gemini", null);
assert.equal(aiTools.view().tools.find((t) => t.id === "gemini").defaultModel, null);
});
});
+19
View File
@@ -82,6 +82,25 @@ describe("/api/ai-tools", () => {
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400); assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
}); });
it("answers a tool's model choices and keeps its default model", async () => {
let r = await request("GET", "/api/ai-tools/antigravity/models");
assert.equal(r.status, 200);
assert.equal(r.body.flag, true);
assert.equal(r.body.defaultModel, null);
r = await request("PUT", "/api/ai-tools/antigravity", { defaultModel: "gemini-3.1-pro" });
assert.equal(r.status, 200);
assert.equal(r.body.tools.find((t) => t.id === "antigravity").defaultModel, "gemini-3.1-pro");
r = await request("GET", "/api/ai-tools/antigravity/models");
assert.equal(r.body.defaultModel, "gemini-3.1-pro");
assert.equal(
(await request("PUT", "/api/ai-tools/antigravity", { defaultModel: "-x" })).status,
400
);
r = await request("PUT", "/api/ai-tools/antigravity", { defaultModel: "" });
assert.equal(r.body.tools.find((t) => t.id === "antigravity").defaultModel, null);
assert.equal((await request("GET", "/api/ai-tools/nope/models")).status, 404);
});
it("refuses a cross-origin write", async () => { it("refuses a cross-origin write", async () => {
const r = await request( const r = await request(
"PUT", "PUT",
+4
View File
@@ -196,6 +196,10 @@ describe("enable / default", () => {
resume: false, resume: false,
bypass: false, bypass: false,
routerCapable: false, routerCapable: false,
modelFlag: false,
defaultModel: null,
modelVendor: null,
configFiles: 0,
install: null, install: null,
update: null, update: null,
}); });
@@ -0,0 +1,42 @@
/**
* @file Two dashboards started at the same instant on a FRESH database must
* both come up: server/db.js runs its probe-then-ALTER migrations under one
* cross-process lock, so the second waits and then finds every column already
* there instead of dying on "duplicate column name".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const DB_MODULE = path.join(__dirname, "..", "db.js");
function openDb(dbPath, dataDir) {
return new Promise((resolve) => {
const child = spawn(process.execPath, ["-e", `require(${JSON.stringify(DB_MODULE)})`], {
env: { ...process.env, DASHBOARD_DB_PATH: dbPath, DASHBOARD_DATA_DIR: dataDir },
stdio: ["ignore", "ignore", "pipe"],
});
let err = "";
child.stderr.on("data", (d) => (err += d));
child.on("exit", (code) => resolve({ code, err }));
});
}
it("lets several processes open one fresh database at once", async () => {
for (let round = 0; round < 3; round++) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "db-concurrent-"));
try {
const results = await Promise.all(
Array.from({ length: 4 }, () => openDb(path.join(dir, "dashboard.db"), dir))
);
for (const r of results) assert.equal(r.code, 0, r.err.split("\n").slice(0, 6).join("\n"));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
});
+84
View File
@@ -344,6 +344,90 @@ describe("/api/lanes", () => {
}); });
}); });
describe("liveness of a just-started run", () => {
// A new run keeps the lane's PREVIOUS session_id until the new session's
// first hook rebinds it, and that session may have been silent for days —
// judged by it alone, a run started a second ago read "dead" until a
// refresh (and stayed so for a resume, whose hooks change no lane field).
it("ages the lane from the newer of its last event and its run's start", async () => {
const tmux = require("../lib/tmux");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-live-start-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
const runId = `agenthub-lane-${id}`;
const old = new Date(Date.now() - 3 * 86400e3).toISOString();
try {
tmux.__setExecImpl(() => "");
db.prepare(
"INSERT INTO sessions (id, status, started_at, cwd) VALUES ('sess-old-run', 'completed', ?, ?)"
).run(old, cwd);
db.prepare(
"INSERT INTO events (session_id, event_type, summary, created_at) VALUES ('sess-old-run', 'Stop', '', ?)"
).run(old);
lanesLib.updateLane(id, { session_id: "sess-old-run", run_id: runId, status: "running" });
const runAt = db.prepare(
"INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, started_at, lane_id) VALUES (?, 'interactive', ?, 'running', ?, ?)"
);
runAt.run(runId, cwd, new Date().toISOString(), id);
const fresh = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(fresh.liveness, "active");
runAt.run(runId, cwd, old, id);
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.liveness, "dead");
// A lane no hook ever binds a session to (a catalog tool, Codex without
// its hooks) has no signal at all, and stays "active" however long it runs.
lanesLib.updateLane(id, { session_id: null });
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.liveness, "active");
} finally {
tmux.__reset();
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("lanes changed by another server on the same DB", () => {
// Hooks go to ONE server per database (the lowest port), so a browser on the
// other one never heard about a lane the hooks moved until it refreshed.
it("re-broadcasts a lane another connection changed, and a removed one", async () => {
const Database = require("better-sqlite3");
const { broadcastForeignLaneChanges } = require("../routes/lanes");
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-foreign-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
const other = new Database(TEST_DB);
try {
broadcastForeignLaneChanges(); // prime what this server has seen
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [], removed: [] });
other
.prepare("UPDATE lanes SET status = 'running', updated_at = ? WHERE id = ?")
.run(new Date(Date.now() + 5).toISOString(), id);
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [id], removed: [] });
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [], removed: [] });
other.prepare("DELETE FROM lanes WHERE id = ?").run(id);
assert.deepEqual(broadcastForeignLaneChanges(), { changed: [], removed: [id] });
} finally {
other.close();
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("lane start input", () => {
it("answers 400 for a model that is not a plain model id", async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-model-"));
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
try {
const r = await request("POST", `/api/lanes/${id}/start`, { model: "--dangerous" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADMODEL");
} finally {
fs.rmSync(cwd, { recursive: true, force: true });
}
});
});
describe("hook → lane binding", () => { describe("hook → lane binding", () => {
it("binds a session to the lane owning its cwd and flags/clears needs_action", async () => { it("binds a session to the lane owning its cwd and flags/clears needs_action", async () => {
const created = await request("POST", "/api/lanes", { const created = await request("POST", "/api/lanes", {
+194 -14
View File
@@ -1,8 +1,10 @@
/** /**
* @file Tests for the automatic pricing sync: a model seen in token_usage with * @file Tests for the automatic pricing sync: a model seen in token_usage with
* no row of its own gets one from the price list, an edited row is never * no row of its own gets one from the price list, a current model gets one
* overwritten, a bad list or a failed fetch changes nothing, and an OpenAI * before it is used, the sync's own rows follow a rate change while a user's
* model (a Codex session's) is priced from its `openai` entry too. * row and a deleted pattern are never touched, a bad list or a failed fetch
* changes nothing, and an OpenAI model (a Codex session's) is priced from its
* `openai` entry too.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -23,6 +25,7 @@ const {
toRow, toRow,
_resetCache, _resetCache,
_setCache, _setCache,
isCurrent,
} = require("../lib/pricing-sync"); } = require("../lib/pricing-sync");
// The real LiteLLM entry for Opus 5.5, trimmed. // The real LiteLLM entry for Opus 5.5, trimmed.
@@ -52,11 +55,20 @@ beforeEach(() => {
_resetCache(); _resetCache();
db.prepare("DELETE FROM token_usage").run(); db.prepare("DELETE FROM token_usage").run();
db.prepare( db.prepare(
"DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%' OR model_pattern LIKE 'gpt-9%'" "DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%' OR model_pattern LIKE 'gpt-9%' OR model_pattern LIKE 'gemini-9%' OR model_pattern LIKE 'gpt-4o%'"
).run(); ).run();
db.prepare("DELETE FROM model_pricing_dismissed").run();
}); });
describe("pricing sync", () => { describe("pricing sync", () => {
it("never adds a retired Claude model in advance, only once it is used", () => {
const today = "2026-10-01";
for (const k of ["claude-opus-4-1", "claude-opus-4", "claude-sonnet-4"]) {
assert.equal(isCurrent(k, { mode: "chat" }, today), false, k);
}
assert.equal(isCurrent("claude-opus-4-5", { mode: "chat" }, today), true);
});
it("reduces an id to its family and names it", () => { it("reduces an id to its family and names it", () => {
assert.equal(familyOf("claude-opus-5-5-20260101"), "claude-opus-5-5"); assert.equal(familyOf("claude-opus-5-5-20260101"), "claude-opus-5-5");
assert.equal(familyOf("gpt-5.6-sol"), "gpt-5.6-sol"); assert.equal(familyOf("gpt-5.6-sol"), "gpt-5.6-sol");
@@ -110,22 +122,137 @@ describe("pricing sync", () => {
assert.deepEqual([r.fast_input_per_mtok, r.fast_output_per_mtok], [8, 40]); assert.deepEqual([r.fast_input_per_mtok, r.fast_output_per_mtok], [8, 40]);
}); });
it("never overwrites a row that exists, and fetches nothing when none is missing", async () => { it("never overwrites a row the user priced, and fetches once a day", async () => {
db.prepare( db.prepare(
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok) VALUES ('claude-opus-9-9%', 'Mine', 1)" "INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, source) VALUES ('claude-opus-9-9%', 'Mine', 1, 'user')"
).run(); ).run();
useModel("claude-opus-9-9"); useModel("claude-opus-9-9");
let fetched = false; let calls = 0;
await syncPricing(db, { const counting = async () => {
fetchList: async () => { calls += 1;
fetched = true; return LIST;
return LIST; };
}, await syncPricing(db, { fetchList: counting });
}); await syncPricing(db, { fetchList: counting, now: Date.now() + 60 * 60_000 });
assert.equal(fetched, false); assert.equal(calls, 1);
await syncPricing(db, { fetchList: counting, now: Date.now() + 25 * 60 * 60_000 });
assert.equal(calls, 2);
assert.equal(row("claude-opus-9-9%").input_per_mtok, 1); assert.equal(row("claude-opus-9-9%").input_per_mtok, 1);
}); });
it("adds a current model before it is ever used, and skips old or non-chat ones", async () => {
const chat = (extra = {}) => ({
litellm_provider: "openai",
mode: "chat",
input_cost_per_token: 1e-6,
output_cost_per_token: 8e-6,
...extra,
});
const list = {
"claude-opus-9-9": { ...LIST["claude-opus-9-9"], mode: "chat" },
"gpt-9.1": chat(),
"gpt-9.1-audio": chat(),
"gpt-9.1-2026-01-01": chat(),
"gpt-9.2": chat({ mode: "embedding" }),
"gpt-9.3": chat({ deprecation_date: "2020-01-01" }),
"gpt-4o": chat(),
"gemini-9.1-pro": chat({ litellm_provider: "gemini" }),
};
const added = await syncPricing(db, { fetchList: async () => list });
assert.deepEqual(added.sort(), ["claude-opus-9-9%", "gemini-9.1-pro%", "gpt-9.1%"]);
assert.equal(row("gpt-9.1%").source, "sync");
assert.equal(row("gpt-4o%"), undefined);
});
it("follows a price change on its own rows, never on the user's", async () => {
const entry = (input) => ({
litellm_provider: "openai",
mode: "chat",
input_cost_per_token: input,
output_cost_per_token: 8e-6,
});
await syncPricing(db, { fetchList: async () => ({ "gpt-9.1": entry(1e-6) }) });
assert.equal(row("gpt-9.1%").input_per_mtok, 1);
db.prepare(
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, source) VALUES ('gpt-9.2%', 'Mine', 7, 8, 'user')"
).run();
_resetCache();
await syncPricing(db, {
fetchList: async () => ({ "gpt-9.1": entry(2e-6), "gpt-9.2": entry(2e-6) }),
});
assert.equal(row("gpt-9.1%").input_per_mtok, 2);
assert.equal(row("gpt-9.2%").input_per_mtok, 7);
});
it("adopts a row from before the marker only while it still equals the list", async () => {
const entry = {
litellm_provider: "openai",
mode: "chat",
input_cost_per_token: 1e-6,
output_cost_per_token: 8e-6,
};
const legacy = db.prepare(
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok) VALUES (?, ?, ?, ?, ?)"
);
legacy.run("gpt-9.1%", "GPT-9.1", 1, 8, 0.1); // what the old sync inserted
legacy.run("gpt-9.2%", "Edited", 5, 8, 0.1); // edited since
await syncPricing(db, { fetchList: async () => ({ "gpt-9.1": entry, "gpt-9.2": entry }) });
assert.equal(row("gpt-9.1%").source, "sync");
assert.equal(row("gpt-9.2%").source, null);
assert.equal(row("gpt-9.2%").input_per_mtok, 5);
});
it("keeps a deleted row deleted", async () => {
db.prepare("INSERT INTO model_pricing_dismissed (model_pattern) VALUES ('gpt-9.1%')").run();
useModel("gpt-9.1");
const list = {
"gpt-9.1": {
litellm_provider: "openai",
mode: "chat",
input_cost_per_token: 1e-6,
output_cost_per_token: 8e-6,
},
};
assert.deepEqual(await syncPricing(db, { fetchList: async () => list }), []);
assert.equal(row("gpt-9.1%"), undefined);
});
it("PUT marks a row the user's, DELETE dismisses it, reset forgets the dismissal", async () => {
const express = require("express");
const http = require("node:http");
const app = express();
app.use(express.json());
app.use("/api/pricing", require("../routes/pricing"));
app.use("/api/settings", require("../routes/settings"));
const server = http.createServer(app);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const base = `http://127.0.0.1:${server.address().port}`;
try {
db.prepare(
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, source) VALUES ('gpt-9.1%', 'GPT-9.1', 1, 'sync')"
).run();
await fetch(`${base}/api/pricing`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model_pattern: "gpt-9.1%",
display_name: "GPT-9.1",
input_per_mtok: 3,
output_per_mtok: 9,
}),
});
assert.equal(row("gpt-9.1%").source, "user");
await fetch(`${base}/api/pricing/${encodeURIComponent("gpt-9.1%")}`, { method: "DELETE" });
assert.ok(
db.prepare("SELECT 1 FROM model_pricing_dismissed WHERE model_pattern = 'gpt-9.1%'").get()
);
await fetch(`${base}/api/settings/reset-pricing`, { method: "POST" });
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM model_pricing_dismissed").get().n, 0);
} finally {
server.close();
}
});
it("skips absurd rates and survives a failed fetch", async () => { it("skips absurd rates and survives a failed fetch", async () => {
useModel("claude-opus-9-9"); useModel("claude-opus-9-9");
const bad = { "claude-opus-9-9": { ...LIST["claude-opus-9-9"], input_cost_per_token: 5 } }; const bad = { "claude-opus-9-9": { ...LIST["claude-opus-9-9"], input_cost_per_token: 5 } };
@@ -215,6 +342,59 @@ describe("pricing sync", () => {
} }
}); });
it("POST /api/settings/reset-pricing re-adds a current model nobody has used", async () => {
const express = require("express");
const http = require("node:http");
const app = express();
app.use(express.json());
app.use("/api/settings", require("../routes/settings"));
const server = http.createServer(app);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const saved = process.env.AGENTHUB_PRICING_SYNC;
process.env.AGENTHUB_PRICING_SYNC = "1";
try {
_setCache({
"gpt-9.7": {
litellm_provider: "openai",
mode: "chat",
input_cost_per_token: 1e-6,
output_cost_per_token: 8e-6,
},
});
// Applied once: the list is cached and current, nothing is missing.
await syncPricing(db);
assert.ok(db.prepare("SELECT 1 FROM model_pricing WHERE model_pattern = 'gpt-9.7%'").get());
const res = await fetch(
`http://127.0.0.1:${server.address().port}/api/settings/reset-pricing`,
{ method: "POST" }
);
const body = await res.json();
assert.ok(body.pricing.some((p) => p.model_pattern === "gpt-9.7%"));
} finally {
if (saved === undefined) delete process.env.AGENTHUB_PRICING_SYNC;
else process.env.AGENTHUB_PRICING_SYNC = saved;
server.close();
}
});
it("does not bring a deleted built-in row back at the next start", () => {
const pattern = db
.prepare(
"SELECT model_pattern FROM model_pricing WHERE model_pattern LIKE 'claude-%' LIMIT 1"
)
.get().model_pattern;
db.prepare("DELETE FROM model_pricing WHERE model_pattern = ?").run(pattern);
db.prepare("INSERT OR IGNORE INTO model_pricing_dismissed (model_pattern) VALUES (?)").run(
pattern
);
require("../db").topUpDefaultPricing();
assert.equal(
db.prepare("SELECT 1 FROM model_pricing WHERE model_pattern = ?").get(pattern),
undefined
);
db.prepare("DELETE FROM model_pricing_dismissed WHERE model_pattern = ?").run(pattern);
});
it("POST /api/settings/reset-pricing with scope claude keeps every other row", async () => { it("POST /api/settings/reset-pricing with scope claude keeps every other row", async () => {
const express = require("express"); const express = require("express");
const http = require("node:http"); const http = require("node:http");
+12 -2
View File
@@ -439,7 +439,7 @@ describe("pty-run", () => {
return i < 0 ? undefined : call[i].slice(key.length + 1); return i < 0 ? undefined : call[i].slice(key.length + 1);
}; };
it("starts antigravity by absolute path, dropping flags it has no use for", () => { it("starts antigravity by absolute path, with its model flag and none it has no use for", () => {
const call = captureNewSession(() => const call = captureNewSession(() =>
pty.spawnRun({ pty.spawnRun({
laneId: 13, laneId: 13,
@@ -451,7 +451,7 @@ describe("pty-run", () => {
initialPrompt: "hello", initialPrompt: "hello",
}) })
); );
assert.deepEqual(paneArgv(call), [at("agy"), "-i", "hello"]); assert.deepEqual(paneArgv(call), [at("agy"), "--model", "opus", "-i", "hello"]);
assert.equal(envOf(call, "PATH"), process.env.PATH); assert.equal(envOf(call, "PATH"), process.env.PATH);
tmux.__reset(); tmux.__reset();
@@ -467,6 +467,16 @@ describe("pty-run", () => {
assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]); assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]);
}); });
it("refuses a flag-shaped model id for any tool, before tmux is asked", () => {
for (const tool of ["claude", "codex", "antigravity"]) {
assert.throws(
() => pty.spawnRun({ laneId: 16, cwd: "/tmp/repo", tool, model: "--yolo" }),
{ code: "EBADMODEL" },
tool
);
}
});
it("builds each prompt mode, with fixed args first", () => { it("builds each prompt mode, with fixed args first", () => {
const argvFor = (tool, extra = {}) => const argvFor = (tool, extra = {}) =>
paneArgv( paneArgv(
+13 -1
View File
@@ -8,6 +8,10 @@
"mode": "flag", "mode": "flag",
"flag": "-i" "flag": "-i"
}, },
"model": {
"flag": "--model",
"vendor": "google"
},
"resume": [ "resume": [
"--continue" "--continue"
], ],
@@ -15,5 +19,13 @@
"--dangerously-skip-permissions" "--dangerously-skip-permissions"
], ],
"homepageUrl": "https://antigravity.google/docs/cli/overview", "homepageUrl": "https://antigravity.google/docs/cli/overview",
"faviconDomain": "antigravity.google" "faviconDomain": "antigravity.google",
"config": [
{
"path": "~/.gemini/GEMINI.md"
},
{
"path": "~/.gemini/antigravity-cli/keybindings.json"
}
]
} }
+9 -1
View File
@@ -7,6 +7,9 @@
"prompt": { "prompt": {
"mode": "argv" "mode": "argv"
}, },
"model": {
"flag": "--model"
},
"bypass": [ "bypass": [
"--yolo" "--yolo"
], ],
@@ -18,5 +21,10 @@
}, },
"update": { "update": {
"kind": "script" "kind": "script"
} },
"config": [
{
"path": "~/.cursor/cli-config.json"
}
]
} }
+14 -1
View File
@@ -8,6 +8,10 @@
"mode": "flag", "mode": "flag",
"flag": "--prompt-interactive" "flag": "--prompt-interactive"
}, },
"model": {
"flag": "--model",
"vendor": "google"
},
"bypass": [ "bypass": [
"--yolo" "--yolo"
], ],
@@ -19,5 +23,14 @@
}, },
"update": { "update": {
"kind": "npm" "kind": "npm"
} },
"config": [
{
"path": "~/.gemini/settings.json",
"format": "jsonc"
},
{
"path": "~/.gemini/GEMINI.md"
}
]
} }
+13 -1
View File
@@ -8,6 +8,9 @@
"mode": "flag", "mode": "flag",
"flag": "--prompt" "flag": "--prompt"
}, },
"model": {
"flag": "--model"
},
"homepageUrl": "https://opencode.ai/docs/cli/", "homepageUrl": "https://opencode.ai/docs/cli/",
"install": { "install": {
"kind": "npm", "kind": "npm",
@@ -15,5 +18,14 @@
}, },
"update": { "update": {
"kind": "npm" "kind": "npm"
} },
"config": [
{
"path": "~/.config/opencode/opencode.json",
"format": "jsonc"
},
{
"path": "~/.config/opencode/opencode.jsonc"
}
]
} }
+13 -1
View File
@@ -8,6 +8,9 @@
"mode": "after-start", "mode": "after-start",
"delayMs": 3000 "delayMs": 3000
}, },
"model": {
"flag": "--model"
},
"bypass": [ "bypass": [
"--approval-mode", "--approval-mode",
"yolo" "yolo"
@@ -20,5 +23,14 @@
}, },
"update": { "update": {
"kind": "npm" "kind": "npm"
} },
"config": [
{
"path": "~/.qwen/settings.json",
"format": "jsonc"
},
{
"path": "~/.qwen/QWEN.md"
}
]
} }
+47 -14
View File
@@ -125,6 +125,18 @@ fs.mkdirSync(DB_DIR, { recursive: true });
// the richest legacy copy found so existing users keep all their history. The // the richest legacy copy found so existing users keep all their history. The
// source files are never modified or deleted, and an existing canonical DB is // source files are never modified or deleted, and an existing canonical DB is
// never overwritten — so this is safe to run on every startup. // never overwritten — so this is safe to run on every startup.
// Every migration below is a check-then-act ("column missing? ALTER it"), and
// two dashboards may start on one database at the same instant: both saw the
// column missing and the second died on "duplicate column name". So the whole
// section runs under a cross-process lock — a write transaction on a separate
// tiny SQLite file (not the main DB: several rebuilds below open their own
// BEGIN and turn foreign_keys off, which a surrounding transaction would
// break). The second process waits on it, then finds every probe satisfied;
// a process that dies holding it releases it with its file handle.
const migrationLock = new Database(`${DB_PATH}-migrate.lock`);
migrationLock.exec("PRAGMA busy_timeout = 120000");
migrationLock.exec("BEGIN IMMEDIATE");
migrateLegacyDatabase(DB_PATH); migrateLegacyDatabase(DB_PATH);
const db = new Database(DB_PATH); const db = new Database(DB_PATH);
@@ -835,6 +847,20 @@ try {
db.prepare("ALTER TABLE model_pricing ADD COLUMN intro_until TEXT").run(); db.prepare("ALTER TABLE model_pricing ADD COLUMN intro_until TEXT").run();
} }
// Migrate: who owns a pricing row. 'sync' = inserted by the automatic sync
// (server/lib/pricing-sync.js), which keeps it at the published rate; 'user' =
// written through PUT /api/pricing, never touched again; NULL = a row from
// before this column (a seed, an old sync insert or an old edit — the sync
// adopts one only while its rates still equal the list exactly). A deleted
// row's pattern goes to model_pricing_dismissed so the sync does not bring it
// back; a reset forgets the dismissals it covers.
try {
db.prepare("SELECT source FROM model_pricing LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE model_pricing ADD COLUMN source TEXT").run();
}
db.exec("CREATE TABLE IF NOT EXISTS model_pricing_dismissed (model_pattern TEXT PRIMARY KEY)");
// Default model pricing — shared by initial seed + startup top-up + reset endpoint // Default model pricing — shared by initial seed + startup top-up + reset endpoint
// Columns: pattern, display_name, input, output, cache_read (hits & refreshes), // Columns: pattern, display_name, input, output, cache_read (hits & refreshes),
// cache_write (5m ephemeral writes), cache_write_1h (1h ephemeral writes), // cache_write (5m ephemeral writes), cache_write_1h (1h ephemeral writes),
@@ -869,16 +895,8 @@ const DEFAULT_PRICING = [
// Once-only (guarded by `user_version`) so a user who deliberately re-adds one // Once-only (guarded by `user_version`) so a user who deliberately re-adds one
// in Settings keeps it. Pruning is safe — these rows only hold rates; historical // in Settings keeps it. Pruning is safe — these rows only hold rates; historical
// token_usage is untouched. // token_usage is untouched.
const RETIRED_PRICING_PATTERNS = [ // The list lives in pricing-sync.js, which must not add them back either.
"claude-opus-4-1%", const { RETIRED_PRICING_PATTERNS } = require("./lib/pricing-sync");
"claude-opus-4-2%",
"claude-sonnet-4-2%",
"claude-3-7-sonnet%",
"claude-3-5-sonnet%",
"claude-3-5-haiku%",
"claude-3-haiku%",
"claude-3-opus%",
];
// Top-up: insert any default pattern that isn't already present. Preserves // Top-up: insert any default pattern that isn't already present. Preserves
// user edits to existing rows — we only add what's missing, never overwrite. // user edits to existing rows — we only add what's missing, never overwrite.
@@ -903,9 +921,17 @@ const RETIRED_PRICING_PATTERNS = [
db.pragma("user_version = 1"); db.pragma("user_version = 1");
} }
topUpDefaultPricing();
}
/** Insert each default pattern that is neither present nor deleted by the user. */
function topUpDefaultPricing() {
// A pattern the user deleted (model_pricing_dismissed) stays deleted.
const existing = new Set( const existing = new Set(
db db
.prepare("SELECT model_pattern FROM model_pricing") .prepare(
"SELECT model_pattern FROM model_pricing UNION SELECT model_pattern FROM model_pricing_dismissed"
)
.all() .all()
.map((r) => r.model_pattern) .map((r) => r.model_pattern)
); );
@@ -1373,6 +1399,9 @@ db.prepare(
` `
).run(); ).run();
migrationLock.exec("COMMIT");
migrationLock.close();
const stmts = { const stmts = {
getSession: db.prepare("SELECT * FROM sessions WHERE id = ?"), getSession: db.prepare("SELECT * FROM sessions WHERE id = ?"),
listSessions: db.prepare( listSessions: db.prepare(
@@ -1701,8 +1730,8 @@ const stmts = {
listPricing: db.prepare("SELECT * FROM model_pricing ORDER BY display_name ASC"), listPricing: db.prepare("SELECT * FROM model_pricing ORDER BY display_name ASC"),
getPricing: db.prepare("SELECT * FROM model_pricing WHERE model_pattern = ?"), getPricing: db.prepare("SELECT * FROM model_pricing WHERE model_pattern = ?"),
upsertPricing: db.prepare(` upsertPricing: db.prepare(`
INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok, fast_input_per_mtok, fast_output_per_mtok, updated_at) INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok, fast_input_per_mtok, fast_output_per_mtok, source, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'user', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(model_pattern) DO UPDATE SET ON CONFLICT(model_pattern) DO UPDATE SET
display_name = excluded.display_name, display_name = excluded.display_name,
input_per_mtok = excluded.input_per_mtok, input_per_mtok = excluded.input_per_mtok,
@@ -1712,6 +1741,7 @@ const stmts = {
cache_write_1h_per_mtok = excluded.cache_write_1h_per_mtok, cache_write_1h_per_mtok = excluded.cache_write_1h_per_mtok,
fast_input_per_mtok = excluded.fast_input_per_mtok, fast_input_per_mtok = excluded.fast_input_per_mtok,
fast_output_per_mtok = excluded.fast_output_per_mtok, fast_output_per_mtok = excluded.fast_output_per_mtok,
source = 'user',
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
`), `),
// Update ONLY the time-limited introductory rates for an existing row. Kept // Update ONLY the time-limited introductory rates for an existing row. Kept
@@ -1733,6 +1763,9 @@ const stmts = {
WHERE model_pattern = ? WHERE model_pattern = ?
`), `),
deletePricing: db.prepare("DELETE FROM model_pricing WHERE model_pattern = ?"), deletePricing: db.prepare("DELETE FROM model_pricing WHERE model_pattern = ?"),
dismissPricing: db.prepare(
"INSERT OR IGNORE INTO model_pricing_dismissed (model_pattern) VALUES (?)"
),
// Longest pattern wins: `claude-fable-5-1%` and `claude-fable-5%` both match // Longest pattern wins: `claude-fable-5-1%` and `claude-fable-5%` both match
// `claude-fable-5-1`, and without an explicit order the winner is whichever // `claude-fable-5-1`, and without an explicit order the winner is whichever
// row happened to be inserted first. Most specific pattern is the right one. // row happened to be inserted first. Most specific pattern is the right one.
@@ -1977,4 +2010,4 @@ const stmts = {
), ),
}; };
module.exports = { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing }; module.exports = { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing, topUpDefaultPricing };
+15
View File
@@ -548,6 +548,21 @@ function startBackgroundServices() {
}, HEAL_MS); }, HEAL_MS);
if (healTimer.unref) healTimer.unref(); if (healTimer.unref) healTimer.unref();
} }
// Another dashboard on the same database (hooks reach only one of them)
// moving a lane: re-broadcast it here. One pragma per beat when nothing
// changed. Off with DASHBOARD_LANE_SYNC_MS=0.
const LANE_SYNC_MS = Number(process.env.DASHBOARD_LANE_SYNC_MS ?? 2_000);
if (Number.isFinite(LANE_SYNC_MS) && LANE_SYNC_MS > 0) {
const { broadcastForeignLaneChanges } = require("./routes/lanes");
const laneSyncTimer = setInterval(() => {
try {
broadcastForeignLaneChanges();
} catch (err) {
console.warn("lane sync failed:", err.message);
}
}, LANE_SYNC_MS);
if (laneSyncTimer.unref) laneSyncTimer.unref();
}
// Continuous discovery of sessions under ~/.claude/projects. The one-time // Continuous discovery of sessions under ~/.claude/projects. The one-time
// legacy backfill above runs only once (marker-gated), so a project added // legacy backfill above runs only once (marker-gated), so a project added
// later whose sessions never flow through hooks would otherwise stay invisible // later whose sessions never flow through hooks would otherwise stay invisible
+151
View File
@@ -0,0 +1,151 @@
/**
* @file A catalog tool's own config files, viewed and edited from its
* Settings tab. The files are ONLY those a trusted catalog file lists in
* `config` (built-in or the synced release copy — never a request, never
* `DASHBOARD_AI_TOOLS_DIR`), under the user's home. Only an existing regular
* text file ≤ 1 MB is read or written, and never one whose name (or whose
* symlink target's name) looks like a credential. A save must match the sha
* the editor opened (`ESTALE`), is syntax-checked for JSON/JSONC, backs the
* old text up under `<data dir>/ai-tool-config-backups/<id>/` (newest 10
* kept), and replaces the file atomically keeping its mode.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const aiTools = require("./ai-tools");
const { getDataDir } = require("./claude-home");
const MAX_BYTES = 1024 * 1024;
const KEEP_BACKUPS = 10;
const SECRET_RE = aiTools.CONFIG_SECRET_RE;
function fail(code, message) {
return Object.assign(new Error(message), { code });
}
const sha = (buf) => crypto.createHash("sha1").update(buf).digest("hex");
const expand = (p) => path.join(os.homedir(), p.slice(2));
/** Declared files with where they live now and whether they exist. */
function listConfig(id) {
const t = aiTools.get(id);
if (!t) throw fail("ENOTOOL", "unknown AI tool");
return t.config.map((c, index) => {
const full = expand(c.path);
let st = null;
try {
st = fs.statSync(full);
} catch {
/* missing */
}
return {
index,
path: full,
format: c.format,
exists: Boolean(st?.isFile()),
size: st?.isFile() ? st.size : null,
};
});
}
/** The declared file at `index`, resolved through any symlink and checked. */
function target(id, index) {
const files = listConfig(id);
const f = Number.isInteger(index) ? files[index] : undefined;
if (!f) throw fail("ENOFILE", "no such config file for this tool");
let real;
try {
real = fs.realpathSync(f.path);
} catch {
throw fail("ENOENT", "the file does not exist");
}
if (SECRET_RE.test(path.basename(real))) throw fail("ESECRET", "refusing a credential file");
const st = fs.statSync(real);
if (!st.isFile()) throw fail("ENOENT", "not a regular file");
if (st.size > MAX_BYTES) throw fail("ETOOBIG", "the file is larger than 1 MB");
return { ...f, real, mode: st.mode & 0o777 };
}
function readConfig(id, index) {
const f = target(id, index);
const buf = fs.readFileSync(f.real);
if (buf.includes(0)) throw fail("EBINARY", "not a text file");
return { path: f.path, format: f.format, text: buf.toString("utf8"), sha: sha(buf) };
}
/** JSONC → JSON: drop comments and trailing commas outside strings. */
function stripJsonc(s) {
let out = "";
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === '"') {
const start = i;
for (i++; i < s.length && s[i] !== '"'; i++) if (s[i] === "\\") i++;
out += s.slice(start, i + 1);
} else if (c === "/" && s[i + 1] === "/") {
while (i < s.length && s[i] !== "\n") i++;
out += "\n";
} else if (c === "/" && s[i + 1] === "*") {
const end = s.indexOf("*/", i + 2);
i = end < 0 ? s.length : end + 1;
} else out += c;
}
return out.replace(/,(\s*[}\]])/g, "$1");
}
function checkSyntax(format, text) {
if (format === "text") return;
try {
JSON.parse(format === "jsonc" ? stripJsonc(text) : text);
} catch (err) {
throw fail("EBADSYNTAX", `not valid ${format.toUpperCase()}: ${err.message}`);
}
}
function backup(id, f, old) {
const dir = path.join(getDataDir(), "ai-tool-config-backups", id);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
fs.chmodSync(dir, 0o700); // the backups may hold keys; mode only applies on create
const name = path.basename(f.path);
const file = path.join(dir, `${name}.${new Date().toISOString().replace(/[:.]/g, "-")}.bak`);
fs.writeFileSync(file, old, { mode: 0o600 });
const mine = fs
.readdirSync(dir)
.filter((n) => n.startsWith(`${name}.`) && n.endsWith(".bak"))
.sort();
for (const n of mine.slice(0, -KEEP_BACKUPS)) fs.rmSync(path.join(dir, n), { force: true });
return file;
}
function writeConfig(id, index, text, baseSha) {
if (typeof text !== "string") throw fail("EBADFIELD", "text must be a string");
if (Buffer.byteLength(text) > MAX_BYTES) throw fail("ETOOBIG", "the text is larger than 1 MB");
if (text.includes("\0")) throw fail("EBINARY", "not a text file");
const f = target(id, index);
const old = fs.readFileSync(f.real);
if (old.includes(0)) throw fail("EBINARY", "not a text file");
if (baseSha !== sha(old)) throw fail("ESTALE", "the file changed since it was opened");
checkSyntax(f.format, text);
const saved = backup(id, f, old);
// A random name created exclusively (`wx`): an existing file or symlink at
// that path is refused, never written through.
const tmp = path.join(
path.dirname(f.real),
`.${path.basename(f.real)}.agenthub-${crypto.randomBytes(6).toString("hex")}`
);
fs.writeFileSync(tmp, text, { mode: f.mode, flag: "wx" });
try {
fs.chmodSync(tmp, f.mode);
fs.renameSync(tmp, f.real);
} catch (err) {
fs.rmSync(tmp, { force: true });
throw err;
}
return { sha: sha(Buffer.from(text)), backup: saved };
}
module.exports = { listConfig, readConfig, writeConfig };
+115
View File
@@ -0,0 +1,115 @@
/**
* @file The model choices a run of one AI tool offers, each from that tool's
* own source — never a hardcoded list that goes stale:
* - **codex**: Codex's own `$CODEX_HOME/models_cache.json` (what this account
* and this Codex build can use), `visibility: "list"` only, by `priority`.
* Only slug/name/description are read — the file also holds the account's
* identity, which never leaves this module.
* - **claude**: the CLI's aliases (`claude --model sonnet` follows the newest
* release), each named after the newest version the pricing table knows,
* then every priced `claude-<name>-<version>` to pin one.
* - **a catalog tool with a `model` flag**: its vendor's priced models
* (`model.vendor`) plus every model its own sessions reported.
* - anything else: no picker.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const aiTools = require("./ai-tools");
const { getCodexHome } = require("./codex-home");
const CLAUDE_ALIASES = [
["fable", "Fable"],
["opus", "Opus"],
["sonnet", "Sonnet"],
["haiku", "Haiku"],
];
const VENDOR_PREFIX = { claude: "claude-", openai: "gpt-", google: "gemini-" };
/** `claude-sonnet-5-5` → [5, 5]; null for anything not `claude-<name>-<n>[-<n>]`. */
function claudeVersion(id, name) {
const m = new RegExp(`^claude-${name}-(\\d+)(?:-(\\d+))?$`).exec(id);
return m ? [Number(m[1]), Number(m[2] || 0)] : null;
}
const byVersionDesc = (a, b) => b.v[0] - a.v[0] || b.v[1] - a.v[1];
/** Priced families (`pattern` minus its `%`) that are bare ids, not globs. */
function pricedIds(db) {
return db
.prepare("SELECT model_pattern, display_name FROM model_pricing")
.all()
.filter((r) => /^[a-z0-9][a-z0-9.-]*%$/.test(r.model_pattern))
.map((r) => ({ id: r.model_pattern.slice(0, -1), label: r.display_name }));
}
function codexModels() {
try {
const cache = JSON.parse(
fs.readFileSync(path.join(getCodexHome(), "models_cache.json"), "utf8")
);
return (Array.isArray(cache.models) ? cache.models : [])
.filter((m) => m && m.visibility === "list" && aiTools.validModel(m.slug))
.sort((a, b) => (a.priority ?? 1e9) - (b.priority ?? 1e9))
.map((m) => ({
id: m.slug,
label: typeof m.display_name === "string" ? m.display_name : m.slug,
...(typeof m.description === "string" ? { hint: m.description } : {}),
}));
} catch {
return [];
}
}
function claudeModels(db) {
const priced = pricedIds(db);
const aliases = [];
const pinned = [];
for (const [alias, name] of CLAUDE_ALIASES) {
const versions = priced
.map((p) => ({ ...p, v: claudeVersion(p.id, alias) }))
.filter((p) => p.v)
.sort(byVersionDesc);
const newest = versions[0];
aliases.push({
id: alias,
label: newest ? `${name} (latest · ${newest.v[0]}.${newest.v[1]})` : `${name} (latest)`,
});
pinned.push(...versions.map((p) => ({ id: p.id, label: p.label })));
}
return [...aliases, ...pinned];
}
function catalogModels(tool, db) {
const out = new Map();
const prefix = tool.model.vendor && VENDOR_PREFIX[tool.model.vendor];
if (prefix) {
for (const p of pricedIds(db)) if (p.id.startsWith(prefix)) out.set(p.id, p);
}
const used = db
.prepare(
`SELECT DISTINCT t.model FROM token_usage t JOIN sessions s ON s.id = t.session_id
WHERE s.tool = ?`
)
.all(tool.id);
for (const { model } of used) {
if (aiTools.validModel(model) && !out.has(model)) out.set(model, { id: model, label: model });
}
return [...out.values()].sort((a, b) => b.id.localeCompare(a.id, undefined, { numeric: true }));
}
/**
* @param {string} id AI Tools catalog id
* @param {import("better-sqlite3").Database} db
* @returns {{flag: boolean, models: {id: string, label: string, hint?: string}[]}}
*/
function listModels(id, db) {
const tool = aiTools.get(id);
if (!tool) return { flag: false, models: [] };
if (tool.launcher === "codex") return { flag: true, models: codexModels() };
if (tool.launcher === "claude") return { flag: true, models: claudeModels(db) };
if (tool.model) return { flag: true, models: catalogModels(tool, db) };
return { flag: false, models: [] };
}
module.exports = { listModels };
+80 -2
View File
@@ -103,6 +103,8 @@ function validate(doc, source) {
launcher = doc.launcher; launcher = doc.launcher;
} }
if ((doc.install !== undefined || doc.update !== undefined) && !TRUSTED.has(source)) return null; if ((doc.install !== undefined || doc.update !== undefined) && !TRUSTED.has(source)) return null;
const config = configFiles(doc.config, source);
if (!config) return null;
const fields = installFields(doc); const fields = installFields(doc);
if (!fields) return null; if (!fields) return null;
let prompt = null; let prompt = null;
@@ -119,9 +121,20 @@ function validate(doc, source) {
prompt.delayMs = Number.isFinite(d) && d >= 0 && d <= 60000 ? d : 3000; prompt.delayMs = Number.isFinite(d) && d >= 0 && d <= 60000 ? d : 3000;
} }
} }
// `model`: how a run picks a model on this CLI. Only a flag name and a
// vendor (which pricing rows to offer as choices) — the value itself is
// the user's, validated by validModel() at spawn time.
let model = null;
if (doc.model !== undefined) {
const m = doc.model;
if (!m || typeof m.flag !== "string" || !/^--?[a-z][a-z0-9-]*$/.test(m.flag)) return null;
if (m.vendor !== undefined && !MODEL_VENDORS.has(m.vendor)) return null;
model = { flag: m.flag, vendor: m.vendor || null };
}
return { return {
id: doc.id, id: doc.id,
label: doc.label.trim(), label: doc.label.trim(),
model,
detect: doc.detect, detect: doc.detect,
requires: doc.requires || [], requires: doc.requires || [],
args: doc.args || [], args: doc.args || [],
@@ -137,10 +150,49 @@ function validate(doc, source) {
launcher, launcher,
install: fields.install, install: fields.install,
update: fields.update, update: fields.update,
config,
source, source,
}; };
} }
/** A name no config editor may open: logins, tokens, keys. */
const CONFIG_SECRET_RE =
/(auth|credential|token|secret|oauth|cookie|passw|api[-_]?key|\.env$|\.pem$|\.key$|id_rsa)/i;
const CONFIG_FORMATS = new Set(["json", "jsonc", "text"]);
/**
* `config`: the tool's own config files its Settings tab may edit — like
* `install`, only from a trusted file. Each is `~/`-relative, never climbs
* out with `..`, and never names a credential. Answers [] when absent, null
* when invalid.
*/
function configFiles(raw, source) {
if (raw === undefined) return [];
if (!TRUSTED.has(source) || !Array.isArray(raw) || raw.length > 8) return null;
const out = [];
for (const c of raw) {
const p = c && c.path;
if (typeof p !== "string" || !p.startsWith("~/") || p.includes("\0")) return null;
if (p.split("/").some((seg) => seg === "..") || CONFIG_SECRET_RE.test(path.basename(p)))
return null;
const format = c.format ?? (/\.jsonc$/.test(p) ? "jsonc" : /\.json$/.test(p) ? "json" : "text");
if (!CONFIG_FORMATS.has(format)) return null;
out.push({ path: p, format });
}
return out;
}
const MODEL_VENDORS = new Set(["claude", "openai", "google"]);
/**
* A model id that may follow a model flag as one argv element: a letter or
* digit first (never `-`, so it can not become a flag of its own), then the
* characters real ids use — `opus[1m]`, `openai/gpt-5.5`, `gemini-3.1-pro`.
*/
function validModel(m) {
return typeof m === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/@+[\]-]{0,127}$/.test(m);
}
const RANK = { builtin: 0, remote: 1, custom: 2 }; const RANK = { builtin: 0, remote: 1, custom: 2 };
/** `[dir, source]` pairs, lowest precedence first. */ /** `[dir, source]` pairs, lowest precedence first. */
@@ -249,12 +301,17 @@ const statePath = () => path.join(getDataDir(), "ai-tools.json");
function readState() { function readState() {
try { try {
const s = JSON.parse(fs.readFileSync(statePath(), "utf8")); const s = JSON.parse(fs.readFileSync(statePath(), "utf8"));
const models = {};
if (s.models && typeof s.models === "object") {
for (const [id, m] of Object.entries(s.models)) if (validModel(m)) models[id] = m;
}
return { return {
disabled: Array.isArray(s.disabled) ? s.disabled.filter((x) => typeof x === "string") : [], disabled: Array.isArray(s.disabled) ? s.disabled.filter((x) => typeof x === "string") : [],
default: typeof s.default === "string" ? s.default : null, default: typeof s.default === "string" ? s.default : null,
models,
}; };
} catch { } catch {
return { disabled: [], default: null }; return { disabled: [], default: null, models: {} };
} }
} }
@@ -290,6 +347,18 @@ function setDefault(id) {
writeState({ ...readState(), default: id }); writeState({ ...readState(), default: id });
} }
/** The model a new run of `id` starts with; null clears it (the CLI's own). */
function setDefaultModel(id, model) {
requireTool(id);
if (model !== null && !validModel(model)) {
throw makeErr("EBADMODEL", "not a valid model id");
}
const s = readState();
if (model === null) delete s.models[id];
else s.models[id] = model;
writeState(s);
}
function effectiveDefault() { function effectiveDefault() {
const s = readState(); const s = readState();
const ok = (id) => has(id) && detect(id).installed && !s.disabled.includes(id); const ok = (id) => has(id) && detect(id).installed && !s.disabled.includes(id);
@@ -300,7 +369,8 @@ function effectiveDefault() {
/** The GET /api/ai-tools body — flags only, never the arg arrays. */ /** The GET /api/ai-tools body — flags only, never the arg arrays. */
function view() { function view() {
const disabled = new Set(readState().disabled); const state = readState();
const disabled = new Set(state.disabled);
return { return {
tools: list().map((t) => { tools: list().map((t) => {
const d = detect(t.id); const d = detect(t.id);
@@ -318,6 +388,11 @@ function view() {
resume: !!t.resume, resume: !!t.resume,
bypass: !!(t.bypass || t.bypassEnv), bypass: !!(t.bypass || t.bypassEnv),
routerCapable: ROUTER_CAPABLE.has(t.id), routerCapable: ROUTER_CAPABLE.has(t.id),
// claude/codex take a model through their own argv builders.
modelFlag: Boolean(t.model || t.launcher),
defaultModel: state.models[t.id] || null,
modelVendor: t.model?.vendor || null,
configFiles: t.config.length,
install: t.install, install: t.install,
update: t.update, update: t.update,
}; };
@@ -345,11 +420,14 @@ module.exports = {
isEnabled, isEnabled,
setEnabled, setEnabled,
setDefault, setDefault,
setDefaultModel,
validModel,
effectiveDefault, effectiveDefault,
view, view,
validate, validate,
which, which,
siteOf, siteOf,
ROUTER_CAPABLE, ROUTER_CAPABLE,
CONFIG_SECRET_RE,
__setCatalogDirs, __setCatalogDirs,
}; };
+144 -36
View File
@@ -1,19 +1,19 @@
/** /**
* @file Automatic pricing for Claude, OpenAI (a Codex session's — see * @file Automatic pricing for Claude, OpenAI (a Codex session's — see
* codex-usage.js) and Gemini (an Antigravity session's — antigravity-usage.js) * codex-usage.js) and Gemini (an Antigravity session's — antigravity-usage.js)
* models the dashboard has seen but has no rate row of its own * models, from LiteLLM's public price list fetched at most once a day. A row
* for. Every model id in `token_usage` is reduced to its family (a * (`source = 'sync'`) is INSERTED for every family used in `token_usage` with
* `-YYYYMMDD` / `-YYYY-MM-DD` snapshot suffix dropped); a family with no * no `<family>%` row of its own (a `-YYYYMMDD` / `-YYYY-MM-DD` snapshot suffix
* `<family>%` row is looked up in LiteLLM's public price list and INSERTED — * dropped; "its own row" is the test — Opus 5.5 was once priced as Opus 5 by
* never updated, so a rate the user edited in Settings always wins. "Its own * the broader `claude-opus-5%`) and for every current-generation chat model of
* row" is the test, not "some row matches": Opus 5.5 was silently priced as * the three vendors before anyone uses it (`isCurrent`). The sync's own rows
* Opus 5 by the broader `claude-opus-5%` pattern. * follow a published rate change; a row from before the `source` column is
* * adopted only while it still equals the list; a `'user'` row (edited in
* The list is fetched only when something is missing, at most once per * Settings) and a deleted pattern (`model_pricing_dismissed`) are never
* CACHE_MS. Off with AGENTHUB_PRICING_SYNC=0|false|off. An entry is taken * touched. Off with AGENTHUB_PRICING_SYNC=0|false|off. An entry is taken only
* only from its own vendor (`anthropic` for claude-…, `openai` for gpt-…, * from its own vendor (`anthropic` for claude-…, `openai` for gpt-…, o<n>…,
* o<n>…, codex-…, Vertex AI or the Gemini API for gemini-…); a router's model id (`clinepass/…`, `Highest`) is left to * codex-…, Vertex AI or the Gemini API for gemini-…); a router's model id
* the user. * (`clinepass/…`, `Highest`) is left to the user.
* *
* ponytail: OpenAI's long-context (>272k) and priority/flex tiers are not * ponytail: OpenAI's long-context (>272k) and priority/flex tiers are not
* modelled — Codex's context window stays under 272k and runs on the default * modelled — Codex's context window stays under 272k and runs on the default
@@ -134,23 +134,84 @@ function missingFamilies(db) {
return [...out]; return [...out];
} }
// ponytail: "current" is a fixed generation floor per vendor, not a release
// date — raise MIN_GEN when a generation retires. Anything older still gets a
// row the moment it is used (missingFamilies).
const MIN_GEN = { openai: 5, gemini: 3 };
const NOT_A_CODING_MODEL =
/(audio|search|tts|realtime|transcribe|image|computer-use|robotics|embedding|moderation|-exp-|latest$)/;
/**
* A price-list key worth a row before anyone uses it: a family id itself (no
* snapshot date), a chat model, not deprecated, of the vendor's current
* generation.
*/
/**
* Retired models (Claude 3.x, Opus 4/4.1, Sonnet 4): out of the defaults and
* pruned once from existing DBs by server/db.js. The sync prices one only when
* it is actually used — never as a "current" model added in advance.
*/
const RETIRED_PRICING_PATTERNS = [
"claude-opus-4-1%",
"claude-opus-4-2%",
"claude-sonnet-4-2%",
"claude-3-7-sonnet%",
"claude-3-5-sonnet%",
"claude-3-5-haiku%",
"claude-3-haiku%",
"claude-3-opus%",
];
// `claude-opus-4-2%` is Opus 4's dated ids (claude-opus-4-20250514): family claude-opus-4.
const RETIRED_FAMILIES = new Set(RETIRED_PRICING_PATTERNS.map((p) => p.replace(/(-2)?%$/, "")));
function isCurrent(key, e, today) {
const fam = familyOf(key);
if (fam !== key || !e || !["chat", "responses"].includes(e.mode)) return false;
if (typeof e.deprecation_date === "string" && e.deprecation_date < today) return false;
if (NOT_A_CODING_MODEL.test(fam) || RETIRED_FAMILIES.has(fam)) return false;
const vendor = vendorOf(fam);
if (vendor === "claude") return !/^claude-\d/.test(fam); // claude-3-5-sonnet naming = retired
const m = fam.match(/^(?:gpt|gemini)-(\d+(?:\.\d+)?)/);
return Boolean(m) && Number(m[1]) >= MIN_GEN[vendor];
}
const RATE_COLS = [
["input", "input_per_mtok"],
["output", "output_per_mtok"],
["read", "cache_read_per_mtok"],
["w5m", "cache_write_per_mtok"],
["w1h", "cache_write_1h_per_mtok"],
["fastIn", "fast_input_per_mtok"],
["fastOut", "fast_output_per_mtok"],
];
const sameRates = (row, db) => RATE_COLS.every(([k, col]) => Math.abs(row[k] - db[col]) < 1e-9);
async function defaultFetch() { async function defaultFetch() {
const res = await fetch(PRICE_LIST_URL, { signal: AbortSignal.timeout(20_000) }); const res = await fetch(PRICE_LIST_URL, { signal: AbortSignal.timeout(20_000) });
if (!res.ok) throw new Error(`price list HTTP ${res.status}`); if (!res.ok) throw new Error(`price list HTTP ${res.status}`);
return res.json(); return res.json();
} }
let appliedAt = null; // cached.at of the list last applied
/** /**
* One pass. Returns the patterns inserted. Never throws: pricing is a display * One pass: fetch the list at most once a day, then (1) add a row for every
* concern and a failed fetch must not reach the server's startup path. * used family with none and every current model of the three vendors, (2)
* keep the sync's own rows (`source = 'sync'`) at the published rate, and (3)
* adopt a row from before the `source` column while it still equals the list.
* A user's row (`'user'`) and a dismissed pattern are never touched. Returns
* the patterns inserted. Never throws: pricing is a display concern and a
* failed fetch must not reach the server's startup path.
*/ */
async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } = {}) { async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } = {}) {
try { try {
const missing = missingFamilies(db); const missing = missingFamilies(db);
if (missing.length === 0) return []; const stale = !cached || now - cached.at > CACHE_MS;
if (!cached || now - cached.at > CACHE_MS) { // The Codex poll calls this every 15 s while a session runs: nothing new
// A failed fetch is retried at the sync's own cadence, not on every // to apply and nothing missing is the common answer, and it is free.
// caller (the Codex poll asks every 15 s while a session is active). if (!stale && appliedAt === cached.at && missing.length === 0) return [];
if (stale) {
// A failed fetch is retried at the sync's own cadence, not on every caller.
if (now - failedAt < CHECK_MS) return []; if (now - failedAt < CHECK_MS) return [];
try { try {
cached = { at: now, list: await fetchList() }; cached = { at: now, list: await fetchList() };
@@ -159,29 +220,66 @@ async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } =
throw err; throw err;
} }
} }
const list = cached.list || {};
const today = new Date(now).toISOString().slice(0, 10);
const rows = new Map(
db
.prepare("SELECT * FROM model_pricing")
.all()
.map((r) => [r.model_pattern, r])
);
const dismissed = new Set(
db
.prepare("SELECT model_pattern FROM model_pricing_dismissed")
.all()
.map((r) => r.model_pattern)
);
const families = new Set(missing);
for (const [key, e] of Object.entries(list)) if (isCurrent(key, e, today)) families.add(key);
for (const pattern of rows.keys()) {
const fam = pattern.endsWith("%") ? pattern.slice(0, -1) : null;
if (fam && familyOf(fam) === fam) families.add(fam);
}
const insert = db.prepare( const insert = db.prepare(
`INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok, `INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok,
output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok,
fast_input_per_mtok, fast_output_per_mtok) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` fast_input_per_mtok, fast_output_per_mtok, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'sync')`
);
const update = db.prepare(
`UPDATE model_pricing SET display_name = ?, input_per_mtok = ?, output_per_mtok = ?,
cache_read_per_mtok = ?, cache_write_per_mtok = ?, cache_write_1h_per_mtok = ?,
fast_input_per_mtok = ?, fast_output_per_mtok = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE model_pattern = ? AND source = 'sync'`
);
const adopt = db.prepare(
"UPDATE model_pricing SET source = 'sync' WHERE model_pattern = ? AND source IS NULL"
); );
const added = []; const added = [];
for (const fam of missing) { const changed = [];
const row = toRow(fam, cached.list?.[fam]); db.transaction(() => {
if (!row) continue; for (const fam of families) {
const { changes } = insert.run( const pattern = `${fam}%`;
row.pattern, if (dismissed.has(pattern)) continue;
row.name, const row = toRow(fam, list[fam]);
row.input, if (!row) continue;
row.output, const rates = [row.input, row.output, row.read, row.w5m, row.w1h, row.fastIn, row.fastOut];
row.read, const have = rows.get(pattern);
row.w5m, if (!have) {
row.w1h, if (insert.run(pattern, row.name, ...rates).changes) added.push(pattern);
row.fastIn, } else if (have.source === "sync") {
row.fastOut if (!sameRates(row, have) && update.run(have.display_name, ...rates, pattern).changes) {
); changed.push(pattern);
if (changes) added.push(row.pattern); }
} } else if (have.source == null && !have.intro_until && sameRates(row, have)) {
adopt.run(pattern);
}
}
})();
appliedAt = cached.at;
if (added.length) console.log(`[pricing] added rates for ${added.join(", ")}`); if (added.length) console.log(`[pricing] added rates for ${added.join(", ")}`);
if (changed.length) console.log(`[pricing] updated rates for ${changed.join(", ")}`);
return added; return added;
} catch (err) { } catch (err) {
console.warn("[pricing] sync failed:", err.message); console.warn("[pricing] sync failed:", err.message);
@@ -202,20 +300,30 @@ function startPricingSync(db) {
function _resetCache() { function _resetCache() {
cached = null; cached = null;
failedAt = -Infinity; failedAt = -Infinity;
appliedAt = null;
}
/** Make the next pass re-apply the cached list (after a reset emptied the table). */
function invalidate() {
appliedAt = null;
} }
/** Test seam: pretend the price list was just fetched. */ /** Test seam: pretend the price list was just fetched. */
function _setCache(list, at = Date.now()) { function _setCache(list, at = Date.now()) {
cached = { at, list }; cached = { at, list };
appliedAt = null;
} }
module.exports = { module.exports = {
RETIRED_PRICING_PATTERNS,
syncPricing, syncPricing,
invalidate,
syncEnabled, syncEnabled,
startPricingSync, startPricingSync,
familyOf, familyOf,
displayName, displayName,
toRow, toRow,
isCurrent,
_resetCache, _resetCache,
_setCache, _setCache,
}; };
+10 -2
View File
@@ -147,12 +147,15 @@ function buildCodexArgv({
/** /**
* Any catalog tool without a hand-written launcher: `[bin, ...args, ...resume?, * Any catalog tool without a hand-written launcher: `[bin, ...args, ...resume?,
* ...bypass?, ...prompt]`. Every piece but the prompt comes from the catalog * ...bypass?, ...prompt]` (`[flag, model]` right after the fixed args when the
* catalog declares a model flag). Every piece but the prompt and the
* validated model comes from the catalog
* file, and the prompt is one argv element — or, for `after-start`, nothing * file, and the prompt is one argv element — or, for `after-start`, nothing
* here: spawnRun pastes it into the pane once the CLI is up. * here: spawnRun pastes it into the pane once the CLI is up.
*/ */
function buildCatalogArgv(entry, bin, { resumeLast, bypass, initialPrompt }) { function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt }) {
const argv = [bin, ...entry.args]; const argv = [bin, ...entry.args];
if (model && entry.model) argv.push(entry.model.flag, model);
if (resumeLast && entry.resume) argv.push(...entry.resume); if (resumeLast && entry.resume) argv.push(...entry.resume);
if (bypass && entry.bypass) argv.push(...entry.bypass); if (bypass && entry.bypass) argv.push(...entry.bypass);
const p = entry.prompt; const p = entry.prompt;
@@ -239,6 +242,11 @@ function spawnRun(args) {
`permissionMode must be one of: ${Array.from(ALLOWED_PERMISSION_MODES).join(", ")}` `permissionMode must be one of: ${Array.from(ALLOWED_PERMISSION_MODES).join(", ")}`
); );
} }
// A model is one argv element after a model flag; one starting with `-`
// would be read as a flag of its own.
if (model != null && model !== "" && !aiTools.validModel(model)) {
throw makeErr("EBADMODEL", "model is not a valid model id");
}
if (effort != null && effort !== "" && !EFFORT_LEVELS.has(effort)) { if (effort != null && effort !== "" && !EFFORT_LEVELS.has(effort)) {
throw makeErr("EBADEFFORT", `effort must be one of: ${Array.from(EFFORT_LEVELS).join(", ")}`); throw makeErr("EBADEFFORT", `effort must be one of: ${Array.from(EFFORT_LEVELS).join(", ")}`);
} }
+7
View File
@@ -893,6 +893,13 @@ function createOpenApiSpec() {
type: "number", type: "number",
description: "Fast-mode output rate (0 = none)", description: "Fast-mode output rate (0 = none)",
}, },
source: {
type: "string",
nullable: true,
enum: ["sync", "user", null],
description:
"sync = kept at LiteLLM's published rate by the automatic sync; user = edited through PUT, never touched by the sync; null = from before this field",
},
updated_at: { type: "string", format: "date-time" }, updated_at: { type: "string", format: "date-time" },
}, },
}, },
+101 -12
View File
@@ -4,8 +4,8 @@
* shell, and enable/disable a tool or make it the default. Nothing here takes * shell, and enable/disable a tool or make it the default. Nothing here takes
* a command or an argument from the request — the catalog files are the only * a command or an argument from the request — the catalog files are the only
* source of what a lane may run, or an install/update may execute. Also: start * source of what a lane may run, or an install/update may execute. Also: start
* an install/update job and read it back, npm version badges, and the * an install/update job and read it back, npm version badges, the
* server-cached icons. Writes sit behind the same-origin guard. * server-cached icons, and each tool's model choices + default model. Writes sit behind the same-origin guard.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -17,6 +17,10 @@ const versions = require("../lib/ai-tool-versions");
const { iconFor } = require("../lib/ai-tool-icons"); const { iconFor } = require("../lib/ai-tool-icons");
const { hydrateShellPath } = require("../lib/shell-path"); const { hydrateShellPath } = require("../lib/shell-path");
const { sameOriginGuard } = require("../lib/same-origin"); const { sameOriginGuard } = require("../lib/same-origin");
const { listModels } = require("../lib/ai-tool-models");
const toolConfig = require("../lib/ai-tool-config");
const { calculateCost } = require("./pricing");
const { db, stmts } = require("../db");
const router = express.Router(); const router = express.Router();
@@ -32,23 +36,30 @@ router.post("/refresh", sameOriginGuard, async (_req, res) => {
}); });
router.put("/:id", sameOriginGuard, (req, res) => { router.put("/:id", sameOriginGuard, (req, res) => {
const { enabled, default: makeDefault } = req.body || {}; const { enabled, default: makeDefault, defaultModel } = req.body || {};
// Every field checked before anything is written: a bad one saves nothing.
if (enabled !== undefined && typeof enabled !== "boolean") {
return res
.status(400)
.json({ error: { code: "EBADFIELD", message: "enabled must be a boolean" } });
}
if (defaultModel && !aiTools.validModel(defaultModel)) {
return res.status(400).json({ error: { code: "EBADMODEL", message: "not a model id" } });
}
try { try {
if (enabled !== undefined) { if (defaultModel !== undefined) {
if (typeof enabled !== "boolean") { // "" or null = no default (the CLI's own).
return res aiTools.setDefaultModel(req.params.id, defaultModel || null);
.status(400)
.json({ error: { code: "EBADFIELD", message: "enabled must be a boolean" } });
}
aiTools.setEnabled(req.params.id, enabled);
} }
if (enabled !== undefined) aiTools.setEnabled(req.params.id, enabled);
if (makeDefault === true) aiTools.setDefault(req.params.id); if (makeDefault === true) aiTools.setDefault(req.params.id);
else if (enabled === undefined && !aiTools.has(req.params.id)) { else if (enabled === undefined && defaultModel === undefined && !aiTools.has(req.params.id)) {
return res.status(404).json({ error: { code: "ENOTOOL", message: "unknown AI tool" } }); return res.status(404).json({ error: { code: "ENOTOOL", message: "unknown AI tool" } });
} }
res.json(aiTools.view()); res.json(aiTools.view());
} catch (e) { } catch (e) {
const status = e.code === "ENOTOOL" ? 404 : e.code === "EBADDEFAULT" ? 400 : 500; const status =
e.code === "ENOTOOL" ? 404 : e.code === "EBADDEFAULT" || e.code === "EBADMODEL" ? 400 : 500;
res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } }); res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
} }
}); });
@@ -85,6 +96,84 @@ router.post("/:id/update", sameOriginGuard, (req, res) =>
startJob(res, () => jobs.start(req.params.id, "update")) startJob(res, () => jobs.start(req.params.id, "update"))
); );
// The model choices a run of this tool offers, from the tool's own source
// (server/lib/ai-tool-models.js), plus the default the user picked.
router.get("/:id/models", (req, res) => {
if (!aiTools.has(req.params.id)) {
return res.status(404).json({ error: { code: "ENOTOOL", message: "unknown AI tool" } });
}
res.json({
...listModels(req.params.id, db),
defaultModel: aiTools.readState().models[req.params.id] || null,
});
});
const CONFIG_STATUS = {
ENOTOOL: 404,
ENOFILE: 404,
ENOENT: 404,
ESECRET: 403,
ESTALE: 409,
ETOOBIG: 413,
EBINARY: 415,
EBADSYNTAX: 400,
EBADFIELD: 400,
};
function configRoute(fn) {
return (req, res) => {
try {
res.json(fn(req));
} catch (err) {
// An unexpected failure (EACCES, a full disk) is a JSON 500 too.
res
.status(CONFIG_STATUS[err.code] || 500)
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
}
};
}
const index = (req) => (/^\d+$/.test(req.params.index) ? Number(req.params.index) : -1);
// The tool's own config files, as its trusted catalog file lists them.
router.get(
"/:id/config",
configRoute((req) => ({ files: toolConfig.listConfig(req.params.id) }))
);
router.get(
"/:id/config/:index",
configRoute((req) => toolConfig.readConfig(req.params.id, index(req)))
);
router.put(
"/:id/config/:index",
sameOriginGuard,
configRoute((req) =>
toolConfig.writeConfig(req.params.id, index(req), req.body?.text, req.body?.baseSha)
)
);
// How much a tool has been used: its sessions and their priced tokens.
router.get("/:id/usage", (req, res) => {
if (!aiTools.has(req.params.id)) {
return res.status(404).json({ error: { code: "ENOTOOL", message: "unknown AI tool" } });
}
const id = req.params.id;
const { n } = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE tool = ?").get(id);
const rows = db
.prepare(
`SELECT tu.model AS model, tu.speed AS speed, tu.inference_geo AS inference_geo,
tu.service_tier AS service_tier,
SUM(tu.input_tokens + tu.baseline_input) AS input_tokens,
SUM(tu.output_tokens + tu.baseline_output) AS output_tokens,
SUM(tu.cache_read_tokens + tu.baseline_cache_read) AS cache_read_tokens,
SUM(tu.cache_write_tokens + tu.baseline_cache_write) AS cache_write_tokens,
SUM(tu.cache_write_1h_tokens + tu.baseline_cache_write_1h) AS cache_write_1h_tokens
FROM token_usage tu JOIN sessions s ON s.id = tu.session_id
WHERE s.tool = ?
GROUP BY tu.model, tu.speed, tu.inference_geo, tu.service_tier`
)
.all(id);
res.json({ sessions: n, cost: calculateCost(rows, stmts.listPricing.all()).total_cost });
});
router.get("/job", (_req, res) => { router.get("/job", (_req, res) => {
res.json(jobs.current()); res.json(jobs.current());
}); });
+62 -8
View File
@@ -76,15 +76,29 @@ const MAX_WORKTREE_DIRECTORY_ATTEMPTS = 50;
const LOG_TAIL_DEFAULT = 64 * 1024; const LOG_TAIL_DEFAULT = 64 * 1024;
const LOG_TAIL_MAX = 1024 * 1024; const LOG_TAIL_MAX = 1024 * 1024;
/** Seconds since this lane's session last emitted an event; null if never. */ /**
* Seconds since this lane last showed life: its session's newest event or the
* start of its current run, whichever is newer; null if neither exists. The
* run counts because a new run keeps the PREVIOUS session_id until the new
* session's first hook rebinds it — judged by that old session alone, a run
* started a second ago read "dead" and, with no lane field changing after
* (a resume, or a hook ingested by another server on the same DB), stayed so
* until a refresh. Only with a session bound: a lane no hook ever gives one
* (a catalog tool, Codex without its hooks) has no signal, so stays null.
*/
const lastEventStmt = db.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?");
const runStartStmt = db.prepare("SELECT started_at FROM dashboard_runs WHERE id = ?");
function lastEventAge(lane) { function lastEventAge(lane) {
if (!lane.session_id) return null; if (!lane.session_id) return null;
const row = db const times = [];
.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?") const row = lastEventStmt.get(lane.session_id);
.get(lane.session_id); if (row?.last) times.push(Date.parse(row.last));
if (!row || !row.last) return null; if (lane.run_id) {
const t = Date.parse(row.last); const run = runStartStmt.get(lane.run_id);
return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000)); if (run?.started_at) times.push(Date.parse(run.started_at));
}
const t = Math.max(...times.filter((x) => !Number.isNaN(x)));
return Number.isFinite(t) ? Math.max(0, Math.round((Date.now() - t) / 1000)) : null;
} }
/** /**
@@ -116,6 +130,43 @@ function healAllRunIds() {
for (const lane of lanesLib.listLanes()) if (lane.run_id) healRunId(lane); for (const lane of lanesLib.listLanes()) if (lane.run_id) healRunId(lane);
} }
/**
* Re-broadcast the lanes ANOTHER process changed. Two dashboards may share one
* database, and hooks reach only one of them (the lowest port), so the other's
* browsers never heard about a lane the hooks moved until they refreshed.
* `PRAGMA data_version` moves only on another connection's commit, so the
* common pass is one pragma; after one, each lane whose `updated_at` differs
* from what this pass saw last is broadcast (a lane this server wrote itself
* may go out twice — the client drops an identical copy), and a vanished
* lane goes out as `removed`.
* ponytail: two writes in the same millisecond share an updated_at; a counter
* column would close that.
*/
let seenDataVersion = null;
const seenLanes = new Map();
const laneStampsStmt = db.prepare("SELECT id, updated_at FROM lanes");
function broadcastForeignLaneChanges() {
const out = { changed: [], removed: [] };
const version = db.pragma("data_version", { simple: true });
if (version === seenDataVersion) return out;
const primed = seenDataVersion !== null;
seenDataVersion = version;
const live = new Set();
for (const { id, updated_at } of laneStampsStmt.all()) {
live.add(id);
if (primed && seenLanes.get(id) !== updated_at) out.changed.push(id);
seenLanes.set(id, updated_at);
}
for (const id of [...seenLanes.keys()]) {
if (live.has(id)) continue;
seenLanes.delete(id);
if (primed) out.removed.push(id);
}
for (const id of out.changed) broadcastLane(id);
for (const id of out.removed) broadcast("lane_update", { removed: id });
return out;
}
function payload(lane) { function payload(lane) {
const healed = healRunId(lane); const healed = healRunId(lane);
return lanesLib.lanePayload(healed, lastEventAge(healed)); return lanesLib.lanePayload(healed, lastEventAge(healed));
@@ -1774,7 +1825,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
if (err.code === "ETMUXMISSING") { if (err.code === "ETMUXMISSING") {
return res.status(503).json({ error: { code: err.code, message: err.message } }); return res.status(503).json({ error: { code: err.code, message: err.message } });
} }
return res.status(500).json({ error: { code: err.code, message: err.message } }); // A refused field (EBADMODEL, EBADEFFORT, …) is the request's fault.
const status = /^EBAD/.test(err.code || "") ? 400 : 500;
return res.status(status).json({ error: { code: err.code, message: err.message } });
} }
broadcastLane(lane.id); broadcastLane(lane.id);
@@ -1794,3 +1847,4 @@ router.delete("/:id", sameOriginGuard, (req, res) => {
module.exports = router; module.exports = router;
module.exports.broadcastLane = broadcastLane; module.exports.broadcastLane = broadcastLane;
module.exports.healAllRunIds = healAllRunIds; module.exports.healAllRunIds = healAllRunIds;
module.exports.broadcastForeignLaneChanges = broadcastForeignLaneChanges;
+5 -1
View File
@@ -369,7 +369,11 @@ router.delete("/:pattern", (req, res) => {
.status(404) .status(404)
.json({ error: { code: "NOT_FOUND", message: "Pricing rule not found" } }); .json({ error: { code: "NOT_FOUND", message: "Pricing rule not found" } });
} }
stmts.deletePricing.run(pattern); // Remembered so the automatic sync does not add the row straight back.
db.transaction(() => {
stmts.deletePricing.run(pattern);
stmts.dismissPricing.run(pattern);
})();
res.json({ ok: true }); res.json({ ok: true });
}); });
+5
View File
@@ -228,8 +228,10 @@ const RESET_SYNC_MS = 3000;
router.post("/reset-pricing", async (req, res) => { router.post("/reset-pricing", async (req, res) => {
if (req.body?.scope === "claude") { if (req.body?.scope === "claude") {
db.prepare("DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-%'").run(); db.prepare("DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-%'").run();
db.prepare("DELETE FROM model_pricing_dismissed WHERE model_pattern LIKE 'claude-%'").run();
} else { } else {
db.prepare("DELETE FROM model_pricing").run(); db.prepare("DELETE FROM model_pricing").run();
db.prepare("DELETE FROM model_pricing_dismissed").run();
} }
const seedPricing = db.prepare( const seedPricing = db.prepare(
@@ -242,6 +244,9 @@ router.post("/reset-pricing", async (req, res) => {
// carries standard rates, so without this a reset silently drops the promo. // carries standard rates, so without this a reset silently drops the promo.
applyIntroPricing(db); applyIntroPricing(db);
if (pricingSync.syncEnabled()) { if (pricingSync.syncEnabled()) {
// The rows just deleted include current models nobody has used yet; the
// cached list counts as applied, so without this they wait for tomorrow.
pricingSync.invalidate();
await Promise.race([ await Promise.race([
pricingSync.syncPricing(db), pricingSync.syncPricing(db),
new Promise((r) => setTimeout(r, RESET_SYNC_MS).unref()), new Promise((r) => setTimeout(r, RESET_SYNC_MS).unref()),