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:
parent
c5d601e3a2
commit
6875a4f0f8
3 changed files with 628 additions and 20 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
Loading…
Reference in a new issue