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
|
|
@ -118,17 +118,24 @@ filters in the browser. It shows no token counts, cache counts or cost.
|
||||||
|
|
||||||
### Classifier settings
|
### Classifier settings
|
||||||
|
|
||||||
Under the try-out form, **classifier settings** shows the model and effort the
|
Under the try-out form, **classifier settings** shows the runtime, model and
|
||||||
[review classifier](review-classifier.md) runs with and how many requested
|
effort the [review classifier](review-classifier.md) runs with and how many
|
||||||
reviews run at once, and lets you change them. The model is free text, one word
|
requested reviews run at once, and lets you change them. The runtime is **API
|
||||||
as claude's `--model` takes it, such as `haiku` or `sonnet`. The effort is one
|
key** (claude on the Anthropic key) or **ACP** (opencode on a provider). On the
|
||||||
of low, medium, high, xhigh and max, or claude's own default. Concurrent
|
API key runtime the model is free text, one word as claude's `--model` takes
|
||||||
reviews is a whole number from 1 to 8; try-outs don't count toward it.
|
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
|
**save** is on once something changed and the fields are valid. A save changes
|
||||||
all three settings. If the controller refuses it, the page shows the reason and
|
the runtime, model, effort and limit, and the provider too while the runtime is
|
||||||
leaves the form as you typed it. A change takes effect from the next run, with
|
ACP, so switching back to API key keeps the stored provider for next time. If
|
||||||
no restart. The page has no API key field: admins set the key in the bao UI.
|
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
|
### Scheduled prompts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
.ui-classifier-settings {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
// <ClassifierSettings> — the review classifier's model, effort and how many
|
// <ClassifierSettings> — the review classifier's runtime, model, effort and
|
||||||
// requested reviews run at once, shown and saved here
|
// how many requested reviews run at once, shown and saved here
|
||||||
// (`GET`/`PUT /api/review-classifier/settings`). A PUT replaces the stored
|
// (`GET`/`PUT /api/review-classifier/settings`). A PUT changes only the
|
||||||
// settings whole, so the form sends all three fields. A refused save shows the
|
// fields it carries: the form sends the runtime, model, effort and limit, and
|
||||||
// route's own reason and leaves the form as typed. There is no API key field:
|
// the ACP provider only while the runtime is ACP, so the stored provider
|
||||||
// admins set the key in the bao UI.
|
// 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 { useEffect, useState } from "preact/hooks";
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
|
|
@ -20,12 +22,18 @@ import {
|
||||||
settingsProblem,
|
settingsProblem,
|
||||||
toSettings,
|
toSettings,
|
||||||
type ClassifierSettingsBody,
|
type ClassifierSettingsBody,
|
||||||
|
type Runtime,
|
||||||
type SettingsDraft,
|
type SettingsDraft,
|
||||||
} from "./SettingsTypes.js";
|
} from "./SettingsTypes.js";
|
||||||
import "./ClassifierSettings.css";
|
import "./ClassifierSettings.css";
|
||||||
|
|
||||||
const SETTINGS = "/api/review-classifier/settings";
|
const SETTINGS = "/api/review-classifier/settings";
|
||||||
|
|
||||||
|
const RUNTIME_OPTIONS = [
|
||||||
|
{ value: "api-key", label: "API key (claude)" },
|
||||||
|
{ value: "acp", label: "ACP (opencode)" },
|
||||||
|
];
|
||||||
|
|
||||||
const EFFORT_OPTIONS = [
|
const EFFORT_OPTIONS = [
|
||||||
{ value: "", label: "claude's default" },
|
{ value: "", label: "claude's default" },
|
||||||
...EFFORTS.map((e) => ({ value: e, label: e })),
|
...EFFORTS.map((e) => ({ value: e, label: e })),
|
||||||
|
|
@ -101,10 +109,12 @@ export function ClassifierSettings() {
|
||||||
return (
|
return (
|
||||||
<Panel title="classifier settings" icon="⚙️">
|
<Panel title="classifier settings" icon="⚙️">
|
||||||
<p>
|
<p>
|
||||||
The model and effort the review classifier runs with, and how many
|
The runtime, model and effort the review classifier runs with, and how
|
||||||
requested reviews run at once. A change takes effect from the next run,
|
many requested reviews run at once. The API key runtime runs claude on
|
||||||
with no restart. Try-outs don't count toward the limit. The API key is
|
the Anthropic key; the ACP runtime runs opencode on the provider given
|
||||||
set in the bao UI, not here.
|
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>
|
</p>
|
||||||
{loadError ? (
|
{loadError ? (
|
||||||
<ApiErrorPanel
|
<ApiErrorPanel
|
||||||
|
|
@ -115,6 +125,16 @@ export function ClassifierSettings() {
|
||||||
{draft ? (
|
{draft ? (
|
||||||
<form class="ui-classifier-settings" onSubmit={(e) => void save(e)}>
|
<form class="ui-classifier-settings" onSubmit={(e) => void save(e)}>
|
||||||
<div class="ui-classifier-settings-fields">
|
<div class="ui-classifier-settings-fields">
|
||||||
|
<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
|
<TextField
|
||||||
id="classifier-model"
|
id="classifier-model"
|
||||||
label="model"
|
label="model"
|
||||||
|
|
@ -122,6 +142,7 @@ export function ClassifierSettings() {
|
||||||
placeholder="haiku"
|
placeholder="haiku"
|
||||||
onInput={(model) => edit({ ...draft, model })}
|
onInput={(model) => edit({ ...draft, model })}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
<SelectField
|
<SelectField
|
||||||
id="classifier-effort"
|
id="classifier-effort"
|
||||||
label="effort"
|
label="effort"
|
||||||
|
|
@ -137,6 +158,50 @@ export function ClassifierSettings() {
|
||||||
onInput={(max) => edit({ ...draft, max })}
|
onInput={(max) => edit({ ...draft, max })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{saveError ? (
|
||||||
<ApiErrorPanel
|
<ApiErrorPanel
|
||||||
context="the settings were not saved"
|
context="the settings were not saved"
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,36 @@
|
||||||
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
||||||
export type Effort = (typeof EFFORTS)[number];
|
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 {
|
export interface ClassifierSettingsBody {
|
||||||
model: string;
|
model: string;
|
||||||
/** `null` is claude's own default, which passes no `--effort`. */
|
/** `null` is claude's own default, which passes no `--effort`. */
|
||||||
effort: Effort | null;
|
effort: Effort | null;
|
||||||
max_concurrent_reviews: number;
|
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. */
|
/** The form's fields as the user typed them. */
|
||||||
|
|
@ -20,16 +44,36 @@ export interface SettingsDraft {
|
||||||
/** An effort, or `""` for claude's default. */
|
/** An effort, or `""` for claude's default. */
|
||||||
effort: string;
|
effort: string;
|
||||||
max: 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 MIN_REVIEWS = 1;
|
||||||
export const MAX_REVIEWS = 8;
|
export const MAX_REVIEWS = 8;
|
||||||
|
|
||||||
export function draftOf(s: ClassifierSettingsBody): SettingsDraft {
|
export function draftOf(s: ClassifierSettingsBody): SettingsDraft {
|
||||||
|
const acp = s.acp;
|
||||||
return {
|
return {
|
||||||
model: s.model,
|
model: s.model,
|
||||||
effort: s.effort ?? "",
|
effort: s.effort ?? "",
|
||||||
max: String(s.max_concurrent_reviews),
|
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;
|
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. */
|
/** Why the draft is not ready to save, or null when it is. */
|
||||||
export function settingsProblem(d: SettingsDraft): string | null {
|
export function settingsProblem(d: SettingsDraft): string | null {
|
||||||
const model = d.model.trim();
|
const model = d.model.trim();
|
||||||
|
|
@ -52,16 +124,38 @@ export function settingsProblem(d: SettingsDraft): string | null {
|
||||||
if (maxValue(d.max) === null) {
|
if (maxValue(d.max) === null) {
|
||||||
return `concurrent reviews is a whole number from ${MIN_REVIEWS} to ${MAX_REVIEWS}`;
|
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. */
|
/** The provider fields as the request carries them. Call only when
|
||||||
export function toSettings(d: SettingsDraft): ClassifierSettingsBody {
|
* `acpProblem` is null. */
|
||||||
return {
|
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(),
|
model: d.model.trim(),
|
||||||
effort: d.effort === "" ? null : (d.effort as Effort),
|
effort: d.effort === "" ? null : (d.effort as Effort),
|
||||||
max_concurrent_reviews: maxValue(d.max) ?? MIN_REVIEWS,
|
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`. */
|
/** Whether `d` would save something other than `saved`. */
|
||||||
|
|
@ -71,9 +165,20 @@ export function changed(
|
||||||
): boolean {
|
): boolean {
|
||||||
if (saved === null) return true;
|
if (saved === null) return true;
|
||||||
const now = draftOf(saved);
|
const now = draftOf(saved);
|
||||||
return (
|
const base =
|
||||||
d.model.trim() !== now.model ||
|
d.model.trim() !== now.model ||
|
||||||
d.effort !== now.effort ||
|
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