Compare commits
7 changed files with 42 additions and 351 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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": "*",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
|
|
@ -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
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue