feat(eve): evaluate - expose standalone evaluation with shared auth (#3472)

Signed-off-by: Andrew Barba <barba@hey.com>
This commit is contained in:
Andrew Barba
2026-09-17 13:09:00 -04:00
committed by GitHub
parent e30181875c
commit db5cee3725
24 changed files with 491 additions and 42 deletions
@@ -0,0 +1,5 @@
---
"eve": patch
---
Add standalone `evaluate` to `eve/experimental/evaluate` for typed evaluations in tools and application code. It shares model authentication with `autoModel`, including the Gateway connection selected during `eve dev`.
+51 -2
View File
@@ -1,12 +1,13 @@
---
title: Automatic Model Selection
description: "Choose an agent model from the current request with an AI SDK evaluation model."
description: "Choose agent models automatically or evaluate typed questions in your tools and application code."
---
Use `autoModel` to choose an agent model from an allowlist before inference begins.
It uses the [AI SDK evaluation API](https://ai-sdk.dev/docs/ai-sdk-core/evaluation),
so the evaluator can be a Vercel AI Gateway model ID or an evaluation model from
an installed provider.
an installed provider. Use `evaluate` from the same entrypoint to ask typed
questions in your own tools or application code.
`eve/experimental/evaluate` is experimental. Its API can change between eve
releases, and the AI SDK evaluation model specification can change in patch
@@ -103,6 +104,54 @@ Supported reasoning values are `"provider-default"`, `"none"`, `"minimal"`,
`"low"`, `"medium"`, `"high"`, and `"xhigh"`. An omitted value inherits the
agent's reasoning setting.
## Evaluate inside a tool
Use `evaluate` to ask choice, score, or boolean questions about the state you pass to it.
It defaults to `typesafe-ai/jev` and uses the same authentication as `autoModel`,
including the Gateway connection selected through `/login` during `eve dev`.
Pass `model` to use another evaluation model ID or a provider instance. A configured
AI SDK default provider takes precedence over the local Gateway connection.
```ts title="agent/tools/classify-request.ts"
import { evaluate } from "eve/experimental/evaluate";
import { defineTool } from "eve/tools";
import { z } from "zod";
export default defineTool({
description: "Choose the team that can help with a customer request.",
inputSchema: z.object({ request: z.string().min(1).max(8000) }),
async execute({ request }, ctx) {
const result = await evaluate({
state: { request },
questions: {
team: {
type: "choice",
instructions: "Select the team best suited to handle the request.",
criteria: {
billing: "Invoices, payments, and refunds",
support: "Product questions and troubleshooting",
},
},
},
abortSignal: ctx.abortSignal,
});
return { team: result.answers.team.choice };
},
});
```
The choice above is typed as `"billing" | "support"`. Each question appears under
its authored key in `result.answers`. Results also include token usage, warnings,
provider metadata, and response metadata.
`evaluate` accepts AI SDK evaluation options, including `maxRetries`, `headers`,
and `providerOptions`. Pass an `abortSignal` to cancel the request. Input and
answer validation, retries, and provider errors follow AI SDK semantics.
You can also call `evaluate` outside a tool; it does not require an active eve
session. Each call performs its own evaluation. `autoModel` uses this function
and adds the per-turn routing behavior described below.
## Runtime behavior
`autoModel` evaluates at the first `step.started` event, after the incoming prompt
+1 -1
View File
@@ -137,7 +137,7 @@ import template from "../../prompts/template.txt?raw";
| `eve/local-dev` | `getLocalDevCapability`, `LocalDevCapability` |
| `eve/models/openai` | `openai`, `chatgpt`, deprecated `experimental_chatgpt` |
| `eve/models/anthropic` | `anthropic` |
| [`eve/experimental/evaluate`](../guides/evaluate) | Experimental `autoModel` |
| [`eve/experimental/evaluate`](../guides/evaluate) | Experimental `autoModel` and standalone `evaluate` |
| `eve/evals` | `defineEval`, `defineEvalConfig`, `mockModel`, eval types |
| `eve/evals/expect` | `includes`, `equals`, `matches`, `similarity` |
| `eve/evals/reporters` | `Braintrust`, `JUnit`, `EvalReporter` |
@@ -8,6 +8,21 @@ const { experimental } = e2eAgentConfig();
export default defineAgent({
experimental,
model: fixtureModel(async (request) => {
if (request.userMessages.some((text) => text.includes("evaluate-request"))) {
const result = request.toolResults.find((result) => result.name === "evaluate-request");
if (result) return JSON.stringify({ isError: result.isError, output: result.output });
return {
toolCalls: [
{
id: "evaluate-request-1",
name: "evaluate-request",
input: {
missingAnswer: request.userMessages.some((text) => text.includes("missing answer")),
},
},
],
};
}
if (request.userMessages.some((text) => text.includes("parallel investigations"))) {
const completed = request.messages.filter((message) =>
message.text.includes("child-result:"),
+2 -2
View File
@@ -10,7 +10,7 @@ export const routing = defineState("evaluate-fixture.routing", () => ({
reasoning: "unselected",
}));
const evaluationModel = {
export const evaluationModel: Exclude<Experimental_EvaluationModel, string> = {
specificationVersion: "v4",
provider: "fixture",
modelId: "fixture-evaluator",
@@ -37,7 +37,7 @@ const evaluationModel = {
response: { modelId: "fixture-evaluator" },
};
},
} satisfies Exclude<Experimental_EvaluationModel, string>;
};
/** Run the real router with deterministic evaluation and language models. */
export function fixtureModel(respond: MockModelResponder) {
@@ -0,0 +1,34 @@
import { evaluate } from "eve/experimental/evaluate";
import { defineTool } from "eve/tools";
import { evaluationModel } from "../testing";
export default defineTool({
description: "Evaluate Alice's request with the fixture evaluation provider.",
inputSchema: {
type: "object",
properties: { missingAnswer: { type: "boolean" } },
required: ["missingAnswer"],
additionalProperties: false,
},
async execute({ missingAnswer }, ctx) {
const result = await evaluate({
model: missingAnswer
? { ...evaluationModel, doEvaluate: async () => ({ answers: {}, warnings: [] }) }
: evaluationModel,
state: { request: "Alice needs a routine summary." },
questions: {
route: {
type: "choice",
instructions: "Choose a model for the request.",
criteria: {
"openai/large": "Difficult investigations",
"openai/small": "Routine requests",
},
},
},
abortSignal: ctx.abortSignal,
});
return { choice: result.answers.route.choice, usage: result.usage };
},
});
@@ -0,0 +1,14 @@
import { defineEval } from "eve/evals";
export default defineEval({
description: "An invalid evaluation response is reported as a tool error.",
async test(t) {
const turn = await t.send(
"Bob uses evaluate-request to review a missing answer from the evaluation service.",
);
turn.expectOk();
turn.calledTool("evaluate-request", { status: "failed", count: 1 });
turn.messageIncludes('"isError":true');
t.succeeded();
},
});
@@ -0,0 +1,16 @@
import { defineEval } from "eve/evals";
export default defineEval({
description: "A tool evaluates structured state and returns typed answers and usage.",
async test(t) {
const turn = await t.send(
"Alice uses evaluate-request to choose a model for her routine summary.",
);
turn.expectOk();
turn.calledTool("evaluate-request");
turn.messageIncludes('"isError":false');
turn.messageIncludes('"choice":"openai/small"');
turn.messageIncludes('"totalTokens":45');
t.succeeded();
},
});
@@ -0,0 +1,9 @@
import { defineTool } from "#public/tools/index.js";
export default defineTool({
description: "Report whether the current tool execution was cancelled.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
execute(_input, ctx) {
return { cancelled: ctx.abortSignal?.aborted ?? false };
},
});
@@ -18,3 +18,4 @@ export {
type WorkflowToolInput,
type WorkflowToolOptions,
} from "../../src/public/tools/workflow.ts";
export { evaluate } from "../../src/public/experimental/evaluate/index.ts";
@@ -0,0 +1,23 @@
{
"kind": "eve-extension-capability-contract",
"capability": "tool",
"epoch": 47,
"sha256": "c8f3dea743ed9d8c175769c1249d099bc5265beda83104a8edb9ff841c7f0766",
"exports": [
"WorkflowTool",
"WorkflowToolInput",
"WorkflowToolOptions",
"defaultWebSearch",
"defineTool",
"defineWorkflowTool",
"disableTool",
"evaluate",
"isDisabledToolSentinel",
"isWebSearchToolDefinition",
"toolOutput",
"toolOutputPart",
"toolResultFrom",
"webSearch",
"workflow"
]
}
@@ -22,8 +22,10 @@ interface ExtensionCapabilityContract {
const EXTENSION_CAPABILITY_CONTRACTS = {
extension: { current: 1, supported: [1], dropped: {} },
tool: {
current: 46,
supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 28, 29, 30, 31, 32, 34, 35, 44, 45, 46],
current: 47,
supported: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 28, 29, 30, 31, 32, 34, 35, 44, 45, 46, 47,
],
dropped: {
14: "TaskExec.delegated was removed; migrate to workflow-backed background tools",
15: "TaskExec replaces stageEffect with send",
@@ -1,9 +1,6 @@
import { createHash } from "node:crypto";
import {
experimental_evaluate as evaluate,
type Experimental_EvaluationModel as EvaluationModel,
} from "ai";
import { type Experimental_EvaluationModel as EvaluationModel } from "ai";
import { loadContext } from "#context/container.js";
import { ContextKey } from "#context/key.js";
@@ -13,13 +10,14 @@ import {
type DynamicSentinel,
} from "#dynamic/definition.js";
import { isAgentReasoningDefinition, isRuntimeLanguageModel } from "#internal/runtime-model.js";
import { localGatewayEvaluationModel } from "#internal/model-auth/transport.js";
import type {
AgentReasoningDefinition,
PublicAgentDynamicModelResult,
PublicAgentStaticModelDefinition,
} from "#shared/agent-definition.js";
import { DEFAULT_EVALUATION_MODEL, evaluate } from "./evaluate.js";
type AutoModelOption =
| string
| {
@@ -36,8 +34,6 @@ interface AutoModelConfig<
readonly options: T;
}
const DEFAULT_EVALUATION_MODEL = "typesafe-ai/jev";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -169,11 +165,7 @@ export function autoModel<const T extends Readonly<Record<string, AutoModelOptio
if (previous?.turnId === currentTurnId) return models.get(previous.model)!;
const result = await evaluate({
model:
typeof evaluationModel === "string" &&
Reflect.get(globalThis, "AI_SDK_DEFAULT_PROVIDER") == null
? (localGatewayEvaluationModel(evaluationModel) ?? evaluationModel)
: evaluationModel,
model: evaluationModel,
state: routingState(ctx),
questions: {
route: {
@@ -0,0 +1,136 @@
import { Experimental_EvaluationMockModelV4 } from "ai/test";
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
import { evaluate } from "#public/experimental/evaluate/index.js";
const localEvaluationModel = vi.hoisted(() => vi.fn());
vi.mock("#internal/model-auth/transport.js", async (importOriginal) => ({
...(await importOriginal<typeof import("#internal/model-auth/transport.js")>()),
localGatewayEvaluationModel: localEvaluationModel,
}));
const questions = {
category: {
type: "choice",
instructions: "Select the team that can help with the request.",
criteria: { billing: "Invoices and payments", support: "Product questions" },
},
urgent: { type: "boolean", instructions: "Does the request require immediate attention?" },
priority: { type: "score", instructions: "Rate urgency.", criteria: ["Low", "High"] },
} as const;
const state = { request: "Alice needs a copy of her invoice." };
function evaluationModel() {
const doEvaluate = vi.fn(async () => ({
answers: {
category: { type: "choice" as const, choice: "billing" },
urgent: { type: "boolean" as const, probability: 0.1 },
priority: { type: "score" as const, score: 0.1 },
},
usage: { inputTokens: 42, outputTokens: 3 },
warnings: [],
providerMetadata: { fixture: { requestId: "request-1" } },
}));
return { doEvaluate, model: new Experimental_EvaluationMockModelV4({ doEvaluate }) };
}
beforeEach(() => {
localEvaluationModel.mockReset();
vi.stubGlobal("AI_SDK_DEFAULT_PROVIDER", undefined);
});
afterEach(() => vi.unstubAllGlobals());
describe("evaluate", () => {
it("defaults to Jev using the local Gateway connection without a session context", async () => {
const evaluator = evaluationModel();
localEvaluationModel.mockReturnValue(evaluator.model);
const result = await evaluate({ state, questions });
expect(localEvaluationModel).toHaveBeenCalledWith("typesafe-ai/jev");
expect(result.answers.category.choice).toBe("billing");
expectTypeOf(result.answers.category.choice).toEqualTypeOf<"billing" | "support">();
expectTypeOf(result.answers.urgent.probability).toEqualTypeOf<number>();
expectTypeOf(result.answers.priority.score).toEqualTypeOf<number>();
expect(result.usage).toEqual({ inputTokens: 42, outputTokens: 3, totalTokens: 45 });
expect(result.providerMetadata).toEqual({ fixture: { requestId: "request-1" } });
});
it("resolves an explicit model ID through the local connection", async () => {
localEvaluationModel.mockReturnValue(evaluationModel().model);
await evaluate({ model: "typesafe-ai/custom", state, questions });
expect(localEvaluationModel).toHaveBeenCalledWith("typesafe-ai/custom");
});
it("preserves the configured default provider for defaults and aliases", async () => {
const evaluator = evaluationModel();
const factory = vi.fn(() => evaluator.model);
vi.stubGlobal("AI_SDK_DEFAULT_PROVIDER", { evaluationModel: factory });
localEvaluationModel.mockReturnValue(evaluationModel().model);
await evaluate({ state, questions });
await evaluate({ model: "internal-evaluator", state, questions });
expect(factory.mock.calls).toEqual([["typesafe-ai/jev"], ["internal-evaluator"]]);
expect(localEvaluationModel).not.toHaveBeenCalled();
});
it("passes explicit model instances and request options through unchanged", async () => {
const evaluator = evaluationModel();
const abortSignal = new AbortController().signal;
await evaluate({
model: evaluator.model,
state,
questions,
abortSignal,
headers: { "x-request-id": "request-1" },
providerOptions: { fixture: { mode: "fast" } },
maxRetries: 0,
});
expect(localEvaluationModel).not.toHaveBeenCalled();
expect(evaluator.doEvaluate).toHaveBeenCalledWith(
expect.objectContaining({
state,
questions,
abortSignal,
headers: expect.objectContaining({ "x-request-id": "request-1" }),
providerOptions: { fixture: { mode: "fast" } },
}),
);
});
it("preserves input and answer validation", async () => {
const evaluator = evaluationModel();
await expect(evaluate({ model: evaluator.model, state, questions: {} })).rejects.toThrow();
expect(evaluator.doEvaluate).not.toHaveBeenCalled();
const invalid = new Experimental_EvaluationMockModelV4({
doEvaluate: async () => ({ answers: {}, warnings: [] }),
});
await expect(evaluate({ model: invalid, state, questions })).rejects.toThrow(
"exactly one answer",
);
});
it("propagates provider errors and aborts before provider I/O", async () => {
const error = new Error("Evaluation unavailable.");
const failed = new Experimental_EvaluationMockModelV4({
doEvaluate: async () => {
throw error;
},
});
await expect(evaluate({ model: failed, state, questions, maxRetries: 0 })).rejects.toBe(error);
const evaluator = evaluationModel();
await expect(
evaluate({
model: evaluator.model,
state,
questions,
abortSignal: AbortSignal.abort(error),
}),
).rejects.toBe(error);
expect(evaluator.doEvaluate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,26 @@
import {
experimental_evaluate as evaluateWithAiSdk,
type Experimental_EvaluationModel as EvaluationModel,
type Experimental_EvaluationQuestion as EvaluationQuestion,
} from "ai";
import { localGatewayEvaluationModel } from "#internal/model-auth/transport.js";
export const DEFAULT_EVALUATION_MODEL = "typesafe-ai/jev";
/** Evaluate typed questions about the state you pass to it using eve's model authentication. */
export function evaluate<const Questions extends Record<string, EvaluationQuestion>>({
model = DEFAULT_EVALUATION_MODEL,
...options
}: Omit<Parameters<typeof evaluateWithAiSdk<Questions>>[0], "model"> & {
/** Evaluation model instance or ID. Defaults to TypeSafe Jev. */
model?: EvaluationModel;
}) {
return evaluateWithAiSdk({
...options,
model:
typeof model === "string" && Reflect.get(globalThis, "AI_SDK_DEFAULT_PROVIDER") == null
? (localGatewayEvaluationModel(model) ?? model)
: model,
});
}
@@ -26,6 +26,20 @@ export const evaluationModel = {
import { autoModel } from "eve/experimental/evaluate";
import { anthropic } from "eve/models/anthropic";
export default defineAgent({ model: autoModel({ options: { "openai/gpt-5.6-sol": "Investigations", my_secret_model: { model: anthropic("sonnet-5"), reasoning: "low", description: "Routine work" } } }) });`,
"agent/tools/classify.ts": `import { defineTool } from "eve/tools";
import { evaluate } from "eve/experimental/evaluate";
export default defineTool({
description: "Classify an incident",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
async execute(_input, ctx) {
const result = await evaluate({
state: { incident: "Alice needs an export summary." },
questions: { category: { type: "choice", instructions: "Choose a category.", criteria: { routine: "Routine work", investigation: "Investigation" } } },
abortSignal: ctx.abortSignal,
});
return result.answers.category.choice;
},
});`,
"agent/subagents/worker/instructions.md": "Review the assigned evidence.",
"agent/subagents/worker/agent.ts": `import { defineAgent } from "eve";
import { autoModel } from "eve/experimental/evaluate";
@@ -210,7 +210,10 @@ export async function bundleExtensionDistributionGraph(input: {
readonly entries: readonly ExtensionDistributionGraphEntry[];
readonly packageRoot: string;
readonly runtimeDependencies: readonly string[];
}): Promise<ReadonlyMap<string, string>> {
}): Promise<{
readonly files: ReadonlyMap<string, string>;
readonly imports: readonly string[];
}> {
const plugins = [
createAuthoredDirectiveGuardPlugin(),
createAuthoredRelativeExtensionResolverPlugin({ extensions: RESOLVE_EXTENSIONS }),
@@ -248,12 +251,14 @@ export async function bundleExtensionDistributionGraph(input: {
});
const files = new Map<string, string>();
const imports = new Set<string>();
for (const item of result.output) {
if (item.type === "chunk") {
files.set(item.fileName, removeRolldownModuleRegionComments(item.code));
for (const specifier of [...item.imports, ...item.dynamicImports]) imports.add(specifier);
}
}
return files;
return { files, imports: [...imports] };
} catch (error) {
throw createAuthoredModuleBundleError(input.packageRoot, error);
}
@@ -5,6 +5,8 @@ type RolldownOutputChunk = {
readonly type: "chunk";
readonly code: string;
readonly fileName: string;
readonly imports: readonly string[];
readonly dynamicImports: readonly string[];
};
type RolldownOutputAsset = {
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, symlink, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
@@ -7,7 +7,9 @@ import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import {
EXTENSION_CAPABILITY_SUPPORT,
EXTENSION_CAPABILITY_VERSIONS,
findUnsupportedExtensionCapabilities,
parseExtensionCompatibilityManifest,
} from "#compiler/extension-compatibility.js";
import {
@@ -81,6 +83,101 @@ describe("extension build output", () => {
);
});
it.each([
["static re-export", 'export { evaluate as assess } from "eve/experimental/evaluate";'],
[
"namespace import",
'import * as evaluation from "eve/experimental/evaluate"; export const assess = evaluation.evaluate;',
],
[
"dynamic import",
'export async function assess(options: Parameters<typeof import("eve/experimental/evaluate").evaluate>[0]): Promise<void> { await (await import("eve/experimental/evaluate")).evaluate(options); }',
],
])(
"stamps evaluation capabilities for a hook-only extension using a %s",
async (_name, helper) => {
const root = await createExtensionPackage();
await rm(join(root, "extension", "tools"), { recursive: true });
await mkdir(join(root, "extension", "hooks"));
await mkdir(join(root, "extension", "lib"));
await writeFile(join(root, "extension", "lib", "evaluation.ts"), helper);
await writeFile(
join(root, "extension", "hooks", "evaluate.ts"),
`import { defineHook } from "eve/hooks";
import { assess } from "../lib/evaluation";
export default defineHook({ events: { "turn.started": async () => {
await assess({ state: { request: "Alice needs a summary." }, questions: {
routine: { type: "boolean", instructions: "Is this routine work?" }
} });
} } });`,
);
const config = await tryReadExtensionBuildConfig(root);
const outDir = await buildExtensionPackage(root, config!);
const manifestPath = join(outDir, "extension", "_manifest.json");
const manifest = parseExtensionCompatibilityManifest(
await readFile(manifestPath, "utf8"),
manifestPath,
);
expect(manifest.requires).toEqual({
extension: EXTENSION_CAPABILITY_VERSIONS.extension,
hook: EXTENSION_CAPABILITY_VERSIONS.hook,
tool: EXTENSION_CAPABILITY_VERSIONS.tool,
dynamicTool: EXTENSION_CAPABILITY_VERSIONS.dynamicTool,
});
expect(findUnsupportedExtensionCapabilities(manifest)).toEqual([]);
expect(
findUnsupportedExtensionCapabilities(manifest, {
...EXTENSION_CAPABILITY_SUPPORT,
tool: EXTENSION_CAPABILITY_SUPPORT.tool.filter((version) => version < 47),
}),
).toEqual([
expect.objectContaining({
capability: "tool",
requiredVersion: EXTENSION_CAPABILITY_VERSIONS.tool,
}),
]);
},
);
it.each([
[
"autoModel",
'import { autoModel } from "eve/experimental/evaluate"; export const route: ReturnType<typeof autoModel> = autoModel({ options: { "openai/small": "Routine work" } });',
true,
],
[
"type-only import",
'import type { evaluate } from "eve/experimental/evaluate"; export type Evaluate = typeof evaluate;',
false,
],
])(
"tracks runtime evaluation imports in a tool-free helper: %s",
async (_name, helper, runtime) => {
const root = await createExtensionPackage();
await rm(join(root, "extension", "tools"), { recursive: true });
await mkdir(join(root, "extension", "lib"));
await writeFile(join(root, "extension", "lib", "evaluation.ts"), helper);
const config = await tryReadExtensionBuildConfig(root);
const outDir = await buildExtensionPackage(root, config!);
const manifestPath = join(outDir, "extension", "_manifest.json");
const manifest = parseExtensionCompatibilityManifest(
await readFile(manifestPath, "utf8"),
manifestPath,
);
expect(manifest.requires).toEqual({
extension: EXTENSION_CAPABILITY_VERSIONS.extension,
...(runtime
? {
tool: EXTENSION_CAPABILITY_VERSIONS.tool,
dynamicTool: EXTENSION_CAPABILITY_VERSIONS.dynamicTool,
}
: {}),
});
},
);
it("stamps extension-owned external dependencies into compatibility metadata", async () => {
const root = await createExtensionPackage({
dependencies: { "layout-sensitive-runtime": "1.0.0" },
@@ -68,7 +68,7 @@ export async function buildExtensionPackage(
let preserveTransactionRoot = false;
try {
await mkdir(stagedDistRoot, { recursive: true });
await emitExtensionDistribution({
const runtimeImports = await emitExtensionDistribution({
appRoot,
declarationModule,
declarationsRoot: join(transactionRoot, "declarations"),
@@ -88,6 +88,7 @@ export async function buildExtensionPackage(
? {}
: { build: { externalDependencies: config.externalDependencies } }),
requires: await deriveExtensionCapabilityRequirements({
runtimeImports,
declarationModule,
manifest,
packageName: config.packageName,
@@ -21,6 +21,7 @@ export async function deriveExtensionCapabilityRequirements(input: {
readonly manifest: AgentSourceManifest;
readonly packageName: string;
readonly runtimeDependencies: readonly string[];
readonly runtimeImports: readonly string[];
readonly shortName: string;
readonly sourceRoot: string;
}): Promise<ExtensionCapabilityRequirements> {
@@ -89,6 +90,11 @@ export async function deriveExtensionCapabilityRequirements(input: {
required.add("config");
}
if (usesState) required.add("state");
// The evaluation entrypoint can be used from any contribution or shared helper.
if (input.runtimeImports.includes("eve/experimental/evaluate")) {
required.add("tool");
required.add("dynamicTool");
}
return Object.fromEntries(
(Object.keys(EXTENSION_CAPABILITY_VERSIONS) as ExtensionCapability[])
@@ -23,7 +23,7 @@ export async function emitExtensionDistribution(input: {
readonly stagedDistRoot: string;
readonly stagedOutDir: string;
readonly transactionRoot: string;
}): Promise<void> {
}): Promise<readonly string[]> {
const sourceFiles = await collectExtensionSourceFiles(input.sourceRoot);
const skillPackageRoots = input.manifest.skills
.filter((skill) => skill.sourceKind === "skill-package")
@@ -44,7 +44,7 @@ export async function emitExtensionDistribution(input: {
packageRoot: input.appRoot,
runtimeDependencies: input.runtimeDependencies,
});
for (const [fileName, code] of emitted) {
for (const [fileName, code] of emitted.files) {
const outputPath = join(input.stagedOutDir, fileName);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, code, "utf8");
@@ -69,6 +69,7 @@ export async function emitExtensionDistribution(input: {
);
}
await emitDeclarationBarrels(input);
return emitted.imports;
}
/**
@@ -1 +1,2 @@
export { autoModel } from "#experimental/evaluate/auto-model.js";
export { evaluate } from "#experimental/evaluate/evaluate.js";
+17 -17
View File
@@ -1,24 +1,26 @@
---
issue: "TBD (maintainer-requested research; no matching issue found)"
status: implemented
last_updated: "2026-09-16"
last_updated: "2026-09-17"
---
# Evaluation model routing in eve
## Recommendation
Add only `autoModel` at `eve/experimental/evaluate`. Build it on AI SDK's
`experimental_evaluate` API instead of owning a TypeSafe client, AI Gateway
transport, credential resolver, or public decision API. Default to the
`typesafe-ai/jev` model string. AI SDK model strings use its default
provider, normally Vercel AI Gateway; explicit model instances use the provider
package that created them.
Expose `autoModel` and standalone `evaluate` at `eve/experimental/evaluate`.
Build both on AI SDK's `experimental_evaluate` API. `evaluate` accepts the SDK's
state, typed questions, and request options, with an optional model defaulting to
`typesafe-ai/jev`. It returns the SDK's typed answers and response metadata.
Keep the first integration narrow: select an allowlisted language model from the
incoming task. Defer a generic agent-facing decision tool. Asking the main model
to generate tool arguments adds output tokens and latency before the cheaper
evaluation can happen.
`autoModel` calls the shared `evaluate` wrapper. Model strings use the configured
AI SDK default provider; without an override, the wrapper resolves eve's local
Gateway connection when available and otherwise leaves Gateway resolution to the
SDK. Explicit provider instances retain their own authentication.
Tool authors can call `evaluate` with structured state and pass `ctx.abortSignal`.
Application code can use it without an active eve session. Standalone calls are
not cached; durable per-turn selection remains specific to `autoModel`.
The API is experimental because AI SDK's evaluation model specification is also
experimental and can change in patch releases. The implemented authoring API is
@@ -160,12 +162,10 @@ direct provider behavior.
## Scope boundaries
The first release intentionally does not expose `experimental_evaluate`, a
TypeSafe-specific `decide` function, decision schemas, direct HTTP clients,
credentials, custom retry logic, provider fallbacks, confidence thresholds, or
an agent-callable decision tool. Applications that need arbitrary evaluations
can call AI SDK directly. eve adds only the lifecycle and durable routing policy
needed to use an evaluation result as an agent model.
eve owns the `evaluate` wrapper and its default model and authentication behavior.
AI SDK owns evaluation schemas, validation, retries, errors, and result metadata.
The integration adds no TypeSafe-specific `decide` function, direct HTTP client,
provider fallback, confidence threshold, or built-in agent-callable decision tool.
No live Jev inference or independent quality, latency, or cost benchmark was run
for this research. Deterministic tests use AI SDK evaluation model mocks and