# Call AI models

> playcode.ai.generate() and stream(): text, typed data, images and PDFs, your functions as tools, and what a call costs, on the models Playcode offers your app.
> Source: https://playcode.io/docs/developers/sdk-ai - last reviewed 2026-10-17.

`playcode.ai` calls the AI models Playcode offers your app through Playcode AI Gateway, with one API for all of them. `generate()` answers once; `stream()` gives the same answer as it is written. Each call is paid from your workspace's credits, at the model's price, and its result says what it cost.

## Choosing a model

Your app names a model by its id. Read the ids, and what each model can do, from Playcode at run time:

```ts
const readers = await playcode.ai.models({
  accepts: 'pdf',
  recommendedFor: 'documents',
})
```

Each model card holds:

| Field | What it says |
|---|---|
| `id` | What you pass as `model` |
| `vendor` | Who makes the model: its maker |
| `api` | The interface the model is called on: `anthropic`, `openai-chat`, `openai-responses` or `google`. `playcode.ai` calls each the same way |
| `accepts` | What it reads: `text`, `image`, `pdf`, with the limits of each. A missing kind is not supported |
| `context` | The most tokens one call can send (`inputTokens`) and answer (`outputTokens`) |
| `thinking` | The thinking levels it supports, and its default |
| `price` | Credits per token, one meter per kind of token |
| `region` | Where its maker processes the call: `USA`, `EU`, `global` (anywhere the maker runs, with no promise of a region) or `unknown` |
| `recommendedFor` | What Playcode's measured runs found it best at, such as `documents` |
| `lifecycle` | `stage`: `preview`, `ga` or `deprecated`; `replacedBy`, its successor; `retiresAt`, the date it retires |

The filters are `accepts`, `recommendedFor`, `vendor` and `api`, and together they narrow the list. To compare prices, read the meters in `price.meters`: `token_in`, `token_out`, `token_cache_read`, `token_cache_write` and `token_cache_write_1h`, each in credits per token:

```ts
import type { ModelCard } from '@playcode/sdk'

const rate = (card: ModelCard, unit: string) => {
  const meter = card.price.meters.find((candidate) => candidate.unit === unit)
  return meter ? Number(meter.creditsPerUnit) : Infinity
}

const cheapest = readers
  .filter((card) => card.lifecycle.stage !== 'deprecated')
  .sort((a, b) => rate(a, 'token_in') - rate(b, 'token_in'))[0]
```

Once a feature works on a model, keep that model's id in one constant. When its `lifecycle.stage` is `deprecated`, move to `lifecycle.replacedBy` before `lifecycle.retiresAt`.

In the samples below, `model` is the id you chose, and `reader` is one whose card reads PDFs.

## Text

```ts
const { text } = await playcode.ai.generate({
  model,
  system: 'You write product descriptions. Two sentences, plain text.',
  prompt: 'A red ceramic mug, hand-glazed, 350 ml.',
})
```

For a conversation, send `messages` instead of `prompt`. A result's `messages` are what the call added, so the next call sends the history with them:

```ts
import type { AiMessage } from '@playcode/sdk'

const history: AiMessage[] = [
  { role: 'user', content: 'Gift ideas for my sister?' },
]
const first = await playcode.ai.generate({ model, messages: history })
history.push(...first.messages, { role: 'user', content: 'Under 30 euros.' })
const second = await playcode.ai.generate({ model, messages: history })
```

A history is portable: one written on one model replays on another.

## Typed data

Give a `schema`, and the answer comes back as a checked, typed `object`:

```ts
import { z } from 'zod'

const Lead = z.object({
  name: z.string(),
  company: z.string().optional(),
  budget: z.number().describe('In euros'),
  wantsCall: z.boolean(),
})

const { object } = await playcode.ai.generate({
  model,
  prompt: contactFormText,
  schema: Lead,
})
console.log(object.budget) // a number
```

A schema is a zod schema (v3 or v4) or a JSON Schema object. An answer that does not match throws `PlaycodeApiError` with the code `invalid_output`, and that call is still paid: the model was called. Say what the model cannot know in `system`, such as "dates on these documents are day-month-year".

## Images and PDFs

```ts
const { object } = await playcode.ai.generate({
  model: reader,
  system: 'You read invoices. Dates on these invoices are day-month-year.',
  prompt: 'Read this invoice.',
  files: [{ data: pdfBytes, mediaType: 'application/pdf' }],
  schema: z.object({ number: z.string(), total: z.number() }),
})
```

- `data` is bytes, base64 text, a `data:` URL, or an `https:` URL that the model's maker fetches itself.
- `mediaType` is `image/jpeg`, `image/png`, `image/webp`, `image/gif` or `application/pdf`. `filename` is the name the model sees, where its wire has a place for one: PDFs.
- The SDK checks each file against the model's card before it sends anything, and names a file the model cannot read.
- One request carries at most 32 MiB. Files travel as base64, so that is about 24 MiB of file bytes.
- In the browser, `prepareImage(file)` from `@playcode/sdk/client` scales a photo down to at most 2,000 px on its long edge, as JPEG, before the upload.

The whole path, from the file picker to checked fields: [Read images and PDFs in your app](/docs/add-to-your-app/read-images-and-pdfs).

## Streaming

```ts
const prompt = 'Write a short welcome for a bakery website.'

let credits = 0
for await (const event of playcode.ai.stream({ model, prompt })) {
  if (event.type === 'text') process.stdout.write(event.text)
  if (event.type === 'done') credits = event.result.usage.credits
}
console.log(`\n${credits} credits`)
```

The events are `text`, `toolCall`, `toolResult`, `usage`, `step`, `compaction`, `fallback`, `subagent` and, last, `done` with the result. Leaving the loop early stops the call. A stream can be read once.

## Your functions as tools

With `tools`, the model can call your code to get what it needs, then answer:

```ts
import { tool } from '@playcode/sdk'

const { text } = await playcode.ai.generate({
  model,
  prompt: 'Is order A-1042 shipped yet?',
  tools: {
    findOrder: tool({
      description: 'An order by its number: its status and its items.',
      input: z.object({ number: z.string() }),
      execute: ({ number }) => db.orders.find(number),
    }),
  },
})
```

`generate()` then makes up to 10 model calls by default (`maxSteps`), each one paid. Its result's `finish` says why it stopped: `stop`, `length`, `content-filter`, `max-steps`, `budget` or `stuck`, each explained in [How a run ends](/docs/developers/sdk-agents#how-a-run-ends). For an assistant that holds a conversation, or a tool a person approves first, use an agent: [Build an agent](/docs/developers/sdk-agents).

## Thinking

`thinking` is `off`, `low`, `medium`, `high` or `max`. The SDK moves it to the nearest level the model's card lists. Leave it out to use the model's default, which measured best on Playcode's own runs. A higher level writes more tokens, so it costs more.

## What a call costs

| Field | What it is |
|---|---|
| `usage.credits` | What the call cost, at the model's price |
| `usage.inputTokens`, `usage.outputTokens` | Every token read and written, thinking and cache included |
| `usage.cacheReadTokens`, `usage.cacheWriteTokens` | The part of the input read from, or written to, the prompt cache |
| `usageIds` | One id per model call: quote one when you ask about a charge |

To cap a cost, set `maxOutputTokens`, the longest answer one model call may write, and `budget: { credits }`. Before each model call, the SDK compares what the calls so far cost with the budget, and stops once it is spent. So a call can end above its budget, by its last step: the step's model call, and a brief or a sub-agent's calls made in that step.

To see what each of your end users costs, pass `user`, your own id for the person a call serves: [What each user costs](/docs/developers/sdk-agent-controls#what-each-user-costs).

On Claude models, `cache` marks the repeated part of the prompt, so the next call reads it at the cache price. A call without tools marks nothing by default: a cache write can cost more than plain input, and nothing may read it back. Pass `cache: true` when many calls repeat one long system prompt. A call with tools marks by default, because each step reads what the step before wrote. GPT and Gemini models cache a long repeated prefix on their own, marked or not.

## Errors, retries and stops

A retry is a new paid call, so the SDK retries only what Playcode shows was not charged: Playcode's own rate limit, when it asks to wait 30 seconds or less; a model maker's rate limit or server error, which Playcode voided; or a maker Playcode could not reach. It retries at most 2 times, and never a stream that broke after it started. Do not wrap AI calls in a retry loop of your own.

`signal` stops a call at once, and `generate()` then throws the signal's reason, or an `AbortError`. A call the model had started is still charged for what it read.

| Error | When |
|---|---|
| `PlaycodeApiError` | Playcode or the model's maker said no. `status`, `code`, `message` in Playcode's own words, `source` (`playcode`, or `provider` for the model's maker), `retryAfterMs`, `usageId` |
| `PlaycodeConnectionError` | No answer came (the network, TLS or a timeout), or the stream broke off mid-answer, which may be charged |
| `PlaycodeConfigError` | The call could not be built: no key, or a file the model cannot read |

Every code and what to do about it: [SDK reference](/docs/developers/sdk-reference#errors).

## A model maker's own SDK

For a feature `playcode.ai` does not cover, point the maker's official SDK at Playcode. The key and the credits stay the same:

```ts
import Anthropic from '@anthropic-ai/sdk'

const anthropic = new Anthropic(playcode.ai.endpoint('anthropic'))
```

The other values are `openai-chat` and `openai-responses` for the `openai` package, and `google` for `@google/genai`, which takes the address as `httpOptions.baseUrl`. The card's `api` says which one a model uses. Tools that a model's maker runs and bills itself, such as its web search, are refused with `server_tool_not_supported`: write the tool as one of your functions instead.
