@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
615 lines (570 loc) • 17.2 kB
text/typescript
/**
* Evaluate Activity
*
* Asks typed questions about a shared state and returns values your code can
* branch on. This is a self-contained module with implementation, types, and
* JSDoc.
*/
import { aiEventClient } from '@tanstack/ai-event-client'
import { resolveDebugOption } from '../../logger/resolve'
import { isAbortShapedError } from '../error-payload'
import {
createGenerationContext,
runGenerationAbort,
runGenerationError,
runGenerationFinish,
runGenerationStart,
runGenerationUsage,
} from '../middleware/run'
import type { InternalLogger } from '../../logger/internal-logger'
import type { DebugOption } from '../../logger/types'
import type { TokenUsage } from '../../types'
import type { GenerationMiddleware } from '../middleware/types'
import type {
EvaluateAdapter,
EvaluateInstructions,
EvaluateState,
WireAnswer,
WireChoiceAnswer,
WireNoulAnswer,
WireQuestion,
WireScoreAnswer,
WireScoreQuestion,
} from './adapter'
// ===========================
// Activity Kind
// ===========================
/** The adapter kind this activity handles */
export const kind = 'evaluate' as const
/** Question key reserved for `result.meta`. */
const RESERVED_QUESTION_KEY = 'meta' as const
// ===========================
// Type Extraction Helpers
// ===========================
/** Extract provider options from an EvaluateAdapter via ~types */
export type EvaluateProviderOptions<TAdapter> = TAdapter extends {
'~types': { providerOptions: infer P extends object }
}
? P
: object
// ===========================
// Unified answers
// ===========================
/**
* Public choice answer. `.value` is the selected option key.
*/
export interface ChoiceAnswer<TValue extends string = string> {
type: 'choice'
value: TValue
/** P(selected option). */
probability: number
confidence: number
probabilities: Record<TValue, number>
}
/**
* Public score answer. `.value` is the nearest level label.
* `.score` is the raw TypeSafe fraction.
*/
export interface ScoreAnswer<TLevel extends string = string> {
type: 'score'
value: TLevel
/** P(nearest level). */
probability: number
confidence: number
score: number
legend: Record<string, string>
probabilities: Record<string, number>
}
/**
* Public yes/no answer. `.value` is `true` when P(true) is 0.5 or more.
* There is no `.confidence`.
*/
export interface BooleanAnswer {
type: 'boolean'
value: boolean
/** P(true), from the wire `noul` field. */
probability: number
}
export interface EvaluateResultMeta {
/** Resolved model id from the provider. */
model: string
usage: TokenUsage
}
/**
* Map a helper question (or wire question) to its public answer type.
*/
export type InferEvaluateAnswer<TQuestion> = TQuestion extends {
type: 'choice'
criteria: infer TCriteria
}
? TCriteria extends Record<string, string | null>
? ChoiceAnswer<Extract<keyof TCriteria, string>>
: ChoiceAnswer
: TQuestion extends { type: 'score'; criteria: infer TLevels }
? TLevels extends ReadonlyArray<string>
? ScoreAnswer<TLevels[number] & string>
: ScoreAnswer
: TQuestion extends { type: 'noul' }
? BooleanAnswer
: never
/**
* Result of `decide()`. Each question key is a top-level answer.
* `meta` holds the resolved model id and usage.
*/
export type EvaluateResult<TQuestions extends Record<string, WireQuestion>> = {
[K in keyof TQuestions as K extends typeof RESERVED_QUESTION_KEY
? never
: K]: InferEvaluateAnswer<TQuestions[K]>
} & {
meta: EvaluateResultMeta
}
// ===========================
// Activity Options Types
// ===========================
/**
* Options for the evaluate activity. The model is extracted from the
* adapter's model property.
*
* @template TAdapter - The evaluate adapter type
* @template TQuestions - The questions object passed to `decide`
*/
export interface EvaluateActivityOptions<
TAdapter extends EvaluateAdapter<string, EvaluateProviderOptions<TAdapter>>,
TQuestions extends Record<string, WireQuestion>,
> {
/** The evaluate adapter to use (must be created with a model) */
adapter: TAdapter & { kind: typeof kind }
/** Shared state every question judges. A JSON array is one state, not a batch. */
state: EvaluateState
/**
* Questions built with `choice`, `score`, and `boolean`.
* The key `meta` is reserved.
*/
questions: TQuestions
/** Provider-specific options */
modelOptions?: EvaluateProviderOptions<TAdapter>
/** Forwarded to the provider request for cancellation. */
abortSignal?: AbortSignal
/**
* Observe-only middleware notified on start, usage, success, abort, and
* error. Pass `otelMiddleware()` to emit OpenTelemetry spans, or implement
* the `GenerationMiddleware` contract for a custom backend.
*/
middleware?: Array<GenerationMiddleware>
/**
* Enable debug logging. Pass `true` to enable all categories, `false` to
* silence everything including errors, or a `DebugConfig` object for granular
* control and/or a custom `Logger`.
*/
debug?: DebugOption
}
// ===========================
// Helper Functions
// ===========================
function createId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
function isAbortError(error: unknown, signal?: AbortSignal): boolean {
// Prefer the error's own identity over the signal state. A genuine
// cancellation throws an abort-shaped error (DOM `AbortError`, the OpenRouter
// SDK's `RequestAbortedError`, ...). Classifying on `signal.aborted` alone
// would misroute a real failure to the abort hook whenever a shared signal
// happens to already be aborted, hiding it from `onError` observers.
if (isAbortShapedError(error)) return true
// Fall back to signal state only for non-Error throws we can't otherwise
// identify; a real Error with a non-abort name is never an abort.
return error instanceof Error ? false : signal?.aborted === true
}
function questionKeys(questions: Record<string, WireQuestion>) {
return Object.keys(questions)
}
function assertQuestions(questions: Record<string, WireQuestion>) {
const keys = questionKeys(questions)
if (keys.length === 0) {
throw new Error('decide() requires at least one question')
}
if (Object.hasOwn(questions, RESERVED_QUESTION_KEY)) {
throw new Error('decide() reserves the question key "meta"')
}
return keys
}
function mapChoiceAnswer(wire: WireChoiceAnswer, key: string) {
const probability = wire.probabilities[wire.choice]
if (typeof probability !== 'number') {
throw new Error(
`decide(): missing probability for choice "${wire.choice}" on "${key}"`,
)
}
return {
type: 'choice' as const,
value: wire.choice,
probability,
confidence: wire.confidence,
probabilities: wire.probabilities,
}
}
function mapScoreAnswer(
question: WireScoreQuestion,
wire: WireScoreAnswer,
key: string,
) {
const levels = question.criteria
if (levels.length < 2) {
throw new Error(
`decide(): score question "${key}" needs at least two levels`,
)
}
const lastIndex = levels.length - 1
const rounded = Math.round(wire.score)
const nearestIndex =
rounded < 0 ? 0 : rounded > lastIndex ? lastIndex : rounded
const value = levels[nearestIndex]
if (value === undefined) {
throw new Error(
`decide(): score question "${key}" has no level at index ${nearestIndex}`,
)
}
const probability = wire.probabilities[String(nearestIndex)]
if (typeof probability !== 'number') {
throw new Error(
`decide(): missing probability for score level ${nearestIndex} on "${key}"`,
)
}
return {
type: 'score' as const,
value,
probability,
confidence: wire.confidence,
score: wire.score,
legend: wire.legend,
probabilities: wire.probabilities,
}
}
function mapBooleanAnswer(wire: WireNoulAnswer) {
return {
type: 'boolean' as const,
value: wire.noul >= 0.5,
probability: wire.noul,
}
}
function mapWireAnswer(question: WireQuestion, wire: WireAnswer, key: string) {
switch (question.type) {
case 'choice': {
if (wire.type !== 'choice') {
throw new Error(
`decide(): expected choice answer for "${key}", got ${wire.type}`,
)
}
return mapChoiceAnswer(wire, key)
}
case 'score': {
if (wire.type !== 'score') {
throw new Error(
`decide(): expected score answer for "${key}", got ${wire.type}`,
)
}
return mapScoreAnswer(question, wire, key)
}
case 'noul': {
if (wire.type !== 'noul') {
throw new Error(
`decide(): expected noul answer for "${key}", got ${wire.type}`,
)
}
return mapBooleanAnswer(wire)
}
}
}
function mapAnswers<TQuestions extends Record<string, WireQuestion>>(
questions: TQuestions,
wireAnswers: Record<string, WireAnswer>,
) {
const answers = {} as {
[K in keyof TQuestions]: InferEvaluateAnswer<TQuestions[K]>
}
const keys = Object.keys(questions) as Array<keyof TQuestions>
for (const key of keys) {
const question = questions[key]
const wire = wireAnswers[String(key)]
if (question === undefined) {
throw new Error(`decide(): missing question "${String(key)}"`)
}
if (wire === undefined) {
throw new Error(`decide(): missing answer for question "${String(key)}"`)
}
answers[key] = mapWireAnswer(
question,
wire,
String(key),
) as InferEvaluateAnswer<TQuestions[typeof key]>
}
return answers
}
function withMeta<TAnswers extends object>(
answers: TAnswers,
meta: EvaluateResultMeta,
) {
return { ...answers, meta }
}
// ===========================
// Question helpers
// ===========================
/**
* Build a choice question. The model picks one key from `options`.
*
* Option keys become the union on `.value`. Use `null` when a key needs no
* extra description. On the wire, `options` is sent as TypeSafe `criteria`.
*
* @param options.instructions What the model should decide.
* @param options.options Map of option key to description, or `null`.
*
* @example
* ```ts
* const queue = choice({
* instructions: 'Which team should handle this ticket?',
* options: {
* billing: 'Payments, invoices, refunds',
* tech: 'Bugs, outages, integrations',
* sales: 'Pricing, upgrades, new accounts',
* },
* })
* ```
*/
export function choice<
const TOptions extends Record<string, string | null>,
>(options: { instructions: EvaluateInstructions; options: TOptions }) {
return {
type: 'choice' as const,
instructions: options.instructions,
criteria: options.options,
}
}
/**
* Build a score question. The model rates `state` on ordered `levels`.
*
* You must pass at least two levels. `.value` is the nearest level label.
* The raw fraction stays on `.score`. On the wire, `levels` is sent as
* TypeSafe `criteria`.
*
* @param options.instructions What the model should rate.
* @param options.levels Ordered labels, lowest first. At least two.
*
* @example
* ```ts
* const urgency = score({
* instructions: 'How urgent is this ticket?',
* levels: ['low', 'medium', 'high'],
* })
* ```
*/
export function score<const TLevels extends ReadonlyArray<string>>(options: {
instructions: EvaluateInstructions
levels: TLevels
}) {
if (options.levels.length < 2) {
throw new Error('score() requires at least two levels')
}
return {
type: 'score' as const,
instructions: options.instructions,
criteria: options.levels,
}
}
/**
* Build a yes/no question.
*
* `.value` is `true` when P(true) is 0.5 or more. There is no `.confidence`.
* On the wire, the type is TypeSafe `noul`.
*
* @param options.instructions The yes/no question to judge.
* @param options.criteria Optional descriptions of yes and no.
*
* @example
* ```ts
* const refund = boolean({
* instructions: 'Is the customer asking for a refund?',
* })
* ```
*/
export function boolean(options: {
instructions: EvaluateInstructions
criteria?: {
true?: string
false?: string
}
}) {
if (options.criteria === undefined) {
return {
type: 'noul' as const,
instructions: options.instructions,
}
}
return {
type: 'noul' as const,
instructions: options.instructions,
criteria: options.criteria,
}
}
// ===========================
// Activity Implementation
// ===========================
/**
* Ask typed questions about `state` and get answers your code can branch on.
*
* You have state (a ticket, a record, a log) and you need typed answers, not
* prose. Pass questions built with `choice`, `score`, and `boolean`. Then
* branch on `result.queue.value` in ordinary TypeScript.
*
* The question key `meta` is reserved. Throws if `questions` is empty or uses
* that key.
*
* @param options.adapter Evaluate adapter created with a model.
* @param options.state Shared state every question judges.
* @param options.questions Questions built with `choice`, `score`, `boolean`.
* @param options.modelOptions Provider-specific options.
* @param options.abortSignal Cancels the in-flight request.
* @param options.middleware Observe-only generation middleware.
* @param options.debug Debug logging option.
*
* @example Route a support ticket
* ```ts
* import { decide, choice, score, boolean } from '@tanstack/ai'
* import { typesafeDecider } from '@tanstack/ai-typesafe'
*
* const result = await decide({
* adapter: typesafeDecider('jev-latest'),
* state: ticket,
* questions: {
* queue: choice({
* instructions: 'Which team should handle this ticket?',
* options: {
* billing: 'Payments, invoices, refunds',
* tech: 'Bugs, outages, integrations',
* sales: 'Pricing, upgrades, new accounts',
* },
* }),
* urgency: score({
* instructions: 'How urgent is this ticket?',
* levels: ['low', 'medium', 'high'],
* }),
* refund: boolean({
* instructions: 'Is the customer asking for a refund?',
* }),
* },
* })
*
* result.queue.value
* result.meta.model
* result.meta.usage
* ```
*/
export async function decide<
TAdapter extends EvaluateAdapter<string, EvaluateProviderOptions<TAdapter>>,
TQuestions extends Record<string, WireQuestion>,
>(options: EvaluateActivityOptions<TAdapter, TQuestions>) {
const {
adapter,
state,
questions,
modelOptions,
abortSignal,
middleware,
debug,
} = options
const model = adapter.model
const keys = assertQuestions(questions)
const requestId = createId('evaluate')
const startTime = Date.now()
const logger: InternalLogger = resolveDebugOption(debug)
const mwCtx = createGenerationContext({
requestId,
activity: 'evaluate',
provider: adapter.name,
model,
modelOptions,
createId,
})
await runGenerationStart(middleware, mwCtx)
aiEventClient.emit('evaluate:request:started', {
requestId,
provider: adapter.name,
model,
questionCount: keys.length,
timestamp: startTime,
})
logger.request(`activity=evaluate provider=${adapter.name}`, {
provider: adapter.name,
model,
questionCount: keys.length,
})
try {
const result = await adapter.evaluate({
model,
state,
questions,
modelOptions,
abortSignal,
logger,
})
const answers = mapAnswers(questions, result.answers)
const duration = Date.now() - startTime
aiEventClient.emit('evaluate:request:completed', {
requestId,
provider: adapter.name,
model: result.model,
questionCount: keys.length,
duration,
timestamp: Date.now(),
})
aiEventClient.emit('evaluate:usage', {
requestId,
model: result.model,
usage: result.usage,
timestamp: Date.now(),
})
logger.output(`activity=evaluate answers=${keys.length}`, {
answerCount: keys.length,
})
await runGenerationUsage(middleware, mwCtx, result.usage)
await runGenerationFinish(middleware, mwCtx, {
duration,
usage: result.usage,
})
return withMeta(answers, {
model: result.model,
usage: result.usage,
})
} catch (error) {
const duration = Date.now() - startTime
if (isAbortError(error, abortSignal)) {
await runGenerationAbort(middleware, mwCtx, {
reason: error instanceof Error ? error.message : undefined,
duration,
})
} else {
await runGenerationError(middleware, mwCtx, { error, duration })
}
logger.errors('evaluate activity failed', { error, source: 'evaluate' })
throw error
}
}
// Re-export adapter types
export type {
EvaluateAdapter,
EvaluateAdapterConfig,
AnyEvaluateAdapter,
EvaluateOptions,
EvaluateAdapterResult,
EvaluateState,
EvaluateInstructions,
EvaluateJsonValue,
WireQuestion,
WireAnswer,
WireChoiceQuestion,
WireScoreQuestion,
WireNoulQuestion,
WireChoiceAnswer,
WireScoreAnswer,
WireNoulAnswer,
} from './adapter'
export { BaseEvaluateAdapter } from './adapter'