Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: set the review classifier's keys from the classifier settings

The classifier's Anthropic and ACP provider keys were written by hand in the
secret store's UI. The controller now stores them: GET
/api/review-classifier/keys says whether each is set and when it changed, and
PUT /api/review-classifier/keys/{runtime} writes one. No route answers a key.

The controller's store policy gains create and update on the two leaves with
no read of their values, and a metadata read for the set state. The swarm UI's
classifier settings card gets a key form beside the settings, saved apart
because it goes to the secret store.
This commit is contained in:
iris 2026-11-11 18:29:29 +01:00 • committed by mara
commit 6875a4f0f8
4 changed files with 628 additions and 20 deletions

View file

@ -0,0 +1,127 @@
// <ClassifierKey> — the key the chosen runtime runs on, set from here instead
// of in the bao UI (`GET /api/review-classifier/keys`, `PUT .../keys/{runtime}`).
// The page can say whether a key is stored and when it changed, never what it
// is: the field takes a new key and is cleared once it's saved. A key is
// saved apart from the settings, because it goes to the secret store and not
// into the settings document.
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";
import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { Button } from "../../ui/button/Button.js";
import { Hint } from "../../ui/hint/Hint.js";
import { TextField } from "../../ui/text-field/TextField.js";
import { keyOf, keyProblem, keyStatus, type KeysBody } from "./KeyTypes.js";
import type { Runtime } from "./SettingsTypes.js";
const KEYS = "/api/review-classifier/keys";
export function ClassifierKey({ runtime }: { runtime: Runtime }) {
const [keys, setKeys] = useState<KeysBody | null>(null);
const [loadError, setLoadError] = useState<ProblemDetails | null>(null);
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
// The key as typed. Never kept after a save.
const [text, setText] = useState("");
const [saving, setSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await fetch(KEYS);
if (cancelled) return;
if (!res.ok) {
setLoadError(await readApiError(res));
return;
}
const body = (await res.json()) as KeysBody;
if (!cancelled) setKeys(body);
} catch (e: unknown) {
if (!cancelled) setLoadError({ detail: String(e) });
}
})();
return () => {
cancelled = true;
};
}, []);
// A key typed for one runtime must not be saved to the other.
useEffect(() => {
setText("");
setSaveError(null);
setJustSaved(false);
}, [runtime]);
const problem = keyProblem(text);
async function save(e: Event) {
e.preventDefault();
if (problem !== null) return;
setSaving(true);
setSaveError(null);
setJustSaved(false);
try {
const res = await fetch(`${KEYS}/${runtime}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ api_key: text.trim() }),
});
if (!res.ok) {
setSaveError(await readApiError(res));
return;
}
setKeys((await res.json()) as KeysBody);
setText("");
setJustSaved(true);
} catch (err: unknown) {
setSaveError({ detail: String(err) });
} finally {
setSaving(false);
}
}
const label = runtime === "acp" ? "provider key" : "Anthropic API key";
return (
<form class="ui-classifier-settings" onSubmit={(e) => void save(e)}>
{loadError ? (
<ApiErrorPanel
context="the key could not be read"
problem={loadError}
/>
) : null}
<TextField
id="classifier-key"
label={`${label}${keys ? ` (${keyStatus(keyOf(keys, runtime))})` : ""}`}
type="password"
value={text}
placeholder={
keys && keyOf(keys, runtime).set
? "paste a new key to replace it"
: "paste the key"
}
onInput={(t) => {
setText(t);
setJustSaved(false);
}}
/>
{saveError ? (
<ApiErrorPanel context="the key was not saved" problem={saveError} />
) : null}
<ActionRow>
<Button
variant="primary"
type="submit"
disabled={saving || problem !== null}
>
{saving ? "saving…" : "save key"}
</Button>
{justSaved ? (
<Hint as="span" role="status">
Saved. The next run uses it.
</Hint>
) : null}
</ActionRow>
</form>
);
}

View file

@ -5,8 +5,8 @@
// 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.
// leaves the form as typed. The runtime's key has a form of its own below
// (<ClassifierKey>), saved apart because it goes to the secret store.
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";
@ -18,6 +18,7 @@ import { Disclosure } from "../../ui/disclosure/Disclosure.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextField } from "../../ui/text-field/TextField.js";
import { ClassifierKey } from "./ClassifierKey.js";
import {
EFFORTS,
MAX_REVIEWS,
@ -137,8 +138,9 @@ export function ClassifierSettings() {
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.
don't count toward the limit. The key of the chosen runtime is saved
below, apart from the settings. The page shows whether a key is stored,
never the key.
</p>
{loadError ? (
<ApiErrorPanel
@ -274,6 +276,7 @@ export function ClassifierSettings() {
{problem !== null ? <Hint>{problem}</Hint> : null}
</form>
) : null}
{draft ? <ClassifierKey runtime={draft.runtime} /> : null}
</Disclosure>
);
}

View file

@ -0,0 +1,45 @@
// Wire shape of the review classifier's keys
// (`GET /api/review-classifier/keys`, `PUT .../keys/{runtime}`), and the
// checks the key form makes before a save is worth sending. The routes never
// answer a key, only whether one is stored; the backend stays the real gate.
import type { Runtime } from "./SettingsTypes.js";
/** Whether one runtime's key is stored. */
export interface KeyState {
set: boolean;
/** When it last changed (RFC 3339), or `null` when none is stored. */
updated: string | null;
}
/** Both keys' state, as both routes answer it. */
export interface KeysBody {
api_key: KeyState;
acp: KeyState;
}
/** The most bytes a key may have, as the route takes it. */
export const MAX_KEY_BYTES = 4096;
/** The state of the key `runtime` runs on. */
export function keyOf(keys: KeysBody, runtime: Runtime): KeyState {
return runtime === "acp" ? keys.acp : keys.api_key;
}
/** Why `text` is not ready to save as a key, or null when it is. A pasted
* trailing newline is fine: the route drops it. */
export function keyProblem(text: string): string | null {
const key = text.trim();
if (key === "") return "paste the key";
if (/\s/.test(key)) return "a key has no whitespace in it";
if (new TextEncoder().encode(key).length > MAX_KEY_BYTES) {
return `a key is at most ${MAX_KEY_BYTES} bytes`;
}
return null;
}
/** One line on whether the key is stored, for the key form. */
export function keyStatus(state: KeyState): string {
if (!state.set) return "not set";
return state.updated ? `set, last changed ${state.updated}` : "set";
}

View file

@ -0,0 +1,47 @@
// The classifier key form's pure logic: which key a runtime means, the checks
// before a save, and the line saying whether a key is stored. 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 {
MAX_KEY_BYTES,
keyOf,
keyProblem,
keyStatus,
} from "../src/pages/review-settings/KeyTypes.ts";
const keys = {
api_key: { set: true, updated: "2026-11-11T10:00:00Z" },
acp: { set: false, updated: null },
};
test("a runtime means its own key", () => {
assert.equal(keyOf(keys, "api-key"), keys.api_key);
assert.equal(keyOf(keys, "acp"), keys.acp);
});
test("an empty key or one with whitespace is not ready to save", () => {
assert.notEqual(keyProblem(""), null);
assert.notEqual(keyProblem(" "), null);
assert.notEqual(keyProblem("two words"), null);
assert.equal(keyProblem("sk-ant-abc"), null);
});
test("a pasted trailing newline is fine", () => {
assert.equal(keyProblem("sk-ant-abc\n"), null);
});
test("a key over the limit is not ready to save", () => {
assert.equal(keyProblem("k".repeat(MAX_KEY_BYTES)), null);
assert.notEqual(keyProblem("k".repeat(MAX_KEY_BYTES + 1)), null);
});
test("the status says whether a key is stored and when it changed", () => {
assert.equal(keyStatus(keys.acp), "not set");
assert.equal(
keyStatus(keys.api_key),
"set, last changed 2026-11-11T10:00:00Z",
);
assert.equal(keyStatus({ set: true, updated: null }), "set");
});