AI
One way to call a model, for text, objects, embeddings, images, video, speech and transcription.
@zap-ts/ai gives the app one way to call an AI model. It covers text,
structured output, summaries, embeddings, images, video, speech and
transcription. Each one is a function. Each function retries itself when the
model provider fails. The app imports @zap-ts/ai, and nothing else. There is
only one export path.
The package is built on @tanstack/ai. The model provider is OpenAI, through
@tanstack/ai-openai.
Every function returns an Effect. An Effect is a description of work. It does not run until you run it.
The AI service
The package does not hold a client of its own. Instead it has two pieces:
AIis a tag. A tag is a name that stands for a service you will provide later. This tag holds one adapter per capability. An adapter is the small piece of code that talks to OpenAI.AILiveis the layer that builds those adapters. A layer is the recipe that makes a service real.
AILive reads OPENAI_API_KEY with requireEnv. So a missing key fails at
once, when you build the layer. It does not fail later, in the middle of a
request.
import { AILive, generateText } from "@zap-ts/ai";
import { Effect } from "effect";
const answer = await Effect.runPromise(
generateText({ messages: [{ role: "user", content: "Hi!" }] }).pipe(Effect.provide(AILive)),
);
In apps/web the layer is built once, in src/lib/ai.ts. Every call shares
it. Your code calls runAI and never names the layer:
import { generateText } from "@zap-ts/ai";
import { runAI } from "../lib/ai";
const answer = await runAI(generateText({ messages: [{ role: "user", content: "Hi!" }] }));
Wait, or stream
Each capability comes in two shapes.
The first shape waits. It gives you the answer when the model has finished:
generateText, generateObject, summarizeText, generateEmbeddings,
createImage, createVideo, createSpeech and transcribe. A video can take
a long time, so getVideoStatus reads the state of a video job that still
runs.
The second shape streams. It gives you the answer in small pieces, as the model
writes it: streamText, streamObject, streamSummarizeText, streamImage,
streamVideo, streamSpeech and streamTranscription.
import { AILive, streamText } from "@zap-ts/ai";
import { Effect, Stream } from "effect";
const program = streamText({
messages: [{ role: "user", content: "Write a poem." }],
}).pipe(Stream.runForEach((chunk) => Effect.log(chunk)));
await Effect.runPromise(program.pipe(Effect.provide(AILive)));
Do you want to send the pieces straight to a browser? Then use
streamTextResponse. It gives you an HTTP response instead of a stream.
Structured output
Sometimes you do not want free text. You want an object with fields you can
trust. Give generateObject an outputSchema. A schema says which fields the
object has, and what type each field is. You get back the parsed object, and
TypeScript knows its shape:
const user = await runAI(
generateObject({
messages: [{ role: "user", content: "Give me a fake user." }],
outputSchema: z.object({ name: z.string(), age: z.number() }),
}),
);
streamObject is the same call. It builds the object piece by piece.
Tools
A tool is a function you let the model call. You declare one with
toolDefinition, which the package re-exports from @tanstack/ai.
Is the work of your tool written with Effect? Then use effectTool. You write
the body as an Effect, and the package runs it for you:
const getTime = effectTool(
{
name: "getTime",
description: "Gives the current time.",
inputSchema: z.object({}),
outputSchema: z.object({ now: z.string() }),
},
() => Effect.succeed({ now: new Date().toISOString() }),
);
Retries and errors
Every call retries in the same way. It waits 200ms, then doubles the wait each time, and it tries three times in total. The wait also gets a small random change, so many calls do not all retry at the same moment.
A retry writes a debug log. Three failed tries write a warning log.
Then you get an AIError. It carries the original error in its cause field.
So your code handles one error type, whatever the provider did.
Models
The model names live in one place, packages/ai/src/index.ts:
export const DEFAULT_MODELS = {
embedding: "text-embedding-3-large",
image: "gpt-image-2",
speech: "tts-1-hd",
text: "gpt-5.2",
transcription: "gpt-4o-transcribe",
video: "sora-2",
} as const;
The text model is used for chat and for summaries. Change a value here, and
every call that uses that capability changes too.
Changing the provider
Only OpenAI is set up. AILive builds its seven adapters with the
createOpenai* functions of @tanstack/ai-openai. An adapter is one
capability from one company, so a swap is one import, one type and one set of
model names.
@tanstack/ai has an adapter package per company:
@tanstack/ai-anthropic, @tanstack/ai-gemini, @tanstack/ai-mistral,
@tanstack/ai-grok, @tanstack/ai-groq, @tanstack/ai-cohere,
@tanstack/ai-ollama, @tanstack/ai-bedrock, @tanstack/ai-openrouter,
@tanstack/ai-perplexity, @tanstack/ai-elevenlabs, @tanstack/ai-fal, and
more.
The five steps
-
Add the adapter package. Put the version in
catalog:inpnpm-workspace.yaml, write"@tanstack/ai-gemini": "catalog:"inpackages/ai/package.json, thenpnpm install. Never a version number in apackage.json. -
Declare the new key in the root
.env.schema, next toOPENAI_API_KEY, with its@docslink and its@type. Put the real key in.env.local. -
In
packages/ai/src/index.ts, change the imports, theAIAdapterstype and theAILivelayer together. They name the same functions three times:import { createGeminiChat, createGeminiEmbedding, createGeminiImage, createGeminiSpeech, createGeminiSummarize, createGeminiVideo, } from "@tanstack/ai-gemini"; export type AIAdapters = { embedding: ReturnType<typeof createGeminiEmbedding>; // ...one line per capability }; export const AILive: Layer.Layer<AI> = Layer.sync(AI, () => { const apiKey = requireEnv("GEMINI_API_KEY"); return { embedding: createGeminiEmbedding(DEFAULT_MODELS.embedding, apiKey), // ...one line per capability }; }); -
In the same file, change
DEFAULT_MODELSto the names that company uses. A model name from one company means nothing to another one. -
Run the gate:
pnpm run env:web && pnpm run typecheck && pnpm run test.
Nothing else changes. generateText and the other functions only ever see an
adapter. They never see the company behind it.
Not every company does everything
OpenAI is the one company that covers all seven capabilities. Most cover a few:
| Package | Covers |
|---|---|
@tanstack/ai-openai |
all seven |
@tanstack/ai-gemini |
text, summarize, embedding, image, video, speech, audio |
@tanstack/ai-anthropic |
text, summarize |
@tanstack/ai-mistral |
text, embedding |
@tanstack/ai-elevenlabs |
speech, transcription |
@tanstack/ai-fal |
video |
Check the factory names the package exports before you start. They follow one
shape, create<Company><Capability>.
Mixing companies
The seven fields of AIAdapters are independent, so one field per company is
fine. That is how you use a company that has no image model, or a voice
company for speech only:
export const AILive: Layer.Layer<AI> = Layer.sync(AI, () => ({
text: createAnthropicChat(DEFAULT_MODELS.text, requireEnv("ANTHROPIC_API_KEY")),
summarize: createAnthropicSummarize(DEFAULT_MODELS.text, requireEnv("ANTHROPIC_API_KEY")),
speech: createElevenLabsSpeech(DEFAULT_MODELS.speech, requireEnv("ELEVENLABS_API_KEY")),
// ...the rest stay on OpenAI
}));
Every key you read with requireEnv must exist when the layer is built. So
declare each one in .env.schema, and keep the OpenAI key as long as one
adapter still comes from OpenAI.
A capability nobody in your app calls still needs an adapter, because
AIAdapters has all seven fields. Leave that one on OpenAI, or drop the field
from the type and from the layer, and the functions that use it stop
typechecking.
Environment
OPENAI_API_KEY=
The root .env.schema file declares this variable. It must be a string that
starts with sk-, and the file links to the OpenAI page where you get one.
The variable is optional there, but AILive needs it. That is why the layer
reads it with requireEnv. So the app starts without an OpenAI key, and only
an AI call complains.
See Environment to learn how varlock turns those
declarations into the typed ENV that the code imports.
Related
- Environment —
envandrequireEnv - Web — where the shared AI runtime lives