Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
7 changed files with 42 additions and 351 deletions

View file

@ -118,24 +118,17 @@ 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 runtime, model and Under the try-out form, **classifier settings** shows the model and effort the
effort the [review classifier](review-classifier.md) runs with and how many [review classifier](review-classifier.md) runs with and how many requested
requested reviews run at once, and lets you change them. The runtime is **API reviews run at once, and lets you change them. The model is free text, one word
key** (claude on the Anthropic key) or **ACP** (opencode on a provider). On the as claude's `--model` takes it, such as `haiku` or `sonnet`. The effort is one
API key runtime the model is free text, one word as claude's `--model` takes of low, medium, high, xhigh and max, or claude's own default. Concurrent
it, such as `haiku` or `sonnet`. Choosing ACP shows the provider's fields in reviews is a whole number from 1 to 8; try-outs don't count toward it.
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 changes **save** is on once something changed and the fields are valid. A save replaces
the runtime, model, effort and limit, and the provider too while the runtime is all three settings. If the controller refuses it, the page shows the reason and
ACP, so switching back to API key keeps the stored provider for next time. If leaves the form as you typed it. A change takes effect from the next run, with
the controller refuses it, the page shows the reason and leaves the form as you no restart. The page has no API key field: admins set the key in the bao UI.
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

View file

@ -6,8 +6,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "node ./build.mjs", "build": "node ./build.mjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit"
"test": "node --test test/*.test.mjs"
}, },
"dependencies": { "dependencies": {
"@hive/shared": "*", "@hive/shared": "*",

View file

@ -1,5 +1,4 @@
/* The settings form: the fields wrap in a row, the provider fields on a row of /* The settings form: the three fields on one line, then the save action. */
* 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;

View file

@ -1,11 +1,9 @@
// <ClassifierSettings> — the review classifier's runtime, model, effort and // <ClassifierSettings> — the review classifier's model, effort and how many
// how many requested reviews run at once, shown and saved here // requested reviews run at once, shown and saved here
// (`GET`/`PUT /api/review-classifier/settings`). A PUT changes only the // (`GET`/`PUT /api/review-classifier/settings`). A PUT replaces the stored
// fields it carries: the form sends the runtime, model, effort and limit, and // settings whole, so the form sends all three fields. A refused save shows the
// the ACP provider only while the runtime is ACP, so the stored provider // route's own reason and leaves the form as typed. There is no API key field:
// survives a switch back. A refused save shows the route's own reason and // admins set the key in the bao UI.
// 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";
@ -22,30 +20,17 @@ 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 = [ const EFFORT_OPTIONS = [
{ value: "api-key", label: "API key (claude)" }, { value: "", label: "claude's default" },
{ value: "acp", label: "ACP (opencode)" }, ...EFFORTS.map((e) => ({ value: e, label: e })),
]; ];
// The empty effort option is the runtime's own default: claude's on the API
// key runtime, opencode's on ACP.
function effortOptions(runtime: Runtime) {
return [
{
value: "",
label: runtime === "acp" ? "opencode's default" : "claude's default",
},
...EFFORTS.map((e) => ({ value: e, label: e })),
];
}
export function ClassifierSettings() { export function ClassifierSettings() {
// The settings as the controller holds them; null until the first read. // The settings as the controller holds them; null until the first read.
const [saved, setSaved] = useState<ClassifierSettingsBody | null>(null); const [saved, setSaved] = useState<ClassifierSettingsBody | null>(null);
@ -116,12 +101,10 @@ export function ClassifierSettings() {
return ( return (
<Panel title="classifier settings" icon="⚙️"> <Panel title="classifier settings" icon="⚙️">
<p> <p>
The runtime, model and effort the review classifier runs with, and how The model and effort the review classifier runs with, and how many
many requested reviews run at once. The API key runtime runs claude on requested reviews run at once. A change takes effect from the next run,
the Anthropic key; the ACP runtime runs opencode on the provider given with no restart. Try-outs don't count toward the limit. The API key is
here. A change takes effect from the next run, with no restart. Try-outs set in the bao UI, not here.
don't count toward the limit. Each runtime's key is set in the bao UI,
not here.
</p> </p>
{loadError ? ( {loadError ? (
<ApiErrorPanel <ApiErrorPanel
@ -132,29 +115,18 @@ 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 <TextField
id="classifier-runtime" id="classifier-model"
label="runtime" label="model"
value={draft.runtime} value={draft.model}
options={RUNTIME_OPTIONS} placeholder="haiku"
onChange={(runtime) => onInput={(model) => edit({ ...draft, model })}
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 <SelectField
id="classifier-effort" id="classifier-effort"
label="effort" label="effort"
value={draft.effort} value={draft.effort}
options={effortOptions(draft.runtime)} options={EFFORT_OPTIONS}
onChange={(effort) => edit({ ...draft, effort })} onChange={(effort) => edit({ ...draft, effort })}
/> />
<TextField <TextField
@ -165,50 +137,6 @@ 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"

View file

@ -6,74 +6,30 @@
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];
/** Which runtime a classification runs on: the claude CLI on the Anthropic /** The settings in force. A PUT replaces them whole. */
* API key, or opencode on an ACP provider. */
export type Runtime = "api-key" | "acp";
/** 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 the runtime's own default: claude's, which passes no /** `null` is claude's own default, which passes no `--effort`. */
* `--effort`, or opencode's. */
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. */
export interface SettingsDraft { export interface SettingsDraft {
model: string; model: string;
/** An effort, or `""` for the runtime's own 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)
: "",
}; };
} }
@ -86,34 +42,6 @@ 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();
@ -124,38 +52,16 @@ 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 d.runtime === "acp" ? acpProblem(d) : null; return null;
} }
/** The provider fields as the request carries them. Call only when /** The draft as the request body. Call only when `settingsProblem` is null. */
* `acpProblem` is null. */ export function toSettings(d: SettingsDraft): ClassifierSettingsBody {
function toAcp(d: SettingsDraft): AcpSettings { return {
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`. */
@ -165,20 +71,9 @@ export function changed(
): boolean { ): boolean {
if (saved === null) return true; if (saved === null) return true;
const now = draftOf(saved); const now = draftOf(saved);
const base = return (
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)
); );
} }

View file

@ -1,119 +0,0 @@
// The classifier settings form's pure logic: the draft a stored document
// becomes, the checks before a save, the request body, and what counts as a
// change. Run by `npm test`, which Node runs with its built-in test runner and
// type stripping, so the `.ts` module is imported as it is.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
changed,
draftOf,
maxValue,
priceValue,
settingsProblem,
toSettings,
} from "../src/pages/review-settings/SettingsTypes.ts";
const apiKey = {
model: "haiku",
effort: null,
max_concurrent_reviews: 2,
runtime: "api-key",
acp: null,
};
const acpStored = {
...apiKey,
runtime: "acp",
acp: {
base_url: "https://llm.example/v1",
model: "Qwen/Qwen3-35B",
prices: { input: 0.5, output: 2, cache_read: 0.1 },
},
};
// An ACP draft over the API key settings, with every provider field filled.
function acpDraft(over = {}) {
return {
...draftOf(apiKey),
runtime: "acp",
acpUrl: "https://llm.example/v1",
acpModel: "Qwen/Qwen3-35B",
priceInput: "0.5",
priceOutput: "2",
...over,
};
}
test("an unedited draft is valid and changes nothing", () => {
const d = draftOf(apiKey);
assert.equal(settingsProblem(d), null);
assert.equal(changed(d, apiKey), false);
});
test("the API key request leaves the provider out, so the stored one stays", () => {
const body = toSettings({ ...draftOf(acpStored), runtime: "api-key" });
assert.deepEqual(body, {
model: "haiku",
effort: null,
max_concurrent_reviews: 2,
runtime: "api-key",
});
assert.equal("acp" in body, false);
});
test("the ACP request carries the provider and its prices", () => {
const body = toSettings(acpDraft({ priceCacheRead: "0.1" }));
assert.equal(body.runtime, "acp");
assert.deepEqual(body.acp, {
base_url: "https://llm.example/v1",
model: "Qwen/Qwen3-35B",
prices: { input: 0.5, output: 2, cache_read: 0.1 },
});
});
test("a cache price left empty is left out of the request", () => {
const { prices } = toSettings(acpDraft()).acp;
assert.equal("cache_read" in prices, false);
assert.equal("cache_write" in prices, false);
});
test("the draft of a stored provider round-trips to the same request", () => {
const body = toSettings(draftOf(acpStored));
assert.deepEqual(body.acp, acpStored.acp);
assert.equal(changed(draftOf(acpStored), acpStored), false);
});
test("the provider fields are checked only on the ACP runtime", () => {
const empty = { ...draftOf(apiKey), acpUrl: "http://nope" };
assert.equal(settingsProblem(empty), null);
assert.match(settingsProblem({ ...empty, runtime: "acp" }), /https:\/\//);
});
test("the ACP checks name the field at fault", () => {
assert.match(settingsProblem(acpDraft({ acpUrl: "http://x" })), /https:\/\//);
assert.match(settingsProblem(acpDraft({ acpModel: "a b" })), /model/);
assert.match(settingsProblem(acpDraft({ priceOutput: "-1" })), /price/);
assert.match(settingsProblem(acpDraft({ priceInput: "" })), /price/);
assert.match(settingsProblem(acpDraft({ priceCacheWrite: "x" })), /cache/);
assert.equal(settingsProblem(acpDraft()), null);
});
test("switching the runtime is a change; edits to the hidden provider are not", () => {
const saved = apiKey;
assert.equal(changed({ ...draftOf(saved), runtime: "acp" }, saved), true);
assert.equal(
changed({ ...draftOf(saved), acpUrl: "https://x" }, saved),
false,
);
assert.equal(changed(acpDraft({ priceOutput: "3" }), acpStored), true);
});
test("the reviews limit and a price parse as the form needs", () => {
assert.equal(maxValue("8"), 8);
assert.equal(maxValue("9"), null);
assert.equal(maxValue("1.5"), null);
assert.equal(priceValue("0"), 0);
assert.equal(priceValue("0.25"), 0.25);
assert.equal(priceValue("-1"), null);
assert.equal(priceValue("1e3"), null);
});

View file

@ -369,10 +369,6 @@ in
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
npm run typecheck --workspace=packages/swarm-ui npm run typecheck --workspace=packages/swarm-ui
# The package's own tests (`test/`, Node's built-in runner) ride this
# derivation: it already has the workspace installed, and a second
# `npm ci` for the same tree would only repeat it.
npm run test --workspace=packages/swarm-ui
runHook postBuild runHook postBuild
''; '';