test custom widget assistant authoring

This commit is contained in:
ajnart committed 2026-08-14 11:32:11 +02:00
1 parent ceed0979a1
commit dfafad15bf
20 files changed
+1675 -36

No files matched your search

@@ -0,0 +1,702 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod/v4";
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetExample,
getCustomWidgetSharedProps,
getCustomWidgetSkill,
} from "../src/core/authoring-resources";
import {
customWidgetAuthoringDefinitionSchema,
customWidgetDefinitionSchema,
normalizeCustomWidgetAuthoringDefinition,
} from "../src/core/custom-jsx-schema";
import type { HomarrCustomWidgetV2 } from "../src/core/custom-jsx-schema";
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "../src/core/ai-prompt";
import { getCustomWidgetJsonSchema } from "../src/core/schema";
import type { CustomWidgetAiEvaluationCase } from "./ai-evaluation-cases";
import {
DEFAULT_GENERATOR_MODEL,
getDeterministicEvaluationIssues,
judgePasses,
parseJudgeResult,
requestCustomWidgetJudge,
} from "./ai-evaluation";
import type { CustomWidgetJudgeResult } from "./ai-evaluation";
const MAX_ASSISTANT_STEPS = 24;
interface OpenRouterToolCall {
id: string;
type: "function";
function: { name: string; arguments: string };
}
interface OpenRouterAssistantMessage {
role: "assistant";
content: string | null;
tool_calls?: OpenRouterToolCall[];
}
type OpenRouterMessage =
| { role: "system" | "user"; content: string }
| OpenRouterAssistantMessage
| { role: "tool"; tool_call_id: string; content: string };
interface OpenRouterResponse {
choices?: Array<{ message?: OpenRouterAssistantMessage }>;
error?: { message?: string };
}
interface ToolDefinition {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
interface PreviewState {
widget: HomarrCustomWidgetV2;
testedQueries: Set<string>;
}
export const getRequiredAssistantEvaluationRequestParams = (request: HomarrCustomWidgetV2["requests"][string]) => {
const serialized = JSON.stringify(request);
return [
...new Set([
...Array.from(serialized.matchAll(/\{param:([^}]+)\}/gu), (match) => match[1]).filter(
(name): name is string => name !== undefined,
),
...Array.from(serialized.matchAll(/"\$param":"([^"]+)"/gu), (match) => match[1]).filter(
(name): name is string => name !== undefined,
),
]),
];
};
export const getAssistantEvaluationPreviewResponse = (
testCase: CustomWidgetAiEvaluationCase,
request: HomarrCustomWidgetV2["requests"][string],
) =>
testCase.previewResponses?.find(({ pathIncludes }) => request.path.includes(pathIncludes))?.response ??
testCase.sampleResponse;
export interface AssistantAttemptState {
calledTools: string[];
validatedDefinitions: Set<string>;
previews: Map<string, PreviewState>;
createdWidget: HomarrCustomWidgetV2 | null;
finalText: string;
}
export interface CustomWidgetAssistantEvaluationResult {
caseId: string;
attempts: number;
widget: HomarrCustomWidgetV2 | null;
judge: CustomWidgetJudgeResult | null;
outputDirectory: string;
errors: string[];
calledTools: string[];
}
const objectSchema = (properties: Record<string, unknown>, required: string[] = []) => ({
type: "object",
properties,
required,
additionalProperties: false,
});
export const customWidgetAssistantEvaluationToolDefinitions: ToolDefinition[] = [
{
type: "function",
function: {
name: "customWidget_getSkill",
description:
"Load the complete installed Custom Widget skill, including SKILL.md and every bundled reference. Call this before authoring.",
parameters: objectSchema({}),
},
},
{
type: "function",
function: {
name: "customWidget_schema",
description: "Get the authoritative installed Homarr Custom Widget v2 JSON Schema.",
parameters: objectSchema({}),
},
},
{
type: "function",
function: {
name: "customWidget_getComponentCatalog",
description:
"Get the compact catalog of supported JSX components, shared props, and example IDs before selecting component documentation.",
parameters: objectSchema({}),
},
},
{
type: "function",
function: {
name: "customWidget_getComponent",
description: "Get installed documentation and safety rules for one named Custom JSX component.",
parameters: objectSchema({ name: { type: "string" } }, ["name"]),
},
},
{
type: "function",
function: {
name: "customWidget_getSharedProps",
description: "Get installed documentation for only the named shared Custom JSX props.",
parameters: objectSchema({ names: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 64 } }, [
"names",
]),
},
},
{
type: "function",
function: {
name: "customWidget_getExample",
description: "Get one installed Custom JSX example by catalog ID.",
parameters: objectSchema({ name: { type: "string" } }, ["name"]),
},
},
{
type: "function",
function: {
name: "customWidget_validate",
description:
"Validate one complete widget without saving it. Supply the complete definition in widget and prefer templateLines for multiline JSX.",
parameters: objectSchema({ widget: { type: "object", additionalProperties: true } }, ["widget"]),
},
},
{
type: "function",
function: {
name: "customWidget_previewCreate",
description:
"Create a preview for the exact complete definition after validation. Returns every query that must be tested.",
parameters: objectSchema(
{
definition: { type: "object", additionalProperties: true },
secrets: { type: "array", items: { type: "object" }, maxItems: 0 },
},
["definition"],
),
},
},
{
type: "function",
function: {
name: "customWidget_previewQuery",
description:
"Execute one query from a preview against the evaluation fixture. Call once for every query returned by previewCreate.",
parameters: objectSchema(
{
sessionId: { type: "string" },
requestId: { type: "string" },
params: { type: "object", additionalProperties: true },
},
["sessionId", "requestId"],
),
},
},
{
type: "function",
function: {
name: "customWidget_createFromPreview",
description: "Persist the exact final tested preview. Every query in that preview must have succeeded first.",
parameters: objectSchema({ previewSessionId: { type: "string" } }, ["previewSessionId"]),
},
},
];
const getDefinitionSignature = (widget: HomarrCustomWidgetV2) => JSON.stringify(widget);
function parseDefinition(value: unknown) {
const authoring = customWidgetAuthoringDefinitionSchema.safeParse(value);
if (!authoring.success) {
return {
success: false as const,
issues: authoring.error.issues.map((issue) => ({
path: issue.path.map(String).join("."),
code: issue.code,
message: issue.message,
})),
};
}
try {
return { success: true as const, widget: normalizeCustomWidgetAuthoringDefinition(authoring.data) };
} catch (error) {
if (!(error instanceof z.ZodError)) throw error;
return {
success: false as const,
issues: error.issues.map((issue) => ({
path: issue.path.map(String).join("."),
code: issue.code,
message: issue.message,
})),
};
}
}
export function executeAssistantEvaluationTool(
testCase: CustomWidgetAiEvaluationCase,
state: AssistantAttemptState,
name: string,
input: Record<string, unknown>,
): unknown {
const componentDocumentLimit = state.calledTools.includes("customWidget_getExample") ? 4 : 8;
if (
name === "customWidget_getComponent" &&
state.calledTools.filter((toolName) => toolName === "customWidget_getComponent").length >= componentDocumentLimit
) {
return {
error:
"The targeted component-document budget is exhausted. Continue with validation using the documentation already loaded.",
};
}
state.calledTools.push(name);
if (name === "customWidget_getSkill") return getCustomWidgetSkill();
if (name === "customWidget_schema") return getCustomWidgetJsonSchema();
if (name === "customWidget_getComponentCatalog") return getCustomWidgetComponentCatalog();
if (name === "customWidget_getComponent") {
const component = typeof input.name === "string" ? getCustomWidgetComponent(input.name) : null;
return component ?? { error: "Custom JSX component not found" };
}
if (name === "customWidget_getSharedProps") {
const names = Array.isArray(input.names)
? input.names.filter((entry): entry is string => typeof entry === "string")
: [];
return names.length > 0
? getCustomWidgetSharedProps(names)
: { error: "At least one shared prop name is required" };
}
if (name === "customWidget_getExample") {
const example = typeof input.name === "string" ? getCustomWidgetExample(input.name) : null;
if (!example) return { error: "Custom JSX example not found" };
const { template, ...widget } = example.widget;
return { ...example, widget: { ...widget, templateLines: template.split("\n") } };
}
if (name === "customWidget_validate") {
const parsed = parseDefinition(input.widget);
if (!parsed.success) return { valid: false, issues: parsed.issues };
state.validatedDefinitions.add(getDefinitionSignature(parsed.widget));
return {
valid: true,
issues: [],
summary: {
name: parsed.widget.name,
sourceIds: Object.keys(parsed.widget.sources),
requestIds: Object.keys(parsed.widget.requests),
optionIds: Object.keys(parsed.widget.options),
templateLineCount: parsed.widget.template.split("\n").length,
},
nextStep: "Reuse this exact definition with customWidget_previewCreate and test every returned query.",
};
}
if (name === "customWidget_previewCreate") {
const parsed = parseDefinition(input.definition);
if (!parsed.success) return { error: "Definition is invalid", issues: parsed.issues };
if (!state.validatedDefinitions.has(getDefinitionSignature(parsed.widget))) {
return { error: "Validate this exact complete definition before creating its preview." };
}
const id = `preview-${state.previews.size + 1}`;
state.previews.set(id, { widget: parsed.widget, testedQueries: new Set() });
return {
success: true,
previewSession: { id },
previewPath: `/manage/custom-widgets/preview/${id}`,
queries: Object.entries(parsed.widget.requests).flatMap(([requestId, request]) =>
request.kind === "query"
? [
{
requestId,
trigger: request.trigger,
requiredParams: getRequiredAssistantEvaluationRequestParams(request),
nextStep: `Call customWidget_previewQuery with sessionId '${id}' and requestId '${requestId}'.`,
},
]
: [],
),
};
}
if (name === "customWidget_previewQuery") {
const sessionId = typeof input.sessionId === "string" ? input.sessionId : "";
const requestId = typeof input.requestId === "string" ? input.requestId : "";
const preview = state.previews.get(sessionId);
const request = preview?.widget.requests[requestId];
if (!preview || request?.kind !== "query") return { error: "Preview query was not found" };
const params =
typeof input.params === "object" && input.params !== null && !Array.isArray(input.params)
? (input.params as Record<string, unknown>)
: {};
const missingParams = getRequiredAssistantEvaluationRequestParams(request).filter(
(paramName) => !(paramName in params) || params[paramName] === null || params[paramName] === "",
);
if (missingParams.length > 0) {
return { error: `Supply the required manual preview parameters: ${missingParams.join(", ")}` };
}
const response = getAssistantEvaluationPreviewResponse(testCase, request);
if (response === undefined) return { error: `No deterministic preview response is configured for ${request.path}` };
preview.testedQueries.add(requestId);
return {
ok: true,
status: 200,
data: response,
request: { method: request.method, path: request.path },
};
}
if (name === "customWidget_createFromPreview") {
const sessionId = typeof input.previewSessionId === "string" ? input.previewSessionId : "";
const preview = state.previews.get(sessionId);
if (!preview) return { error: "Preview session not found" };
const untested = Object.entries(preview.widget.requests).flatMap(([requestId, request]) =>
request.kind === "query" && !preview.testedQueries.has(requestId) ? [requestId] : [],
);
if (untested.length > 0) return { error: `Test every preview query before creation: ${untested.join(", ")}` };
const completedPreviewCycles = [...state.previews.values()].filter((candidate) =>
Object.entries(candidate.widget.requests).every(
([requestId, request]) => request.kind !== "query" || candidate.testedQueries.has(requestId),
),
).length;
const minimumPreviewCycles = testCase.minimumPreviewCycles ?? 1;
if (completedPreviewCycles < minimumPreviewCycles) {
return {
error: `Complete at least ${minimumPreviewCycles} validated preview-and-query cycles before creation; ${completedPreviewCycles} completed. Revise the candidate meaningfully, validate it, create a fresh preview, and test every query again.`,
};
}
state.createdWidget = customWidgetDefinitionSchema.parse(preview.widget);
return {
id: `created-${testCase.id}`,
managementPath: `/manage/custom-widgets/edit/created-${testCase.id}`,
nextAction: { type: "place-custom-widget", widgetKind: "customApi" },
};
}
return { error: `Unknown evaluation tool '${name}'` };
}
export function getAssistantEvaluationLifecycleIssues(state: AssistantAttemptState) {
const required = [
"customWidget_getSkill",
"customWidget_schema",
"customWidget_getComponentCatalog",
"customWidget_validate",
"customWidget_previewCreate",
"customWidget_previewQuery",
"customWidget_createFromPreview",
];
const issues = required.flatMap((name) =>
state.calledTools.includes(name) ? [] : [`The assistant never called ${name}.`],
);
const componentDocumentCount = state.calledTools.filter((name) => name === "customWidget_getComponent").length;
if (componentDocumentCount > 8) {
issues.push(
`The assistant fetched ${componentDocumentCount} component documents before creation; use at most eight targeted component documents.`,
);
}
return issues;
}
export function createAssistantEvaluationState(): AssistantAttemptState {
return {
calledTools: [],
validatedDefinitions: new Set(),
previews: new Map(),
createdWidget: null,
finalText: "",
};
}
function buildAssistantPrompt(testCase: CustomWidgetAiEvaluationCase, feedback: readonly string[]) {
const sections = [
testCase.request,
`Verified API documentation: ${testCase.documentationUrl}`,
`Verified API notes:\n${testCase.apiNotes}`,
];
if (testCase.sampleResponse !== undefined) {
sections.push(`Representative preview response:\n${JSON.stringify(testCase.sampleResponse, null, 2)}`);
}
if (testCase.previewResponses?.length) {
sections.push(
`Representative preview responses:\n${testCase.previewResponses
.map(({ pathIncludes, response }) => `${pathIncludes}:\n${JSON.stringify(response, null, 2)}`)
.join("\n\n")}`,
);
}
if (feedback.length > 0) {
sections.push(
`A previous independent review found these problems. Correct them in a fresh complete lifecycle:\n${feedback.join("\n")}`,
);
}
sections.push(
"Use the available Custom Widget tools and continue automatically until the exact tested preview is created. Do not merely return JSON or instructions.",
);
return sections.join("\n\n");
}
async function callAssistantStep(args: {
apiKey: string;
model: string;
messages: OpenRouterMessage[];
tools: ToolDefinition[];
toolChoice?: string;
}): Promise<OpenRouterAssistantMessage> {
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${args.apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://homarr.dev",
"X-Title": "Homarr Custom Widget Assistant Evaluation",
},
body: JSON.stringify({
model: args.model,
messages: args.messages,
tools: args.tools,
tool_choice: args.toolChoice ? { type: "function", function: { name: args.toolChoice } } : "auto",
temperature: 0.1,
max_tokens: 12_000,
reasoning: { effort: "low", exclude: true },
}),
signal: AbortSignal.timeout(180_000),
});
const payload = (await response.json()) as OpenRouterResponse;
if (!response.ok) {
throw new Error(`OpenRouter request failed (${response.status}): ${payload.error?.message ?? "Unknown error"}`);
}
const message = payload.choices?.[0]?.message;
if (!message) throw new Error("OpenRouter returned no assistant message");
return message;
}
export const getAssistantEvaluationRecoveryToolName = (state: AssistantAttemptState, minimumPreviewCycles = 1) => {
for (const requiredTool of ["customWidget_getSkill", "customWidget_schema", "customWidget_getComponentCatalog"]) {
if (!state.calledTools.includes(requiredTool)) return requiredTool;
}
if (state.validatedDefinitions.size === 0) return "customWidget_validate";
const latestPreview = [...state.previews.entries()].at(-1);
if (!latestPreview) return "customWidget_previewCreate";
const [previewId, preview] = latestPreview;
const untestedQuery = Object.entries(preview.widget.requests).find(
([requestId, request]) => request.kind === "query" && !preview.testedQueries.has(requestId),
);
if (untestedQuery) return "customWidget_previewQuery";
const completedPreviewCycles = [...state.previews.values()].filter((candidate) =>
Object.entries(candidate.widget.requests).every(
([requestId, request]) => request.kind !== "query" || candidate.testedQueries.has(requestId),
),
).length;
if (completedPreviewCycles < minimumPreviewCycles) return "customWidget_validate";
if (previewId && !state.createdWidget) return "customWidget_createFromPreview";
return undefined;
};
async function callAssistantStepWithRecovery(args: {
apiKey: string;
model: string;
messages: OpenRouterMessage[];
tools: ToolDefinition[];
state: AssistantAttemptState;
testCase: CustomWidgetAiEvaluationCase;
}) {
try {
const message = await callAssistantStep(args);
if ((message.tool_calls?.length ?? 0) > 0 || (args.state.createdWidget && (message.content ?? "").trim())) {
return message;
}
} catch (error) {
if (!(error instanceof Error) || (error.name !== "TimeoutError" && error.name !== "AbortError")) throw error;
}
const toolChoice = getAssistantEvaluationRecoveryToolName(args.state, args.testCase.minimumPreviewCycles ?? 1);
if (!toolChoice || !args.tools.some(({ function: definition }) => definition.name === toolChoice)) {
throw new Error(
"The model returned a prose-only, empty, or timed-out step before completing the Custom Widget lifecycle.",
);
}
process.stdout.write(` recovering with required tool: ${toolChoice}\n`);
const recovered = await callAssistantStep({ ...args, toolChoice });
if ((!recovered.tool_calls || recovered.tool_calls.length === 0) && !(recovered.content ?? "").trim()) {
throw new Error("The model returned an empty assistant step before completing the Custom Widget lifecycle.");
}
return recovered;
}
async function runAssistantAttempt(args: {
testCase: CustomWidgetAiEvaluationCase;
apiKey: string;
model: string;
feedback: readonly string[];
}) {
const state = createAssistantEvaluationState();
const messages: OpenRouterMessage[] = [
{
role: "system",
content: `${CUSTOM_WIDGET_MCP_AUTHORING_PROMPT}\n\nThis is a tool-driven evaluation. You must begin by loading the complete installed skill, live schema, and component catalog through their tools. Do not stop until customWidget_createFromPreview succeeds.`,
},
{ role: "user", content: buildAssistantPrompt(args.testCase, args.feedback) },
];
for (let step = 0; step < MAX_ASSISTANT_STEPS; step += 1) {
const componentDocumentCount = state.calledTools.filter((name) => name === "customWidget_getComponent").length;
const componentDocumentLimit = state.calledTools.includes("customWidget_getExample") ? 4 : 8;
const tools =
componentDocumentCount >= componentDocumentLimit
? customWidgetAssistantEvaluationToolDefinitions.filter(
({ function: definition }) => definition.name !== "customWidget_getComponent",
)
: customWidgetAssistantEvaluationToolDefinitions;
const message = await callAssistantStepWithRecovery({
apiKey: args.apiKey,
model: args.model,
messages,
tools,
state,
testCase: args.testCase,
});
if ((!message.tool_calls || message.tool_calls.length === 0) && !(message.content ?? "").trim()) {
throw new Error("The model returned an empty assistant step before completing the Custom Widget lifecycle.");
}
messages.push(message);
if (!message.tool_calls || message.tool_calls.length === 0) {
state.finalText = message.content ?? "";
break;
}
for (const toolCall of message.tool_calls) {
process.stdout.write(` tool: ${toolCall.function.name}\n`);
let output: unknown;
try {
const parsed = JSON.parse(toolCall.function.arguments) as unknown;
const input =
parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
output = executeAssistantEvaluationTool(args.testCase, state, toolCall.function.name, input);
} catch (error) {
output = { error: error instanceof Error ? error.message : "Tool input was not valid JSON" };
}
messages.push({ role: "tool", tool_call_id: toolCall.id, content: JSON.stringify(output) });
}
if (state.createdWidget) {
break;
}
}
return { state, messages };
}
export async function evaluateCustomWidgetAssistantCase(args: {
testCase: CustomWidgetAiEvaluationCase;
apiKey: string;
outputRoot: string;
maxLoops: number;
generatorModel?: string;
judgeModel?: string;
}): Promise<CustomWidgetAssistantEvaluationResult> {
const caseDirectory = path.join(args.outputRoot, `assistant-${args.testCase.id}`);
await mkdir(caseDirectory, { recursive: true });
const errors: string[] = [];
const feedback: string[] = [];
let bestWidget: HomarrCustomWidgetV2 | null = null;
let bestJudge: CustomWidgetJudgeResult | null = null;
let bestCalledTools: string[] = [];
for (let attempt = 1; attempt <= args.maxLoops; attempt += 1) {
process.stdout.write(` assistant attempt ${attempt}/${args.maxLoops}\n`);
let run: Awaited<ReturnType<typeof runAssistantAttempt>>;
try {
run = await runAssistantAttempt({
testCase: args.testCase,
apiKey: args.apiKey,
model: args.generatorModel ?? DEFAULT_GENERATOR_MODEL,
feedback,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown assistant evaluation error";
errors.push(`Attempt ${attempt}: ${message}`);
feedback.splice(0, feedback.length, message);
continue;
}
await writeFile(path.join(caseDirectory, `trace-${attempt}.json`), JSON.stringify(run.messages, null, 2), "utf8");
const lifecycleIssues = getAssistantEvaluationLifecycleIssues(run.state);
if (!run.state.createdWidget || lifecycleIssues.length > 0) {
const issues = [
...lifecycleIssues,
...(run.state.createdWidget ? [] : ["The assistant did not create the final tested preview."]),
];
errors.push(`Attempt ${attempt}: lifecycle failed — ${issues.join(" ")}`);
feedback.splice(0, feedback.length, ...issues);
continue;
}
const deterministicIssues = getDeterministicEvaluationIssues(args.testCase, run.state.createdWidget);
if (deterministicIssues.length > 0) {
const issues = deterministicIssues.map(({ message }) => message);
errors.push(`Attempt ${attempt}: deterministic checks failed — ${issues.join("; ")}`);
feedback.splice(0, feedback.length, ...issues);
continue;
}
await writeFile(
path.join(caseDirectory, `widget-${attempt}.json`),
JSON.stringify(run.state.createdWidget, null, 2),
"utf8",
);
let judgeRaw: string;
try {
judgeRaw = await requestCustomWidgetJudge({
testCase: args.testCase,
widget: run.state.createdWidget,
apiKey: args.apiKey,
judgeModel: args.judgeModel,
});
await writeFile(path.join(caseDirectory, `judge-${attempt}.json`), judgeRaw, "utf8");
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown judge error";
errors.push(`Attempt ${attempt}: judge request failed — ${message}`);
feedback.splice(0, feedback.length, "Return a complete polished widget for another independent review.");
continue;
}
let judgeResult: CustomWidgetJudgeResult;
try {
judgeResult = parseJudgeResult(judgeRaw);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown judge parse error";
errors.push(`Attempt ${attempt}: judge response invalid — ${message}`);
feedback.splice(0, feedback.length, "Return a complete polished widget for another independent review.");
continue;
}
if (!bestJudge || judgeResult.total > bestJudge.total) {
bestWidget = run.state.createdWidget;
bestJudge = judgeResult;
bestCalledTools = run.state.calledTools;
}
if (judgePasses(judgeResult)) {
return {
caseId: args.testCase.id,
attempts: attempt,
widget: run.state.createdWidget,
judge: judgeResult,
outputDirectory: caseDirectory,
errors,
calledTools: run.state.calledTools,
};
}
const issues = judgeResult.highestImpactFixes.length ? judgeResult.highestImpactFixes : judgeResult.problems;
errors.push(`Attempt ${attempt}: judge ${judgeResult.total}/100 — ${issues.join("; ")}`);
feedback.splice(0, feedback.length, ...issues);
}
return {
caseId: args.testCase.id,
attempts: args.maxLoops,
widget: bestWidget,
judge: bestJudge,
outputDirectory: caseDirectory,
errors,
calledTools: bestCalledTools,
};
}
@@ -3,6 +3,25 @@ export interface CustomWidgetAiEvaluationCase {
request: string;
documentationUrl: string;
apiNotes: string;
sampleResponse?: unknown;
previewResponses?: Array<{
pathIncludes: string;
response: unknown;
}>;
minimumPreviewCycles?: number;
expectations?: {
sourceBaseUrl: string;
sourceAuth: "none" | "apiKeyQuery";
sourceAuthName?: string;
requests: Array<{
kind: "query" | "action";
method: "GET" | "POST";
pathIncludes: string;
trigger?: "load" | "manual";
queryIncludes?: Record<string, string>;
}>;
templateIncludes?: string[];
};
}
export const CUSTOM_WIDGET_AI_EVALUATION_CASES: readonly CustomWidgetAiEvaluationCase[] = [
@@ -10,9 +29,49 @@ export const CUSTOM_WIDGET_AI_EVALUATION_CASES: readonly CustomWidgetAiEvaluatio
id: "pokedex",
documentationUrl: "https://pokeapi.co/docs/v2",
request:
"Create a polished Pokédex browser with searchable Pokémon, a responsive result grid, manual detail loading, sprites, types, abilities, base-stat progress bars, and clear loading, empty, and failure states.",
"Create a complete Pokédex widget using PokéAPI. Make at least three deliberate refinement passes through validation and preview testing. It must have a substantial polished UI with a direct name lookup, a searchable loaded list that stays visible while details open in a separate responsive result area, sprites, types, abilities, base-stat progress bars, and clear initial, loading, empty, failure, and success states. Use a restrained accent and flexible tile sizing rather than repetitive nested cards or a fixed-height list. Completeness must come from tested useful interactions, not filler JSX.",
apiNotes:
"Use GET /api/v2/pokemon?limit=<option>&offset=<option> for the list and GET /api/v2/pokemon/{param:name} as a manual detail query. PokeAPI needs no authentication.",
"Use GET /api/v2/pokemon?limit=<option>&offset=<option> for the list and GET /api/v2/pokemon/{param:name} as a manual detail query for both direct typed lookup and selected list items. Keep the list visible while rendering detail output. Prefer sprites.other.official-artwork.front_default from the tested detail response rather than parsing an ID from a list URL. PokeAPI needs no authentication.",
previewResponses: [
{
pathIncludes: "/api/v2/pokemon/{param:name}",
response: {
id: 25,
name: "pikachu",
height: 4,
weight: 60,
sprites: { other: { "official-artwork": { front_default: "https://example.test/pikachu.png" } } },
types: [{ slot: 1, type: { name: "electric" } }],
abilities: [{ ability: { name: "static" }, is_hidden: false }],
stats: [
{ base_stat: 35, stat: { name: "hp" } },
{ base_stat: 90, stat: { name: "speed" } },
],
},
},
{
pathIncludes: "/api/v2/pokemon",
response: {
count: 1_302,
next: "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20",
previous: null,
results: [
{ name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" },
{ name: "pikachu", url: "https://pokeapi.co/api/v2/pokemon/25/" },
],
},
},
],
minimumPreviewCycles: 3,
expectations: {
sourceBaseUrl: "https://pokeapi.co",
sourceAuth: "none",
requests: [
{ kind: "query", method: "GET", pathIncludes: "/api/v2/pokemon", trigger: "load" },
{ kind: "query", method: "GET", pathIncludes: "/api/v2/pokemon/{param:name}", trigger: "manual" },
],
templateIncludes: ["TextInput", "SubFetch", "Image", "types", "abilities", "stats", "Progress"],
},
},
{
id: "portainer-containers",
@@ -46,4 +105,157 @@ export const CUSTOM_WIDGET_AI_EVALUATION_CASES: readonly CustomWidgetAiEvaluatio
apiNotes:
"Use bearer auth. GET /api/states/{option:sensorEntity} and /api/states/{option:lightEntity} load entity state. POST /api/services/light/turn_on and /turn_off accept a body with entity_id from an option and should invalidate the light query.",
},
{
id: "fake-service-health",
documentationUrl: "https://status.example.test/docs",
request:
"Using the supplied fake API contract, create a polished service-health widget for a small homelab. Prioritize the overall state, incident count, latency, last check time, and a compact list of services. It must be immediately useful in narrow and wide dashboard tiles.",
apiNotes:
"This is a deterministic fake API. Use https://status.example.test with no authentication. GET /v1/health returns the exact sample response. Do not invent additional endpoints or actions.",
sampleResponse: {
status: "degraded",
checkedAt: "2026-08-10T12:00:00Z",
latencyMs: 184,
openIncidents: 1,
services: [
{ id: "media", name: "Media", status: "operational", latencyMs: 42 },
{ id: "photos", name: "Photos", status: "degraded", latencyMs: 331 },
{ id: "dns", name: "DNS", status: "operational", latencyMs: 8 },
],
},
expectations: {
sourceBaseUrl: "https://status.example.test",
sourceAuth: "none",
requests: [{ kind: "query", method: "GET", pathIncludes: "/v1/health" }],
templateIncludes: ["checkedAt", "latencyMs", "openIncidents", "services", "RefreshButton"],
},
},
{
id: "coinmarketcap-keyless",
documentationUrl: "https://coinmarketcap.com/api/documentation/pro-api-reference/keyless-public-api",
request:
"Create a compact but premium cryptocurrency watchlist for Bitcoin, Ethereum, and Solana. Show price, 24-hour change, market cap, volume, clear positive/negative styling, refresh context, and excellent narrow-tile behavior.",
apiNotes:
"Use the current keyless CoinMarketCap public API. The source is https://pro-api.coinmarketcap.com with no authentication. GET /public-api/v3/cryptocurrency/quotes/latest with id=1,1027,5426 and convert=USD so similarly named symbols cannot add unrelated assets. The response object has a data array; each asset has name, symbol, is_active, cmc_rank, quote[0].price, quote[0].percent_change_24h, quote[0].market_cap, quote[0].volume_24h, and last_updated.",
sampleResponse: {
data: [
{
id: 1,
name: "Bitcoin",
symbol: "BTC",
is_active: 1,
cmc_rank: 1,
quote: [
{
symbol: "USD",
price: 118_240.42,
percent_change_24h: 2.41,
market_cap: 2_354_000_000_000,
volume_24h: 48_700_000_000,
},
],
last_updated: "2026-08-10T12:00:00Z",
},
{
id: 1027,
name: "Ethereum",
symbol: "ETH",
is_active: 1,
cmc_rank: 2,
quote: [
{
symbol: "USD",
price: 4_210.18,
percent_change_24h: -1.13,
market_cap: 508_000_000_000,
volume_24h: 21_300_000_000,
},
],
last_updated: "2026-08-10T12:00:00Z",
},
{
id: 5426,
name: "Solana",
symbol: "SOL",
is_active: 1,
cmc_rank: 6,
quote: [
{
symbol: "USD",
price: 181.72,
percent_change_24h: 4.02,
market_cap: 98_400_000_000,
volume_24h: 5_900_000_000,
},
],
last_updated: "2026-08-10T12:00:00Z",
},
],
status: { timestamp: "2026-08-10T12:00:01Z", error_code: "0", error_message: "", elapsed: 6 },
},
expectations: {
sourceBaseUrl: "https://pro-api.coinmarketcap.com",
sourceAuth: "none",
requests: [
{
kind: "query",
method: "GET",
pathIncludes: "/public-api/v3/cryptocurrency/quotes/latest",
trigger: "load",
queryIncludes: { id: "1,1027,5426", convert: "USD" },
},
],
templateIncludes: ["?.data", "percent_change_24h", "market_cap", "volume_24h", "last_updated", "RefreshButton"],
},
},
{
id: "bored-activity",
documentationUrl: "https://bored-api.appbrewery.com/",
request:
"Create a delightful activity discovery widget. It should load one random activity, make the suggestion and practical constraints easy to scan, link to the activity when available, and provide a safe manual way to fetch another suggestion. It must feel useful in both a small dashboard tile and a wide one.",
apiNotes:
"Use https://bored-api.appbrewery.com with no authentication. GET /random returns one object with activity, availability, type, participants, price, accessibility, duration, kidFriendly, link, and key. The service is rate limited, so use a sensible query cache and do not create duplicate load requests.",
sampleResponse: {
activity: "Learn Express.js",
availability: 0.25,
type: "education",
participants: 1,
price: 0.1,
accessibility: "Few to no challenges",
duration: "hours",
kidFriendly: true,
link: "https://expressjs.com/",
key: "3943506",
},
expectations: {
sourceBaseUrl: "https://bored-api.appbrewery.com",
sourceAuth: "none",
requests: [{ kind: "query", method: "GET", pathIncludes: "/random", trigger: "load" }],
templateIncludes: ["activity", "participants", "price", "accessibility", "duration", "RefreshButton"],
},
},
{
id: "agify-name",
documentationUrl: "https://agify.io/documentation/api/reference",
request:
"Create a polished age-estimation lookup widget powered by Agify. Let the user enter a full name, manually run the prediction, optionally scope it with a two-letter country code, and clearly explain the estimate and evidence count without presenting it as certainty. Include thoughtful initial, loading, no-result, error, and success states.",
apiNotes:
"Use https://api.agify.io. Authentication is an API key supplied through the source as apiKeyQuery with query parameter name apikey; never embed it. The manual GET / request receives name from {param:name} and an optional country_id from {param:country}. A successful response has name, age (integer or null), count, and optionally country_id.",
sampleResponse: { name: "michael", age: 58, count: 108_496, country_id: "US" },
expectations: {
sourceBaseUrl: "https://api.agify.io",
sourceAuth: "apiKeyQuery",
sourceAuthName: "apikey",
requests: [
{
kind: "query",
method: "GET",
pathIncludes: "/",
trigger: "manual",
queryIncludes: { name: "$param:name", country_id: "$param:country" },
},
],
templateIncludes: ["TextInput", "SubFetch", "age", "count"],
},
},
] as const;
+151 -10
View File
@@ -4,13 +4,16 @@ import path from "node:path";
import { z } from "zod/v4";
import { buildCustomWidgetAiPrompt } from "../src/core/ai-prompt";
import { getCustomWidgetSkillContent } from "../src/core/authoring-resources";
import { customWidgetDefinitionSchema } from "../src/core/custom-jsx-schema";
import type { HomarrCustomWidgetV2 } from "../src/core/custom-jsx-schema";
import { formatCustomWidgetImportIssues, parseCustomWidgetAiResponse } from "../src/core/import";
import type { CustomWidgetAiEvaluationCase } from "./ai-evaluation-cases";
export const DEFAULT_GENERATOR_MODEL = "deepseek/deepseek-v4-pro";
export const DEFAULT_JUDGE_MODEL = "deepseek/deepseek-v4-flash";
// OpenRouter exposes the rolling "latest" route with a leading tilde. The non-tilde
// deepseek/deepseek-v4-flash-latest alias is rejected by the chat-completions API.
export const DEFAULT_GENERATOR_MODEL = "~deepseek/deepseek-v4-flash-latest";
export const DEFAULT_JUDGE_MODEL = "~deepseek/deepseek-v4-flash-latest";
export const MAX_AI_EVALUATION_LOOPS = 10;
const scoreSchema = z.number().int().min(0).max(100);
@@ -85,13 +88,101 @@ interface OpenRouterResponse {
export function buildEvaluationPrompt(testCase: CustomWidgetAiEvaluationCase): string {
return buildCustomWidgetAiPrompt(
undefined,
null,
getEvaluationResponseFixtureText(testCase),
null,
`${testCase.request}\n\nVerified API notes:\n${testCase.apiNotes}`,
testCase.documentationUrl,
);
}
export function getEvaluationResponseFixtureText(testCase: CustomWidgetAiEvaluationCase): string | null {
if (testCase.previewResponses?.length) {
return testCase.previewResponses
.map(({ pathIncludes, response }) => `${pathIncludes}:\n${JSON.stringify(response, null, 2)}`)
.join("\n\n");
}
return testCase.sampleResponse === undefined ? null : JSON.stringify(testCase.sampleResponse, null, 2);
}
const getExpectedBindingValue = (value: unknown): string => {
if (typeof value === "string") return value;
if (typeof value !== "object" || value === null || Array.isArray(value)) return JSON.stringify(value);
if ("$param" in value && typeof value.$param === "string") return `$param:${value.$param}`;
if ("$option" in value && typeof value.$option === "string") return `$option:${value.$option}`;
return JSON.stringify(value);
};
export function getDeterministicEvaluationIssues(
testCase: CustomWidgetAiEvaluationCase,
widget: HomarrCustomWidgetV2,
): Array<{ path?: Array<string | number>; message: string }> {
const expectations = testCase.expectations;
if (!expectations) return [];
const issues: Array<{ path?: Array<string | number>; message: string }> = [];
const source = widget.sources.default;
if (!source || source.baseUrl !== expectations.sourceBaseUrl) {
issues.push({
path: ["sources", "default", "baseUrl"],
message: `Use the verified source URL ${expectations.sourceBaseUrl}.`,
});
}
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
if (authType !== expectations.sourceAuth) {
issues.push({
path: ["sources", "default", "auth"],
message: `Use the verified ${expectations.sourceAuth} authentication mode.`,
});
}
if (
expectations.sourceAuth === "apiKeyQuery" &&
typeof source?.auth === "object" &&
source.auth.name !== expectations.sourceAuthName
) {
issues.push({
path: ["sources", "default", "auth", "name"],
message: `Use '${expectations.sourceAuthName}' as the verified API-key query parameter.`,
});
}
const matchedRequestIds = new Set<string>();
for (const expected of expectations.requests) {
const match = Object.entries(widget.requests).find(([requestId, request]) => {
if (matchedRequestIds.has(requestId)) return false;
if (
request.kind !== expected.kind ||
request.method !== expected.method ||
!request.path.includes(expected.pathIncludes) ||
(expected.trigger !== undefined && request.trigger !== expected.trigger)
) {
return false;
}
return Object.entries(expected.queryIncludes ?? {}).every(
([key, value]) => getExpectedBindingValue(request.query?.[key]) === value,
);
});
if (!match) {
issues.push({
path: ["requests"],
message:
`Define the verified ${expected.trigger ?? ""} ${expected.kind} request ${expected.method} ${expected.pathIncludes} with its documented query bindings.`.replace(
/\s+/gu,
" ",
),
});
} else {
matchedRequestIds.add(match[0]);
}
}
for (const requiredText of expectations.templateIncludes ?? []) {
if (!widget.template.includes(requiredText)) {
issues.push({
path: ["template"],
message: `Render or use the required '${requiredText}' capability from the verified response and request.`,
});
}
}
return issues;
}
export function buildRepairPrompt(
originalPrompt: string,
previousResponse: string,
@@ -106,12 +197,20 @@ export function buildRepairPrompt(
export function buildJudgePrompt(testCase: CustomWidgetAiEvaluationCase, widget: HomarrCustomWidgetV2): string {
return `You are a hostile-but-fair product review panel evaluating a safe dashboard widget. Most competent drafts should score 55-75, not 90. Judge only evidence present in the manifest and JSX. Never reward unsupported capabilities, invented API routes, aspirational claims, or code that merely validates.
The installed Homarr skill and runtime references below are authoritative. In particular, request state is exposed as status.<requestId> with loading/ok/status/error fields while successful payloads are exposed as data.<requestId>. RefreshButton is an installed runtime helper that manually refreshes load queries. Every component in this already-validated template exists in the installed release. Do not penalize those documented facts. The widget has already passed Homarr's real schema and JSX analyzer, which proves syntax and component compatibility but does not prove API correctness, visual quality, usefulness, or accessibility.
Installed Homarr Custom Widget authoring contract:
${getCustomWidgetSkillContent()}
Request:
${testCase.request}
Verified API notes:
${testCase.apiNotes}
Representative API response used by the preview test:
${getEvaluationResponseFixtureText(testCase) ?? "Not supplied."}
Validated widget:
${JSON.stringify(widget, null, 2)}
@@ -128,8 +227,10 @@ Required review behavior:
- Judge visual quality, not component count: hierarchy, density, whitespace, typography, restrained color, scanability, and avoidance of repetitive nested cards.
- Judge daily usefulness: information priority, interaction cost, refresh behavior, narrow-tile usability, and whether the widget is pleasant rather than demo-like.
- Judge complexity discipline: penalize duplicate requests/options, unnecessary controls, excessive JSX, cleverness, and UI chrome that does not help the goal. Complexity must earn its place.
- Recommend only interactions supported by the installed authoring contract. Do not suggest portals, modals, arbitrary event handlers, or other blocked capabilities. Prefer a responsive in-widget detail area when separation is useful.
- A visually generic but valid widget should normally score below 75 for visualQuality. A widget that is attractive but inconvenient should score below 75 for dailyUsefulness.
- Give a concrete evidence sentence for every category. List all score-capping issues under fatalProblems.
- Return an empty fatalProblems array when there are no fatal problems; never put "none" or an explanation of their absence in that array.
Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 90, every category at least 75, goalFulfillment/visualQuality/dailyUsefulness at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`;
}
@@ -158,6 +259,30 @@ export function getJudgeResponseFormat() {
};
}
export async function requestCustomWidgetJudge(args: {
testCase: CustomWidgetAiEvaluationCase;
widget: HomarrCustomWidgetV2;
apiKey: string;
judgeModel?: string;
}) {
return callOpenRouter({
apiKey: args.apiKey,
model: args.judgeModel ?? DEFAULT_JUDGE_MODEL,
prompt: buildJudgePrompt(args.testCase, args.widget),
purpose: "judge",
});
}
export async function judgeCustomWidgetCase(args: {
testCase: CustomWidgetAiEvaluationCase;
widget: HomarrCustomWidgetV2;
apiKey: string;
judgeModel?: string;
}) {
const raw = await requestCustomWidgetJudge(args);
return { raw, result: parseJudgeResult(raw) };
}
export async function evaluateCustomWidgetCase(args: {
testCase: CustomWidgetAiEvaluationCase;
apiKey: string;
@@ -200,17 +325,25 @@ export async function evaluateCustomWidgetCase(args: {
}
const canonical = customWidgetDefinitionSchema.parse(parsed.widget);
const deterministicIssues = getDeterministicEvaluationIssues(args.testCase, canonical);
if (deterministicIssues.length > 0) {
errors.push(
`Attempt ${attempt}: deterministic scenario checks failed — ${deterministicIssues.map((issue) => issue.message).join("; ")}`,
);
prompt = buildRepairPrompt(originalPrompt, response, deterministicIssues);
continue;
}
await writeWidgetFiles(caseDirectory, canonical, `attempt-${attempt}`);
let judge: CustomWidgetJudgeResult;
try {
const judgeRaw = await callOpenRouter({
const { raw: judgeRaw, result } = await judgeCustomWidgetCase({
testCase: args.testCase,
widget: canonical,
apiKey: args.apiKey,
model: args.judgeModel ?? DEFAULT_JUDGE_MODEL,
prompt: buildJudgePrompt(args.testCase, canonical),
purpose: "judge",
judgeModel: args.judgeModel,
});
await writeFile(path.join(caseDirectory, `judge-${attempt}.json`), judgeRaw, "utf8");
judge = parseJudgeResult(judgeRaw);
judge = result;
} catch (error) {
errors.push(
`Attempt ${attempt}: judge response invalid — ${error instanceof Error ? error.message : "Unknown error"}`,
@@ -253,14 +386,22 @@ export async function evaluateCustomWidgetCase(args: {
}
export function parseJudgeResult(raw: string): CustomWidgetJudgeResult {
const parsed: unknown = JSON.parse(raw);
const normalizedRaw = raw
.trim()
.replace(/^```(?:json)?\s*/iu, "")
.replace(/\s*```$/u, "");
const parsed: unknown = JSON.parse(normalizedRaw);
const result = judgeResultSchema.parse(parsed);
const fatalProblems = result.fatalProblems.filter(
(problem) => !/^(?:none|no fatal problems?)(?:\b|\s*[:.-])/iu.test(problem.trim()),
);
const weightedTotal = Object.entries(categoryWeights).reduce(
(sum, [category, weight]) => sum + result.categories[category as keyof typeof categoryWeights] * weight,
0,
);
const normalized = {
...result,
fatalProblems,
total: Math.round(weightedTotal / 100),
};
return { ...normalized, verdict: judgePasses(normalized) ? "pass" : "fail" };
@@ -290,7 +431,7 @@ async function callOpenRouter(args: {
messages: [{ role: "user", content: args.prompt }],
temperature: isJudge ? 0 : 0.2,
max_tokens: isJudge ? 8_000 : 20_000,
reasoning: { effort: "xhigh", exclude: true },
reasoning: isJudge ? { enabled: false, exclude: true } : { effort: "high", exclude: true },
...(isJudge ? { response_format: getJudgeResponseFormat() } : {}),
}),
signal: AbortSignal.timeout(180_000),
@@ -2,6 +2,8 @@ import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { CUSTOM_WIDGET_AI_EVALUATION_CASES } from "./ai-evaluation-cases";
import { evaluateCustomWidgetAssistantCase } from "./ai-assistant-evaluation";
import type { CustomWidgetAssistantEvaluationResult } from "./ai-assistant-evaluation";
import { DEFAULT_GENERATOR_MODEL, DEFAULT_JUDGE_MODEL, evaluateCustomWidgetCase } from "./ai-evaluation";
import type { AiEvaluationResult } from "./ai-evaluation";
@@ -9,11 +11,19 @@ const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) throw new Error("OPENROUTER_API_KEY is required for the live Custom Widget AI evaluation");
const requestedCase = process.argv.find((value) => value.startsWith("--case="))?.slice("--case=".length);
const assistantMode = process.argv.includes("--assistant");
const configuredLoops = Number(process.env.CUSTOM_WIDGET_AI_MAX_LOOPS ?? 10);
const totalLoopBudget = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10;
const selectableCases = assistantMode
? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter(
(testCase) =>
(testCase.sampleResponse !== undefined || testCase.previewResponses?.length) &&
testCase.expectations !== undefined,
)
: CUSTOM_WIDGET_AI_EVALUATION_CASES;
const selectedCases = requestedCase
? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase)
: CUSTOM_WIDGET_AI_EVALUATION_CASES;
: selectableCases;
if (selectedCases.length === 0) throw new Error(`Unknown AI evaluation case '${requestedCase}'`);
if (totalLoopBudget < selectedCases.length) {
throw new Error(`CUSTOM_WIDGET_AI_MAX_LOOPS must be at least ${selectedCases.length} for this run`);
@@ -23,20 +33,29 @@ const runId = new Date().toISOString().replaceAll(/[:.]/gu, "-");
const outputRoot = path.resolve(process.cwd(), ".ai-evaluations", runId);
await mkdir(outputRoot, { recursive: true });
const results: AiEvaluationResult[] = [];
const results: Array<AiEvaluationResult | CustomWidgetAssistantEvaluationResult> = [];
let remainingLoopBudget = totalLoopBudget;
for (const testCase of selectedCases) {
const remainingCases = selectedCases.length - results.length;
const maxLoops = Math.max(1, Math.floor(remainingLoopBudget / remainingCases));
process.stdout.write(`Evaluating ${testCase.id}...\n`);
const result = await evaluateCustomWidgetCase({
testCase,
apiKey,
outputRoot,
maxLoops,
generatorModel: process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL,
judgeModel: process.env.OPENROUTER_JUDGE_MODEL ?? DEFAULT_JUDGE_MODEL,
});
const result = assistantMode
? await evaluateCustomWidgetAssistantCase({
testCase,
apiKey,
outputRoot,
maxLoops,
generatorModel: process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL,
judgeModel: process.env.OPENROUTER_JUDGE_MODEL ?? DEFAULT_JUDGE_MODEL,
})
: await evaluateCustomWidgetCase({
testCase,
apiKey,
outputRoot,
maxLoops,
generatorModel: process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL,
judgeModel: process.env.OPENROUTER_JUDGE_MODEL ?? DEFAULT_JUDGE_MODEL,
});
results.push(result);
remainingLoopBudget -= result.attempts;
process.stdout.write(
@@ -48,6 +67,7 @@ for (const testCase of selectedCases) {
const summary = {
generatedAt: new Date().toISOString(),
mode: assistantMode ? "assistant-tool-loop" : "manifest",
generatorModel: process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL,
judgeModel: process.env.OPENROUTER_JUDGE_MODEL ?? DEFAULT_JUDGE_MODEL,
results: results.map((result) => ({
@@ -57,6 +77,7 @@ const summary = {
verdict: result.judge?.verdict ?? "fail",
categories: result.judge?.categories ?? null,
errors: result.errors,
calledTools: "calledTools" in result ? result.calledTools : undefined,
outputDirectory: path.relative(process.cwd(), result.outputDirectory),
})),
};
@@ -2,6 +2,11 @@ import { customJsxRequestSchema } from "../src/core/request-schema";
import { executeCustomWidgetRequest } from "../src/server/request-executor";
import { renderRequestTarget, resolveCustomWidgetRequestValues } from "../src/server/request-manifest";
if (process.argv.includes("--public-ai-apis-only")) {
await verifyPublicAiEvaluationApis();
process.exit(0);
}
const pokemonListRequest = customJsxRequestSchema.parse({
path: "/api/v2/pokemon",
query: { limit: { $option: "limit" }, offset: 0 },
@@ -136,3 +141,52 @@ async function runPortainerAction(action: string, containerId: string, ignoreCon
throw new Error(`Portainer ${action} returned HTTP ${response.status}`);
}
}
async function verifyPublicAiEvaluationApis() {
const bored = await fetchJson(new URL("https://bored-api.appbrewery.com/random"), {});
if (!isRecord(bored) || typeof bored.activity !== "string" || typeof bored.participants !== "number") {
throw new Error("Bored response did not match the evaluation fixture contract");
}
process.stdout.write("Bored activity request passed\n");
const coinMarketCapRequest = customJsxRequestSchema.parse({
path: "/public-api/v3/cryptocurrency/quotes/latest",
query: { id: "1,1027,5426", convert: "USD" },
});
const coinMarketCapTarget = renderRequestTarget(
"https://pro-api.coinmarketcap.com",
coinMarketCapRequest,
resolveCustomWidgetRequestValues(coinMarketCapRequest, {}),
);
const response = await fetchJson(coinMarketCapTarget, {});
const assets = isRecord(response) ? response.data : undefined;
if (
!Array.isArray(assets) ||
!["BTC", "ETH", "SOL"].every((symbol) =>
assets.some((asset) => isRecord(asset) && asset.symbol === symbol && Array.isArray(asset.quote)),
)
) {
throw new Error("CoinMarketCap response did not match the evaluation fixture contract");
}
process.stdout.write("CoinMarketCap keyless watchlist request passed\n");
const agifyApiKey = process.env.AGIFY_API_KEY;
if (!agifyApiKey) {
process.stdout.write("Agify live request skipped (AGIFY_API_KEY is not configured)\n");
return;
}
const agifyTarget = new URL("https://api.agify.io/");
agifyTarget.searchParams.set("name", "michael");
agifyTarget.searchParams.set("country_id", "US");
agifyTarget.searchParams.set("apikey", agifyApiKey);
const prediction = await fetchJson(agifyTarget, {});
if (
!isRecord(prediction) ||
typeof prediction.name !== "string" ||
!(typeof prediction.age === "number" || prediction.age === null) ||
typeof prediction.count !== "number"
) {
throw new Error("Agify response did not match the evaluation fixture contract");
}
process.stdout.write("Agify authenticated lookup request passed\n");
}