fix: add proxy eval coverage for gemini schemas (#8897)

* feat: add proxy transport for ai evals

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: strip propertyNames for gemini schemas

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: require explicit eval transport

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-04-21 18:03:44 +02:00
committed by GitHub
parent aaf3a19747
commit fddd8e288f
24 changed files with 1926 additions and 1055 deletions
+12 -1
View File
@@ -55,6 +55,7 @@ bun run cli -- run flow flow-test4-order-processing-loop --model opus
bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o
bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose
bun run cli -- run flow --record
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro --transport proxy
WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview
bun run cli -- run cli bun-hello-script
```
@@ -71,6 +72,7 @@ Public CLI surface:
- `--output <path>`: custom result JSON path
- `--model <alias>`: choose the model under test
- `--models <a,b,c>`: run the same cases sequentially against several model aliases
- `--transport <mode>`: frontend request transport (`direct` by default, `proxy` to exercise `/api/w/{workspace}/ai/proxy`)
- `--verbose`: stream assistant output for frontend runs
- `--record`: append a compact tracked summary line to `ai_evals/history/<mode>.jsonl` for full-suite runs only
- `--backend-validation <mode>`: optional backend smoke validation (`off` or `preview`) for `script` and `flow` evals
@@ -155,6 +157,15 @@ Supported backend validation env vars:
- `WMILL_AI_EVAL_KEEP_WORKSPACES=1`
- `WMILL_AI_EVAL_WORKSPACE_PREFIX=ai-evals`
Frontend proxy transport uses the same backend auth/workspace env vars.
When `--transport proxy` is set:
- `ai_evals` creates or reuses a backend workspace
- it upserts a provider resource under `f/evals/ai/<provider>`
- frontend requests go through `/api/w/{workspace}/ai/proxy`
- result JSON and history records include `transport` so direct vs proxy runs stay distinguishable
## Results And Artifacts
Every run writes:
@@ -171,7 +182,7 @@ If `--record` is used, the CLI also appends one compact JSON line to:
Each recorded line contains:
- run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `judgeModel`)
- run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `transport`, `judgeModel`)
- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averageJudgeScore`)
- average token usage (`averageTokenUsagePerAttempt`)
- per-case metrics under `cases[]` (`averageDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, pass rate)
+46 -12
View File
@@ -1,5 +1,6 @@
import { loadSelectedCases } from "../../core/cases";
import { resolveBackendValidationSettings } from "../../core/backendValidation";
import { resolveFrontendEvalTransportSettings } from "../../core/frontendTransport";
import {
formatRunModelLabel,
getFrontendEvalModel,
@@ -18,18 +19,35 @@ export type FrontendBenchmarkMode = "flow" | "app" | "script";
export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult> {
const mode = parseMode(process.env.WMILL_FRONTEND_AI_EVAL_MODE);
const caseIds = parseOptionalJsonStringArray(process.env.WMILL_FRONTEND_AI_EVAL_CASE_IDS);
const runs = parsePositiveInteger(process.env.WMILL_FRONTEND_AI_EVAL_RUNS, "WMILL_FRONTEND_AI_EVAL_RUNS");
const caseIds = parseOptionalJsonStringArray(
process.env.WMILL_FRONTEND_AI_EVAL_CASE_IDS,
);
const runs = parsePositiveInteger(
process.env.WMILL_FRONTEND_AI_EVAL_RUNS,
"WMILL_FRONTEND_AI_EVAL_RUNS",
);
const emitProgress = process.env.WMILL_FRONTEND_AI_EVAL_PROGRESS === "1";
const verbose = process.env.WMILL_FRONTEND_AI_EVAL_VERBOSE === "1";
const model = resolveEvalModel(mode, process.env.WMILL_FRONTEND_AI_EVAL_MODEL);
const model = resolveEvalModel(
mode,
process.env.WMILL_FRONTEND_AI_EVAL_MODEL,
);
const backendValidation = resolveBackendValidationSettings({
evalMode: mode,
requestedMode: process.env.WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION,
});
const transportSettings = resolveFrontendEvalTransportSettings({
evalMode: mode,
requestedTransport: process.env.WMILL_FRONTEND_AI_EVAL_TRANSPORT,
});
const selectedCases = await loadSelectedCases(mode, caseIds);
const modeRunner = getModeRunner(mode, getFrontendEvalModel(model), backendValidation);
const modeRunner = getModeRunner(
mode,
getFrontendEvalModel(model),
backendValidation,
transportSettings,
);
const runModel = formatRunModelLabel(mode, model);
const caseResults = await runSuite({
modeRunner,
@@ -39,13 +57,16 @@ export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult>
judgeModel: DEFAULT_JUDGE_MODEL,
concurrency: verbose ? 1 : undefined,
verbose,
onProgress: emitProgress ? (event) => emitFrontendBenchmarkProgress(event) : undefined,
onProgress: emitProgress
? (event) => emitFrontendBenchmarkProgress(event)
: undefined,
});
return buildRunResult({
mode,
runs,
runModel,
transport: transportSettings.transport,
judgeModel: DEFAULT_JUDGE_MODEL,
caseResults,
});
@@ -54,15 +75,20 @@ export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult>
function getModeRunner(
mode: FrontendBenchmarkMode,
model: ReturnType<typeof getFrontendEvalModel>,
backendValidation: ReturnType<typeof resolveBackendValidationSettings>
backendValidation: ReturnType<typeof resolveBackendValidationSettings>,
transportSettings: ReturnType<typeof resolveFrontendEvalTransportSettings>,
): ModeRunner<any, any, any> {
switch (mode) {
case "flow":
return createFlowModeRunner(model, backendValidation);
return createFlowModeRunner(model, backendValidation, transportSettings);
case "app":
return createAppModeRunner(model);
return createAppModeRunner(model, transportSettings);
case "script":
return createScriptModeRunner(model, backendValidation);
return createScriptModeRunner(
model,
backendValidation,
transportSettings,
);
}
}
@@ -78,13 +104,21 @@ function parseOptionalJsonStringArray(value: string | undefined): string[] {
return [];
}
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) {
throw new Error("WMILL_FRONTEND_AI_EVAL_CASE_IDS must be a JSON string array");
if (
!Array.isArray(parsed) ||
parsed.some((entry) => typeof entry !== "string")
) {
throw new Error(
"WMILL_FRONTEND_AI_EVAL_CASE_IDS must be a JSON string array",
);
}
return parsed;
}
function parsePositiveInteger(value: string | undefined, envName: string): number {
function parsePositiveInteger(
value: string | undefined,
envName: string,
): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${envName} must be a positive integer`);
@@ -1,95 +1,106 @@
import { mkdtemp } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { mkdtemp } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import type {
BackendRunnable,
AppAIChatHelpers
} from '../../../../../frontend/src/lib/components/copilot/chat/app/core'
BackendRunnable,
AppAIChatHelpers,
} from "../../../../../frontend/src/lib/components/copilot/chat/app/core";
import {
getAppTools,
prepareAppSystemMessage,
prepareAppUserMessage
} from '../../../../../frontend/src/lib/components/copilot/chat/app/core'
import type { Tool as ProductionTool } from '../../../../../frontend/src/lib/components/copilot/chat/shared'
import { createAppFileHelpers } from './fileHelpers'
import { runEval } from '../shared'
import type { AIProvider } from '$lib/gen/types.gen'
import type { ModeRunContext } from '../../../../core/types'
import type { TokenUsage } from '../shared/types'
import type { AppFilesState } from '../../../../core/validators'
getAppTools,
prepareAppSystemMessage,
prepareAppUserMessage,
} from "../../../../../frontend/src/lib/components/copilot/chat/app/core";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import { createAppFileHelpers } from "./fileHelpers";
import { runEval } from "../shared";
import type { AIProvider } from "$lib/gen/types.gen";
import type { ModeRunContext } from "../../../../core/types";
import type { TokenUsage } from "../shared/types";
import type { AppFilesState } from "../../../../core/validators";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
export interface AppEvalResult {
success: boolean
files: AppFilesState
error?: string
assistantMessageCount: number
toolCallCount: number
toolsUsed: string[]
tokenUsage: TokenUsage
success: boolean;
files: AppFilesState;
error?: string;
assistantMessageCount: number;
toolCallCount: number;
toolsUsed: string[];
tokenUsage: TokenUsage;
}
export interface AppEvalOptions {
initialFrontend?: Record<string, string>
initialBackend?: Record<string, BackendRunnable>
initialDatatables?: AppFilesState['datatables']
model?: string
maxIterations?: number
provider?: AIProvider
workspaceRoot?: string
runContext?: ModeRunContext
initialFrontend?: Record<string, string>;
initialBackend?: AppFilesState["backend"];
initialDatatables?: AppFilesState["datatables"];
model?: string;
maxIterations?: number;
provider?: AIProvider;
transport?: FrontendEvalTransport;
backend?: WindmillBackendSettings;
workspaceRoot?: string;
runContext?: ModeRunContext;
}
export async function runAppEval(
userPrompt: string,
apiKey: string,
options?: AppEvalOptions
userPrompt: string,
apiKey: string,
options?: AppEvalOptions,
): Promise<AppEvalResult> {
const workspaceRoot =
options?.workspaceRoot ??
(await mkdtemp(join(tmpdir(), 'wmill-frontend-app-benchmark-')))
const { helpers, getEvalState, cleanup } = await createAppFileHelpers(
options?.initialFrontend ?? {},
options?.initialBackend ?? {},
options?.initialDatatables ?? [],
workspaceRoot
)
const workspaceRoot =
options?.workspaceRoot ??
(await mkdtemp(join(tmpdir(), "wmill-frontend-app-benchmark-")));
const { helpers, getEvalState, cleanup } = await createAppFileHelpers(
options?.initialFrontend ?? {},
(options?.initialBackend ?? {}) as Record<string, BackendRunnable>,
options?.initialDatatables ?? [],
workspaceRoot,
);
try {
const systemMessage = prepareAppSystemMessage()
const tools = getAppTools() as ProductionTool<AppAIChatHelpers>[]
const model = options?.model ?? 'claude-haiku-4-5-20251001'
const userMessage = prepareAppUserMessage(userPrompt, helpers.getSelectedContext())
try {
const systemMessage = prepareAppSystemMessage();
const tools = getAppTools() as ProductionTool<AppAIChatHelpers>[];
const model = options?.model ?? "claude-haiku-4-5-20251001";
const userMessage = prepareAppUserMessage(
userPrompt,
helpers.getSelectedContext(),
);
const rawResult = await runEval({
userPrompt,
systemMessage,
userMessage,
tools,
helpers,
apiKey,
getOutput: getEvalState,
onAssistantMessageStart: options?.runContext?.onAssistantMessageStart,
onAssistantToken: options?.runContext?.onAssistantChunk,
onAssistantMessageEnd: options?.runContext?.onAssistantMessageEnd,
onToolCall: options?.runContext?.onToolCall,
options: {
maxIterations: options?.maxIterations,
model,
workspace: workspaceRoot,
provider: options?.provider
}
})
const rawResult = await runEval({
userPrompt,
systemMessage,
userMessage,
tools,
helpers,
apiKey,
getOutput: getEvalState,
onAssistantMessageStart: options?.runContext?.onAssistantMessageStart,
onAssistantToken: options?.runContext?.onAssistantChunk,
onAssistantMessageEnd: options?.runContext?.onAssistantMessageEnd,
onToolCall: options?.runContext?.onToolCall,
options: {
maxIterations: options?.maxIterations,
model,
workspace: workspaceRoot,
provider: options?.provider,
transport: options?.transport,
backend: options?.backend,
proxyCaseId: options?.runContext?.caseId,
proxyAttempt: options?.runContext?.attempt,
},
});
return {
files: rawResult.output,
success: rawResult.success,
error: rawResult.error,
assistantMessageCount: rawResult.iterations,
toolCallCount: rawResult.toolCallsCount,
toolsUsed: rawResult.toolsCalled,
tokenUsage: rawResult.tokenUsage
}
} finally {
await cleanup()
}
return {
files: rawResult.output,
success: rawResult.success,
error: rawResult.error,
assistantMessageCount: rawResult.iterations,
toolCallCount: rawResult.toolCallsCount,
toolsUsed: rawResult.toolsCalled,
tokenUsage: rawResult.tokenUsage,
};
} finally {
await cleanup();
}
}
@@ -1,16 +1,16 @@
import type {
BackendRunnable,
DataTableSchema,
InlineScript
} from '../../../../../frontend/src/lib/components/copilot/chat/app/core'
import type { AppFilesState } from '../../../../core/validators'
BackendRunnable,
DataTableSchema,
InlineScript,
} from "../../../../../frontend/src/lib/components/copilot/chat/app/core";
import type { AppFilesState } from "../../../../core/validators";
/**
* Backend runnable metadata stored in meta.json files.
*/
interface BackendMeta {
name: string
language: 'bun' | 'python3'
name: string;
language: "bun" | "python3";
}
/**
@@ -18,49 +18,53 @@ interface BackendMeta {
* File paths are relative to the base directory with a leading '/'.
*/
async function readFilesRecursively(
dir: string,
basePath: string = ''
dir: string,
basePath: string = "",
): Promise<Record<string, string>> {
// @ts-ignore - Node.js fs/promises
const { readdir, readFile } = await import('fs/promises')
// @ts-ignore - Node.js path
const { join } = await import('path')
// @ts-ignore - Node.js fs/promises
const { readdir, readFile } = await import("fs/promises");
// @ts-ignore - Node.js path
const { join } = await import("path");
const result: Record<string, string> = {}
const entries = await readdir(dir, { withFileTypes: true })
const result: Record<string, string> = {};
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name)
const relativePath = basePath ? `${basePath}/${entry.name}` : `/${entry.name}`
for (const entry of entries) {
const fullPath = join(dir, entry.name);
const relativePath = basePath
? `${basePath}/${entry.name}`
: `/${entry.name}`;
if (entry.isDirectory()) {
const subFiles = await readFilesRecursively(fullPath, relativePath)
Object.assign(result, subFiles)
} else {
const content = await readFile(fullPath, 'utf-8')
result[relativePath] = content
}
}
if (entry.isDirectory()) {
const subFiles = await readFilesRecursively(fullPath, relativePath);
Object.assign(result, subFiles);
} else {
const content = await readFile(fullPath, "utf-8");
result[relativePath] = content;
}
}
return result
return result;
}
/**
* Loads frontend files from a directory.
* All files are read recursively and paths become keys with leading '/'.
*/
async function loadFrontend(frontendPath: string): Promise<Record<string, string>> {
// @ts-ignore - Node.js fs/promises
const { access } = await import('fs/promises')
async function loadFrontend(
frontendPath: string,
): Promise<Record<string, string>> {
// @ts-ignore - Node.js fs/promises
const { access } = await import("fs/promises");
try {
await access(frontendPath)
} catch {
// Directory doesn't exist, return empty
return {}
}
try {
await access(frontendPath);
} catch {
// Directory doesn't exist, return empty
return {};
}
return readFilesRecursively(frontendPath)
return readFilesRecursively(frontendPath);
}
/**
@@ -69,78 +73,89 @@ async function loadFrontend(frontendPath: string): Promise<Record<string, string
* - main.ts or main.py: The code content
* - meta.json: Metadata { name, language }
*/
async function loadBackend(backendPath: string): Promise<Record<string, BackendRunnable>> {
// @ts-ignore - Node.js fs/promises
const { readdir, readFile, access } = await import('fs/promises')
// @ts-ignore - Node.js path
const { join } = await import('path')
async function loadBackend(
backendPath: string,
): Promise<Record<string, BackendRunnable>> {
// @ts-ignore - Node.js fs/promises
const { readdir, readFile, access } = await import("fs/promises");
// @ts-ignore - Node.js path
const { join } = await import("path");
try {
await access(backendPath)
} catch {
// Directory doesn't exist, return empty
return {}
}
try {
await access(backendPath);
} catch {
// Directory doesn't exist, return empty
return {};
}
const result: Record<string, BackendRunnable> = {}
const entries = await readdir(backendPath, { withFileTypes: true })
const result: Record<string, BackendRunnable> = {};
const entries = await readdir(backendPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const runnableKey = entry.name
const runnablePath = join(backendPath, entry.name)
const runnableKey = entry.name;
const runnablePath = join(backendPath, entry.name);
// Read meta.json
const metaPath = join(runnablePath, 'meta.json')
let meta: BackendMeta
try {
const metaContent = await readFile(metaPath, 'utf-8')
meta = JSON.parse(metaContent)
} catch {
console.warn(`Missing or invalid meta.json for runnable '${runnableKey}', skipping`)
continue
}
// Read meta.json
const metaPath = join(runnablePath, "meta.json");
let meta: BackendMeta;
try {
const metaContent = await readFile(metaPath, "utf-8");
meta = JSON.parse(metaContent);
} catch {
console.warn(
`Missing or invalid meta.json for runnable '${runnableKey}', skipping`,
);
continue;
}
// Find and read the main file (main.ts or main.py)
const runnableFiles = await readdir(runnablePath)
const mainFile = runnableFiles.find((f) => f === 'main.ts' || f === 'main.py')
// Find and read the main file (main.ts or main.py)
const runnableFiles = await readdir(runnablePath);
const mainFile = runnableFiles.find(
(f) => f === "main.ts" || f === "main.py",
);
if (!mainFile) {
console.warn(`No main.ts or main.py found for runnable '${runnableKey}', skipping`)
continue
}
if (!mainFile) {
console.warn(
`No main.ts or main.py found for runnable '${runnableKey}', skipping`,
);
continue;
}
const content = await readFile(join(runnablePath, mainFile), 'utf-8')
const content = await readFile(join(runnablePath, mainFile), "utf-8");
const inlineScript: InlineScript = {
language: meta.language,
content
}
const inlineScript: InlineScript = {
language: meta.language,
content,
};
result[runnableKey] = {
name: meta.name,
type: 'inline',
inlineScript
}
}
result[runnableKey] = {
name: meta.name,
type: "inline",
inlineScript,
};
}
return result
return result;
}
async function loadDatatables(fixturePath: string): Promise<DataTableSchema[]> {
// @ts-ignore - Node.js fs/promises
const { readFile } = await import('fs/promises')
// @ts-ignore - Node.js path
const { join } = await import('path')
// @ts-ignore - Node.js fs/promises
const { readFile } = await import("fs/promises");
// @ts-ignore - Node.js path
const { join } = await import("path");
try {
const content = await readFile(join(fixturePath, 'datatables.json'), 'utf-8')
const parsed = JSON.parse(content)
return Array.isArray(parsed) ? (parsed as DataTableSchema[]) : []
} catch {
return []
}
try {
const content = await readFile(
join(fixturePath, "datatables.json"),
"utf-8",
);
const parsed = JSON.parse(content);
return Array.isArray(parsed) ? (parsed as DataTableSchema[]) : [];
} catch {
return [];
}
}
/**
@@ -162,32 +177,32 @@ async function loadDatatables(fixturePath: string): Promise<DataTableSchema[]> {
* @param fixturePath - Path to the fixture directory
* @returns AppFiles object with frontend and backend
*/
export async function loadAppFixture(fixturePath: string): Promise<AppFilesState> {
// @ts-ignore - Node.js path
const { join } = await import('path')
export async function loadAppFixture(
fixturePath: string,
): Promise<AppFilesState> {
// @ts-ignore - Node.js path
const { join } = await import("path");
const frontend = await loadFrontend(join(fixturePath, 'frontend'))
const backend = await loadBackend(join(fixturePath, 'backend'))
const datatables = await loadDatatables(fixturePath)
const frontend = await loadFrontend(join(fixturePath, "frontend"));
const backend = await loadBackend(join(fixturePath, "backend"));
const datatables = await loadDatatables(fixturePath);
return { frontend, backend, datatables }
return { frontend, backend, datatables };
}
/**
* Loads an app fixture and returns the separate frontend and backend objects.
* Convenience function for use with runAppEval options.
*/
export async function loadAppFixtureForEval(
fixturePath: string
): Promise<{
initialFrontend: Record<string, string>
initialBackend: Record<string, BackendRunnable>
initialDatatables: DataTableSchema[]
export async function loadAppFixtureForEval(fixturePath: string): Promise<{
initialFrontend: Record<string, string>;
initialBackend: AppFilesState["backend"];
initialDatatables: DataTableSchema[];
}> {
const { frontend, backend, datatables } = await loadAppFixture(fixturePath)
return {
initialFrontend: frontend,
initialBackend: backend,
initialDatatables: datatables
}
const { frontend, backend, datatables } = await loadAppFixture(fixturePath);
return {
initialFrontend: frontend,
initialBackend: backend,
initialDatatables: datatables,
};
}
@@ -1,108 +1,119 @@
import { mkdtemp } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import type { FlowModule } from '$lib/gen'
import type { AIProvider } from '$lib/gen/types.gen'
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
import { mkdtemp } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import type { FlowModule } from "$lib/gen";
import type { AIProvider } from "$lib/gen/types.gen";
import type { ExtendedOpenFlow } from "$lib/components/flows/types";
import {
flowTools,
prepareFlowSystemMessage,
prepareFlowUserMessage,
type FlowAIChatHelpers
} from '../../../../../frontend/src/lib/components/copilot/chat/flow/core'
import type { Tool as ProductionTool } from '../../../../../frontend/src/lib/components/copilot/chat/shared'
import { createFlowFileHelpers, type FlowWorkspaceFixtures } from './fileHelpers'
import { runEval } from '../shared'
import type { ModeRunContext } from '../../../../core/types'
import type { TokenUsage } from '../shared/types'
flowTools,
prepareFlowSystemMessage,
prepareFlowUserMessage,
type FlowAIChatHelpers,
} from "../../../../../frontend/src/lib/components/copilot/chat/flow/core";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import {
createFlowFileHelpers,
type FlowWorkspaceFixtures,
} from "./fileHelpers";
import { runEval } from "../shared";
import type { ModeRunContext } from "../../../../core/types";
import type { TokenUsage } from "../shared/types";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
export interface FlowFixture {
value?: {
modules?: FlowModule[]
preprocessor_module?: FlowModule
failure_module?: FlowModule
}
schema?: Record<string, unknown>
value?: {
modules?: FlowModule[];
preprocessor_module?: FlowModule;
failure_module?: FlowModule;
};
schema?: Record<string, unknown>;
}
export interface FlowEvalResult {
success: boolean
flow: ExtendedOpenFlow
error?: string
assistantMessageCount: number
toolCallCount: number
toolsUsed: string[]
tokenUsage: TokenUsage
success: boolean;
flow: ExtendedOpenFlow;
error?: string;
assistantMessageCount: number;
toolCallCount: number;
toolsUsed: string[];
tokenUsage: TokenUsage;
}
export interface FlowEvalOptions {
initialFlow?: FlowFixture
workspaceFixtures?: FlowWorkspaceFixtures
model?: string
maxIterations?: number
provider?: AIProvider
workspaceRoot?: string
runContext?: ModeRunContext
initialFlow?: FlowFixture;
workspaceFixtures?: FlowWorkspaceFixtures;
model?: string;
maxIterations?: number;
provider?: AIProvider;
transport?: FrontendEvalTransport;
backend?: WindmillBackendSettings;
workspaceRoot?: string;
runContext?: ModeRunContext;
}
export async function runFlowEval(
userPrompt: string,
apiKey: string,
options?: FlowEvalOptions
userPrompt: string,
apiKey: string,
options?: FlowEvalOptions,
): Promise<FlowEvalResult> {
const workspaceRoot =
options?.workspaceRoot ??
(await mkdtemp(join(tmpdir(), 'wmill-frontend-flow-benchmark-')))
const { helpers, getFlow, cleanup } = await createFlowFileHelpers(
options?.initialFlow?.value?.modules ?? [],
options?.initialFlow?.schema,
options?.initialFlow?.value?.preprocessor_module,
options?.initialFlow?.value?.failure_module,
workspaceRoot,
options?.workspaceFixtures
)
const workspaceRoot =
options?.workspaceRoot ??
(await mkdtemp(join(tmpdir(), "wmill-frontend-flow-benchmark-")));
const { helpers, getFlow, cleanup } = await createFlowFileHelpers(
options?.initialFlow?.value?.modules ?? [],
options?.initialFlow?.schema,
options?.initialFlow?.value?.preprocessor_module,
options?.initialFlow?.value?.failure_module,
workspaceRoot,
options?.workspaceFixtures,
);
try {
const systemMessage = prepareFlowSystemMessage()
const tools = flowTools as ProductionTool<FlowAIChatHelpers>[]
const model = options?.model ?? 'claude-haiku-4-5-20251001'
const userMessage = prepareFlowUserMessage(
userPrompt,
helpers.getFlowAndSelectedId(),
[],
helpers.inlineScriptSession
)
try {
const systemMessage = prepareFlowSystemMessage();
const tools = flowTools as ProductionTool<FlowAIChatHelpers>[];
const model = options?.model ?? "claude-haiku-4-5-20251001";
const userMessage = prepareFlowUserMessage(
userPrompt,
helpers.getFlowAndSelectedId(),
[],
helpers.inlineScriptSession,
);
const rawResult = await runEval({
userPrompt,
systemMessage,
userMessage,
tools,
helpers,
apiKey,
getOutput: getFlow,
onAssistantMessageStart: options?.runContext?.onAssistantMessageStart,
onAssistantToken: options?.runContext?.onAssistantChunk,
onAssistantMessageEnd: options?.runContext?.onAssistantMessageEnd,
onToolCall: options?.runContext?.onToolCall,
options: {
maxIterations: options?.maxIterations,
model,
workspace: workspaceRoot,
provider: options?.provider
}
})
const rawResult = await runEval({
userPrompt,
systemMessage,
userMessage,
tools,
helpers,
apiKey,
getOutput: getFlow,
onAssistantMessageStart: options?.runContext?.onAssistantMessageStart,
onAssistantToken: options?.runContext?.onAssistantChunk,
onAssistantMessageEnd: options?.runContext?.onAssistantMessageEnd,
onToolCall: options?.runContext?.onToolCall,
options: {
maxIterations: options?.maxIterations,
model,
workspace: workspaceRoot,
provider: options?.provider,
transport: options?.transport,
backend: options?.backend,
proxyCaseId: options?.runContext?.caseId,
proxyAttempt: options?.runContext?.attempt,
},
});
return {
flow: rawResult.output,
success: rawResult.success,
error: rawResult.error,
assistantMessageCount: rawResult.iterations,
toolCallCount: rawResult.toolCallsCount,
toolsUsed: rawResult.toolsCalled,
tokenUsage: rawResult.tokenUsage
}
} finally {
await cleanup()
}
return {
flow: rawResult.output,
success: rawResult.success,
error: rawResult.error,
assistantMessageCount: rawResult.iterations,
toolCallCount: rawResult.toolCallsCount,
toolsUsed: rawResult.toolsCalled,
tokenUsage: rawResult.tokenUsage,
};
} finally {
await cleanup();
}
}
@@ -1,110 +1,119 @@
import { mkdtemp } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import type { AIProvider, AIProviderModel } from '$lib/gen/types.gen'
import type { ContextElement } from '../../../../../frontend/src/lib/components/copilot/chat/context'
import { mkdtemp } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import type { AIProvider, AIProviderModel } from "$lib/gen/types.gen";
import type { ContextElement } from "../../../../../frontend/src/lib/components/copilot/chat/context";
import {
prepareScriptSystemMessage,
prepareScriptTools,
prepareScriptUserMessage,
type ScriptChatHelpers
} from '../../../../../frontend/src/lib/components/copilot/chat/script/core'
import type { Tool as ProductionTool } from '../../../../../frontend/src/lib/components/copilot/chat/shared'
import { createScriptFileHelpers, type ScriptEvalState } from './fileHelpers'
import { runEval } from '../shared'
import type { ModeRunContext } from '../../../../core/types'
import type { TokenUsage } from '../shared/types'
prepareScriptSystemMessage,
prepareScriptTools,
prepareScriptUserMessage,
type ScriptChatHelpers,
} from "../../../../../frontend/src/lib/components/copilot/chat/script/core";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import { createScriptFileHelpers, type ScriptEvalState } from "./fileHelpers";
import { runEval } from "../shared";
import type { ModeRunContext } from "../../../../core/types";
import type { TokenUsage } from "../shared/types";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
export interface ScriptEvalResult {
success: boolean
script: ScriptEvalState
error?: string
assistantMessageCount: number
toolCallCount: number
toolsUsed: string[]
tokenUsage: TokenUsage
success: boolean;
script: ScriptEvalState;
error?: string;
assistantMessageCount: number;
toolCallCount: number;
toolsUsed: string[];
tokenUsage: TokenUsage;
}
export interface ScriptEvalOptions {
initialScript: ScriptEvalState
model?: string
maxIterations?: number
provider?: AIProvider
workspaceRoot?: string
runContext?: ModeRunContext
initialScript: ScriptEvalState;
model?: string;
maxIterations?: number;
provider?: AIProvider;
transport?: FrontendEvalTransport;
backend?: WindmillBackendSettings;
workspaceRoot?: string;
runContext?: ModeRunContext;
}
function resolveModelProvider(
model: string,
provider?: AIProvider
model: string,
provider?: AIProvider,
): AIProviderModel {
if (provider) {
return { provider, model }
}
if (model.startsWith('claude')) {
return { provider: 'anthropic', model }
}
return { provider: 'openai', model }
if (provider) {
return { provider, model };
}
if (model.startsWith("claude")) {
return { provider: "anthropic", model };
}
return { provider: "openai", model };
}
export async function runScriptEval(
userPrompt: string,
apiKey: string,
options: ScriptEvalOptions
userPrompt: string,
apiKey: string,
options: ScriptEvalOptions,
): Promise<ScriptEvalResult> {
const workspaceRoot =
options.workspaceRoot ?? (await mkdtemp(join(tmpdir(), 'wmill-frontend-script-benchmark-')))
const { helpers, getScript, cleanup } = await createScriptFileHelpers(
options.initialScript,
workspaceRoot
)
const workspaceRoot =
options.workspaceRoot ??
(await mkdtemp(join(tmpdir(), "wmill-frontend-script-benchmark-")));
const { helpers, getScript, cleanup } = await createScriptFileHelpers(
options.initialScript,
workspaceRoot,
);
try {
const model = options.model ?? 'claude-haiku-4-5-20251001'
const modelProvider = resolveModelProvider(model, options.provider)
const selectedContext: ContextElement[] = []
const systemMessage = prepareScriptSystemMessage(
modelProvider,
options.initialScript.lang,
{}
)
const tools = prepareScriptTools(
modelProvider,
options.initialScript.lang,
selectedContext
) as ProductionTool<ScriptChatHelpers>[]
const userMessage = prepareScriptUserMessage(userPrompt, selectedContext)
try {
const model = options.model ?? "claude-haiku-4-5-20251001";
const modelProvider = resolveModelProvider(model, options.provider);
const selectedContext: ContextElement[] = [];
const systemMessage = prepareScriptSystemMessage(
modelProvider,
options.initialScript.lang,
{},
);
const tools = prepareScriptTools(
modelProvider,
options.initialScript.lang,
selectedContext,
) as ProductionTool<ScriptChatHelpers>[];
const userMessage = prepareScriptUserMessage(userPrompt, selectedContext);
const rawResult = await runEval({
userPrompt,
systemMessage,
userMessage,
tools,
helpers,
apiKey,
getOutput: getScript,
onAssistantMessageStart: options.runContext?.onAssistantMessageStart,
onAssistantToken: options.runContext?.onAssistantChunk,
onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd,
onToolCall: options.runContext?.onToolCall,
options: {
maxIterations: options.maxIterations,
model,
workspace: workspaceRoot,
provider: modelProvider.provider
}
})
const rawResult = await runEval({
userPrompt,
systemMessage,
userMessage,
tools,
helpers,
apiKey,
getOutput: getScript,
onAssistantMessageStart: options.runContext?.onAssistantMessageStart,
onAssistantToken: options.runContext?.onAssistantChunk,
onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd,
onToolCall: options.runContext?.onToolCall,
options: {
maxIterations: options.maxIterations,
model,
workspace: workspaceRoot,
provider: modelProvider.provider,
transport: options.transport,
backend: options.backend,
proxyCaseId: options.runContext?.caseId,
proxyAttempt: options.runContext?.attempt,
},
});
return {
script: rawResult.output,
success: rawResult.success,
error: rawResult.error,
assistantMessageCount: rawResult.iterations,
toolCallCount: rawResult.toolCallsCount,
toolsUsed: rawResult.toolsCalled,
tokenUsage: rawResult.tokenUsage
}
} finally {
await cleanup()
}
return {
script: rawResult.output,
success: rawResult.success,
error: rawResult.error,
assistantMessageCount: rawResult.iterations,
toolCallCount: rawResult.toolCallsCount,
toolsUsed: rawResult.toolsCalled,
tokenUsage: rawResult.tokenUsage,
};
} finally {
await cleanup();
}
}
@@ -1,46 +1,51 @@
import type {
ChatCompletionMessageParam,
ChatCompletionSystemMessageParam
} from 'openai/resources/chat/completions.mjs'
import type { AIProvider } from '$lib/gen/types.gen'
import type { ToolCallDetail, EvalRunnerOptions, RawEvalResult } from './types'
import { runChatLoop, type ChatClients } from '../../../../../frontend/src/lib/components/copilot/chat/chatLoop'
import type {
Tool as ProductionTool,
ToolCallbacks
} from '../../../../../frontend/src/lib/components/copilot/chat/shared'
ChatCompletionMessageParam,
ChatCompletionSystemMessageParam,
} from "openai/resources/chat/completions.mjs";
import type { AIProvider } from "$lib/gen/types.gen";
import type { ToolCallDetail, EvalRunnerOptions, RawEvalResult } from "./types";
import {
createEvalClients,
type FrontendEvalProvider,
resolveEvalModelProvider
} from './providerConfig'
runChatLoop,
type ChatClients,
} from "../../../../../frontend/src/lib/components/copilot/chat/chatLoop";
import type {
Tool as ProductionTool,
ToolCallbacks,
} from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import {
buildProxyResourcePath,
createEvalClients,
type FrontendEvalProvider,
resolveEvalModelProvider,
} from "./providerConfig";
import { WindmillBackendClient } from "../../windmillBackend";
/**
* Parameters for running a base evaluation.
*/
export interface RunEvalParams<THelpers, TOutput> {
/** The user's prompt/instruction */
userPrompt: string
/** System message for the LLM */
systemMessage: ChatCompletionSystemMessageParam
/** User message for the LLM */
userMessage: ChatCompletionMessageParam
/** Tool definitions for the LLM API (unused — derived from tools) */
toolDefs?: unknown
/** Full tool implementations for execution */
tools: ProductionTool<THelpers>[]
/** Domain-specific helpers for tool execution */
helpers: THelpers
/** API key for the provider */
apiKey: string
/** Function to get the current output state */
getOutput: () => TOutput
/** Optional configuration */
options?: EvalRunnerOptions
onAssistantMessageStart?: () => void
onAssistantToken?: (token: string) => void
onAssistantMessageEnd?: () => void
onToolCall?: (input: { toolName: string; argumentsText: string }) => void
/** The user's prompt/instruction */
userPrompt: string;
/** System message for the LLM */
systemMessage: ChatCompletionSystemMessageParam;
/** User message for the LLM */
userMessage: ChatCompletionMessageParam;
/** Tool definitions for the LLM API (unused — derived from tools) */
toolDefs?: unknown;
/** Full tool implementations for execution */
tools: ProductionTool<THelpers>[];
/** Domain-specific helpers for tool execution */
helpers: THelpers;
/** API key for the provider */
apiKey: string;
/** Function to get the current output state */
getOutput: () => TOutput;
/** Optional configuration */
options?: EvalRunnerOptions;
onAssistantMessageStart?: () => void;
onAssistantToken?: (token: string) => void;
onAssistantMessageEnd?: () => void;
onToolCall?: (input: { toolName: string; argumentsText: string }) => void;
}
/**
@@ -48,152 +53,206 @@ export interface RunEvalParams<THelpers, TOutput> {
* Uses streaming via real provider SDKs instead of OpenRouter non-streaming.
*/
export async function runEval<THelpers, TOutput>(
params: RunEvalParams<THelpers, TOutput>
params: RunEvalParams<THelpers, TOutput>,
): Promise<RawEvalResult<TOutput>> {
const {
systemMessage,
userMessage,
tools,
helpers,
apiKey,
getOutput,
options,
onAssistantMessageStart,
onAssistantToken,
onAssistantMessageEnd,
onToolCall
} = params
let shouldEmitMessageStart = true
const {
systemMessage,
userMessage,
tools,
helpers,
apiKey,
getOutput,
options,
onAssistantMessageStart,
onAssistantToken,
onAssistantMessageEnd,
onToolCall,
} = params;
let shouldEmitMessageStart = true;
const model = options?.model ?? 'gpt-4o'
const maxIterations = options?.maxIterations ?? 20
const workspace = options?.workspace ?? 'test-workspace'
const provider = toFrontendEvalProvider(options?.provider)
const model = options?.model ?? "gpt-4o";
const maxIterations = options?.maxIterations ?? 20;
const workspace = options?.workspace ?? "test-workspace";
const provider = toFrontendEvalProvider(options?.provider);
const modelProvider = resolveEvalModelProvider(model, provider)
const clients = createEvalClients(modelProvider.provider, apiKey) as unknown as ChatClients
const modelProvider = resolveEvalModelProvider(model, provider);
const messages: ChatCompletionMessageParam[] = [userMessage]
let toolCallsCount = 0
const toolsCalled: string[] = []
const toolCallDetails: ToolCallDetail[] = []
const messages: ChatCompletionMessageParam[] = [userMessage];
let toolCallsCount = 0;
const toolsCalled: string[] = [];
const toolCallDetails: ToolCallDetail[] = [];
// Wrap tools to intercept fn calls for tracking.
// Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type
// but the actual callbacks passed at runtime will satisfy both interfaces.
const wrappedTools = tools.map((tool) => ({
...tool,
fn: async (p: any) => {
toolCallsCount++
toolsCalled.push(tool.def.function.name)
let argumentsText = ''
try {
const args = typeof p.args === 'string' ? JSON.parse(p.args) : p.args
toolCallDetails.push({ name: tool.def.function.name, arguments: args })
argumentsText = JSON.stringify(args)
} catch {
toolCallDetails.push({
name: tool.def.function.name,
arguments: p.args
})
argumentsText = typeof p.args === 'string' ? p.args : JSON.stringify(p.args)
}
onToolCall?.({
toolName: tool.def.function.name,
argumentsText
})
return tool.fn(p)
}
}))
// Wrap tools to intercept fn calls for tracking.
// Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type
// but the actual callbacks passed at runtime will satisfy both interfaces.
const wrappedTools = tools.map((tool) => ({
...tool,
fn: async (p: any) => {
toolCallsCount++;
toolsCalled.push(tool.def.function.name);
let argumentsText = "";
try {
const args = typeof p.args === "string" ? JSON.parse(p.args) : p.args;
toolCallDetails.push({ name: tool.def.function.name, arguments: args });
argumentsText = JSON.stringify(args);
} catch {
toolCallDetails.push({
name: tool.def.function.name,
arguments: p.args,
});
argumentsText =
typeof p.args === "string" ? p.args : JSON.stringify(p.args);
}
onToolCall?.({
toolName: tool.def.function.name,
argumentsText,
});
return tool.fn(p);
},
}));
// No-op callbacks for eval
const callbacks: ToolCallbacks & {
onNewToken: (token: string) => void
onMessageEnd: () => void
} = {
setToolStatus: () => {},
removeToolStatus: () => {},
onNewToken: (token: string) => {
if (shouldEmitMessageStart) {
onAssistantMessageStart?.()
shouldEmitMessageStart = false
}
onAssistantToken?.(token)
},
onMessageEnd: () => {
if (!shouldEmitMessageStart) {
onAssistantMessageEnd?.()
}
shouldEmitMessageStart = true
}
}
// No-op callbacks for eval
const callbacks: ToolCallbacks & {
onNewToken: (token: string) => void;
onMessageEnd: () => void;
} = {
setToolStatus: () => {},
removeToolStatus: () => {},
onNewToken: (token: string) => {
if (shouldEmitMessageStart) {
onAssistantMessageStart?.();
shouldEmitMessageStart = false;
}
onAssistantToken?.(token);
},
onMessageEnd: () => {
if (!shouldEmitMessageStart) {
onAssistantMessageEnd?.();
}
shouldEmitMessageStart = true;
},
};
const abortController = new AbortController()
const abortController = new AbortController();
try {
const result = await runChatLoop({
messages,
systemMessage,
tools: wrappedTools,
helpers,
abortController,
callbacks,
modelProvider,
clients,
workspace,
maxIterations,
skipResponsesApi: modelProvider.provider !== 'openai'
})
const executeChatLoop = async (clients: ChatClients) => {
try {
const result = await runChatLoop({
messages,
systemMessage,
tools: wrappedTools,
helpers,
abortController,
callbacks,
modelProvider,
clients,
workspace,
maxIterations,
skipResponsesApi: modelProvider.provider !== "openai",
});
if (result.hitMaxIterations) {
return {
success: false,
output: getOutput(),
error: `Reached max turns (${maxIterations})`,
tokenUsage: result.tokenUsage,
toolCallsCount,
toolsCalled,
toolCallDetails,
iterations: Math.max(1, result.addedMessages.filter((m) => m.role === 'assistant').length),
messages
}
}
if (result.hitMaxIterations) {
return {
success: false,
output: getOutput(),
error: `Reached max turns (${maxIterations})`,
tokenUsage: result.tokenUsage,
toolCallsCount,
toolsCalled,
toolCallDetails,
iterations: Math.max(
1,
result.addedMessages.filter((m) => m.role === "assistant").length,
),
messages,
};
}
return {
success: true,
output: getOutput(),
tokenUsage: result.tokenUsage,
toolCallsCount,
toolsCalled,
toolCallDetails,
iterations: Math.max(1, result.addedMessages.filter((m) => m.role === 'assistant').length),
messages
}
} catch (err) {
let errorMessage: string
if (err instanceof Error) {
errorMessage = err.stack ?? err.message
} else {
errorMessage = String(err)
}
return {
success: true,
output: getOutput(),
tokenUsage: result.tokenUsage,
toolCallsCount,
toolsCalled,
toolCallDetails,
iterations: Math.max(
1,
result.addedMessages.filter((m) => m.role === "assistant").length,
),
messages,
};
} catch (err) {
let errorMessage: string;
if (err instanceof Error) {
errorMessage = err.stack ?? err.message;
} else {
errorMessage = String(err);
}
return {
success: false,
output: getOutput(),
error: errorMessage,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
toolCallsCount,
toolsCalled,
toolCallDetails,
iterations: 0,
messages
}
}
return {
success: false,
output: getOutput(),
error: errorMessage,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
toolCallsCount,
toolsCalled,
toolCallDetails,
iterations: 0,
messages,
};
}
};
if (options?.transport === "proxy") {
const backendSettings = options.backend;
if (!backendSettings) {
throw new Error("Missing backend settings for proxy transport");
}
const backendClient = new WindmillBackendClient(backendSettings);
return await backendClient.withWorkspace(
options.proxyCaseId ?? "eval",
options.proxyAttempt ?? 1,
async (proxyWorkspaceId) => {
const resourcePath = buildProxyResourcePath(modelProvider.provider);
await backendClient.upsertResource({
workspaceId: proxyWorkspaceId,
path: resourcePath,
resourceType: modelProvider.provider,
value: { api_key: apiKey },
});
const token = await backendClient.getToken();
const clients = createEvalClients({
provider: modelProvider.provider,
apiKey,
transport: "proxy",
proxy: {
baseURL: `${backendSettings.baseUrl}/api/w/${encodeURIComponent(proxyWorkspaceId)}/ai/proxy`,
bearerToken: token,
resourcePath,
},
}) as unknown as ChatClients;
return await executeChatLoop(clients);
},
);
}
const clients = createEvalClients({
provider: modelProvider.provider,
apiKey,
}) as unknown as ChatClients;
return await executeChatLoop(clients);
}
function toFrontendEvalProvider(provider?: AIProvider): FrontendEvalProvider | undefined {
if (provider === 'anthropic' || provider === 'openai' || provider === 'googleai') {
return provider
}
return undefined
function toFrontendEvalProvider(
provider?: AIProvider,
): FrontendEvalProvider | undefined {
if (
provider === "anthropic" ||
provider === "openai" ||
provider === "googleai"
) {
return provider;
}
return undefined;
}
@@ -1,12 +1,17 @@
import { describe, expect, it } from "bun:test";
import {
buildProxyHeaders,
buildProxyResourcePath,
buildOpenAICompatibleClientOptions,
resolveEvalModelProvider,
} from "./providerConfig";
describe("buildOpenAICompatibleClientOptions", () => {
it("adds Gemini's OpenAI-compatible base URL and client header", () => {
const options = buildOpenAICompatibleClientOptions("googleai", "gemini-test-key");
const options = buildOpenAICompatibleClientOptions(
"googleai",
"gemini-test-key",
);
expect(options).toMatchObject({
apiKey: "gemini-test-key",
@@ -18,12 +23,28 @@ describe("buildOpenAICompatibleClientOptions", () => {
});
it("keeps the default OpenAI-compatible config for OpenAI", () => {
expect(buildOpenAICompatibleClientOptions("openai", "openai-test-key")).toEqual({
expect(
buildOpenAICompatibleClientOptions("openai", "openai-test-key"),
).toEqual({
apiKey: "openai-test-key",
});
});
});
describe("proxy helpers", () => {
it("builds provider-scoped proxy resource paths", () => {
expect(buildProxyResourcePath("googleai")).toBe("f/evals/ai/googleai");
expect(buildProxyResourcePath("anthropic")).toBe("f/evals/ai/anthropic");
});
it("adds auth and resource headers for workspace proxy requests", () => {
expect(buildProxyHeaders("token-123", "f/evals/ai/googleai")).toEqual({
Authorization: "Bearer token-123",
"X-Resource-Path": "f/evals/ai/googleai",
});
});
});
describe("resolveEvalModelProvider", () => {
it("infers googleai from Gemini model ids", () => {
expect(resolveEvalModelProvider("gemini-2.5-flash")).toEqual({
@@ -1,6 +1,7 @@
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
import type { FrontendEvalModelConfig } from "../../../../core/models";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
export type FrontendEvalProvider = FrontendEvalModelConfig["provider"];
@@ -14,12 +15,34 @@ export interface ResolvedEvalModelProvider {
model: string;
}
const GEMINI_OPENAI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/";
export interface EvalProxyClientConfig {
baseURL: string;
bearerToken: string;
resourcePath: string;
}
const GEMINI_OPENAI_BASE_URL =
"https://generativelanguage.googleapis.com/v1beta/openai/";
const GEMINI_GOOG_API_CLIENT = "windmill-ai-evals/1.0";
const EVAL_PROXY_RESOURCE_PREFIX = "f/evals/ai";
export function buildProxyHeaders(
bearerToken: string,
resourcePath: string,
): Record<string, string> {
return {
Authorization: `Bearer ${bearerToken}`,
"X-Resource-Path": resourcePath,
};
}
export function buildProxyResourcePath(provider: FrontendEvalProvider): string {
return `${EVAL_PROXY_RESOURCE_PREFIX}/${provider}`;
}
export function buildOpenAICompatibleClientOptions(
provider: Exclude<FrontendEvalProvider, "anthropic">,
apiKey: string
apiKey: string,
): ConstructorParameters<typeof OpenAI>[0] {
if (provider === "googleai") {
return {
@@ -34,26 +57,71 @@ export function buildOpenAICompatibleClientOptions(
return { apiKey };
}
export function createEvalClients(
provider: FrontendEvalProvider,
apiKey: string
): EvalClients {
if (provider === "anthropic") {
function buildProxyOpenAIClientOptions(
proxy: EvalProxyClientConfig,
): ConstructorParameters<typeof OpenAI>[0] {
return {
apiKey: "unused",
baseURL: proxy.baseURL,
defaultHeaders: buildProxyHeaders(proxy.bearerToken, proxy.resourcePath),
};
}
export function createEvalClients(input: {
provider: FrontendEvalProvider;
apiKey: string;
transport?: FrontendEvalTransport;
proxy?: EvalProxyClientConfig;
}): EvalClients {
const transport = input.transport ?? "direct";
if (input.provider === "anthropic") {
if (transport === "proxy") {
if (!input.proxy) {
throw new Error(
"Missing proxy client configuration for proxy transport",
);
}
return {
openai: new OpenAI({ apiKey: "unused" }),
anthropic: new Anthropic({
apiKey: "unused",
baseURL: input.proxy.baseURL,
defaultHeaders: buildProxyHeaders(
input.proxy.bearerToken,
input.proxy.resourcePath,
),
}),
};
}
return {
openai: new OpenAI({ apiKey: "unused" }),
anthropic: new Anthropic({ apiKey }),
anthropic: new Anthropic({ apiKey: input.apiKey }),
};
}
if (transport === "proxy") {
if (!input.proxy) {
throw new Error("Missing proxy client configuration for proxy transport");
}
return {
openai: new OpenAI(buildProxyOpenAIClientOptions(input.proxy)),
anthropic: new Anthropic({ apiKey: "unused" }),
};
}
return {
openai: new OpenAI(buildOpenAICompatibleClientOptions(provider, apiKey)),
openai: new OpenAI(
buildOpenAICompatibleClientOptions(input.provider, input.apiKey),
),
anthropic: new Anthropic({ apiKey: "unused" }),
};
}
export function resolveEvalModelProvider(
model: string,
provider?: FrontendEvalProvider
provider?: FrontendEvalProvider,
): ResolvedEvalModelProvider {
if (provider) {
return { provider, model };
+26 -20
View File
@@ -1,32 +1,38 @@
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs'
import type { AIProvider } from '$lib/gen/types.gen'
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs";
import type { AIProvider } from "$lib/gen/types.gen";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
export interface TokenUsage {
prompt: number
completion: number
total: number
prompt: number;
completion: number;
total: number;
}
export interface ToolCallDetail {
name: string
arguments: Record<string, unknown>
name: string;
arguments: Record<string, unknown>;
}
export interface EvalRunnerOptions {
maxIterations?: number
model?: string
workspace?: string
provider?: AIProvider
maxIterations?: number;
model?: string;
workspace?: string;
provider?: AIProvider;
transport?: FrontendEvalTransport;
backend?: WindmillBackendSettings;
proxyCaseId?: string;
proxyAttempt?: number;
}
export interface RawEvalResult<TOutput> {
success: boolean
output: TOutput
error?: string
tokenUsage: TokenUsage
toolCallsCount: number
toolsCalled: string[]
toolCallDetails: ToolCallDetail[]
iterations: number
messages: ChatCompletionMessageParam[]
success: boolean;
output: TOutput;
error?: string;
tokenUsage: TokenUsage;
toolCallsCount: number;
toolsCalled: string[];
toolCallDetails: ToolCallDetail[];
iterations: number;
messages: ChatCompletionMessageParam[];
}
+187 -172
View File
@@ -1,213 +1,228 @@
import { spawn } from 'node:child_process'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
formatFrontendBenchmarkProgressEvent,
parseFrontendBenchmarkProgressLine
} from './progress'
import type { BenchmarkRunResult } from '../../core/types'
formatFrontendBenchmarkProgressEvent,
parseFrontendBenchmarkProgressLine,
} from "./progress";
import type { BenchmarkRunResult } from "../../core/types";
const REPO_ROOT = fileURLToPath(new URL('../../../', import.meta.url))
const FRONTEND_DIR = path.join(REPO_ROOT, 'frontend')
const FRONTEND_BENCHMARK_TEST = '../ai_evals/adapters/frontend/vitestAdapter.test.ts'
const FRONTEND_BENCHMARK_CONFIG = '../ai_evals/adapters/frontend/vitest.config.ts'
const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
const FRONTEND_DIR = path.join(REPO_ROOT, "frontend");
const FRONTEND_BENCHMARK_TEST =
"../ai_evals/adapters/frontend/vitestAdapter.test.ts";
const FRONTEND_BENCHMARK_CONFIG =
"../ai_evals/adapters/frontend/vitest.config.ts";
export type FrontendMode = 'flow' | 'app' | 'script'
export type FrontendMode = "flow" | "app" | "script";
export async function runFrontendBenchmarkAdapter(input: {
mode: FrontendMode
caseIds: string[]
runs: number
model?: string
verbose?: boolean
backendValidation?: string
mode: FrontendMode;
caseIds: string[];
runs: number;
model?: string;
transport?: string;
verbose?: boolean;
backendValidation?: string;
}): Promise<BenchmarkRunResult> {
const tempDir = await mkdtemp(path.join(tmpdir(), 'wmill-frontend-benchmark-'))
const outputPath = path.join(tempDir, 'result.json')
const tempDir = await mkdtemp(
path.join(tmpdir(), "wmill-frontend-benchmark-"),
);
const outputPath = path.join(tempDir, "result.json");
const env: NodeJS.ProcessEnv = {
...process.env,
BROWSERSLIST_IGNORE_OLD_DATA: "1",
WMILL_FRONTEND_AI_EVAL_OUTPUT_PATH: outputPath,
WMILL_FRONTEND_AI_EVAL_MODE: input.mode,
WMILL_FRONTEND_AI_EVAL_CASE_IDS: JSON.stringify(input.caseIds),
WMILL_FRONTEND_AI_EVAL_RUNS: String(input.runs),
WMILL_FRONTEND_AI_EVAL_MODEL: input.model ?? "",
WMILL_FRONTEND_AI_EVAL_PROGRESS: "1",
WMILL_FRONTEND_AI_EVAL_VERBOSE: input.verbose ? "1" : "0",
WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION: input.backendValidation ?? "",
};
try {
await runVitestBenchmark(
path.join(FRONTEND_DIR, 'node_modules', '.bin', 'vitest'),
[
'run',
FRONTEND_BENCHMARK_TEST,
'--project',
'server',
'--config',
FRONTEND_BENCHMARK_CONFIG
],
{
cwd: FRONTEND_DIR,
env: {
...process.env,
BROWSERSLIST_IGNORE_OLD_DATA: '1',
WMILL_FRONTEND_AI_EVAL_OUTPUT_PATH: outputPath,
WMILL_FRONTEND_AI_EVAL_MODE: input.mode,
WMILL_FRONTEND_AI_EVAL_CASE_IDS: JSON.stringify(input.caseIds),
WMILL_FRONTEND_AI_EVAL_RUNS: String(input.runs),
WMILL_FRONTEND_AI_EVAL_MODEL: input.model ?? "",
WMILL_FRONTEND_AI_EVAL_PROGRESS: '1',
WMILL_FRONTEND_AI_EVAL_VERBOSE: input.verbose ? '1' : '0',
WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION: input.backendValidation ?? ''
}
}
)
if (input.transport) {
env.WMILL_FRONTEND_AI_EVAL_TRANSPORT = input.transport;
}
const raw = await readFile(outputPath, 'utf8')
return JSON.parse(raw) as BenchmarkRunResult
} catch (error) {
throw new Error(`Frontend benchmark adapter failed:\n${toErrorMessage(error)}`)
} finally {
await rm(tempDir, { recursive: true, force: true })
}
try {
await runVitestBenchmark(
path.join(FRONTEND_DIR, "node_modules", ".bin", "vitest"),
[
"run",
FRONTEND_BENCHMARK_TEST,
"--project",
"server",
"--config",
FRONTEND_BENCHMARK_CONFIG,
],
{
cwd: FRONTEND_DIR,
env,
},
);
const raw = await readFile(outputPath, "utf8");
return JSON.parse(raw) as BenchmarkRunResult;
} catch (error) {
throw new Error(
`Frontend benchmark adapter failed:\n${toErrorMessage(error)}`,
);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
async function runVitestBenchmark(
command: string,
args: string[],
options: {
cwd: string
env: NodeJS.ProcessEnv
}
command: string,
args: string[],
options: {
cwd: string;
env: NodeJS.ProcessEnv;
},
): Promise<void> {
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env,
stdio: ['ignore', 'pipe', 'pipe']
})
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = ''
let stderr = ''
let stderrLineBuffer = ''
let assistantStreamOpen = false
let stdout = "";
let stderr = "";
let stderrLineBuffer = "";
let assistantStreamOpen = false;
child.stdout?.setEncoding('utf8')
child.stdout?.on('data', (chunk: string) => {
stdout += chunk
})
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr?.setEncoding('utf8')
child.stderr?.on('data', (chunk: string) => {
stderrLineBuffer += chunk
const { remainder, passthrough, nextAssistantStreamOpen } = drainProgressLines(
stderrLineBuffer,
assistantStreamOpen
)
stderrLineBuffer = remainder
stderr += passthrough
assistantStreamOpen = nextAssistantStreamOpen
})
child.stderr?.setEncoding("utf8");
child.stderr?.on("data", (chunk: string) => {
stderrLineBuffer += chunk;
const { remainder, passthrough, nextAssistantStreamOpen } =
drainProgressLines(stderrLineBuffer, assistantStreamOpen);
stderrLineBuffer = remainder;
stderr += passthrough;
assistantStreamOpen = nextAssistantStreamOpen;
});
await new Promise<void>((resolve, reject) => {
child.on('error', reject)
child.on('close', (code) => {
if (stderrLineBuffer.length > 0) {
const {
remainder,
passthrough,
nextAssistantStreamOpen
} = drainProgressLines(`${stderrLineBuffer}\n`, assistantStreamOpen)
stderrLineBuffer = remainder
stderr += passthrough
assistantStreamOpen = nextAssistantStreamOpen
}
await new Promise<void>((resolve, reject) => {
child.on("error", reject);
child.on("close", (code) => {
if (stderrLineBuffer.length > 0) {
const { remainder, passthrough, nextAssistantStreamOpen } =
drainProgressLines(`${stderrLineBuffer}\n`, assistantStreamOpen);
stderrLineBuffer = remainder;
stderr += passthrough;
assistantStreamOpen = nextAssistantStreamOpen;
}
if (code === 0) {
if (assistantStreamOpen) {
process.stderr.write('\n')
}
resolve()
return
}
if (code === 0) {
if (assistantStreamOpen) {
process.stderr.write("\n");
}
resolve();
return;
}
const details = [`vitest exited with code ${code}`, stdout, stderr].filter(Boolean).join('\n')
reject(new Error(details))
})
})
const details = [`vitest exited with code ${code}`, stdout, stderr]
.filter(Boolean)
.join("\n");
reject(new Error(details));
});
});
}
function drainProgressLines(
buffer: string,
initialAssistantStreamOpen: boolean
buffer: string,
initialAssistantStreamOpen: boolean,
): {
remainder: string
passthrough: string
nextAssistantStreamOpen: boolean
remainder: string;
passthrough: string;
nextAssistantStreamOpen: boolean;
} {
let remainder = buffer
let passthrough = ''
let assistantStreamOpen = initialAssistantStreamOpen
let remainder = buffer;
let passthrough = "";
let assistantStreamOpen = initialAssistantStreamOpen;
while (true) {
const newlineIndex = remainder.indexOf('\n')
if (newlineIndex === -1) {
return { remainder, passthrough, nextAssistantStreamOpen: assistantStreamOpen }
}
while (true) {
const newlineIndex = remainder.indexOf("\n");
if (newlineIndex === -1) {
return {
remainder,
passthrough,
nextAssistantStreamOpen: assistantStreamOpen,
};
}
const line = remainder.slice(0, newlineIndex).replace(/\r$/, '')
remainder = remainder.slice(newlineIndex + 1)
const line = remainder.slice(0, newlineIndex).replace(/\r$/, "");
remainder = remainder.slice(newlineIndex + 1);
const progressEvent = parseFrontendBenchmarkProgressLine(line)
if (progressEvent) {
if (progressEvent.type === 'assistant-message-start') {
if (assistantStreamOpen) {
process.stderr.write('\n')
}
process.stderr.write(
`${formatCasePrefix(progressEvent.caseNumber, progressEvent.totalCases)} ${progressEvent.caseId} attempt ${progressEvent.attempt}/${progressEvent.runs} assistant:\n`
)
assistantStreamOpen = true
continue
}
const progressEvent = parseFrontendBenchmarkProgressLine(line);
if (progressEvent) {
if (progressEvent.type === "assistant-message-start") {
if (assistantStreamOpen) {
process.stderr.write("\n");
}
process.stderr.write(
`${formatCasePrefix(progressEvent.caseNumber, progressEvent.totalCases)} ${progressEvent.caseId} attempt ${progressEvent.attempt}/${progressEvent.runs} assistant:\n`,
);
assistantStreamOpen = true;
continue;
}
if (progressEvent.type === 'assistant-chunk') {
process.stderr.write(progressEvent.chunk)
continue
}
if (progressEvent.type === "assistant-chunk") {
process.stderr.write(progressEvent.chunk);
continue;
}
if (progressEvent.type === 'assistant-message-end') {
if (assistantStreamOpen) {
process.stderr.write('\n')
}
assistantStreamOpen = false
continue
}
if (progressEvent.type === "assistant-message-end") {
if (assistantStreamOpen) {
process.stderr.write("\n");
}
assistantStreamOpen = false;
continue;
}
if (assistantStreamOpen) {
process.stderr.write('\n')
assistantStreamOpen = false
}
process.stderr.write(`${formatFrontendBenchmarkProgressEvent(progressEvent)}\n`)
continue
}
if (assistantStreamOpen) {
process.stderr.write("\n");
assistantStreamOpen = false;
}
process.stderr.write(
`${formatFrontendBenchmarkProgressEvent(progressEvent)}\n`,
);
continue;
}
if (shouldSuppressFrontendStderrLine(line)) {
continue
}
if (shouldSuppressFrontendStderrLine(line)) {
continue;
}
passthrough += `${line}\n`
process.stderr.write(`${line}\n`)
}
passthrough += `${line}\n`;
process.stderr.write(`${line}\n`);
}
}
function formatCasePrefix(caseNumber: number, totalCases: number): string {
return `[${caseNumber}/${totalCases}]`
return `[${caseNumber}/${totalCases}]`;
}
function shouldSuppressFrontendStderrLine(line: string): boolean {
return (
line.startsWith('[baseline-browser-mapping] ') ||
line.startsWith('Browserslist: browsers data (caniuse-lite) is ') ||
line.includes('update-browserslist-db@latest') ||
line.includes('update-db#readme')
)
return (
line.startsWith("[baseline-browser-mapping] ") ||
line.startsWith("Browserslist: browsers data (caniuse-lite) is ") ||
line.includes("update-browserslist-db@latest") ||
line.includes("update-db#readme")
);
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return String(error)
if (error instanceof Error) {
return error.message;
}
return String(error);
}
@@ -0,0 +1,184 @@
import { randomUUID } from "node:crypto";
import type { WindmillBackendSettings } from "../../core/windmillBackendSettings";
const tokenCache = new Map<string, Promise<string>>();
const sharedWorkspaceQueue = new Map<string, Promise<void>>();
export class WindmillBackendClient {
constructor(private readonly settings: WindmillBackendSettings) {}
async withWorkspace<T>(
caseId: string,
attempt: number,
body: (workspaceId: string) => Promise<T>,
): Promise<T> {
const workspaceId =
this.settings.workspaceOverride ??
buildWorkspaceId(this.settings.workspacePrefix, caseId, attempt);
const run = async () => {
await this.ensureWorkspace(workspaceId);
try {
return await body(workspaceId);
} finally {
if (!this.settings.keepWorkspaces && !this.settings.workspaceOverride) {
await this.deleteWorkspace(workspaceId).catch(() => undefined);
}
}
};
if (this.settings.workspaceOverride) {
return await withSharedWorkspaceLock(workspaceId, run);
}
return await run();
}
async request(path: string, init?: RequestInit): Promise<Response> {
const token = await this.getToken();
return await fetch(`${this.settings.baseUrl}/api${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
...(init?.headers ?? {}),
},
});
}
async getToken(): Promise<string> {
const cacheKey = `${this.settings.baseUrl}|${this.settings.email}`;
let tokenPromise = tokenCache.get(cacheKey);
if (!tokenPromise) {
tokenPromise = this.login().catch((error) => {
if (tokenCache.get(cacheKey) === tokenPromise) {
tokenCache.delete(cacheKey);
}
throw error;
});
tokenCache.set(cacheKey, tokenPromise);
}
return await tokenPromise;
}
async upsertResource(input: {
workspaceId: string;
path: string;
resourceType: string;
value: Record<string, unknown>;
}): Promise<void> {
const response = await this.request(
`/w/${encodeURIComponent(input.workspaceId)}/resources/create?update_if_exists=true`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: input.path,
resource_type: input.resourceType,
value: input.value,
}),
},
);
await expectOk(response, `upsert resource ${input.path}`);
}
private async ensureWorkspace(workspaceId: string): Promise<void> {
const existsResponse = await this.request("/workspaces/exists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: workspaceId }),
});
await expectOk(existsResponse, `check workspace ${workspaceId}`);
if ((await existsResponse.text()).trim() === "true") {
return;
}
const createResponse = await this.request("/workspaces/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: workspaceId, name: workspaceId }),
});
try {
await expectOk(createResponse, `create workspace ${workspaceId}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("maximum number of workspaces")) {
throw new Error(
`${message}. Reuse an existing workspace with WMILL_AI_EVAL_BACKEND_WORKSPACE=<workspace-id>.`,
);
}
throw error;
}
}
private async deleteWorkspace(workspaceId: string): Promise<void> {
const response = await this.request(
`/workspaces/delete/${encodeURIComponent(workspaceId)}`,
{
method: "DELETE",
},
);
await expectOk(response, `delete workspace ${workspaceId}`);
}
private async login(): Promise<string> {
const response = await fetch(`${this.settings.baseUrl}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: this.settings.email,
password: this.settings.password,
}),
});
await expectOk(response, "login to Windmill backend");
return (await response.text()).trim();
}
}
async function withSharedWorkspaceLock<T>(
workspaceId: string,
body: () => Promise<T>,
): Promise<T> {
const previous = sharedWorkspaceQueue.get(workspaceId) ?? Promise.resolve();
let releaseCurrent: (() => void) | undefined;
const current = new Promise<void>((resolve) => {
releaseCurrent = resolve;
});
const tail = previous.catch(() => undefined).then(() => current);
sharedWorkspaceQueue.set(workspaceId, tail);
await previous.catch(() => undefined);
try {
return await body();
} finally {
releaseCurrent?.();
if (sharedWorkspaceQueue.get(workspaceId) === tail) {
sharedWorkspaceQueue.delete(workspaceId);
}
}
}
function buildWorkspaceId(
prefix: string,
caseId: string,
attempt: number,
): string {
const caseSlug = caseId
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 30);
const suffix = randomUUID().slice(0, 8);
return `${prefix}-${caseSlug || "case"}-a${attempt}-${suffix}`;
}
async function expectOk(response: Response, context: string): Promise<void> {
if (response.ok) {
return;
}
throw new Error(
`${context} failed: ${response.status} ${response.statusText} - ${await response.text()}`,
);
}
+79 -21
View File
@@ -27,11 +27,18 @@ import { EVAL_MODES, type EvalMode } from "../core/types";
import { DEFAULT_JUDGE_MODEL } from "../core/judge";
import { createCliModeRunner } from "../modes/cli";
import { runFrontendBenchmarkAdapter } from "../adapters/frontend/runtime";
import {
FRONTEND_EVAL_TRANSPORTS,
type FrontendEvalTransport,
parseFrontendEvalTransport,
} from "../core/frontendTransport";
async function main() {
const program = new Command()
.name("bun run cli --")
.description("Run AI eval cases against the current production prompts and guidance")
.description(
"Run AI eval cases against the current production prompts and guidance",
)
.showHelpAfterError()
.showSuggestionAfterError()
.addHelpText(
@@ -53,7 +60,7 @@ async function main() {
"",
"Models:",
getEvalModelHelpText(),
].join("\n")
].join("\n"),
);
program
@@ -76,15 +83,33 @@ async function main() {
.description("Run one benchmark mode")
.argument("<mode>", "cli, flow, script, or app", parseMode)
.argument("[caseIds...]", "specific case ids to run")
.option("--runs <n>", "number of attempts per case", parsePositiveInteger, 1)
.option(
"--runs <n>",
"number of attempts per case",
parsePositiveInteger,
1,
)
.option("--output <path>", "write the result JSON to this path")
.option("--model <name>", `model alias (${EVAL_MODELS.map((entry) => entry.id).join(", ")})`)
.option("--models <names>", "comma-separated model aliases to run sequentially")
.option(
"--model <name>",
`model alias (${EVAL_MODELS.map((entry) => entry.id).join(", ")})`,
)
.option(
"--models <names>",
"comma-separated model aliases to run sequentially",
)
.option(
"--transport <mode>",
`frontend transport (${FRONTEND_EVAL_TRANSPORTS.join(", ")})`,
)
.option("--verbose", "stream assistant output during frontend runs")
.option("--record", "append a compact summary line to ai_evals/history/<mode>.jsonl")
.option(
"--record",
"append a compact summary line to ai_evals/history/<mode>.jsonl",
)
.option(
"--backend-validation <mode>",
`backend smoke validation (${BACKEND_VALIDATION_MODES.join(", ")})`
`backend smoke validation (${BACKEND_VALIDATION_MODES.join(", ")})`,
)
.action(
async (
@@ -95,10 +120,11 @@ async function main() {
output?: string;
model?: string;
models?: string;
transport?: string;
verbose?: boolean;
record?: boolean;
backendValidation?: string;
}
},
) => {
await handleRun({
mode,
@@ -107,11 +133,14 @@ async function main() {
outputPath: options.output,
model: options.model,
models: options.models,
transport: options.transport
? parseFrontendEvalTransport(options.transport)
: undefined,
verbose: options.verbose ?? false,
record: options.record ?? false,
backendValidation: options.backendValidation,
});
}
},
);
await program.parseAsync(process.argv);
@@ -137,7 +166,10 @@ function handleModels() {
...(model.frontend ? ["flow", "script", "app"] : []),
...(model.cli ? ["cli"] : []),
];
const aliases = [model.id, ...model.aliases.filter((alias) => alias !== model.id)];
const aliases = [
model.id,
...model.aliases.filter((alias) => alias !== model.id),
];
process.stdout.write(`- ${model.id}: ${model.label}\n`);
process.stdout.write(` aliases: ${aliases.join(", ")}\n`);
process.stdout.write(` modes: ${supports.join(", ")}\n`);
@@ -152,48 +184,72 @@ async function handleRun(input: {
outputPath?: string;
model?: string;
models?: string;
transport?: FrontendEvalTransport;
verbose: boolean;
record: boolean;
backendValidation?: string;
}) {
if (input.record && input.caseIds.length > 0) {
throw new Error("--record only supports full-suite runs; omit case ids to record history");
throw new Error(
"--record only supports full-suite runs; omit case ids to record history",
);
}
if (input.model && input.models) {
throw new Error("Use either --model or --models, not both");
}
if (input.mode === "cli" && input.transport === "proxy") {
throw new Error(
"--transport proxy is only supported for flow, script, and app modes",
);
}
const selectedCases = await loadSelectedCases(input.mode, input.caseIds);
const models = resolveRequestedModels(input.mode, input.model, input.models);
const backendValidation = parseBackendValidationMode(
input.backendValidation ?? process.env.WMILL_AI_EVAL_BACKEND_VALIDATION
input.backendValidation ?? process.env.WMILL_AI_EVAL_BACKEND_VALIDATION,
);
if (input.outputPath && models.length > 1) {
throw new Error("--output only supports a single model run");
}
if (backendValidation !== "off" && input.mode !== "flow" && input.mode !== "script") {
throw new Error("--backend-validation currently supports only flow and script modes");
if (
backendValidation !== "off" &&
input.mode !== "flow" &&
input.mode !== "script"
) {
throw new Error(
"--backend-validation currently supports only flow and script modes",
);
}
const summaries: Array<{ label: string; passRate: number; averageDurationMs: number }> = [];
const summaries: Array<{
label: string;
passRate: number;
averageDurationMs: number;
}> = [];
for (const [index, model] of models.entries()) {
const runModel = formatRunModelLabel(input.mode, model);
if (models.length > 1) {
process.stdout.write(
`${index > 0 ? "\n" : ""}=== ${input.mode} ${model.id} (${runModel}) ===\n`
`${index > 0 ? "\n" : ""}=== ${input.mode} ${model.id} (${runModel}) ===\n`,
);
}
process.stderr.write(`Starting ${input.mode} benchmark...\n`);
const result =
input.mode === "cli"
? await runCliBenchmark(selectedCases, input.runs, getCliEvalModel(model), runModel)
? await runCliBenchmark(
selectedCases,
input.runs,
getCliEvalModel(model),
runModel,
)
: await runFrontendBenchmarkAdapter({
mode: input.mode,
caseIds: input.caseIds,
runs: input.runs,
model: model.id,
transport: input.transport,
verbose: input.verbose,
backendValidation,
});
@@ -225,7 +281,7 @@ async function handleRun(input: {
process.stdout.write("\nModel summary\n");
for (const summary of summaries) {
process.stdout.write(
`- ${summary.label}: ${formatPercent(summary.passRate)} | ${Math.round(summary.averageDurationMs)}ms\n`
`- ${summary.label}: ${formatPercent(summary.passRate)} | ${Math.round(summary.averageDurationMs)}ms\n`,
);
}
}
@@ -235,7 +291,7 @@ async function runCliBenchmark(
cases: Awaited<ReturnType<typeof loadSelectedCases>>,
runs: number,
model: ReturnType<typeof getCliEvalModel>,
runModel: string
runModel: string,
) {
const caseResults = await runSuite({
modeRunner: createCliModeRunner(model),
@@ -258,7 +314,9 @@ function parseMode(value: string): EvalMode {
if (EVAL_MODES.includes(value as EvalMode)) {
return value as EvalMode;
}
throw new InvalidArgumentError(`mode must be one of: ${EVAL_MODES.join(", ")}`);
throw new InvalidArgumentError(
`mode must be one of: ${EVAL_MODES.join(", ")}`,
);
}
function parseOptionalMode(value: string | undefined): EvalMode | undefined {
@@ -276,7 +334,7 @@ function parsePositiveInteger(value: string): number {
function resolveRequestedModels(
mode: EvalMode,
singleModel?: string,
multipleModels?: string
multipleModels?: string,
): EvalModelSpec[] {
if (!multipleModels) {
return [resolveEvalModel(mode, singleModel)];
+153 -54
View File
@@ -7,7 +7,14 @@ const BACKEND_ROOT = "/__ai_evals__/backend";
const FRONTEND_REACT_SHIM_PATH = `${FRONTEND_ROOT}/__react_shim__.d.ts`;
const FRONTEND_WMILL_TYPES_PATH = `${FRONTEND_ROOT}/wmill.d.ts`;
const BACKEND_WINDMILL_CLIENT_SHIM_PATH = `${BACKEND_ROOT}/__windmill_client__.d.ts`;
const TS_LIKE_LANGUAGES = new Set(["bun", "deno", "nativets", "bunnative", "ts", "typescript"]);
const TS_LIKE_LANGUAGES = new Set([
"bun",
"deno",
"nativets",
"bunnative",
"ts",
"typescript",
]);
const JS_LIKE_LANGUAGES = new Set(["javascript", "js", "nodejs"]);
const SAFE_TYPE_REFERENCE_NAMES = new Set([
"Array",
@@ -121,20 +128,26 @@ export interface AppDiagnosticsResult {
}
export function buildAppWmillTypes(
backend: Record<string, AppDiagnosticRunnable> = {}
backend: Record<string, AppDiagnosticRunnable> = {},
): string {
return `// THIS FILE IS READ-ONLY
// AND GENERATED AUTOMATICALLY FROM YOUR RUNNABLES
export declare const backend: {
${Object.entries(backend)
.map(([key, runnable]) => ` ${JSON.stringify(key)}: ${getRunnableSignature(runnable, false)};`)
.map(
([key, runnable]) =>
` ${JSON.stringify(key)}: ${getRunnableSignature(runnable, false)};`,
)
.join("\n")}
};
export declare const backendAsync: {
${Object.entries(backend)
.map(([key, runnable]) => ` ${JSON.stringify(key)}: ${getRunnableSignature(runnable, true)};`)
.map(
([key, runnable]) =>
` ${JSON.stringify(key)}: ${getRunnableSignature(runnable, true)};`,
)
.join("\n")}
};
@@ -165,16 +178,26 @@ export function collectAppDiagnostics(input: {
frontend: Record<string, string>;
backend: Record<string, AppDiagnosticRunnable>;
}): AppDiagnosticsResult {
const frontendDiagnostics = collectFrontendDiagnostics(input.frontend, input.backend);
const frontendDiagnostics = collectFrontendDiagnostics(
input.frontend,
input.backend,
);
const backendDiagnostics = collectBackendDiagnostics(input.backend);
const diagnostics = dedupeDiagnostics([...frontendDiagnostics, ...backendDiagnostics]).sort(compareDiagnostics);
const diagnostics = dedupeDiagnostics([
...frontendDiagnostics,
...backendDiagnostics,
]).sort(compareDiagnostics);
return {
diagnostics,
lintResult: {
errors: {
frontend: groupMessages(diagnostics.filter((diagnostic) => diagnostic.source === "frontend")),
backend: groupMessages(diagnostics.filter((diagnostic) => diagnostic.source === "backend")),
frontend: groupMessages(
diagnostics.filter((diagnostic) => diagnostic.source === "frontend"),
),
backend: groupMessages(
diagnostics.filter((diagnostic) => diagnostic.source === "backend"),
),
},
warnings: {
frontend: {},
@@ -188,20 +211,26 @@ export function collectAppDiagnostics(input: {
function collectFrontendDiagnostics(
frontend: Record<string, string>,
backend: Record<string, AppDiagnosticRunnable>
backend: Record<string, AppDiagnosticRunnable>,
): AppStaticDiagnostic[] {
const frontendFiles = Object.entries(frontend)
.filter(([filePath]) => isFrontendCodeFile(filePath))
.map(([filePath, content]) => [toFrontendVirtualPath(filePath), content] as const);
.map(
([filePath, content]) =>
[toFrontendVirtualPath(filePath), content] as const,
);
const virtualFiles = new Map<string, string>([
[FRONTEND_REACT_SHIM_PATH, FRONTEND_REACT_SHIM],
[FRONTEND_WMILL_TYPES_PATH, wrapModuleDeclaration("wmill", buildAppWmillTypes(backend))],
[
FRONTEND_WMILL_TYPES_PATH,
wrapModuleDeclaration("wmill", buildAppWmillTypes(backend)),
],
...frontendFiles,
]);
const host = createVirtualCompilerHost(
virtualFiles,
getFrontendCompilerOptions()
getFrontendCompilerOptions(),
);
const rootNames = [...virtualFiles.keys()];
const program = ts.createProgram({
@@ -210,9 +239,8 @@ function collectFrontendDiagnostics(
host,
});
return ts
.getPreEmitDiagnostics(program)
.flatMap((diagnostic) => mapTypeScriptDiagnostic({
return ts.getPreEmitDiagnostics(program).flatMap((diagnostic) =>
mapTypeScriptDiagnostic({
diagnostic,
source: "frontend",
toTarget(fileName) {
@@ -228,18 +256,22 @@ function collectFrontendDiagnostics(
}
return normalized.slice(FRONTEND_ROOT.length);
},
}));
}),
);
}
function collectBackendDiagnostics(
backend: Record<string, AppDiagnosticRunnable>
backend: Record<string, AppDiagnosticRunnable>,
): AppStaticDiagnostic[] {
const backendFiles = Object.entries(backend)
.filter(([, runnable]) => isTypeCheckableBackendRunnable(runnable))
.map(([key, runnable]) => [
`${BACKEND_ROOT}/${key}/main.${getBackendFileExtension(runnable.inlineScript?.language)}`,
runnable.inlineScript?.content ?? "",
] as const);
.map(
([key, runnable]) =>
[
`${BACKEND_ROOT}/${key}/main.${getBackendFileExtension(runnable.inlineScript?.language)}`,
runnable.inlineScript?.content ?? "",
] as const,
);
if (backendFiles.length === 0) {
return [];
@@ -251,7 +283,7 @@ function collectBackendDiagnostics(
]);
const host = createVirtualCompilerHost(
virtualFiles,
getBackendCompilerOptions()
getBackendCompilerOptions(),
);
const rootNames = [...virtualFiles.keys()];
const program = ts.createProgram({
@@ -260,9 +292,8 @@ function collectBackendDiagnostics(
host,
});
return ts
.getPreEmitDiagnostics(program)
.flatMap((diagnostic) => mapTypeScriptDiagnostic({
return ts.getPreEmitDiagnostics(program).flatMap((diagnostic) =>
mapTypeScriptDiagnostic({
diagnostic,
source: "backend",
toTarget(fileName) {
@@ -277,7 +308,8 @@ function collectBackendDiagnostics(
const runnableKey = relativePath.split("/")[0];
return runnableKey || null;
},
}));
}),
);
}
function getFrontendCompilerOptions(): ts.CompilerOptions {
@@ -317,25 +349,37 @@ function getBackendCompilerOptions(): ts.CompilerOptions {
function createVirtualCompilerHost(
files: Map<string, string>,
options: ts.CompilerOptions
options: ts.CompilerOptions,
): ts.CompilerHost {
const originalHost = ts.createCompilerHost(options, true);
const originalGetSourceFile = originalHost.getSourceFile.bind(originalHost);
const originalReadFile = originalHost.readFile.bind(originalHost);
const originalFileExists = originalHost.fileExists.bind(originalHost);
const originalDirectoryExists = originalHost.directoryExists?.bind(originalHost);
const originalGetDirectories = originalHost.getDirectories?.bind(originalHost);
const originalDirectoryExists =
originalHost.directoryExists?.bind(originalHost);
const originalGetDirectories =
originalHost.getDirectories?.bind(originalHost);
return {
...originalHost,
getCurrentDirectory: () => "/",
getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile) {
getSourceFile(
fileName,
languageVersion,
onError,
shouldCreateNewSourceFile,
) {
const normalized = normalizeFileName(fileName);
const content = files.get(normalized);
if (content !== undefined) {
return ts.createSourceFile(fileName, content, languageVersion, true);
}
return originalGetSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile);
return originalGetSourceFile(
fileName,
languageVersion,
onError,
shouldCreateNewSourceFile,
);
},
readFile(fileName) {
const normalized = normalizeFileName(fileName);
@@ -347,7 +391,11 @@ function createVirtualCompilerHost(
},
directoryExists(dirName) {
const normalized = normalizeFileName(dirName);
return hasVirtualDirectory(files, normalized) || originalDirectoryExists?.(dirName) || false;
return (
hasVirtualDirectory(files, normalized) ||
originalDirectoryExists?.(dirName) ||
false
);
},
getDirectories(dirName) {
const normalized = normalizeFileName(dirName);
@@ -386,7 +434,9 @@ function mapTypeScriptDiagnostic(input: {
{
source,
target,
message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n").trim(),
message: ts
.flattenDiagnosticMessageText(diagnostic.messageText, "\n")
.trim(),
line: position ? position.line + 1 : undefined,
column: position ? position.character + 1 : undefined,
code: diagnostic.code,
@@ -395,7 +445,7 @@ function mapTypeScriptDiagnostic(input: {
}
function groupMessages(
diagnostics: AppStaticDiagnostic[]
diagnostics: AppStaticDiagnostic[],
): Record<string, string[]> {
const grouped: Record<string, string[]> = {};
@@ -414,7 +464,9 @@ function formatLintMessage(diagnostic: AppStaticDiagnostic): string {
return diagnostic.message;
}
function dedupeDiagnostics(diagnostics: AppStaticDiagnostic[]): AppStaticDiagnostic[] {
function dedupeDiagnostics(
diagnostics: AppStaticDiagnostic[],
): AppStaticDiagnostic[] {
const uniqueDiagnostics = new Map<string, AppStaticDiagnostic>();
for (const diagnostic of diagnostics) {
@@ -434,7 +486,10 @@ function dedupeDiagnostics(diagnostics: AppStaticDiagnostic[]): AppStaticDiagnos
return [...uniqueDiagnostics.values()];
}
function compareDiagnostics(a: AppStaticDiagnostic, b: AppStaticDiagnostic): number {
function compareDiagnostics(
a: AppStaticDiagnostic,
b: AppStaticDiagnostic,
): number {
if (a.source !== b.source) {
return a.source.localeCompare(b.source);
}
@@ -464,7 +519,10 @@ function normalizeFileName(fileName: string): string {
return path.posix.normalize(fileName.replace(/\\/g, "/"));
}
function hasVirtualDirectory(files: Map<string, string>, dirName: string): boolean {
function hasVirtualDirectory(
files: Map<string, string>,
dirName: string,
): boolean {
const normalizedDirectory = dirName.endsWith("/") ? dirName : `${dirName}/`;
for (const fileName of files.keys()) {
if (fileName === dirName || fileName.startsWith(normalizedDirectory)) {
@@ -474,7 +532,10 @@ function hasVirtualDirectory(files: Map<string, string>, dirName: string): boole
return false;
}
function listVirtualDirectories(files: Map<string, string>, dirName: string): string[] {
function listVirtualDirectories(
files: Map<string, string>,
dirName: string,
): string[] {
const normalizedDirectory = dirName.endsWith("/") ? dirName : `${dirName}/`;
const directories = new Set<string>();
@@ -504,14 +565,16 @@ function wrapModuleDeclaration(moduleName: string, content: string): string {
function getRunnableSignature(
runnable: AppDiagnosticRunnable | undefined,
asyncMode: boolean
asyncMode: boolean,
): string {
const returnType = asyncMode ? "Promise<string>" : "Promise<any>";
const parameter = getRunnableParameterSignature(runnable);
return `${parameter} => ${returnType}`;
}
function getRunnableParameterSignature(runnable: AppDiagnosticRunnable | undefined): string {
function getRunnableParameterSignature(
runnable: AppDiagnosticRunnable | undefined,
): string {
const parameterInfo = getRunnableParameterInfo(runnable);
if (!parameterInfo) {
return "()";
@@ -525,9 +588,12 @@ function getRunnableParameterSignature(runnable: AppDiagnosticRunnable | undefin
}
function getRunnableParameterInfo(
runnable: AppDiagnosticRunnable | undefined
runnable: AppDiagnosticRunnable | undefined,
): { typeText?: string; optional: boolean } | null {
if (!runnable?.inlineScript?.content || !isTypeCheckableBackendRunnable(runnable)) {
if (
!runnable?.inlineScript?.content ||
!isTypeCheckableBackendRunnable(runnable)
) {
return { typeText: "any", optional: true };
}
@@ -536,7 +602,7 @@ function getRunnableParameterInfo(
runnable.inlineScript.content,
ts.ScriptTarget.Latest,
true,
getScriptKindForLanguage(runnable.inlineScript.language)
getScriptKindForLanguage(runnable.inlineScript.language),
);
const mainDeclaration = findExportedMainDeclaration(sourceFile);
@@ -545,7 +611,8 @@ function getRunnableParameterInfo(
}
const [parameter] = mainDeclaration.parameters;
const optional = Boolean(parameter.questionToken) || Boolean(parameter.initializer);
const optional =
Boolean(parameter.questionToken) || Boolean(parameter.initializer);
if (!parameter.type || !isPortableTypeNode(parameter.type)) {
return { typeText: "any", optional: true };
@@ -558,10 +625,14 @@ function getRunnableParameterInfo(
}
function findExportedMainDeclaration(
sourceFile: ts.SourceFile
sourceFile: ts.SourceFile,
): ts.SignatureDeclarationBase | null {
for (const statement of sourceFile.statements) {
if (ts.isFunctionDeclaration(statement) && statement.name?.text === "main" && hasExportModifier(statement)) {
if (
ts.isFunctionDeclaration(statement) &&
statement.name?.text === "main" &&
hasExportModifier(statement)
) {
return statement;
}
@@ -570,11 +641,18 @@ function findExportedMainDeclaration(
}
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "main") {
if (
!ts.isIdentifier(declaration.name) ||
declaration.name.text !== "main"
) {
continue;
}
const initializer = declaration.initializer;
if (initializer && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))) {
if (
initializer &&
(ts.isArrowFunction(initializer) ||
ts.isFunctionExpression(initializer))
) {
return initializer;
}
}
@@ -584,7 +662,14 @@ function findExportedMainDeclaration(
}
function hasExportModifier(node: ts.Node): boolean {
return Boolean(node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
const modifiers = ts.canHaveModifiers(node)
? ts.getModifiers(node)
: undefined;
return Boolean(
modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
),
);
}
function isPortableTypeNode(node: ts.TypeNode): boolean {
@@ -607,10 +692,15 @@ function isPortableTypeNode(node: ts.TypeNode): boolean {
}
if (ts.isTypeReferenceNode(node)) {
if (!ts.isIdentifier(node.typeName) || !SAFE_TYPE_REFERENCE_NAMES.has(node.typeName.text)) {
if (
!ts.isIdentifier(node.typeName) ||
!SAFE_TYPE_REFERENCE_NAMES.has(node.typeName.text)
) {
return false;
}
return (node.typeArguments ?? []).every((typeArgument) => isPortableTypeNode(typeArgument));
return (node.typeArguments ?? []).every((typeArgument) =>
isPortableTypeNode(typeArgument),
);
}
return false;
@@ -637,10 +727,17 @@ function isKeywordTypeNode(node: ts.TypeNode): boolean {
function isFrontendCodeFile(filePath: string): boolean {
const extension = path.posix.extname(filePath).toLowerCase();
return extension === ".js" || extension === ".jsx" || extension === ".ts" || extension === ".tsx";
return (
extension === ".js" ||
extension === ".jsx" ||
extension === ".ts" ||
extension === ".tsx"
);
}
function isTypeCheckableBackendRunnable(runnable: AppDiagnosticRunnable | undefined): boolean {
function isTypeCheckableBackendRunnable(
runnable: AppDiagnosticRunnable | undefined,
): boolean {
if (!runnable || runnable.type !== "inline") {
return false;
}
@@ -655,5 +752,7 @@ function getBackendFileExtension(language: string | undefined): string {
function getScriptKindForLanguage(language: string | undefined): ts.ScriptKind {
const normalizedLanguage = language?.toLowerCase() ?? "";
return JS_LIKE_LANGUAGES.has(normalizedLanguage) ? ts.ScriptKind.JS : ts.ScriptKind.TS;
return JS_LIKE_LANGUAGES.has(normalizedLanguage)
? ts.ScriptKind.JS
: ts.ScriptKind.TS;
}
+27 -55
View File
@@ -1,4 +1,8 @@
import type { EvalMode } from "./types";
import {
parsePositiveInteger,
resolveWindmillBackendSettings,
} from "./windmillBackendSettings";
export const BACKEND_VALIDATION_MODES = ["off", "preview"] as const;
@@ -16,10 +20,17 @@ export interface BackendValidationSettings {
maxWaitMs: number;
}
export function parseBackendValidationMode(value?: string | null): BackendValidationMode {
export function parseBackendValidationMode(
value?: string | null,
): BackendValidationMode {
const normalized = value?.trim().toLowerCase();
if (!normalized || normalized === "off" || normalized === "false" || normalized === "0") {
if (
!normalized ||
normalized === "off" ||
normalized === "false" ||
normalized === "0"
) {
return "off";
}
@@ -28,7 +39,7 @@ export function parseBackendValidationMode(value?: string | null): BackendValida
}
throw new Error(
`Unsupported backend validation mode: ${value}. Use one of: ${BACKEND_VALIDATION_MODES.join(", ")}`
`Unsupported backend validation mode: ${value}. Use one of: ${BACKEND_VALIDATION_MODES.join(", ")}`,
);
}
@@ -37,68 +48,29 @@ export function resolveBackendValidationSettings(input: {
requestedMode?: string | null;
}): BackendValidationSettings {
const mode = parseBackendValidationMode(
input.requestedMode ?? process.env.WMILL_AI_EVAL_BACKEND_VALIDATION
input.requestedMode ?? process.env.WMILL_AI_EVAL_BACKEND_VALIDATION,
);
if (mode !== "off" && input.evalMode !== "flow" && input.evalMode !== "script") {
if (
mode !== "off" &&
input.evalMode !== "flow" &&
input.evalMode !== "script"
) {
throw new Error(
`Backend validation mode "${mode}" is only supported for flow and script evals`
`Backend validation mode "${mode}" is only supported for flow and script evals`,
);
}
return {
mode,
baseUrl: normalizeBaseUrl(
process.env.WMILL_AI_EVAL_BACKEND_URL ??
process.env.WINDMILL_URL ??
process.env.WINDMILL_BASE_URL ??
process.env.REMOTE ??
"http://127.0.0.1:8000"
),
email: process.env.WMILL_AI_EVAL_BACKEND_EMAIL ?? "admin@windmill.dev",
password: process.env.WMILL_AI_EVAL_BACKEND_PASSWORD ?? "changeme",
keepWorkspaces: isTruthy(process.env.WMILL_AI_EVAL_KEEP_WORKSPACES),
workspaceOverride: sanitizeOptionalWorkspaceId(process.env.WMILL_AI_EVAL_BACKEND_WORKSPACE),
workspacePrefix: sanitizeWorkspacePrefix(
process.env.WMILL_AI_EVAL_WORKSPACE_PREFIX ?? "ai-evals"
),
...resolveWindmillBackendSettings(),
pollIntervalMs: parsePositiveInteger(
process.env.WMILL_AI_EVAL_BACKEND_POLL_INTERVAL_MS,
2000
2000,
),
maxWaitMs: parsePositiveInteger(
process.env.WMILL_AI_EVAL_BACKEND_MAX_WAIT_MS,
120000,
),
maxWaitMs: parsePositiveInteger(process.env.WMILL_AI_EVAL_BACKEND_MAX_WAIT_MS, 120000),
};
}
function normalizeBaseUrl(value: string): string {
return value.replace(/\/+$/, "");
}
function sanitizeWorkspacePrefix(value: string): string {
const sanitized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
return sanitized.length > 0 ? sanitized : "ai-evals";
}
function sanitizeOptionalWorkspaceId(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function isTruthy(value: string | undefined): boolean {
if (!value) {
return false;
}
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
function parsePositiveInteger(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it } from "bun:test";
import {
parseFrontendEvalTransport,
resolveFrontendEvalTransportSettings,
} from "./frontendTransport";
const ORIGINAL_ENV = {
WMILL_AI_EVAL_BACKEND_URL: process.env.WMILL_AI_EVAL_BACKEND_URL,
};
afterEach(() => {
if (ORIGINAL_ENV.WMILL_AI_EVAL_BACKEND_URL === undefined) {
delete process.env.WMILL_AI_EVAL_BACKEND_URL;
} else {
process.env.WMILL_AI_EVAL_BACKEND_URL =
ORIGINAL_ENV.WMILL_AI_EVAL_BACKEND_URL;
}
});
describe("parseFrontendEvalTransport", () => {
it("defaults to direct when unset", () => {
expect(parseFrontendEvalTransport(undefined)).toBe("direct");
});
it("accepts proxy explicitly", () => {
expect(parseFrontendEvalTransport("proxy")).toBe("proxy");
});
it("rejects unsupported values", () => {
expect(() => parseFrontendEvalTransport("worker")).toThrow(
"Unsupported frontend eval transport: worker",
);
});
});
describe("resolveFrontendEvalTransportSettings", () => {
it("includes backend settings for proxy transport", () => {
process.env.WMILL_AI_EVAL_BACKEND_URL = "http://127.0.0.1:8000/";
expect(
resolveFrontendEvalTransportSettings({
evalMode: "app",
requestedTransport: "proxy",
}),
).toMatchObject({
transport: "proxy",
backend: {
baseUrl: "http://127.0.0.1:8000",
},
});
});
it("keeps direct transport for cli runs", () => {
expect(
resolveFrontendEvalTransportSettings({
evalMode: "cli",
requestedTransport: "direct",
}),
).toEqual({
transport: "direct",
backend: undefined,
});
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { EvalMode } from "./types";
import type { WindmillBackendSettings } from "./windmillBackendSettings";
import { resolveWindmillBackendSettings } from "./windmillBackendSettings";
export const FRONTEND_EVAL_TRANSPORTS = ["direct", "proxy"] as const;
export type FrontendEvalTransport = (typeof FRONTEND_EVAL_TRANSPORTS)[number];
export interface FrontendEvalTransportSettings {
transport: FrontendEvalTransport;
backend?: WindmillBackendSettings;
}
export function parseFrontendEvalTransport(
value?: string | null,
): FrontendEvalTransport {
const normalized = value?.trim().toLowerCase();
if (!normalized || normalized === "direct") {
return "direct";
}
if (normalized === "proxy") {
return "proxy";
}
throw new Error(
`Unsupported frontend eval transport: ${value}. Use one of: ${FRONTEND_EVAL_TRANSPORTS.join(", ")}`,
);
}
export function resolveFrontendEvalTransportSettings(input: {
evalMode: EvalMode;
requestedTransport?: string | null;
}): FrontendEvalTransportSettings {
const transport = parseFrontendEvalTransport(input.requestedTransport);
if (transport === "proxy" && input.evalMode === "cli") {
throw new Error(
'Frontend eval transport "proxy" is only supported for flow, script, and app evals',
);
}
return {
transport,
backend:
transport === "proxy" ? resolveWindmillBackendSettings() : undefined,
};
}
+89 -43
View File
@@ -12,26 +12,34 @@ import type {
export async function writeRunResult(
result: BenchmarkRunResult,
outputPath?: string
outputPath?: string,
): Promise<string> {
const targetPath = resolveRunOutputPath(result.mode, outputPath);
await mkdir(path.dirname(targetPath), { recursive: true });
await writeFile(targetPath, JSON.stringify(toSerializableRunResult(result), null, 2) + "\n", "utf8");
await writeFile(
targetPath,
JSON.stringify(toSerializableRunResult(result), null, 2) + "\n",
"utf8",
);
return targetPath;
}
export async function appendHistoryRecord(
result: BenchmarkRunResult,
historyPath = resolveHistoryPath(result.mode)
historyPath = resolveHistoryPath(result.mode),
): Promise<string> {
await mkdir(path.dirname(historyPath), { recursive: true });
await appendFile(historyPath, JSON.stringify(toHistoryRecord(result)) + "\n", "utf8");
await appendFile(
historyPath,
JSON.stringify(toHistoryRecord(result)) + "\n",
"utf8",
);
return historyPath;
}
export async function writeRunArtifacts(
result: BenchmarkRunResult,
outputPath?: string
outputPath?: string,
): Promise<string | null> {
const targetPath = resolveRunOutputPath(result.mode, outputPath);
const artifactRoot = defaultArtifactsRoot(targetPath);
@@ -47,7 +55,11 @@ export async function writeRunArtifacts(
continue;
}
const attemptDir = path.join(artifactRoot, caseResult.id, `attempt-${attempt.attempt}`);
const attemptDir = path.join(
artifactRoot,
caseResult.id,
`attempt-${attempt.attempt}`,
);
await writeArtifactFiles(attemptDir, artifactFiles);
attempt.artifactsPath = attemptDir;
wroteArtifacts = true;
@@ -62,17 +74,24 @@ export function buildRunResult(input: {
mode: EvalMode;
runs: number;
runModel: string | null;
transport?: BenchmarkRunResult["transport"];
judgeModel: string | null;
caseResults: BenchmarkCaseResult[];
}): BenchmarkRunResult {
const attemptCount = input.caseResults.reduce((sum, entry) => sum + entry.attempts.length, 0);
const attemptCount = input.caseResults.reduce(
(sum, entry) => sum + entry.attempts.length,
0,
);
const passedAttempts = input.caseResults.reduce(
(sum, entry) => sum + entry.attempts.filter((attempt) => attempt.passed).length,
0
(sum, entry) =>
sum + entry.attempts.filter((attempt) => attempt.passed).length,
0,
);
const durationTotal = input.caseResults.reduce(
(sum, entry) => sum + entry.attempts.reduce((inner, attempt) => inner + attempt.durationMs, 0),
0
(sum, entry) =>
sum +
entry.attempts.reduce((inner, attempt) => inner + attempt.durationMs, 0),
0,
);
const tokenUsageTotal = input.caseResults.reduce<BenchmarkTokenUsage | null>(
(sum, entry) => {
@@ -87,7 +106,7 @@ export function buildRunResult(input: {
}
return sum;
},
null
null,
);
return {
@@ -97,6 +116,7 @@ export function buildRunResult(input: {
gitSha: getGitSha(),
runs: input.runs,
runModel: input.runModel,
transport: input.transport ?? null,
judgeModel: input.judgeModel,
caseCount: input.caseResults.length,
attemptCount,
@@ -122,6 +142,9 @@ export function formatRunSummary(result: BenchmarkRunResult): string {
`Pass rate: ${formatPercent(result.passRate)} (${result.passedAttempts}/${result.attemptCount})`,
`Average duration: ${Math.round(result.averageDurationMs)}ms`,
];
if (result.transport) {
lines.splice(1, 0, `Transport: ${result.transport}`);
}
const failures = collectFailures(result);
if (failures.length > 0) {
@@ -142,9 +165,11 @@ function collectFailures(result: BenchmarkRunResult): string[] {
if (attempt.passed) {
continue;
}
const failedChecks = attempt.checks.filter((check) => !check.passed).map((check) => check.name);
const failedChecks = attempt.checks
.filter((check) => !check.passed)
.map((check) => check.name);
failures.push(
`${caseResult.id} attempt ${attempt.attempt}: ${failedChecks.join(", ") || attempt.error || "failed"}`
`${caseResult.id} attempt ${attempt.attempt}: ${failedChecks.join(", ") || attempt.error || "failed"}`,
);
}
}
@@ -156,8 +181,13 @@ function defaultFileName(mode: EvalMode): string {
return `${new Date().toISOString().replaceAll(":", "-")}__${mode}.json`;
}
export function resolveRunOutputPath(mode: EvalMode, outputPath?: string): string {
return outputPath ?? path.join(getAiEvalsRoot(), "results", defaultFileName(mode));
export function resolveRunOutputPath(
mode: EvalMode,
outputPath?: string,
): string {
return (
outputPath ?? path.join(getAiEvalsRoot(), "results", defaultFileName(mode))
);
}
export function resolveHistoryPath(mode: EvalMode): string {
@@ -172,7 +202,7 @@ function defaultArtifactsRoot(resultPath: string): string {
async function writeArtifactFiles(
rootDir: string,
files: BenchmarkArtifactFile[]
files: BenchmarkArtifactFile[],
): Promise<void> {
for (const file of files) {
const relativePath = normalizeArtifactPath(file.path);
@@ -185,18 +215,25 @@ async function writeArtifactFiles(
function normalizeArtifactPath(filePath: string): string {
const normalized = filePath.replaceAll("\\", "/").replace(/^\/+/, "");
const parts = normalized.split("/").filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
if (
parts.length === 0 ||
parts.some((part) => part === "." || part === "..")
) {
throw new Error(`Invalid artifact path: ${filePath}`);
}
return parts.join("/");
}
function toSerializableRunResult(result: BenchmarkRunResult): BenchmarkRunResult {
function toSerializableRunResult(
result: BenchmarkRunResult,
): BenchmarkRunResult {
return {
...result,
cases: result.cases.map((caseResult) => ({
...caseResult,
attempts: caseResult.attempts.map(({ artifactFiles, ...attempt }) => attempt),
attempts: caseResult.attempts.map(
({ artifactFiles, ...attempt }) => attempt,
),
})),
};
}
@@ -204,8 +241,8 @@ function toSerializableRunResult(result: BenchmarkRunResult): BenchmarkRunResult
function toHistoryRecord(result: BenchmarkRunResult) {
const judgeScores = result.cases.flatMap((caseResult) =>
caseResult.attempts.flatMap((attempt) =>
typeof attempt.judgeScore === "number" ? [attempt.judgeScore] : []
)
typeof attempt.judgeScore === "number" ? [attempt.judgeScore] : [],
),
);
return {
@@ -214,6 +251,7 @@ function toHistoryRecord(result: BenchmarkRunResult) {
mode: result.mode,
runs: result.runs,
runModel: result.runModel,
transport: result.transport,
judgeModel: result.judgeModel,
caseCount: result.caseCount,
attemptCount: result.attemptCount,
@@ -223,49 +261,57 @@ function toHistoryRecord(result: BenchmarkRunResult) {
averageJudgeScore:
judgeScores.length === 0
? null
: judgeScores.reduce((sum, score) => sum + score, 0) / judgeScores.length,
: judgeScores.reduce((sum, score) => sum + score, 0) /
judgeScores.length,
averageTokenUsagePerAttempt: result.averageTokenUsagePerAttempt ?? null,
failedCaseIds: Array.from(
new Set(
result.cases
.filter((caseResult) => caseResult.attempts.some((attempt) => !attempt.passed))
.map((caseResult) => caseResult.id)
)
.filter((caseResult) =>
caseResult.attempts.some((attempt) => !attempt.passed),
)
.map((caseResult) => caseResult.id),
),
),
cases: result.cases.map((caseResult) => {
const attemptCount = caseResult.attempts.length;
const passedAttempts = caseResult.attempts.filter((attempt) => attempt.passed).length;
const passedAttempts = caseResult.attempts.filter(
(attempt) => attempt.passed,
).length;
const totalDurationMs = caseResult.attempts.reduce(
(sum, attempt) => sum + attempt.durationMs,
0
0,
);
const judgeScores = caseResult.attempts.flatMap((attempt) =>
typeof attempt.judgeScore === "number" ? [attempt.judgeScore] : []
typeof attempt.judgeScore === "number" ? [attempt.judgeScore] : [],
);
const totalTokenUsage = caseResult.attempts.reduce<BenchmarkTokenUsage | null>(
(sum, attempt) => {
if (!attempt.tokenUsage) {
const totalTokenUsage =
caseResult.attempts.reduce<BenchmarkTokenUsage | null>(
(sum, attempt) => {
if (!attempt.tokenUsage) {
return sum;
}
sum ??= { prompt: 0, completion: 0, total: 0 };
sum.prompt += attempt.tokenUsage.prompt;
sum.completion += attempt.tokenUsage.completion;
sum.total += attempt.tokenUsage.total;
return sum;
}
sum ??= { prompt: 0, completion: 0, total: 0 };
sum.prompt += attempt.tokenUsage.prompt;
sum.completion += attempt.tokenUsage.completion;
sum.total += attempt.tokenUsage.total;
return sum;
},
null
);
},
null,
);
return {
id: caseResult.id,
attemptCount,
passedAttempts,
passRate: attemptCount === 0 ? 0 : passedAttempts / attemptCount,
averageDurationMs: attemptCount === 0 ? 0 : totalDurationMs / attemptCount,
averageDurationMs:
attemptCount === 0 ? 0 : totalDurationMs / attemptCount,
averageJudgeScore:
judgeScores.length === 0
? null
: judgeScores.reduce((sum, score) => sum + score, 0) / judgeScores.length,
: judgeScores.reduce((sum, score) => sum + score, 0) /
judgeScores.length,
averageTokenUsagePerAttempt:
attemptCount === 0 || !totalTokenUsage
? null
+3 -1
View File
@@ -1,6 +1,7 @@
export const EVAL_MODES = ["cli", "flow", "script", "app"] as const;
export type EvalMode = (typeof EVAL_MODES)[number];
export type FrontendEvalTransport = "direct" | "proxy";
export interface EvalCaseRuntimeBackendPreview {
args?: Record<string, unknown>;
@@ -151,7 +152,7 @@ export interface ModeRunner<TInitial, TExpected, TActual> {
run(
prompt: string,
initial: TInitial | undefined,
context: ModeRunContext
context: ModeRunContext,
): Promise<ModeRunOutput<TActual>>;
validate(input: {
evalCase: EvalCase;
@@ -205,6 +206,7 @@ export interface BenchmarkRunResult {
gitSha: string | null;
runs: number;
runModel: string | null;
transport: FrontendEvalTransport | null;
judgeModel: string | null;
caseCount: number;
attemptCount: number;
+67
View File
@@ -0,0 +1,67 @@
export interface WindmillBackendSettings {
baseUrl: string;
email: string;
password: string;
keepWorkspaces: boolean;
workspaceOverride?: string;
workspacePrefix: string;
}
export function resolveWindmillBackendSettings(): WindmillBackendSettings {
return {
baseUrl: normalizeBaseUrl(
process.env.WMILL_AI_EVAL_BACKEND_URL ??
process.env.WINDMILL_URL ??
process.env.WINDMILL_BASE_URL ??
process.env.REMOTE ??
"http://127.0.0.1:8000",
),
email: process.env.WMILL_AI_EVAL_BACKEND_EMAIL ?? "admin@windmill.dev",
password: process.env.WMILL_AI_EVAL_BACKEND_PASSWORD ?? "changeme",
keepWorkspaces: isTruthy(process.env.WMILL_AI_EVAL_KEEP_WORKSPACES),
workspaceOverride: sanitizeOptionalWorkspaceId(
process.env.WMILL_AI_EVAL_BACKEND_WORKSPACE,
),
workspacePrefix: sanitizeWorkspacePrefix(
process.env.WMILL_AI_EVAL_WORKSPACE_PREFIX ?? "ai-evals",
),
};
}
export function parsePositiveInteger(
value: string | undefined,
fallback: number,
): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function normalizeBaseUrl(value: string): string {
return value.replace(/\/+$/, "");
}
function sanitizeWorkspacePrefix(value: string): string {
const sanitized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
return sanitized.length > 0 ? sanitized : "ai-evals";
}
function sanitizeOptionalWorkspaceId(
value: string | undefined,
): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function isTruthy(value: string | undefined): boolean {
if (!value) {
return false;
}
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
+24 -13
View File
@@ -5,31 +5,42 @@ import type { FrontendEvalModelConfig } from "../core/models";
import { validateAppState, type AppFilesState } from "../core/validators";
import type { BenchmarkArtifactFile, ModeRunner } from "../core/types";
import { runAppEval } from "../adapters/frontend/core/app/appEvalRunner";
import { DEFAULT_FRONTEND_EVAL_MODEL, getFrontendApiKey } from "./frontendCommon";
import {
DEFAULT_FRONTEND_EVAL_MODEL,
getFrontendApiKey,
} from "./frontendCommon";
import type { FrontendEvalTransportSettings } from "../core/frontendTransport";
export function createAppModeRunner(
modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL
modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL,
transportSettings?: FrontendEvalTransportSettings,
): ModeRunner<AppFilesState, AppFilesState, AppFilesState> {
return {
mode: "app",
concurrency: 5,
judgeThreshold: 80,
async loadInitial(path) {
return path ? (await loadAppFixture(path)) : undefined;
return path ? await loadAppFixture(path) : undefined;
},
async loadExpected(path) {
return path ? (await loadAppFixture(path)) : undefined;
return path ? await loadAppFixture(path) : undefined;
},
async run(prompt, initial, context) {
const result = await runAppEval(prompt, getFrontendApiKey(modelConfig.provider), {
initialFrontend: initial?.frontend,
initialBackend: initial?.backend,
initialDatatables: initial?.datatables,
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
runContext: context,
});
const result = await runAppEval(
prompt,
getFrontendApiKey(modelConfig.provider),
{
initialFrontend: initial?.frontend,
initialBackend: initial?.backend,
initialDatatables: initial?.datatables,
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
transport: transportSettings?.transport,
backend: transportSettings?.backend,
runContext: context,
},
);
return {
success: result.success,
+72 -50
View File
@@ -7,7 +7,11 @@ import type { BenchmarkArtifactFile, ModeRunner } from "../core/types";
import { runFlowEval } from "../adapters/frontend/core/flow/flowEvalRunner";
import type { FlowWorkspaceFixtures } from "../adapters/frontend/core/flow/fileHelpers";
import { BackendPreviewClient } from "../adapters/frontend/backendPreview";
import { DEFAULT_FRONTEND_EVAL_MODEL, getFrontendApiKey } from "./frontendCommon";
import {
DEFAULT_FRONTEND_EVAL_MODEL,
getFrontendApiKey,
} from "./frontendCommon";
import type { FrontendEvalTransportSettings } from "../core/frontendTransport";
import {
normalizeFlowInitialFixture,
normalizeFlowStateFixture,
@@ -16,7 +20,8 @@ import {
export function createFlowModeRunner(
modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL,
backendValidation?: BackendValidationSettings
backendValidation?: BackendValidationSettings,
transportSettings?: FrontendEvalTransportSettings,
): ModeRunner<FlowInitialFixture, FlowState, FlowState> {
return {
mode: "flow",
@@ -35,14 +40,20 @@ export function createFlowModeRunner(
return normalizeFlowStateFixture(await readJsonFile<unknown>(path));
},
async run(prompt, initial, context) {
const result = await runFlowEval(prompt, getFrontendApiKey(modelConfig.provider), {
initialFlow: initial?.flowFixture,
workspaceFixtures: initial?.workspace,
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
runContext: context,
});
const result = await runFlowEval(
prompt,
getFrontendApiKey(modelConfig.provider),
{
initialFlow: initial?.flowFixture,
workspaceFixtures: initial?.workspace,
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
transport: transportSettings?.transport,
backend: transportSettings?.backend,
runContext: context,
},
);
return {
success: result.success,
@@ -64,7 +75,10 @@ export function createFlowModeRunner(
});
},
async backendValidate({ evalCase, initial, actual, context }) {
if (backendValidation?.mode !== "preview" || !evalCase.runtime?.backendPreview) {
if (
backendValidation?.mode !== "preview" ||
!evalCase.runtime?.backendPreview
) {
return null;
}
@@ -81,46 +95,54 @@ export function createFlowModeRunner(
}
const previewClient = new BackendPreviewClient(backendValidation);
return await previewClient.withWorkspace(evalCase.id, context.attempt, async (workspaceId) => {
await seedWorkspaceFixtures(previewClient, workspaceId, initial?.workspace);
return await previewClient.withWorkspace(
evalCase.id,
context.attempt,
async (workspaceId) => {
await seedWorkspaceFixtures(
previewClient,
workspaceId,
initial?.workspace,
);
const completedJob = await previewClient.runFlowPreview({
workspaceId,
value: actual.value as Record<string, unknown>,
args: evalCase.runtime?.backendPreview?.args ?? {},
timeoutSeconds: evalCase.runtime?.backendPreview?.timeoutSeconds,
});
const completedJob = await previewClient.runFlowPreview({
workspaceId,
value: actual.value as Record<string, unknown>,
args: evalCase.runtime?.backendPreview?.args ?? {},
timeoutSeconds: evalCase.runtime?.backendPreview?.timeoutSeconds,
});
return {
checks: [
{
name: "backend flow preview succeeded",
passed: completedJob.success,
details: completedJob.success
? `workspace=${workspaceId}`
: `workspace=${workspaceId}; job=${completedJob.id}`,
},
],
artifactFiles: [
{
path: "backend-preview.json",
content:
JSON.stringify(
{
workspaceId,
jobId: completedJob.id,
success: completedJob.success,
result: completedJob.result,
logs: completedJob.logs,
completedJob: completedJob.raw,
},
null,
2
) + "\n",
},
],
};
});
return {
checks: [
{
name: "backend flow preview succeeded",
passed: completedJob.success,
details: completedJob.success
? `workspace=${workspaceId}`
: `workspace=${workspaceId}; job=${completedJob.id}`,
},
],
artifactFiles: [
{
path: "backend-preview.json",
content:
JSON.stringify(
{
workspaceId,
jobId: completedJob.id,
success: completedJob.success,
result: completedJob.result,
logs: completedJob.logs,
completedJob: completedJob.raw,
},
null,
2,
) + "\n",
},
],
};
},
);
},
buildArtifacts(actual): BenchmarkArtifactFile[] {
return [
@@ -136,7 +158,7 @@ export function createFlowModeRunner(
async function seedWorkspaceFixtures(
previewClient: BackendPreviewClient,
workspaceId: string,
fixtures?: FlowWorkspaceFixtures
fixtures?: FlowWorkspaceFixtures,
): Promise<void> {
for (const script of fixtures?.scripts ?? []) {
await previewClient.createScript({
+69 -52
View File
@@ -6,11 +6,16 @@ import type { BenchmarkArtifactFile, ModeRunner } from "../core/types";
import { BackendPreviewClient } from "../adapters/frontend/backendPreview";
import { runScriptEval } from "../adapters/frontend/core/script/scriptEvalRunner";
import type { ScriptEvalState } from "../adapters/frontend/core/script/fileHelpers";
import { DEFAULT_FRONTEND_EVAL_MODEL, getFrontendApiKey } from "./frontendCommon";
import {
DEFAULT_FRONTEND_EVAL_MODEL,
getFrontendApiKey,
} from "./frontendCommon";
import type { FrontendEvalTransportSettings } from "../core/frontendTransport";
export function createScriptModeRunner(
modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL,
backendValidation?: BackendValidationSettings
backendValidation?: BackendValidationSettings,
transportSettings?: FrontendEvalTransportSettings,
): ModeRunner<ScriptEvalState, ScriptEvalState, ScriptEvalState> {
return {
mode: "script",
@@ -27,13 +32,19 @@ export function createScriptModeRunner(
throw new Error("Script evals require an initial script fixture");
}
const result = await runScriptEval(prompt, getFrontendApiKey(modelConfig.provider), {
initialScript: initial,
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
runContext: context,
});
const result = await runScriptEval(
prompt,
getFrontendApiKey(modelConfig.provider),
{
initialScript: initial,
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
transport: transportSettings?.transport,
backend: transportSettings?.backend,
runContext: context,
},
);
return {
success: result.success,
@@ -55,50 +66,56 @@ export function createScriptModeRunner(
}
const previewClient = new BackendPreviewClient(backendValidation);
return await previewClient.withWorkspace(evalCase.id, context.attempt, async (workspaceId) => {
const completedJob = await previewClient.runScriptPreview({
workspaceId,
content: actual.code,
args:
(evalCase.runtime?.backendPreview?.args as Record<string, unknown> | undefined) ??
actual.args ??
initial?.args ??
{},
language: normalizePreviewLanguage(actual.lang),
path: toPreviewScriptPath(actual.path),
timeoutSeconds: evalCase.runtime?.backendPreview?.timeoutSeconds,
});
return await previewClient.withWorkspace(
evalCase.id,
context.attempt,
async (workspaceId) => {
const completedJob = await previewClient.runScriptPreview({
workspaceId,
content: actual.code,
args:
(evalCase.runtime?.backendPreview?.args as
| Record<string, unknown>
| undefined) ??
actual.args ??
initial?.args ??
{},
language: normalizePreviewLanguage(actual.lang),
path: toPreviewScriptPath(actual.path),
timeoutSeconds: evalCase.runtime?.backendPreview?.timeoutSeconds,
});
return {
checks: [
{
name: "backend script preview succeeded",
passed: completedJob.success,
details: completedJob.success
? `workspace=${workspaceId}`
: `workspace=${workspaceId}; job=${completedJob.id}`,
},
],
artifactFiles: [
{
path: "backend-preview.json",
content:
JSON.stringify(
{
workspaceId,
jobId: completedJob.id,
success: completedJob.success,
result: completedJob.result,
logs: completedJob.logs,
completedJob: completedJob.raw,
},
null,
2
) + "\n",
},
],
};
});
return {
checks: [
{
name: "backend script preview succeeded",
passed: completedJob.success,
details: completedJob.success
? `workspace=${workspaceId}`
: `workspace=${workspaceId}; job=${completedJob.id}`,
},
],
artifactFiles: [
{
path: "backend-preview.json",
content:
JSON.stringify(
{
workspaceId,
jobId: completedJob.id,
success: completedJob.success,
result: completedJob.result,
logs: completedJob.logs,
completedJob: completedJob.raw,
},
null,
2,
) + "\n",
},
],
};
},
);
},
buildArtifacts(actual): BenchmarkArtifactFile[] {
return [
+22 -2
View File
@@ -643,6 +643,7 @@ pub fn gemini_event_to_openai_sse_chunks(
pub fn sanitize_schema_for_google(value: &mut serde_json::Value) {
const UNSUPPORTED: &[&str] = &[
"additionalProperties",
"propertyNames",
"strict",
"$schema",
"default",
@@ -747,8 +748,8 @@ fn openai_tool_call_json(
#[cfg(test)]
mod tests {
use super::{
gemini_event_to_openai_sse_chunks, gemini_response_to_openai, GeminiParsedEvent,
GeminiToolCallEvent,
gemini_event_to_openai_sse_chunks, gemini_response_to_openai, sanitize_schema_for_google,
GeminiParsedEvent, GeminiToolCallEvent,
};
#[test]
@@ -814,4 +815,23 @@ mod tests {
);
assert_eq!(tool_call["index"], 0);
}
#[test]
fn sanitize_schema_for_google_removes_property_names() {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"staticInputs": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": true
}
}
});
sanitize_schema_for_google(&mut schema);
assert!(schema["properties"]["staticInputs"]["propertyNames"].is_null());
assert!(schema["properties"]["staticInputs"]["additionalProperties"].is_null());
}
}