swarm-ui: choose the review classifier's runtime on the settings page
The classifier settings page showed model and effort only, so the API-key or ACP runtime could be switched only through a raw API call. It now has a runtime select, and under ACP the provider's URL, model and prices. A PUT carries the provider only on the ACP runtime, so the stored record stays for a switch back. Closes #5249
This commit is contained in:
parent
739c35757a
commit
a04096ea19
4 changed files with 213 additions and 35 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
// <ClassifierSettings> — 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.
|
||||
// <ClassifierSettings> — 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 (
|
||||
<Panel title="classifier settings" icon="⚙️">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
{loadError ? (
|
||||
<ApiErrorPanel
|
||||
|
|
@ -115,13 +125,24 @@ export function ClassifierSettings() {
|
|||
{draft ? (
|
||||
<form class="ui-classifier-settings" onSubmit={(e) => void save(e)}>
|
||||
<div class="ui-classifier-settings-fields">
|
||||
<TextField
|
||||
id="classifier-model"
|
||||
label="model"
|
||||
value={draft.model}
|
||||
placeholder="haiku"
|
||||
onInput={(model) => edit({ ...draft, model })}
|
||||
<SelectField
|
||||
id="classifier-runtime"
|
||||
label="runtime"
|
||||
value={draft.runtime}
|
||||
options={RUNTIME_OPTIONS}
|
||||
onChange={(runtime) =>
|
||||
edit({ ...draft, runtime: runtime as Runtime })
|
||||
}
|
||||
/>
|
||||
{draft.runtime === "api-key" ? (
|
||||
<TextField
|
||||
id="classifier-model"
|
||||
label="model"
|
||||
value={draft.model}
|
||||
placeholder="haiku"
|
||||
onInput={(model) => edit({ ...draft, model })}
|
||||
/>
|
||||
) : null}
|
||||
<SelectField
|
||||
id="classifier-effort"
|
||||
label="effort"
|
||||
|
|
@ -137,6 +158,50 @@ export function ClassifierSettings() {
|
|||
onInput={(max) => edit({ ...draft, max })}
|
||||
/>
|
||||
</div>
|
||||
{draft.runtime === "acp" ? (
|
||||
<div class="ui-classifier-settings-fields">
|
||||
<TextField
|
||||
id="classifier-acp-url"
|
||||
label="provider URL"
|
||||
value={draft.acpUrl}
|
||||
placeholder="https://llm.example/v1"
|
||||
onInput={(acpUrl) => edit({ ...draft, acpUrl })}
|
||||
/>
|
||||
<TextField
|
||||
id="classifier-acp-model"
|
||||
label="provider model"
|
||||
value={draft.acpModel}
|
||||
placeholder="Qwen/Qwen3-35B"
|
||||
onInput={(acpModel) => edit({ ...draft, acpModel })}
|
||||
/>
|
||||
<TextField
|
||||
id="classifier-acp-price-input"
|
||||
label="input price (USD per million tokens)"
|
||||
value={draft.priceInput}
|
||||
onInput={(priceInput) => edit({ ...draft, priceInput })}
|
||||
/>
|
||||
<TextField
|
||||
id="classifier-acp-price-output"
|
||||
label="output price (USD per million tokens)"
|
||||
value={draft.priceOutput}
|
||||
onInput={(priceOutput) => edit({ ...draft, priceOutput })}
|
||||
/>
|
||||
<TextField
|
||||
id="classifier-acp-price-cache-read"
|
||||
label="cache read price (optional)"
|
||||
value={draft.priceCacheRead}
|
||||
onInput={(priceCacheRead) => edit({ ...draft, priceCacheRead })}
|
||||
/>
|
||||
<TextField
|
||||
id="classifier-acp-price-cache-write"
|
||||
label="cache write price (optional)"
|
||||
value={draft.priceCacheWrite}
|
||||
onInput={(priceCacheWrite) =>
|
||||
edit({ ...draft, priceCacheWrite })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{saveError ? (
|
||||
<ApiErrorPanel
|
||||
context="the settings were not saved"
|
||||
|
|
|
|||
|
|
@ -6,12 +6,36 @@
|
|||
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
||||
export type Effort = (typeof EFFORTS)[number];
|
||||
|
||||
/** The settings in force. A PUT replaces them whole. */
|
||||
/** Which runtime a classification runs on: the claude CLI on the Anthropic
|
||||
* API key, or opencode on an ACP provider. */
|
||||
export const RUNTIMES = ["api-key", "acp"] as const;
|
||||
export type Runtime = (typeof RUNTIMES)[number];
|
||||
|
||||
/** What the provider charges, in USD per million tokens. A cache price left
|
||||
* out counts as `input`. */
|
||||
export interface AcpPrices {
|
||||
input: number;
|
||||
output: number;
|
||||
cache_read?: number;
|
||||
cache_write?: number;
|
||||
}
|
||||
|
||||
/** The provider the ACP runtime runs on. */
|
||||
export interface AcpSettings {
|
||||
base_url: string;
|
||||
model: string;
|
||||
prices: AcpPrices;
|
||||
}
|
||||
|
||||
/** The settings in force. A PUT changes only the fields it carries. */
|
||||
export interface ClassifierSettingsBody {
|
||||
model: string;
|
||||
/** `null` is claude's own default, which passes no `--effort`. */
|
||||
effort: Effort | null;
|
||||
max_concurrent_reviews: number;
|
||||
runtime: Runtime;
|
||||
/** The provider record. It stays stored while the runtime is `api-key`. */
|
||||
acp: AcpSettings | null;
|
||||
}
|
||||
|
||||
/** The form's fields as the user typed them. */
|
||||
|
|
@ -20,16 +44,36 @@ export interface SettingsDraft {
|
|||
/** An effort, or `""` for claude's default. */
|
||||
effort: string;
|
||||
max: string;
|
||||
runtime: Runtime;
|
||||
acpUrl: string;
|
||||
acpModel: string;
|
||||
/** USD per million tokens. The two cache prices may be `""`. */
|
||||
priceInput: string;
|
||||
priceOutput: string;
|
||||
priceCacheRead: string;
|
||||
priceCacheWrite: string;
|
||||
}
|
||||
|
||||
export const MIN_REVIEWS = 1;
|
||||
export const MAX_REVIEWS = 8;
|
||||
|
||||
export function draftOf(s: ClassifierSettingsBody): SettingsDraft {
|
||||
const acp = s.acp;
|
||||
return {
|
||||
model: s.model,
|
||||
effort: s.effort ?? "",
|
||||
max: String(s.max_concurrent_reviews),
|
||||
runtime: s.runtime,
|
||||
acpUrl: acp?.base_url ?? "",
|
||||
acpModel: acp?.model ?? "",
|
||||
priceInput: acp ? String(acp.prices.input) : "",
|
||||
priceOutput: acp ? String(acp.prices.output) : "",
|
||||
priceCacheRead:
|
||||
acp?.prices.cache_read !== undefined ? String(acp.prices.cache_read) : "",
|
||||
priceCacheWrite:
|
||||
acp?.prices.cache_write !== undefined
|
||||
? String(acp.prices.cache_write)
|
||||
: "",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -42,6 +86,34 @@ export function maxValue(text: string): number | null {
|
|||
return n >= 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<ClassifierSettingsBody, "acp"> & { 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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue