AI as a Function — define a Zod schema, get validated structured output back. Retry, fallback, tracing, multimodal, and cost tracking built in.
Built on the Vercel AI SDK. Wraps generateObject into typed, callable functions.
pnpm add funcai zodimport { z } from "zod";
import { createAiFn } from "funcai";
import { openrouter } from "funcai/providers/openrouter";
const ai = createAiFn({ provider: openrouter() });
const classifySentiment = ai.fn({
model: "anthropic/claude-sonnet-4",
system: "Classify the sentiment of the given text.",
schema: z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
confidence: z.number().min(0).max(1),
}),
input: (text: string) => text,
});
await classifySentiment("This product exceeded all my expectations!");
// → { sentiment: "positive", confidence: 0.95 }Pass structured data instead of plain strings — the input function formats it for the model.
const analyzeReview = ai.fn({
model: "google/gemini-3.1-flash-lite-preview",
system: "Analyze product reviews. Identify actionable feedback, sentiment, and suggest improvements.",
schema: z.object({
sentiment: z.enum(["positive", "negative", "mixed"]),
topics: z.array(z.string()),
actionable: z.boolean(),
suggestedAction: z.string(),
}),
input: (review: { title: string; body: string; rating: number; category: string }) =>
`Category: ${review.category}\nRating: ${review.rating}/5\nTitle: ${review.title}\n\n${review.body}`,
});
await analyzeReview({
title: "Great features but slow loading",
body: "The new dashboard is beautiful and the analytics are exactly what we needed. However, page load times have gotten noticeably worse since the last update.",
rating: 3,
category: "SaaS Analytics",
});
// → {
// sentiment: "mixed",
// topics: ["dashboard", "performance", "analytics"],
// actionable: true,
// suggestedAction: "Investigate page load regression in latest release"
// }Return ContentPart[] from input to send images, files, or audio alongside text.
const analyzeProductImage = ai.fn({
model: "google/gemini-2.5-flash",
system: "Analyze product images. Identify the product type, condition, and key visual features.",
schema: z.object({
productType: z.string(),
condition: z.enum(["new", "like-new", "good", "fair", "poor"]),
features: z.array(z.string()),
backgroundQuality: z.enum(["professional", "decent", "poor"]),
}),
input: (photo: { url: string; productId: string }) => [
{ type: "text" as const, text: `Product ${photo.productId} — analyze this image:` },
{ type: "image" as const, image: photo.url },
],
});
await analyzeProductImage({ url: "https://example.com/products/42/photo.jpg", productId: "SKU-042" });
// → {
// productType: "wireless headphones",
// condition: "new",
// features: ["noise cancelling", "over-ear", "foldable design"],
// backgroundQuality: "professional"
// }PDF extraction — parse invoices and documents
const extractInvoice = ai.fn({
model: "google/gemini-2.5-flash",
system: "Extract structured data from invoice PDFs.",
schema: z.object({
vendor: z.string(),
invoiceNumber: z.string(),
date: z.string(),
total: z.number(),
currency: z.string(),
lineItems: z.array(z.object({ description: z.string(), amount: z.number() })),
}),
input: (invoiceUrl: string) => [
{ type: "text" as const, text: "Extract all details from this invoice:" },
{ type: "file" as const, data: new URL(invoiceUrl), mediaType: "application/pdf" },
],
});
await extractInvoice("https://example.com/invoices/INV-2025-001.pdf");
// → {
// vendor: "Acme Corp",
// invoiceNumber: "INV-2025-001",
// date: "2025-03-01",
// total: 1250.00,
// currency: "USD",
// lineItems: [{ description: "Consulting services", amount: 1000 }, ...]
// }Audio transcription — analyze support calls and recordings
const analyzeCallRecording = ai.fn({
model: "google/gemini-2.5-flash",
system: "Transcribe and analyze customer support call recordings. Extract sentiment, key issues, and resolution status.",
schema: z.object({
transcript: z.string(),
sentiment: z.enum(["very-positive", "positive", "neutral", "negative"]),
keyIssues: z.array(z.string()),
resolved: z.boolean(),
followUpNeeded: z.boolean(),
}),
input: (recording: { audioUrl: string; ticketId: string }) => [
{ type: "text" as const, text: `Support call for ticket ${recording.ticketId}:` },
{ type: "file" as const, data: new URL(recording.audioUrl), mediaType: "audio/ogg" },
],
});
await analyzeCallRecording({
audioUrl: "https://example.com/calls/ticket-8f3/recording.ogg",
ticketId: "TKT-042",
});
// → {
// transcript: "Customer reported login issues after the latest update...",
// sentiment: "negative",
// keyIssues: ["login failure", "password reset not working"],
// resolved: false,
// followUpNeeded: true
// }Guide the model with input/output pairs. Injected into the system prompt automatically.
const classifyEmail = ai.fn({
model: "google/gemini-3.1-flash-lite-preview",
system: "Classify incoming emails by intent and urgency for the support queue.",
schema: z.object({
intent: z.enum(["support", "billing", "feature-request", "bug-report", "spam", "other"]),
urgency: z.enum(["high", "medium", "low"]),
suggestedAction: z.string(),
}),
input: (message: string) => message,
examples: [
{
input: "Our entire team can't log in since this morning. Production is blocked.",
output: { intent: "bug-report", urgency: "high", suggestedAction: "Escalate to engineering immediately — service outage" },
},
{
input: "Can you add dark mode to the dashboard?",
output: { intent: "feature-request", urgency: "low", suggestedAction: "Add to feature backlog, send acknowledgement" },
},
{
input: "I was charged twice on my last invoice.",
output: { intent: "billing", urgency: "high", suggestedAction: "Forward to billing team, respond within 2 hours" },
},
],
});
await classifyEmail("We'd like to upgrade to the enterprise plan. Can someone walk us through pricing?");
// → {
// intent: "billing",
// urgency: "medium",
// suggestedAction: "Route to sales team — expansion opportunity"
// }Add optional reasoning to examples to teach the model why — not just what — to output. Improves accuracy on ambiguous inputs.
const parseSearch = ai.fn({
model: "google/gemini-2.5-flash",
system: "Extract structured search filters from natural language product queries.",
schema: searchFiltersSchema,
input: (query: string) => query,
examples: [
{
input: "Cheap wireless headphones under $50 with noise cancelling",
reasoning:
'"Cheap" + "under $50" both indicate price constraint — map to maxPrice: 50. ' +
'"Wireless" and "noise cancelling" are feature filters, not categories.',
output: {
categories: ["headphones"],
filters: { wireless: true, noiseCancelling: true },
priceRange: { max: 50 },
queryText: { must: ["headphones"], should: ["wireless", "noise cancelling"], mustNot: [] },
},
},
],
});Reasoning is rendered between Input and Output in the system prompt. Examples without reasoning work exactly as before — the field is fully optional.
Enable extended thinking for models that support it. Control reasoning effort or set a max token budget.
const analyzeContract = ai.fn({
model: "anthropic/claude-opus-4",
system: "Analyze complex legal contracts. Identify risks, obligations, and key terms.",
schema: contractSchema,
input: (doc: string) => doc,
reasoning: { effort: "high" }, // extended thinking for complex tasks
});
// Or set a token budget for reasoning
const classify = ai.fn({
model: "openai/o3",
system: "Classify support tickets.",
schema: ticketSchema,
input: (text: string) => text,
reasoning: { maxTokens: 2048 },
});Effort levels: xhigh, high, medium, low, minimal, none. Passed through to the provider via providerOptions — models that don't support reasoning ignore it.
Automatic retries with exponential backoff, then fallback to alternative models.
const generateDescription = ai.fn({
model: "anthropic/claude-sonnet-4",
system: "Write compelling product descriptions. Be specific, highlight key features, avoid cliches.",
schema: z.object({
headline: z.string(),
description: z.string(),
highlights: z.array(z.string()).max(5),
}),
input: (product: { name: string; details: string }) =>
`${product.name}\n\n${product.details}`,
retries: 2,
fallback: ["openai/gpt-4o", "google/gemini-2.5-pro"],
// Claude fails → try gpt-4o (with retries) → try gemini (with retries) → AiFnError
});Error handling — inspect attempt history on failure
import { AiFnError } from "funcai";
try {
await generateDescription({ name: "Widget Pro", details: "..." });
} catch (error) {
if (error instanceof AiFnError) {
error.attempts;
// → [
// { model: "anthropic/claude-sonnet-4", error: RateLimitError, durationMs: 1200 },
// { model: "openai/gpt-4o", error: TimeoutError, durationMs: 5000 },
// ...
// ]
}
}.detailed() returns output alongside usage, cost, latency, and trace context.
const result = await classifyEmail.detailed("Our team can't access the API since this morning", {
traceId: "req-abc-123",
userId: "user_sarah",
sessionId: "sess_8f3a1b",
properties: { env: "production", feature: "email-triage", ticketId: "TKT-042" },
});
// → {
// output: { intent: "bug-report", urgency: "high", suggestedAction: "Escalate..." },
// model: "google/gemini-3.1-flash-lite-preview",
// usage: { inputTokens: 142, outputTokens: 38 },
// cost: 0.00018,
// traceId: "req-abc-123",
// latencyMs: 620,
// attempts: 1,
// providerMetadata: { ... },
// }Post-process with transform — reshape or enrich output
transform receives the schema-validated output and original input. Can be async.
const estimatePrice = ai.fn({
model: "anthropic/claude-sonnet-4",
system: "Estimate competitive market price based on product details and comparable items.",
schema: z.object({
estimatedPrice: z.number(),
confidence: z.enum(["low", "medium", "high"]),
reasoning: z.string(),
}),
input: (product: { name: string; msrp: number; category: string; condition: string }) =>
`${product.name} — ${product.category}\nMSRP: $${product.msrp}\nCondition: ${product.condition}`,
transform: (output, product) => ({
...output,
productName: product.name,
msrp: product.msrp,
delta: output.estimatedPrice - product.msrp,
}),
});
await estimatePrice({
name: "Sony WH-1000XM5",
msrp: 399,
category: "Headphones",
condition: "Like New",
});
// → {
// estimatedPrice: 320,
// confidence: "high",
// reasoning: "Strong demand for XM5, like-new condition commands 80% of MSRP...",
// productName: "Sony WH-1000XM5",
// msrp: 399,
// delta: -79
// }Reusable prompts — definePrompt() with template variables
Separate prompt config from function logic. Supports {{VARIABLE}} injection — unresolved placeholders throw at runtime.
const prompt = ai.definePrompt({
id: "product-description",
model: "google/gemini-3.1-flash-lite-preview",
system: "Write a product description in {{LANGUAGE}} for the {{MARKET}} market. Tone: {{TONE}}.",
temperature: 0.7,
});
const describeProduct = ai.fn({
prompt,
schema: z.object({ headline: z.string(), body: z.string(), callToAction: z.string() }),
input: (details: string) => details,
});
const system = ai.injectVariables(prompt.system, { LANGUAGE: "English", MARKET: "US", TONE: "professional" });
// → "Write a product description in English for the US market. Tone: professional."Full working examples in examples/:
| # | Script | What it shows |
|---|---|---|
| 01 | pnpm basic |
String in, structured output out |
| 02 | pnpm prompt |
definePrompt() with template variables |
| 03 | pnpm typed-input |
Typed complex input objects |
| 04 | pnpm messages |
Multi-turn conversation history |
| 05 | pnpm few-shots |
Few-shot examples for model guidance |
| 06 | pnpm transform |
Post-process output with transform() |
| 07 | pnpm detailed |
.detailed() with metadata, cost, tracing |
| 08 | pnpm retry |
Retry, fallback, and AiFnError |
| 09 | pnpm codegen |
CLI generate from .prompt.md files |
| 10 | pnpm multimodal |
Images, PDFs, and ContentPart[] input |
| 11 | pnpm scaffold |
CLI scaffold — bootstrap a feature folder |
cd examples
OPENROUTER_API_KEY=sk-or-... pnpm basic # most examples need an API key
pnpm codegen # no API key needed
pnpm scaffold # no API key neededScaffolds a working feature folder with schema, prompt, few-shots, index, tests, and README.
npx funcai scaffold # interactive TUI (defaults work out of the box)
npx funcai scaffold --name invoice-parser --fields "vendor,amount,currency" -y
npx funcai scaffold -y # accept all defaults, no promptsOutput structure
classify-sentiment/
├── schema.ts # Zod schema with .describe() annotations
├── few-shots.ts # Typed input/output examples
├── classify-sentiment.prompt.md # System prompt (YAML frontmatter + markdown)
├── classify-sentiment.prompt.ts # Auto-generated TypeScript from prompt.md
├── index.ts # Callable ai.fn() with JSDoc
├── README.md # Quick start guide
└── tests/
├── classify-sentiment.test.ts # Unit: schema + few-shot validation
├── classify-sentiment.integration.test.ts # Integration: MockLanguageModelV3
└── classify-sentiment.e2e.test.ts # E2E: live API (skipped without key)
Flags: --name, --fields, --model, --description, --posthog, --ai, -y
Write system prompts in markdown, generate type-safe TypeScript modules.
<!-- prompts/review-ticket.prompt.md -->
---
id: review-ticket
model: anthropic/claude-sonnet-4
temperature: 0.1
maxTokens: 200
---
You are a support ticket reviewer. Analyze the ticket for quality,
completeness, and urgency. Flag missing details and suggest next steps.npx funcai generate prompts/ # one-time
npx funcai generate prompts/ --watch # regenerate on saveimport { reviewTicket } from "./prompts/review-ticket.prompt";
const review = ai.fn({
prompt: reviewTicket,
schema: z.object({
score: z.number().min(0).max(10),
issues: z.array(z.string()),
suggestion: z.string(),
}),
input: (description: string) => description,
});
await review("App crashes on login. Please fix.");
// → {
// score: 3,
// issues: ["No device/OS info", "No steps to reproduce", "No error message"],
// suggestion: "Ask for device, OS version, and steps to reproduce the crash",
// }Variants for A/B testing: review-ticket.concise.prompt.md generates a group index with getPrompt("concise").
Built-in .mock() / .unmock() on every function. No test-runner dependency — works with Vitest, Jest, node:test.
// Static mock — always returns this value
classifySentiment.mock({ sentiment: "positive", confidence: 0.95 });
await classifySentiment("anything");
// → { sentiment: "positive", confidence: 0.95 }
// Dynamic mock — output depends on input
classifySentiment.mock((text) => ({
sentiment: text.includes("love") ? "positive" : "negative",
confidence: 0.8,
}));
// Single-use queue — FIFO, then falls through to permanent mock or real call
classifySentiment.mockOnce({ sentiment: "positive", confidence: 1 });
classifySentiment.mockOnce({ sentiment: "negative", confidence: 0.9 });
await classifySentiment("a"); // → positive (from queue)
await classifySentiment("b"); // → negative (from queue)
await classifySentiment("c"); // → real LLM call (queue empty, no permanent mock)
// Cleanup
classifySentiment.unmock();Batch cleanup — track and unmock across tests
import { track, unmockAll } from "funcai/test";
beforeEach(() => {
track(classifySentiment).mock({ sentiment: "positive", confidence: 1 });
track(analyzeReview).mock({ sentiment: "positive", topics: [], actionable: false, suggestedAction: "none" });
});
afterEach(() => unmockAll()); // unmocks all tracked functions, clears registryValidate few-shots — check examples against schema
import { validateExamples } from "funcai/test";
validateExamples(examples, schema); // throws with descriptive error if any example mismatchesOpenRouter ships built-in. Reads OPENROUTER_API_KEY from env or accepts it explicitly:
import { openrouter } from "funcai/providers/openrouter";
createAiFn({ provider: openrouter() });
createAiFn({ provider: openrouter({ apiKey: "sk-or-..." }) });Response healing & usage accounting — enabled by default
- Response healing — auto-repairs malformed JSON responses before they reach your schema validation. Perfect for
generateObject(all funcai calls are non-streaming). - Usage accounting — surfaces cost, cached tokens, and reasoning tokens in
providerMetadata. Extracted automatically by.detailed().
Both can be opted out if needed:
openrouter({ responseHealing: false, usage: false });Advanced options — headers, extraBody, provider features
openrouter({
headers: { "anthropic-beta": "fine-grained-tool-streaming-2025-05-14" },
extraBody: { transforms: ["middle-out"] },
});Model registry — 55+ models with pricing and capabilities
Curated registry with typed IDs, pricing, modalities, and capabilities. Use pnpm update:models to refresh from the OpenRouter API.
import {
OPENROUTER_MODELS, // full registry with metadata
OPENROUTER_MODEL_IDS, // all model ID strings
MULTIMODAL_IMAGE_MODELS, // models accepting image input
MULTIMODAL_FILE_MODELS, // models accepting file/PDF input
REASONING_MODELS, // models with reasoning capabilities
} from "funcai/providers/openrouter";Custom provider — wrap any AI SDK-compatible model
import { createProvider } from "funcai";
import { createAnthropic } from "@ai-sdk/anthropic";
const anthropic = createProvider(({ modelId }) =>
createAnthropic({ apiKey: "sk-ant-..." })(modelId)
);
createAiFn({ provider: anthropic });pnpm add posthog-node @posthog/aiimport { posthog } from "funcai/trace/posthog";
const ai = createAiFn({
provider: openrouter(),
trace: posthog("phc_your_project_key"),
});
// userId, sessionId, and properties from .detailed() flow into PostHog automatically
await classify.detailed("input", {
userId: "user_2xK9mQ", // → posthogDistinctId
sessionId: "sess_8f3a1b", // → $ai_session_id
properties: { env: "prod" }, // → custom event properties
});Bring your own client — control flush/shutdown
By default, the plugin creates an internal PostHog client. Pass your own to control its lifecycle — useful in tests, serverless, or anywhere you need to guarantee events flush before exit.
import { PostHog } from "posthog-node";
import { posthog } from "funcai/trace/posthog";
const ph = new PostHog("phc_your_project_key", { host: "https://eu.i.posthog.com" });
const ai = createAiFn({
provider: openrouter(),
trace: posthog({ apiKey: "phc_your_project_key", client: ph }),
});
// When done (e.g. afterAll in tests, or before process exit):
await ph.shutdown();Custom trace plugin — bring your own observability
import type { TracePlugin } from "funcai";
const myTrace: TracePlugin = {
wrap: (model, context) => {
// context: { traceId, model, feature, userId?, sessionId?, properties? }
return myObservabilityWrapper(model, context);
},
};
createAiFn({ provider: openrouter(), trace: myTrace });schema and input are always required. Provide either model + system or prompt — not both.
model + system + prompt
model: "anthropic/claude-sonnet-4"
model: "google/gemini-3.1-flash-lite-preview" // cheaper, faster
model: "google/gemini-2.5-flash" // vision + PDF supportsystem: "You are a product review analyst. Extract actionable insights from customer feedback."*
modelandsystemare required unless you provideprompt, which bundles both.
const reviewPrompt = ai.definePrompt({
id: "review-analysis",
model: "google/gemini-3.1-flash-lite-preview",
system: "Analyze customer reviews for the {{CATEGORY}} department.",
temperature: 0.2,
maxTokens: 500,
});
const analyze = ai.fn({ prompt: reviewPrompt, schema, input });schema — Zod output schema
schema: z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
confidence: z.number().min(0).max(1),
topics: z.array(z.string()).max(5),
actionable: z.boolean(),
})input — transform input data into a user message
String for text-only, ContentPart[] for multimodal:
// Simple string
input: (text: string) => text
// Typed object → formatted string
input: (review: { title: string; body: string; rating: number }) =>
`Title: ${review.title}\nRating: ${review.rating}/5\n\n${review.body}`
// Multimodal — image + text
input: (data: { imageUrl: string; notes: string }) => [
{ type: "text" as const, text: data.notes },
{ type: "image" as const, image: data.imageUrl },
]
// Multimodal — PDF
input: (pdfUrl: string) => [
{ type: "text" as const, text: "Extract key details:" },
{ type: "file" as const, data: new URL(pdfUrl), mediaType: "application/pdf" },
]Part types: TextPart, ImagePart, FilePart, AudioPart. Accepts string | URL | Buffer for images, string | URL | Uint8Array | ArrayBuffer | Buffer for files.
examples — few-shot input/output pairs with optional reasoning
Injected into the system prompt. Use {{FEW_SHOTS}} in the prompt to control placement. Add reasoning to teach the model why — rendered between Input and Output.
examples: [
{
input: "App crashes every time I open settings on iOS 18.",
reasoning: "Specific device/OS mentioned, reproducible steps — this is a high-urgency bug.",
output: { category: "bug", urgency: "high", suggestedAction: "Escalate to mobile team" },
},
{
input: "Would be nice to have dark mode.",
output: { category: "feature-request", urgency: "low", suggestedAction: "Add to backlog" },
},
]messages — conversation history
Static array or dynamic function. Prepended before the final user message.
// Static context
messages: [
{ role: "user", content: "I'm looking at products in the electronics category." },
{ role: "assistant", content: "I'll focus on electronics pricing and features." },
]
// Dynamic — built from input
messages: (input: { history: Array<{ role: "user" | "assistant"; content: string }>; query: string }) =>
input.historytransform — post-process validated output
Receives the schema-validated output and the original input. Can be async.
// Sort results by score
transform: (output, input) =>
output.rankings.sort((a, b) => b.score - a.score)
// Enrich with input data
transform: (output, product) => ({
...output,
productName: product.name,
pricePerUnit: product.price / product.quantity,
})
// Async — fetch additional data
transform: async (output, input) => {
const related = await fetchRelatedProducts(output.category);
return { ...output, related };
}reasoning — extended thinking mode
Enable reasoning/thinking for models that support it (Claude Opus, OpenAI o-series, DeepSeek R1, etc.).
// By effort level
reasoning: { effort: "high" } // xhigh | high | medium | low | minimal | none
// By token budget
reasoning: { maxTokens: 4096 }Passed as providerOptions.openrouter.reasoning to generateObject. Models without reasoning support ignore it.
retries + fallback — resilience options
Exponential backoff with jitter (500ms–5s). Only retryable errors trigger retries (429, 5xx, network).
retries: 3 // 3 retries = 4 total attempts per model
retries: 0 // no retries, fail immediatelyTried in order after the primary model exhausts all retries.
fallback: ["openai/gpt-4o", "google/gemini-2.5-pro"]
// Primary fails → try gpt-4o (with retries) → try gemini (with retries) → AiFnErrorReturns output alongside usage, cost, latency, and trace context. All options flow into your tracing plugin (e.g. PostHog).
const result = await classifySentiment.detailed("The customer service was incredibly helpful", {
traceId: "req-abc-123", // correlate with request logs
userId: "user_2xK9mQ", // → posthogDistinctId
sessionId: "sess_8f3a1b", // groups calls within a session
properties: { // custom metadata for your trace
env: "production",
feature: "feedback-analysis",
},
});
// → {
// output: { sentiment: "positive", confidence: 0.92 },
// model: "anthropic/claude-sonnet-4",
// usage: { inputTokens: 38, outputTokens: 12 },
// cost: 0.00042, // USD — when provider reports it (e.g. OpenRouter)
// traceId: "req-abc-123",
// latencyMs: 740,
// attempts: 1,
// providerMetadata: { ... }, // raw provider data (OpenRouter cost breakdown, etc.)
// }cost is extracted from providerMetadata when available. OpenRouter always includes it; other providers return undefined.
| Path | Exports |
|---|---|
funcai |
createAiFn, AiFnError, definePrompt, createProvider, buildSystemPrompt, formatExamples, injectVariables |
funcai/providers/openrouter |
openrouter |
funcai/trace/posthog |
posthog |
funcai/test |
track, unmockAll, isMocked, validateExamples |
- Node.js >= 20
- zod >= 3.22 (peer dependency)
- posthog-node + @posthog/ai (optional, for tracing)
- ESM and CJS supported
For internals and design decisions, see HOW-IT-WORKS.md.