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:
@@ -43,7 +43,7 @@ import {
|
||||
RotateCcw,
|
||||
Lock,
|
||||
} 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 {
|
||||
CwdSuggestion,
|
||||
DashboardRunHistoryItem,
|
||||
@@ -409,6 +409,8 @@ export function RunSetup(props: RunSetupProps) {
|
||||
// Per run, never remembered: the form starts unticked every time it mounts.
|
||||
const [bypass, setBypass] = useState(false);
|
||||
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
|
||||
// (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
|
||||
// is shown and the selection is visible.
|
||||
@@ -441,8 +443,8 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
{/* A catalog tool takes no model, permission or effort flag. */}
|
||||
{!catalogTool && (
|
||||
{/* A catalog tool takes no permission or effort flag, and a model only when it declares one. */}
|
||||
{hasModel && (
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
@@ -532,7 +534,8 @@ export function RunSetup(props: RunSetupProps) {
|
||||
// Pass the picked session explicitly: the parent's state
|
||||
// update has not landed yet on this tick, so reading
|
||||
// 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
|
||||
// an unfiltered picker lists every session from every repo.
|
||||
@@ -559,7 +562,9 @@ export function RunSetup(props: RunSetupProps) {
|
||||
<PromptEditor
|
||||
value={props.prompt}
|
||||
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) })}
|
||||
rows={5}
|
||||
slash={props.tool === "claude"}
|
||||
@@ -615,7 +620,9 @@ export function RunSetup(props: RunSetupProps) {
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleStart(props, undefined, canBypass && bypass)}
|
||||
onClick={() =>
|
||||
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel)
|
||||
}
|
||||
disabled={
|
||||
!props.binaryFound ||
|
||||
// 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
|
||||
* 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 codex = props.tool === "codex";
|
||||
// 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
|
||||
// cwd field shows, so it's what gets sent.
|
||||
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,
|
||||
sandbox: codex ? props.sandbox : undefined,
|
||||
approval: codex ? props.approval : undefined,
|
||||
@@ -1029,7 +1047,7 @@ function SessionPicker({
|
||||
// the "inherit from settings" choice, so use a non-empty marker.
|
||||
const MODEL_CUSTOM = "__custom__";
|
||||
|
||||
function ModelPicker({
|
||||
export function ModelPicker({
|
||||
value,
|
||||
onChange,
|
||||
tool,
|
||||
@@ -1039,11 +1057,31 @@ function ModelPicker({
|
||||
tool: LaneTool;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const choices = tool === "codex" ? RUN_MODEL_CHOICES_CODEX : RUN_MODEL_CHOICES;
|
||||
// "Custom" is selected when the value isn't one of our curated IDs.
|
||||
// The tool's own list (Codex's models cache, Claude's aliases named after
|
||||
// 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 isCustom = value !== "" && !knownIds.includes(value);
|
||||
const [showCustom, setShowCustom] = useState(isCustom);
|
||||
const isCustom = fetched !== null && value !== "" && !knownIds.includes(value);
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
|
||||
// Reuse the shared Select so the Model dropdown renders identically to the
|
||||
// Permission Mode and Effort dropdowns (Tailwind + lucide popover) instead of
|
||||
|
||||
Reference in New Issue
Block a user