npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

funcai

v1.1.0

Published

AI as a Function — structured AI output with typed providers, tracing, and retry

Readme

funcai

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.

Install

pnpm add funcai zod

Quick Start

import { 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 }

Examples

Typed input objects

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"
//   }

Multimodal — images, PDFs, audio

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"
//   }
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 }, ...]
//   }
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
//   }

Few-shot examples

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"
//   }

Chain-of-thought reasoning

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.

Reasoning mode

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.

Retry + fallback

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
});
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 metadata

.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: { ... },
//   }

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
//   }

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."

Runnable demos

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 needed

CLI

funcai scaffold — Generate a complete AI feature

Scaffolds 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 prompts
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

funcai generate — Prompt-as-code

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 save
import { 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").


Testing

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();
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 registry
import { validateExamples } from "funcai/test";

validateExamples(examples, schema); // throws with descriptive error if any example mismatches

Providers

OpenRouter 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 — 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 });
openrouter({
  headers: { "anthropic-beta": "fine-grained-tool-streaming-2025-05-14" },
  extraBody: { transforms: ["middle-out"] },
});

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";
import { createProvider } from "funcai";
import { createAnthropic } from "@ai-sdk/anthropic";

const anthropic = createProvider(({ modelId }) =>
  createAnthropic({ apiKey: "sk-ant-..." })(modelId)
);
createAiFn({ provider: anthropic });

Tracing

PostHog

pnpm add posthog-node @posthog/ai
import { 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
});

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();
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 });

API Reference

ai.fn(options) — all options

schema and input are always required. Provide either model + system or prompt — not both.

model (required*) — OpenRouter model ID

model: "anthropic/claude-sonnet-4"
model: "google/gemini-3.1-flash-lite-preview"          // cheaper, faster
model: "google/gemini-2.5-flash"     // vision + PDF support

system (required*) — System prompt

system: "You are a product review analyst. Extract actionable insights from customer feedback."

* model and system are required unless you provide prompt, which bundles both.

prompt (optional) — Reusable prompt config (replaces model + system)

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: 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(),
})

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.

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" },
  },
]

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.history

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 };
}

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 — retry count per model

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 immediately

fallback — fallback model IDs

Tried 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) → AiFnError

.detailed() — full generation metadata

Returns 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.


Exports

| Path | Exports | |------|---------| | funcai | createAiFn, AiFnError, definePrompt, createProvider, buildSystemPrompt, formatExamples, injectVariables | | funcai/providers/openrouter | openrouter | | funcai/trace/posthog | posthog | | funcai/test | track, unmockAll, isMocked, validateExamples |

Requirements

  • 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.