diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index f20a9e2b..4bcddfc7 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -118,17 +118,24 @@ filters in the browser. It shows no token counts, cache counts or cost. ### Classifier settings -Under the try-out form, **classifier settings** shows the model and effort the -[review classifier](review-classifier.md) runs with and how many requested -reviews run at once, and lets you change them. The model is free text, one word -as claude's `--model` takes it, such as `haiku` or `sonnet`. The effort is one -of low, medium, high, xhigh and max, or claude's own default. Concurrent -reviews is a whole number from 1 to 8; try-outs don't count toward it. +Under the try-out form, **classifier settings** shows the runtime, model and +effort the [review classifier](review-classifier.md) runs with and how many +requested reviews run at once, and lets you change them. The runtime is **API +key** (claude on the Anthropic key) or **ACP** (opencode on a provider). On the +API key runtime the model is free text, one word as claude's `--model` takes +it, such as `haiku` or `sonnet`. Choosing ACP shows the provider's fields in +place of the model: its `https://` URL, its model name, and what it charges in +USD per million tokens for input and output, with optional cache read and +cache write prices. The effort is one of low, medium, high, xhigh and max, or +the runtime's own default. Concurrent reviews is a whole number from 1 to 8; +try-outs don't count toward it. -**save** is on once something changed and the fields are valid. A save replaces -all three settings. If the controller refuses it, the page shows the reason and -leaves the form as you typed it. A change takes effect from the next run, with -no restart. The page has no API key field: admins set the key in the bao UI. +**save** is on once something changed and the fields are valid. A save changes +the runtime, model, effort and limit, and the provider too while the runtime is +ACP, so switching back to API key keeps the stored provider for next time. If +the controller refuses it, the page shows the reason and leaves the form as you +typed it. A change takes effect from the next run, with no restart. The page +has no API key field: admins set each runtime's key in the bao UI. ### Scheduled prompts diff --git a/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.css b/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.css index 01757fca..570ad988 100644 --- a/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.css +++ b/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.css @@ -1,4 +1,5 @@ -/* The settings form: the three fields on one line, then the save action. */ +/* The settings form: the fields wrap in a row, the provider fields on a row of + * their own under the ACP runtime, then the save action. */ .ui-classifier-settings { display: flex; flex-direction: column; diff --git a/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.tsx b/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.tsx index 154eab73..475c371a 100644 --- a/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-settings/ClassifierSettings.tsx @@ -1,9 +1,11 @@ -// — the review classifier's model, effort and how many -// requested reviews run at once, shown and saved here -// (`GET`/`PUT /api/review-classifier/settings`). A PUT replaces the stored -// settings whole, so the form sends all three fields. A refused save shows the -// route's own reason and leaves the form as typed. There is no API key field: -// admins set the key in the bao UI. +// — the review classifier's runtime, model, effort and +// how many requested reviews run at once, shown and saved here +// (`GET`/`PUT /api/review-classifier/settings`). A PUT changes only the +// fields it carries: the form sends the runtime, model, effort and limit, and +// the ACP provider only while the runtime is ACP, so the stored provider +// survives a switch back. A refused save shows the route's own reason and +// leaves the form as typed. There is no API key field: admins set each +// runtime's key in the bao UI. import { useEffect, useState } from "preact/hooks"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; @@ -20,12 +22,18 @@ import { settingsProblem, toSettings, type ClassifierSettingsBody, + type Runtime, type SettingsDraft, } from "./SettingsTypes.js"; import "./ClassifierSettings.css"; const SETTINGS = "/api/review-classifier/settings"; +const RUNTIME_OPTIONS = [ + { value: "api-key", label: "API key (claude)" }, + { value: "acp", label: "ACP (opencode)" }, +]; + const EFFORT_OPTIONS = [ { value: "", label: "claude's default" }, ...EFFORTS.map((e) => ({ value: e, label: e })), @@ -101,10 +109,12 @@ export function ClassifierSettings() { return (

- The model and effort the review classifier runs with, and how many - requested reviews run at once. A change takes effect from the next run, - with no restart. Try-outs don't count toward the limit. The API key is - set in the bao UI, not here. + The runtime, model and effort the review classifier runs with, and how + many requested reviews run at once. The API key runtime runs claude on + the Anthropic key; the ACP runtime runs opencode on the provider given + here. A change takes effect from the next run, with no restart. Try-outs + don't count toward the limit. Each runtime's key is set in the bao UI, + not here.

{loadError ? ( void save(e)}>
- edit({ ...draft, model })} + + edit({ ...draft, runtime: runtime as Runtime }) + } /> + {draft.runtime === "api-key" ? ( + edit({ ...draft, model })} + /> + ) : null} edit({ ...draft, max })} />
+ {draft.runtime === "acp" ? ( +
+ edit({ ...draft, acpUrl })} + /> + edit({ ...draft, acpModel })} + /> + edit({ ...draft, priceInput })} + /> + edit({ ...draft, priceOutput })} + /> + edit({ ...draft, priceCacheRead })} + /> + + edit({ ...draft, priceCacheWrite }) + } + /> +
+ ) : null} {saveError ? ( = MIN_REVIEWS && n <= MAX_REVIEWS ? n : null; } +/** A price as a number, or null when it is not a number of 0 or more. */ +export function priceValue(text: string): number | null { + const t = text.trim(); + if (!/^\d+(\.\d+)?$/.test(t)) return null; + return Number(t); +} + +/** Why the provider fields are not ready to save, or null when they are. */ +function acpProblem(d: SettingsDraft): string | null { + const url = d.acpUrl.trim(); + if (!url.startsWith("https://") || /\s/.test(url)) { + return "the provider URL starts with https://"; + } + const model = d.acpModel.trim(); + if (model === "" || /\s/.test(model)) { + return "give the provider's model, one word as the provider names it"; + } + if (priceValue(d.priceInput) === null || priceValue(d.priceOutput) === null) { + return "give the input and output price, a number of 0 or more"; + } + for (const cache of [d.priceCacheRead, d.priceCacheWrite]) { + if (cache.trim() !== "" && priceValue(cache) === null) { + return "a cache price is a number of 0 or more, or empty"; + } + } + return null; +} + /** Why the draft is not ready to save, or null when it is. */ export function settingsProblem(d: SettingsDraft): string | null { const model = d.model.trim(); @@ -52,16 +124,38 @@ export function settingsProblem(d: SettingsDraft): string | null { if (maxValue(d.max) === null) { return `concurrent reviews is a whole number from ${MIN_REVIEWS} to ${MAX_REVIEWS}`; } - return null; + return d.runtime === "acp" ? acpProblem(d) : null; } -/** The draft as the request body. Call only when `settingsProblem` is null. */ -export function toSettings(d: SettingsDraft): ClassifierSettingsBody { - return { +/** The provider fields as the request carries them. Call only when + * `acpProblem` is null. */ +function toAcp(d: SettingsDraft): AcpSettings { + const prices: AcpPrices = { + input: priceValue(d.priceInput) ?? 0, + output: priceValue(d.priceOutput) ?? 0, + }; + if (d.priceCacheRead.trim() !== "") { + prices.cache_read = priceValue(d.priceCacheRead) ?? 0; + } + if (d.priceCacheWrite.trim() !== "") { + prices.cache_write = priceValue(d.priceCacheWrite) ?? 0; + } + return { base_url: d.acpUrl.trim(), model: d.acpModel.trim(), prices }; +} + +/** The draft as the request body. Call only when `settingsProblem` is null. + * On the API key runtime the body leaves `acp` out, so the stored provider + * stays for the next switch back. */ +export function toSettings( + d: SettingsDraft, +): Omit & { acp?: AcpSettings } { + const body = { model: d.model.trim(), effort: d.effort === "" ? null : (d.effort as Effort), max_concurrent_reviews: maxValue(d.max) ?? MIN_REVIEWS, + runtime: d.runtime, }; + return d.runtime === "acp" ? { ...body, acp: toAcp(d) } : body; } /** Whether `d` would save something other than `saved`. */ @@ -71,9 +165,20 @@ export function changed( ): boolean { if (saved === null) return true; const now = draftOf(saved); - return ( + const base = d.model.trim() !== now.model || d.effort !== now.effort || - d.max.trim() !== now.max + d.max.trim() !== now.max || + d.runtime !== now.runtime; + if (base) return true; + // The provider fields only save on the ACP runtime. + return ( + d.runtime === "acp" && + (d.acpUrl.trim() !== now.acpUrl || + d.acpModel.trim() !== now.acpModel || + d.priceInput.trim() !== now.priceInput || + d.priceOutput.trim() !== now.priceOutput || + d.priceCacheRead.trim() !== now.priceCacheRead || + d.priceCacheWrite.trim() !== now.priceCacheWrite) ); }