AI SDK

Usage Patterns

streamText, generateText, multi-step agents, RAG, and multiple models — every common AI SDK pattern wired into evlog.

Every pattern below uses the same createAILogger(log) setup. Wrap the model with ai.wrap() and the middleware accumulates tokens, tools, and timing on the wide event automatically.

On Next.js, Nuxt/Nitro, SvelteKit, Hono, React Router, and oRPC, evlog defers wide-event emit for streaming responses (for example text/event-stream and AI SDK UI streams) until the body finishes, so late ai metadata stays on the same request event.

streamText

The most common pattern is streaming chat with full observability:

server/api/chat.post.tsroutes/api/chat.post.tsapp/api/chat/route.tssrc/index.tssrc/index.ts
import { useLogger } from 'evlog'
import { streamText } from 'ai'
import { createAILogger } from 'evlog/ai'

export default defineEventHandler(async (event) => {
  const log = useLogger(event)

  const ai = createAILogger(log)
  const { messages } = await readBody(event)

  log.set({ action: 'chat', messagesCount: messages.length })

  const result = streamText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    messages,
    onFinish: ({ text }) => {
      saveConversation(text)
    },
  })

  return result.toTextStreamResponse()
})
import { defineHandler } from 'nitro/h3'
import { useLogger } from 'evlog/nitro/v3'
import { streamText } from 'ai'
import { createAILogger } from 'evlog/ai'

export default defineHandler(async (event) => {
  const log = useLogger(event)

  const ai = createAILogger(log)
  const { messages } = await readBody(event)

  log.set({ action: 'chat', messagesCount: messages.length })

  const result = streamText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    messages,
    onFinish: ({ text }) => {
      saveConversation(text)
    },
  })

  return result.toTextStreamResponse()
})
import { withEvlog, useLogger } from '@/lib/evlog'
import { streamText } from 'ai'
import { createAILogger } from 'evlog/ai'

export const POST = withEvlog(async (request: Request) => {
  const log = useLogger()

  const ai = createAILogger(log)
  const { messages } = await request.json()

  log.set({ action: 'chat', messagesCount: messages.length })

  const result = streamText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    messages,
    onFinish: ({ text }) => {
      saveConversation(text)
    },
  })

  return result.toTextStreamResponse()
})
import { streamText } from 'ai'
import { createAILogger } from 'evlog/ai'

app.post('/api/chat', async (c) => {
  const log = c.get('log')

  const ai = createAILogger(log)
  const { messages } = await c.req.json()

  log.set({ action: 'chat', messagesCount: messages.length })

  const result = streamText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    messages,
    onFinish: ({ text }) => {
      saveConversation(text)
    },
  })

  return result.toTextStreamResponse()
})
import { streamText } from 'ai'
import { createAILogger } from 'evlog/ai'

app.post('/api/chat', async (req, res) => {
  const log = req.log

  const ai = createAILogger(log)
  const { messages } = req.body

  log.set({ action: 'chat', messagesCount: messages.length })

  const result = streamText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    messages,
    onFinish: ({ text }) => {
      saveConversation(text)
    },
  })

  result.pipeTextStreamToResponse(res)
})

The middleware never touches your onFinish callback, so your code runs as usual.

generateText

Synchronous generation. The middleware captures the result automatically:

server/api/summarize.post.tsroutes/api/summarize.post.tsapp/api/summarize/route.tssrc/index.tssrc/index.ts
import { useLogger } from 'evlog'
import { generateText } from 'ai'
import { createAILogger } from 'evlog/ai'

export default defineEventHandler(async (event) => {
  const log = useLogger(event)

  const ai = createAILogger(log)

  const result = await generateText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    prompt: 'Summarize this document',
  })

  return { text: result.text }
})
import { defineHandler } from 'nitro/h3'
import { useLogger } from 'evlog/nitro/v3'
import { generateText } from 'ai'
import { createAILogger } from 'evlog/ai'

export default defineHandler(async (event) => {
  const log = useLogger(event)

  const ai = createAILogger(log)

  const result = await generateText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    prompt: 'Summarize this document',
  })

  return { text: result.text }
})
import { withEvlog, useLogger } from '@/lib/evlog'
import { generateText } from 'ai'
import { createAILogger } from 'evlog/ai'

export const POST = withEvlog(async () => {
  const log = useLogger()

  const ai = createAILogger(log)

  const result = await generateText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    prompt: 'Summarize this document',
  })

  return Response.json({ text: result.text })
})
import { generateText } from 'ai'
import { createAILogger } from 'evlog/ai'

app.post('/api/summarize', async (c) => {
  const log = c.get('log')

  const ai = createAILogger(log)

  const result = await generateText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    prompt: 'Summarize this document',
  })

  return c.json({ text: result.text })
})
import { generateText } from 'ai'
import { createAILogger } from 'evlog/ai'

app.post('/api/summarize', async (req, res) => {
  const log = req.log

  const ai = createAILogger(log)

  const result = await generateText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    prompt: 'Summarize this document',
  })

  return res.json({ text: result.text })
})

Trace a multi-step agent

The middleware fires for each step automatically. Steps, tool calls, and tokens are accumulated across the agent loop:

server/api/agent.post.tsroutes/api/agent.post.tsapp/api/agent/route.tssrc/index.tssrc/index.ts
import { useLogger } from 'evlog'
import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from 'ai'
import { createAILogger } from 'evlog/ai'

export default defineEventHandler(async (event) => {
  const log = useLogger(event)

  const { messages } = await readBody(event)
  const ai = createAILogger(log, {
    toolInputs: { maxLength: 500 },
  })

  const agent = new ToolLoopAgent({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    tools: { searchWeb, queryDatabase },
    stopWhen: stepCountIs(5),
  })

  return createAgentUIStreamResponse({
    agent,
    uiMessages: messages,
  })
})
import { defineHandler } from 'nitro/h3'
import { useLogger } from 'evlog/nitro/v3'
import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from 'ai'
import { createAILogger } from 'evlog/ai'

export default defineHandler(async (event) => {
  const log = useLogger(event)

  const { messages } = await readBody(event)
  const ai = createAILogger(log, {
    toolInputs: { maxLength: 500 },
  })

  const agent = new ToolLoopAgent({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    tools: { searchWeb, queryDatabase },
    stopWhen: stepCountIs(5),
  })

  return createAgentUIStreamResponse({
    agent,
    uiMessages: messages,
  })
})
import { withEvlog, useLogger } from '@/lib/evlog'
import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from 'ai'
import { createAILogger } from 'evlog/ai'

export const POST = withEvlog(async (request: Request) => {
  const log = useLogger()

  const { messages } = await request.json()
  const ai = createAILogger(log, {
    toolInputs: { maxLength: 500 },
  })

  const agent = new ToolLoopAgent({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    tools: { searchWeb, queryDatabase },
    stopWhen: stepCountIs(5),
  })

  return createAgentUIStreamResponse({
    agent,
    uiMessages: messages,
  })
})
import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from 'ai'
import { createAILogger } from 'evlog/ai'

app.post('/api/agent', async (c) => {
  const log = c.get('log')

  const { messages } = await c.req.json()
  const ai = createAILogger(log, {
    toolInputs: { maxLength: 500 },
  })

  const agent = new ToolLoopAgent({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    tools: { searchWeb, queryDatabase },
    stopWhen: stepCountIs(5),
  })

  return createAgentUIStreamResponse({
    agent,
    uiMessages: messages,
  })
})
import { ToolLoopAgent, pipeAgentUIStreamToResponse, stepCountIs } from 'ai'
import { createAILogger } from 'evlog/ai'

app.post('/api/agent', async (req, res) => {
  const log = req.log

  const { messages } = req.body
  const ai = createAILogger(log, {
    toolInputs: { maxLength: 500 },
  })

  const agent = new ToolLoopAgent({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    tools: { searchWeb, queryDatabase },
    stopWhen: stepCountIs(5),
  })

  await pipeAgentUIStreamToResponse({
    response: res,
    agent,
    uiMessages: messages,
  })
})

Wide event after a 3-step agent run:

Wide Event
{
  "ai": {
    "calls": 3,
    "steps": 3,
    "model": "claude-sonnet-4.6",
    "provider": "anthropic",
    "inputTokens": 4500,
    "outputTokens": 1200,
    "totalTokens": 5700,
    "finishReason": "stop",
    "toolCalls": [
      { "name": "searchWeb", "input": { "query": "TypeScript 6.0 features" } },
      { "name": "queryDatabase", "input": { "sql": "SELECT * FROM docs WHERE topic = 'typescript'" } },
      { "name": "searchWeb", "input": { "query": "TypeScript 6.0 release date" } }
    ],
    "responseId": "msg_01XFDUDYJgAACzvnptvVoYEL",
    "stepsUsage": [
      { "model": "claude-sonnet-4.6", "inputTokens": 1200, "outputTokens": 300, "toolCalls": ["searchWeb"] },
      { "model": "claude-sonnet-4.6", "inputTokens": 1500, "outputTokens": 400, "toolCalls": ["queryDatabase", "searchWeb"] },
      { "model": "claude-sonnet-4.6", "inputTokens": 1800, "outputTokens": 500 }
    ],
    "msToFirstChunk": 312,
    "msToFinish": 8200,
    "tokensPerSecond": 146
  }
}
Pair this with createEvlogIntegration to also capture per-tool execution timing and the agent's total wall time.

RAG (embed + generate)

Embedding models use a different type that cannot be wrapped with middleware. Use captureEmbed instead:

server/api/rag.post.tsroutes/api/rag.post.tsapp/api/rag/route.tssrc/index.tssrc/index.ts
import { useLogger } from 'evlog'
import { embed, generateText } from 'ai'
import { createAILogger } from 'evlog/ai'

export default defineEventHandler(async (event) => {
  const log = useLogger(event)

  const ai = createAILogger(log)

  const { embedding, usage } = await embed({
    model: openai.embedding('text-embedding-3-small'),
    value: query,
  })
  ai.captureEmbed({
    usage,
    model: 'text-embedding-3-small',
    dimensions: 1536,
  })

  const docs = await findSimilar(embedding)

  const result = await generateText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    prompt: buildPrompt(docs),
  })

  return { text: result.text }
})
import { defineHandler } from 'nitro/h3'
import { useLogger } from 'evlog/nitro/v3'
import { embed, generateText } from 'ai'
import { createAILogger } from 'evlog/ai'

export default defineHandler(async (event) => {
  const log = useLogger(event)

  const ai = createAILogger(log)

  const { embedding, usage } = await embed({
    model: openai.embedding('text-embedding-3-small'),
    value: query,
  })
  ai.captureEmbed({
    usage,
    model: 'text-embedding-3-small',
    dimensions: 1536,
  })

  const docs = await findSimilar(embedding)

  const result = await generateText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    prompt: buildPrompt(docs),
  })

  return { text: result.text }
})
import { withEvlog, useLogger } from '@/lib/evlog'
import { embed, generateText } from 'ai'
import { createAILogger } from 'evlog/ai'

export const POST = withEvlog(async () => {
  const log = useLogger()

  const ai = createAILogger(log)

  const { embedding, usage } = await embed({
    model: openai.embedding('text-embedding-3-small'),
    value: query,
  })
  ai.captureEmbed({
    usage,
    model: 'text-embedding-3-small',
    dimensions: 1536,
  })

  const docs = await findSimilar(embedding)

  const result = await generateText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    prompt: buildPrompt(docs),
  })

  return Response.json({ text: result.text })
})
import { embed, generateText } from 'ai'
import { createAILogger } from 'evlog/ai'

app.post('/api/rag', async (c) => {
  const log = c.get('log')

  const ai = createAILogger(log)

  const { embedding, usage } = await embed({
    model: openai.embedding('text-embedding-3-small'),
    value: query,
  })
  ai.captureEmbed({
    usage,
    model: 'text-embedding-3-small',
    dimensions: 1536,
  })

  const docs = await findSimilar(embedding)

  const result = await generateText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    prompt: buildPrompt(docs),
  })

  return c.json({ text: result.text })
})
import { embed, generateText } from 'ai'
import { createAILogger } from 'evlog/ai'

app.post('/api/rag', async (req, res) => {
  const log = req.log

  const ai = createAILogger(log)

  const { embedding, usage } = await embed({
    model: openai.embedding('text-embedding-3-small'),
    value: query,
  })
  ai.captureEmbed({
    usage,
    model: 'text-embedding-3-small',
    dimensions: 1536,
  })

  const docs = await findSimilar(embedding)

  const result = await generateText({
    model: ai.wrap('anthropic/claude-sonnet-4.6'),
    prompt: buildPrompt(docs),
  })

  return res.json({ text: result.text })
})

For embedMany, pass the batch count:

const { embeddings, usage } = await embedMany({
  model: openai.embedding('text-embedding-3-small'),
  values: documents,
})
ai.captureEmbed({ usage, model: 'text-embedding-3-small', count: documents.length })

Wrap more than one model

Wrap each model separately. They share the same accumulator. When more than one model is used, the wide event includes both model (last model) and models (all unique models):

const ai = createAILogger(log)

const fast = ai.wrap('anthropic/claude-haiku-4.5')
const smart = ai.wrap('anthropic/claude-sonnet-4.6')

const classification = await generateText({ model: fast, prompt: classifyPrompt })
const response = await generateText({ model: smart, prompt: detailedPrompt })

Pass a model object from any provider

wrap() accepts model objects from provider SDKs: both LanguageModelV3 (AI SDK v6) and LanguageModelV4 (AI SDK v7):

server/api/chat.post.ts
import { anthropic } from '@ai-sdk/anthropic'

const model = ai.wrap(anthropic('claude-sonnet-4.6'))