--- url: /introduction.md --- # Introduction xsAI is an extra-small TypeScript toolkit for building AI applications and agents. It is a set of small packages, and each package does one job. You install only the packages that you use. The packages run on any runtime that provides `fetch` and web streams. This includes Node.js, Deno, Bun, Cloudflare Workers, and browsers. ## Why use xsAI? AI services speak a few different HTTP protocols, and each protocol has its own request and response shapes. Code that calls one service directly is hard to move to another. xsAI hides these differences behind one shape. You create a model, then pass it to an operation: ```ts import type { LanguageModel } from '@xsai/text' declare const model: LanguageModel // ---cut--- import { generateText } from '@xsai/text' const { text } = await generateText(model, { input: 'Say hello.' }) ``` * **The model comes first.** Every operation takes a model first and the request options second. * **A model belongs to a protocol, not to a provider.** One adapter covers every service that speaks the same protocol, so you can change service without changing your code. * **Web standards.** xsAI uses `fetch`, `Request`, `Response`, `ReadableStream`, `Blob`, `FormData`, and `AbortSignal`, and its source does not import any Node.js module. Streams are `ReadableStream`s, you cancel a request with an `AbortSignal`, and you can replace `fetch` to add a proxy, a test double, or your own retry logic. Schemas follow [Standard Schema](https://standardschema.dev), so you can use the validation library that you already have. * **Small parts.** Each package does one job, and the packages work with each other without extra setup. Every package is an ES module that is free of side effects, so a bundler removes the code that you do not use. * **Typed from end to end.** Options, results, events, and tool inputs have TypeScript types. ## What is in xsAI? | You want to | Use | Read | | --- | --- | --- | | Generate or stream text, call tools, and get structured output | `@xsai/text` | [Generate text](/text/generating) | | Talk to OpenAI Responses, Chat Completions, or Anthropic Messages | `@xsai/text-responses`, `@xsai/text-chat`, `@xsai/text-messages` | [Choose an adapter](/text/adapters) | | Generate speech or transcribe audio | `@xsai/audio` | [Audio](/audio) | | Get typed answers to yes-or-no, choice, and score questions | `@xsai/decide` | [Decisions](/decide) | | Embed text | `@xsai/embed` | [Embeddings](/embed) | | Generate images | `@xsai/image` | [Images](/image) | | List the models of a service | `@xsai/model` | [Models](/model) | | Convert and validate schemas | `xsschema` | [xsschema](/xsschema) | [Choose packages](/packages) describes every package, including `xsai`, the single package that re-exports the others. ## Model providers xsAI does not ship one package for each provider. It ships one adapter for each protocol. A service works with xsAI when it implements one of these protocols: * **OpenAI Responses**, with `responses()`. * **Chat Completions**, with `chat()`. * **Anthropic Messages**, with `messages()`. Many services and gateways accept at least one of them. If yours speaks another protocol, you can [write a custom model](/advanced/custom-models). ## Where to go next * [Getting started](/getting-started) installs a package and sends your first request. * [Choose packages](/packages) lists every package and what it provides. * [Working with AI](/ai) explains the agent skills and `llms.txt` that help AI tools write xsAI code. ## Community Ask questions and report bugs in the [GitHub repository](https://github.com/moeru-ai/xsai). --- --- url: /getting-started.md --- # Getting started This page installs one adapter and sends a first request. For what xsAI is and how its packages fit together, read the [Introduction](/introduction). Pick the tab for the protocol that your service speaks. [Choose an adapter](/text/adapters) lists the options for each one. ## Generate text ::: code-group ```sh [Chat] pnpm add @xsai/text @xsai/text-chat ``` ```sh [Responses] pnpm add @xsai/text @xsai/text-responses ``` ```sh [Messages] pnpm add @xsai/text @xsai/text-messages ``` ::: ::: code-group ```ts [Chat] import { generateText } from '@xsai/text' import { chat } from '@xsai/text-chat' const model = chat({ apiKey: process.env.OPENAI_API_KEY, baseURL: 'https://api.openai.com/v1/', model: 'gpt-6-luna', }) const { text } = await generateText(model, { input: 'Say hello.' }) console.log(text) ``` ```ts [Responses] import { generateText } from '@xsai/text' import { responses } from '@xsai/text-responses' const model = responses({ apiKey: process.env.OPENAI_API_KEY, baseURL: 'https://api.openai.com/v1/', model: 'gpt-6-luna', }) const { text } = await generateText(model, { input: 'Say hello.' }) console.log(text) ``` ```ts [Messages] import { generateText } from '@xsai/text' import { messages } from '@xsai/text-messages' const model = messages({ apiKey: process.env.ANTHROPIC_API_KEY, baseURL: 'https://api.anthropic.com/v1/', model: 'claude-haiku-5.5', }) const { text } = await generateText(model, { input: 'Say hello.', maxOutputTokens: 128, }) console.log(text) ``` ::: The Chat and Responses examples read an OpenAI API key from `OPENAI_API_KEY`. The Messages example reads an Anthropic API key from `ANTHROPIC_API_KEY`. Each example works with any service that implements the same protocol. `chat()`, `responses()`, and `messages()` create a language model that speaks one protocol. `generateText` sends one request, waits for the complete reply, and returns a result. ## Next steps * [Stream text](/text/streaming) to show output while it arrives. * [Call tools](/text/tools) to let the model run your functions. * [Choose packages](/packages) to install only what you use. --- --- url: /packages.md --- # Choose packages Install only the packages that you use. Every package takes a model as its first argument, so you can mix them freely. | Package | Provides | | --- | --- | | `@xsai/text` | `generateText`, `streamText`, `loop`, `collect`, and `tool`. | | `@xsai/text-responses` | `responses()`, an adapter for OpenAI Responses. | | `@xsai/text-chat` | `chat()`, an adapter for Chat Completions. | | `@xsai/text-messages` | `messages()`, an adapter for Anthropic Messages. | | `@xsai/audio` | Speech generation and transcription. | | `@xsai/decide` | Typed answers to yes-or-no, choice, and score questions. | | `@xsai/embed` | Text embeddings. | | `@xsai/image` | Image generation. | | `@xsai/model` | Model lists from a service. | | `@xsai/shared` | HTTP options, `sendRequest`, and error types. The other packages depend on it. | | `xsschema` | Schema conversion and validation. It is a separate package. | | `xsai` | One package that re-exports everything above except `xsschema`. | ## One dependency Use `xsai` when you prefer a single import path. It exports the same functions as the individual packages. ```sh pnpm add xsai ``` ```ts import { generateText, responses } from 'xsai' const model = responses({ apiKey: process.env.OPENAI_API_KEY, baseURL: 'https://api.openai.com/v1/', model: 'gpt-6-luna', }) const { text } = await generateText(model, { input: 'Say hello.' }) console.log(text) ``` --- --- url: /ai.md --- # Working with AI xsAI publishes agent skills, Markdown pages, and an `llms.txt` index. Use them when an AI tool writes xsAI code for you. ## Agent skills An [agent skill](https://agentskills.io) is a folder of instructions that your coding tool loads when a task matches. xsAI has one skill for each package, and they point to the pages on this site. ```sh npx skills add moeru-ai/xsai ``` Select the skills that match the packages you use. For example, install `xsai-text` and `xsai-embed` if your project needs only text and embeddings. The `xsai` skill is optional. It routes tasks that span several packages. You can also copy a directory from [`skills/`](https://github.com/moeru-ai/xsai/tree/main/skills) into your tool's skill directory. ## Markdown pages and llms.txt Add `.md` to the URL of any page to get its Markdown source, for example [`/text/tools.md`](/text/tools.md){target="\_self"}. [`/llms.txt`](/llms.txt){target="\_self"} lists every page. [`/llms-full.txt`](/llms-full.txt){target="\_self"} contains all pages in one file. --- --- url: /text/generating.md --- # Generate text `generateText(model, options)` sends a request and returns when the model finishes. Use it when you need the whole reply before your code continues. To show output while it arrives, use [`streamText`](/text/streaming). ```ts import type { LanguageModel } from '@xsai/text' declare const model: LanguageModel // ---cut--- import { generateText } from '@xsai/text' const result = await generateText(model, { input: 'Name three uses for a paperclip.', instructions: 'Answer in one short sentence per item.', maxOutputTokens: 200, temperature: 0.2, }) console.log(result.text) ``` `input` is a string or a list of [messages](/text/messages). `instructions` sets the system prompt. xsAI sets no defaults for `temperature`, `topP`, or `maxOutputTokens`, so the service applies its own when you omit them. The full list of options is in the [text API reference](/text/api#options). ## Read the result `result` describes the last model request, and it adds the history of earlier ones. | Field | Value | | --- | --- | | `text` | The text of the last step. | | `status` | `completed`, `incomplete`, or `cancelled`. A `failed` step throws instead. | | `reason` | The finish reason, such as `stop` or `length`. | | `message` | The assistant message, including reasoning and tool calls. | | `steps` | One result per model request. Tool use creates more than one step. | | `totalUsage` | Token counts summed over all steps, if the service reports them. | An `incomplete` status means that the service stopped before the answer was finished. Check `reason` to see why. For example, `length` means that the output hit `maxOutputTokens`. A missing `totalUsage` does not mean that the request was free. It means that the service did not report usage. ## Cancel a request Pass an `AbortSignal` as `signal` to any text operation. The adapter forwards it to the HTTP request, and an aborted request rejects with the abort reason. ```ts import type { LanguageModel } from '@xsai/text' declare const model: LanguageModel // ---cut--- import { generateText } from '@xsai/text' const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 5_000) try { await generateText(model, { input: 'Write a long story.', signal: controller.signal, }) } catch (error) { if (controller.signal.aborted) console.log('Cancelled.') else throw error } finally { clearTimeout(timer) } ``` The loop passes the same signal to `execute`, so a tool can stop its own work. ### Streams Cancelling the output stream of `streamText` cancels its reader and rejects `result`. Pass `signal` as well to cancel the underlying HTTP request. A step that the provider reports as `cancelled` is a result status. It does not throw. --- --- url: /text/streaming.md --- # Stream text `streamText(model, options)` returns right away with two values. `stream` is a `ReadableStream` of [`TextEvent`](/text/events) values, and `result` is a promise for the same result that `generateText` returns. ```ts import type { LanguageModel } from '@xsai/text' declare const model: LanguageModel // ---cut--- import { streamText } from '@xsai/text' const { result, stream } = streamText(model, { input: 'Describe a quiet forest.' }) for await (const event of stream) { if (event.type === 'text.delta') process.stdout.write(event.delta) } const { totalUsage } = await result ``` A `text.delta` event carries a fragment of text. Other events describe reasoning, refusals, tool calls, and the end of each step. ## Handle failures A failed request ends the stream normally and rejects `result`. This means that a `for await` loop alone cannot tell you about the failure. Always await `result`, or attach a handler to it, even when you only read the stream. If you do not need the stream, call `generateText` instead. If you need events without a result, call `loop(model, options)`, which returns only the stream. ## Stop early Pass an `AbortSignal` as `signal` to stop the request. [Cancel a request](/text/generating#cancel-a-request) shows the details. --- --- url: /text/tools.md --- # Call tools A tool is a function that the model can ask your code to run. `tool()` pairs a function with an input schema, and `generateText` runs the loop for you: it calls the model, runs the requested tools, sends the results back, and repeats. ```ts import type { LanguageModel } from '@xsai/text' declare const model: LanguageModel // ---cut--- import { generateText, maxSteps, tool } from '@xsai/text' import * as z from 'zod' const weather = tool({ description: 'Get the current temperature for a city.', execute: ({ city }) => `It is 21 degrees in ${city}.`, inputSchema: z.object({ city: z.string() }), name: 'get_weather', }) const result = await generateText(model, { input: 'Is it warm in Lisbon?', stopWhen: maxSteps(5), tools: [weather], }) console.log(result.text) console.log(result.steps.length) ``` `inputSchema` accepts a plain JSON Schema or a schema library that supports Standard JSON Schema, such as Zod 4. With a library, `execute` receives validated and typed input. A plain JSON Schema describes the input to the model but does not validate it. To use a library without native support, convert its schema with [xsschema](/xsschema). ## Loop behavior The loop stops when the model replies without a tool call, or when `stopWhen` matches. The default is `maxSteps(10)`. `hasToolCall(name?)` stops after a matching call, and `and`, `or`, and `not` combine conditions. Tool calls from one step run at the same time. A missing tool, invalid JSON arguments, failed validation, or a thrown error becomes a tool result with `isError: true`, and the loop continues, so the model can retry or explain. If the step stopped because of `length`, the calls are incomplete, so the loop sends error results instead of running them. An abort still rejects the whole operation. A tool without `execute` only describes itself to the model. If the model calls it, the loop sends an error result, unless a `preToolCall` hook returns a result for that call. ## Control the loop `generateText`, `streamText`, and `loop` accept three hooks that run around each step. Use them to change the request between steps, to guard tools, or to rewrite their results. ```ts import type { LanguageModel } from '@xsai/text' declare const model: LanguageModel // ---cut--- import { generateText, hasToolCall, maxSteps, or } from '@xsai/text' await generateText(model, { input: 'Look up the order and cancel it.', prepareStep: ({ stepNumber }) => stepNumber >= 3 ? { toolChoice: 'none' } : undefined, preToolCall: (call) => { if (call.name === 'cancel_order') return { callId: call.callId, isError: true, output: 'Cancellation needs approval.', type: 'tool-result' } }, stopWhen: or(maxSteps(5), hasToolCall('cancel_order')), }) ``` `prepareStep` runs before each request. It receives `stepNumber`, `steps`, the current `input`, and `signal`. Return request fields to override for that step, such as `toolChoice` or `input`, or return a different `model`. `preToolCall(call, { signal })` runs before a tool. Return a replacement call to change the input, a tool result to skip execution, or nothing to continue. `postToolCall(result, { signal })` runs after a tool. Return a replacement result, or nothing to keep the original. It does not run when `preToolCall` returns a result, or when the tool is missing, not executable, or given invalid input. Both tool hooks must keep the original `callId`. A different ID throws an error. --- --- url: /text/structured-output.md --- # Generate structured output `outputFormat` asks the service to return JSON that matches a schema. xsAI always sends the schema in strict mode. ```ts import type { LanguageModel } from '@xsai/text' declare const model: LanguageModel // ---cut--- import { generateText } from '@xsai/text' import * as z from 'zod' const Color = z.object({ color: z.string(), hex: z.string() }) const result = await generateText(model, { input: 'Describe the color of a clear sky.', outputFormat: Color, }) const color = Color.parse(JSON.parse(result.text)) ``` `generateText` returns text, not a parsed object. Parse and validate it yourself, as in the last line. Parsing can fail when `result.status` is `incomplete` or when the model refuses. Check `status` and `message.content` before you parse. `outputFormat` accepts the same schemas as [tools](/text/tools): a JSON Schema, or a schema library with Standard JSON Schema support. Each adapter keeps only the schema features that its protocol supports, so a schema that works with one adapter can fail with another. --- --- url: /text/messages.md --- # Messages and Parts `input` accepts a list of messages when a request needs more than one turn, an image, or earlier tool results. A message has a `role` and a `content`. `content` is a string or an array of Parts. A Part is one typed item of content, such as text, an image, or a tool call. ## Continue a conversation `generateText` does not keep state. To continue, append the assistant message from `result.message` and your next user message, then send the whole list again. ```ts import type { LanguageModel, Message } from '@xsai/text' declare const model: LanguageModel // ---cut--- import { generateText } from '@xsai/text' const messages: Message[] = [{ content: 'Pick a color.', role: 'user' }] const first = await generateText(model, { input: messages }) messages.push(first.message, { content: 'Why that one?', role: 'user' }) const second = await generateText(model, { input: messages }) ``` Return the message exactly as you received it. Adapters store provider metadata on it, and some services need that metadata in later turns. After a tool loop, `result.message` is only the last step. Append every step's `message` and, when the step has `toolResults`, a user message that holds them. ## Send images and files A user message can mix text, image, and file Parts: ```ts import type { Message } from '@xsai/text' const message: Message = { content: [ { text: 'What is in this picture?', type: 'text' }, { data: new URL('https://example.com/cat.png'), detail: 'low', type: 'image' }, ], role: 'user', } ``` `data` is a URL or a string, such as a base64 data URL. The adapter decides which Parts the service accepts. ## Parts by role | Role | Allowed Parts | | --- | --- | | `user` | `text`, `image`, `file`, `tool-result` | | `assistant` | `text`, `reasoning`, `refusal`, `tool-call`, `provider` | | `system`, `developer` | `text` | The field names of each Part are in the [text API reference](/text/api#parts). A refusal is its own Part and never appears as `text`. A `provider` Part keeps content that has no shared form, so you can send it back unchanged. --- --- url: /text/events.md --- # Text events A `TextEvent` describes one change in a model response. Wire adapters translate each provider's stream into these events, so your code reads one format for every service. `streamText` and `loop` expose the events directly. A response with one text answer produces this sequence: ```ts const sequence = [ { type: 'step.start' }, { contentType: 'text', index: 0, type: 'content.start' }, { delta: 'Hel', index: 0, type: 'text.delta' }, { delta: 'lo.', index: 0, type: 'text.delta' }, { content: { text: 'Hello.', type: 'text' }, index: 0, type: 'content.end' }, { message: { content: [{ text: 'Hello.', type: 'text' }], role: 'assistant' }, reason: 'stop', status: 'completed', type: 'step.end' }, ] ``` A step is one model request. Inside it, each Part of the reply starts with `content.start`, grows through `*.delta` events, and ends with `content.end`. `index` is the position of the Part in the message, so interleaved deltas stay attached to the right Part. Use the deltas to render output and `content.end` when you need the finished Part. | Event | Meaning | | --- | --- | | `step.start` | A model request begins. | | `content.start` | A Part begins. Its `contentType` names the kind. | | `text.delta`, `reasoning.delta`, `refusal.delta` | A fragment of that Part. | | `tool-call.delta` | A fragment of tool input. `callId` identifies the call. | | `content.end` | The finished Part. | | `step.end` | The request ends. It carries the message, `status`, `usage`, and `reason` or `error`. | | `raw` | The provider event, if you set `includeRawEvents: true`. | ## The terminal event Each model request ends with exactly one `step.end`, called the terminal event. Its `status` is `completed`, `incomplete`, `cancelled`, or `failed`. A failed event carries an `error` and no `reason`. The other statuses can carry a normalized `reason`. A stream that ends without `step.end` is truncated, so the operation rejects with `truncated-stream`. A loop with tools emits one `step.end` for each step, and it also emits `content.start` and `content.end` events for local tool results. To produce these events yourself, see [Write a custom model](/advanced/custom-models). ## Use event listeners `TextEventTarget` lets you handle events by name instead of with an `if` chain. `withEventTarget(target)` returns a transform stream that dispatches each event to the target and passes it on unchanged. ```ts import type { LanguageModel } from '@xsai/text' declare const model: LanguageModel // ---cut--- import { loop, TextEventTarget, withEventTarget } from '@xsai/text' const target = new TextEventTarget() target.addEventListener('text.delta', event => process.stdout.write(event.detail.delta)) target.addEventListener('step.end', event => console.log(event.detail.status)) const stream = loop(model, { input: 'Hello.' }).pipeThrough(withEventTarget(target)) for await (const _ of stream) { // Reading the stream dispatches the events. } ``` Listeners run only while something reads the stream. Creating the transform does not start it. Each listener gets a `CustomEvent`. For most events, `detail` holds the event fields without `type`. For `raw` events, `detail` is the provider value itself. Use `toCustomEvent(event)` to wrap one event yourself. --- --- url: /text/adapters.md --- # Choose an adapter A wire adapter translates one HTTP protocol into xsAI's shared text events. Choose it by the protocol that your endpoint speaks, not by the provider name. Many providers and gateways accept the same protocol, so one adapter often covers several of them. | Protocol | Package | Factory | Request path | | --- | --- | --- | --- | | OpenAI Responses | `@xsai/text-responses` | `responses()` | `responses` | | Chat Completions | `@xsai/text-chat` | `chat()` | `chat/completions` | | Anthropic Messages | `@xsai/text-messages` | `messages()` | `messages` | Each factory returns a `LanguageModel`. Pass it to `generateText`, `streamText`, or `loop`. Adapters always request a streamed response, and `generateText` collects it for you. ::: code-group ```ts [Responses] import { responses } from '@xsai/text-responses' const model = responses({ apiKey: process.env.OPENAI_API_KEY, baseURL: 'https://api.openai.com/v1/', model: 'gpt-6-luna', }) ``` ```ts [Chat Completions] import { chat } from '@xsai/text-chat' const model = chat({ apiKey: process.env.OPENAI_API_KEY, baseURL: 'https://api.openai.com/v1/', model: 'gpt-6-luna', }) ``` ```ts [Anthropic Messages] import { messages } from '@xsai/text-messages' const model = messages({ apiKey: process.env.ANTHROPIC_API_KEY, baseURL: 'https://api.anthropic.com/v1/', model: 'claude-haiku-5.5', }) ``` ::: ## HTTP options All three factories take the same options. | Option | Description | | --- | --- | | `baseURL` | Required. The API root as a string or `URL`, including its version prefix such as `/v1/`. The adapter appends its request path. | | `model` | Required. The model ID that the service expects. | | `apiKey` | Sent as a bearer token. Messages sends it as `x-api-key`. | | `headers` | Extra request headers. | | `fetch` | Replaces `fetch`. It receives a complete `Request` and returns a `Response`. | Keep API keys out of client bundles. For errors that the adapters throw, see [shared](/shared). ## Provider options Fields that have no shared meaning go into `providerOptions`, under the namespace of the adapter. | Namespace | Fields | | --- | --- | | `responses` | `frequencyPenalty`, `presencePenalty`, `parallelToolCalls`, `include`, `store`, and provider tools. | | `chat` | `frequencyPenalty`, `presencePenalty`, `parallelToolCalls`, `seed`, `stopSequences`, `topK`. | | `messages` | `betas`, `cacheControl`, `mcpServers`, `stopSequences`, `thinking`, and provider tools. | ```ts import type { LanguageModel } from '@xsai/text' declare const model: LanguageModel // ---cut--- import { generateText } from '@xsai/text' await generateText(model, { input: 'Say hello.', providerOptions: { responses: { store: true } }, }) ``` Shared fields such as `maxOutputTokens` and `reasoningEffort` stay outside `providerOptions`. Support by an adapter does not mean that every compatible endpoint accepts every field. ## Adapter differences Responses sets `store` to `false` unless you change it. Messages requires `maxOutputTokens` and throws `invalid-input` without it. It sends `anthropic-version: 2023-06-01`. `betas` is an array of strings, joined into the `anthropic-beta` header. Each entry in `mcpServers` needs `name`, `type: 'url'`, and `url`, with an optional `authorization_token`. `providerOptions.messages.thinking` selects the thinking mode. Use `{ type: 'adaptive' }`, `{ type: 'enabled', budget_tokens: 2048 }`, `{ type: 'disabled' }`, or `{ type: 'between_tools' }`. For adaptive or enabled thinking, `display` accepts `'summarized'` or `'omitted'`. Keep reasoning depth in `reasoningEffort`, which maps to `output_config.effort`. Thinking modes and their compatibility with effort depend on the model. The adapter sends both fields independently and leaves their validation to the endpoint. See [Anthropic thinking configuration](https://platform.claude.com/docs/en/build-with-claude/thinking). `providerOptions.messages.cacheControl` maps to the request-level `cache_control` field. Use `{ type: 'ephemeral' }` for automatic caching with the endpoint's default lifetime. Set `ttl` to `'5m'` or `'1h'` to select the cache lifetime. The endpoint caches the prompt prefix through the last cacheable part. Cache control leaves thinking, effort, and output format unchanged. See [Anthropic automatic caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching#automatic-caching). Provider tools for Messages and Responses are plain objects with a string `type` and fields specific to the protocol. They are separate from the executable tools that you create with `tool()`. For their exact shapes, read the exported types of [`@xsai/text-responses`](https://github.com/moeru-ai/xsai/blob/main/packages/text-responses/src/index.ts) and [`@xsai/text-messages`](https://github.com/moeru-ai/xsai/blob/main/packages/text-messages/src/index.ts). Some services return content that has no shared form. Keep the assistant messages that `generateText` returns, and send them back unchanged in later turns. --- --- url: /text/troubleshooting.md --- # Troubleshoot text requests Find the failed step from the error, then use the matching section. Do not retry a request that runs tools unless your tools are safe to run twice. ## HTTP error A `HttpError` means that the service answered with a failure status. Read `error.status` and `error.body`. * `404`: `baseURL` is missing its prefix, such as `/v1/`, or the endpoint does not speak the protocol of the adapter. Compare [the adapters](/text/adapters). * `401` or `403`: the API key is missing or wrong. * `429`: wait for the time in the `retry-after` header. * `400`: the service rejected a field. Remove `providerOptions` and optional fields, then add them back one by one. ## Network error `network-error` means that `fetch` failed before any response arrived. Read `error.cause`. Typical causes are DNS, a blocked host, or a TLS certificate that the runtime does not trust. An aborted request rejects with its abort reason instead. ## Incomplete or failed output An `incomplete` result still holds the text that arrived. Read `reason`. If it is `length`, raise `maxOutputTokens` when the service allows it. A `failed` step carries an `error`. `generateText` and the `result` of `streamText` reject with it. When you read a stream, also await `result`, because the stream itself ends without throwing. ## Truncated stream `truncated-stream` means that the response ended without `step.end`. The connection probably dropped. Set `includeRawEvents: true` to see the last provider events. If you wrote a custom model, make sure that it emits exactly one `step.end` for each request. ## Protocol error `protocol-error` means that one request produced several terminal events. A loop with tools legitimately produces several `step.end` events, one for each step. The error points to a custom model or adapter that emits twice in a single request. --- --- url: /text/api.md --- # Text API Import these exports from `@xsai/text`. Create a language model with a [wire adapter](/text/adapters). ## Operations ```ts:no-twoslash generateText(model: LanguageModel, options: LoopOptions): Promise streamText(model: LanguageModel, options: LoopOptions): { result: Promise, stream: ReadableStream } loop(model: LanguageModel, options: LoopOptions): ReadableStream collect(stream: ReadableStream): Promise ``` `loop` runs model requests and executable tools until a stop condition applies. `generateText` is `collect(loop(...))`. `streamText` also consumes the model stream when you do not read `stream`, and you must handle a rejection of `result`. `collect` rejects when a step fails or when the stream contains no `step.end`. ## Options All operations take `LanguageModelOptions`. The loop operations also take [loop options](#loop-options). | Field | Description | | --- | --- | | `input` | Required. A string, which becomes one user message, or a `Message[]`. | | `instructions` | System instructions. | | `maxOutputTokens` | The output token limit. The provider defines the range. | | `temperature`, `topP` | Sampling controls. The provider defines the range and the support. | | `reasoningEffort` | A string. Common values are `none`, `low`, `medium`, `high`, `xhigh`, and `max`. | | `tools` | An array of tools from `tool()`. | | `toolChoice` | `auto`, `none`, `required`, or `{ name: string }`. | | `outputFormat` | A strict schema for [structured output](/text/structured-output). | | `includeRawEvents` | If `true`, the stream also emits `raw` events with the provider events. | | `providerOptions` | Adapter-specific options, by [namespace](/text/adapters#provider-options). | | `signal` | An `AbortSignal`. | xsAI sets no default for sampling or token fields. The adapter or the provider supplies them. A schema is a JSON Schema, or a value that exposes `~standard.jsonSchema.input`. If it also exposes `~standard.validate`, the loop uses it to validate tool input. A raw JSON Schema has no validator. A Standard Schema without JSON Schema support must go through [xsschema](/xsschema) first. ## Loop options | Field | Description | | --- | --- | | `stopWhen` | A function of the completed steps. Defaults to `maxSteps(10)`. | | `prepareStep` | Runs before each request and can override the model, the input, and the request options. | | `preToolCall` | Runs before a local tool. | | `postToolCall` | Runs after a local tool. | A step is one model request. The loop stops after a `failed` or `cancelled` step. It also stops when `stopWhen` returns `true`, or when a step has no tool calls and its reason is not `pause_turn`. Otherwise, it runs the local tools, appends their results as a user message, and sends the next request. `maxSteps(n)` stops once at least `n` steps have run. `hasToolCall(name?)` stops when the last step has a matching tool call, or any tool call without a name. `and`, `or`, and `not` combine conditions. Details of the hooks are in [Control the tool loop](/text/tools#control-the-loop). ## Result `CollectResult` is the `StepResult` of the last step plus `steps` and an optional `totalUsage`. | Field | Description | | --- | --- | | `message` | The assistant message of the step. | | `status` | `completed`, `incomplete`, or `cancelled`. | | `reason` | A finish reason: `stop`, `length`, `tool-calls`, `content-filter`, `refusal`, or another provider string. | | `usage` | Optional `Usage` for the step. | | `text` | The text Parts of the step, joined. Reasoning and refusals stay in `message.content`. | | `toolCalls` | The tool calls of the step. | | `toolResults` | The local tool results that followed the step. | | `steps` | The results of all steps that did not fail. | | `totalUsage` | `usage` summed over the steps. Absent when no step reports usage. | `Usage` has `inputTokens`, `outputTokens`, and `totalTokens`, and it can have `reasoningTokens`, `cacheReadInputTokens`, and `cacheCreationInputTokens`. ## Messages | Role | Content | | --- | --- | | `user` | A string, or an array of `text`, `image`, `file`, and `tool-result` Parts. | | `assistant` | A string, or an array of `text`, `reasoning`, `refusal`, `tool-call`, and `provider` Parts. | | `system`, `developer` | A string, or an array of `text` Parts. | An assistant message can also have an `id` and `providerMetadata`. `MessageProviderMetadata` and `PartProviderMetadata` are interfaces that adapters extend. ## Parts | Part | Fields | | --- | --- | | `text` | `text`, optional `providerMetadata`. | | `image` | `data: string \| URL`, optional `detail: 'auto' \| 'high' \| 'low'`. | | `file` | `data: string \| URL`. | | `tool-call` | `arguments: string`, `callId`, `id`, `name`. | | `tool-result` | `callId`, `output`, optional `isError`. `output` is a string or an array of `text` and `image` Parts. | | `reasoning` | `content`, an array of `{ type, text }` where `type` is `text`, `summary`, `encrypted`, or `redacted`. Optional `id` and `providerMetadata`. | | `refusal` | `refusal: string`. | | `provider` | `source: string`, `value: unknown`. Holds content that has no shared form. | ## Tools ```ts:no-twoslash tool({ name, inputSchema, description?, outputSchema?, execute? }) ``` Without `execute`, `tool()` returns a declaration. With `execute`, it returns an `ExecutableTool` that the loop can run. `execute(input, { signal })` receives validated input when the schema has a validator. Without `outputSchema`, it returns a string or an array of tool-result content. With `outputSchema`, it returns a value of that schema's type, which the tool serializes as JSON. xsAI does not validate the output at runtime. ## Events See [Text events](/text/events) for the event types. `TextEventTarget`, `toCustomEvent`, and `withEventTarget` are covered in [Use event listeners](/text/events#use-event-listeners). ## Errors | Code | Cause | | --- | --- | | `http-error` | An HTTP error status. Thrown as `HttpError`. | | `network-error` | `fetch` rejected. | | `invalid-input` | The adapter cannot accept the options. | | `invalid-response` | The provider data is not valid. | | `truncated-stream` | The stream ended without `step.end`. | | `model-error` | The adapter reports a model failure. | | `protocol-error` | One request emitted several terminal events. | A `failed` `step.end` rejects `generateText` and the `result` of `streamText`. The event stream can still close normally. A thrown stream error rejects `result` and errors the output stream. Cancelling the output stream rejects `result`. A `cancelled` step from the provider is a status and does not throw. --- --- url: /audio.md --- # Audio `@xsai/audio` generates speech from text and transcribes recordings. It needs a service with `audio/speech` for speech, and `audio/transcriptions` for transcription. ## Generate speech ```sh pnpm add @xsai/audio ``` ```ts import { writeFile } from 'node:fs/promises' import { generateSpeech, speech } from '@xsai/audio' const model = speech({ apiKey: process.env.OPENAI_API_KEY, baseURL: 'https://api.openai.com/v1/', model: 'YOUR_SPEECH_MODEL_ID', }) const audio = await generateSpeech(model, { input: 'Welcome to the forest.', voice: 'alloy' }) await writeFile('speech.mp3', new Uint8Array(await audio.arrayBuffer())) ``` `speech()` creates a speech model, and `generateSpeech` returns the audio as a `Blob`. The adapter requests MP3 unless you set `providerOptions.speech.outputFormat` to `aac`, `flac`, `opus`, or `wav`. Use `streamSpeech` to get the `Response` before its body is read, so you can pipe the audio as it arrives. ## Transcribe audio ```ts import { openAsBlob } from 'node:fs' import { generateTranscription, transcriptionsNonStreaming } from '@xsai/audio' const model = transcriptionsNonStreaming({ apiKey: process.env.OPENAI_API_KEY, baseURL: 'https://api.openai.com/v1/', model: 'YOUR_TRANSCRIPTION_MODEL_ID', }) const { text } = await generateTranscription(model, { audio: await openAsBlob('recording.wav'), fileName: 'recording.wav', }) ``` Two factories cover the endpoint. `transcriptions()` asks for server-sent events (SSE) and emits transcript events as they arrive. `transcriptionsNonStreaming()` asks for one JSON reply and adapts it to the same events. Use the second one when the service does not support SSE. `streamTranscription(model, options)` returns the events as a stream. ## Reference ### Speech `generateSpeech(model, options)` returns `Promise`. `streamSpeech(model, options)` returns `Promise`. Both take `SpeechModelOptions`. | Option | Description | | --- | --- | | `input` | Required. The text to speak. | | `voice` | Required. A voice ID that the model accepts. | | `providerOptions.speech` | `instructions`, `speed`, and `outputFormat`. `outputFormat` defaults to `mp3`. | | `signal` | An `AbortSignal`. | The endpoint decides which voices, speeds, and instructions are valid. If the response has no media type or `application/octet-stream`, the adapter fills in the type of the requested format. A different media type cancels the body and throws `invalid-response`. ### Transcription `generateTranscription(model, options)` collects events and returns `Promise`. It rejects with `truncated-stream` if no `transcription.end` event arrives. | Option | Description | | --- | --- | | `audio` | Required. A `Blob` with the audio bytes. | | `fileName` | The file name for the multipart upload. | | `language` | A language code that the endpoint accepts. | | `providerOptions.transcriptions` | `chunkingStrategy: 'auto'`, `prompt`, `temperature`, `responseFormat`, and `timestampGranularities`. | | `signal` | An `AbortSignal`. | `responseFormat` is `json`, `verbose_json`, or `diarized_json`, and defaults to `json`. `timestampGranularities` is an array of `segment` and `word`. With `verbose_json` and no granularities, the adapter requests segments. | Event | Fields | | --- | --- | | `transcription.start` | None. | | `transcription.text.delta` | `delta`, and optional `segmentId`. | | `transcription.text.segment` | A complete segment with text and timestamps. | | `transcription.end` | The complete `TranscriptionResult`. | `TranscriptionResult` has `text`, and it can also have `durationInSeconds`, `language`, `segments`, and `words`. A segment has `text`, `startSecond`, `endSecond`, and optional `id` and `providerMetadata`. A word has `text`, `startSecond`, `endSecond`, and optional `providerMetadata`. The service decides which fields it fills. The adapter stores extra segment data under `providerMetadata.transcriptions`: `avgLogprob`, `compressionRatio`, `noSpeechProb`, `seek`, `speaker`, `temperature`, and `tokens`. Words can carry `probability`. ### Errors A provider error inside an SSE stream throws `invalid-response`. A stream that ends without `transcript.text.done` throws `truncated-stream`. HTTP failures throw `HttpError`, and network failures throw `network-error`. See [shared](/shared). --- --- url: /decide.md --- # Decisions `@xsai/decide` asks questions about an input and returns typed answers. Use it to classify a message, route a ticket, check a policy, or rate a text. It needs a service with a `decisions` or `systemone` endpoint, or any [language model](/text/adapters). ```sh pnpm add @xsai/decide ``` ```ts import { decide, decisions } from '@xsai/decide' const model = decisions({ apiKey: process.env.OPENAI_API_KEY, baseURL: 'https://api.openai.com/v1/', model: 'gpt-6-luna', }) const { answers } = await decide(model, { input: 'The package arrived with a broken screen.', questions: { damaged: { instructions: 'Does the customer report a damaged item?', type: 'boolean' }, }, }) console.log(answers.damaged.probability) ``` `decide(model, { input, questions })` sends one request that holds every question. It returns `{ answers, usage }`. Each key of `questions` is the ID of one question, and the same key holds its answer in `answers`. The type of an answer follows the `type` of its question, so TypeScript knows which fields exist. ## Question types A question has `instructions` that tell the model what to judge, and a `type` that sets the shape of the answer. | Type | Question | Answer | | --- | --- | --- | | `boolean` | A yes or no question. | `probability`, a number from 0 to 1 for the answer yes. | | `choice` | Pick one value from `choices`. | `choice`, the picked value. | | `score` | Rate the input on the scale in `levels`. | `score`, a number that can have a fraction. | A score of `1.43` on three levels lies between the second level and the third level. The first level has the number 0. The model decides the answer, and you decide what to do with it. For a boolean answer, compare `probability` with a threshold that suits your use case. ```ts import type { DecisionModel } from '@xsai/decide' declare const model: DecisionModel // ---cut--- import { decide } from '@xsai/decide' const { answers } = await decide(model, { input: 'I was charged twice. Please fix this today.', questions: { department: { choices: [ { description: 'Payments, invoices, and refunds.', value: 'billing' }, { description: 'Problems using the product.', value: 'technical' }, ], instructions: 'Which department should handle this message?', type: 'choice', }, urgency: { instructions: 'How urgent is this message?', levels: [{ label: 'Can wait' }, { label: 'Needs a reply today' }, { label: 'Blocks the customer' }], type: 'score', }, }, }) console.log(answers.department.choice, answers.urgency.score) ``` `answers.department.choice` has the type `'billing' | 'technical'`. To keep these literal values, write `choices` inline as shown, or declare them with `as const`. `input` and `instructions` take a string or an object. Adapters serialize an object to JSON when the service expects text. ## Use a language model `toDecisionModel` turns any `LanguageModel` into a decision model. Use it when your service has no `decisions` endpoint. ```ts import type { LanguageModel } from '@xsai/text' declare const languageModel: LanguageModel // ---cut--- import { decide, toDecisionModel } from '@xsai/decide' const model = toDecisionModel(languageModel, { temperature: 0 }) const { answers } = await decide(model, { input: 'The package arrived with a broken screen.', questions: { damaged: { instructions: 'Does the customer report a damaged item?', type: 'boolean' }, }, }) ``` The adapter makes one request with [structured output](/text/structured-output) for all questions. The model writes the numbers itself, so a `boolean` probability is an estimate and not a token probability. The adapter checks each answer against its question, because some services, such as Ollama, do not enforce the number limits in the schema. Choice answers and score answers have no `confidence` and no `probabilities`. Use `decisions()` or `systemone()` when you need those fields. `toDecisionModel(model, options?)` passes `maxOutputTokens`, `providerOptions`, `reasoningEffort`, `temperature`, and `topP` to the language model. ## Handle a refusal If the service refuses a question, `decide` throws `DecisionRefusalError` and returns no partial answers. Its `questionIds` field lists the refused IDs. ```ts import type { DecisionModel } from '@xsai/decide' declare const model: DecisionModel // ---cut--- import { decide, DecisionRefusalError } from '@xsai/decide' try { await decide(model, { input: 'A support message.', questions: { safe: { instructions: 'Is the message safe to publish?', type: 'boolean' } }, }) } catch (error) { if (DecisionRefusalError.isInstance(error)) console.log(error.questionIds) else throw error } ``` A model that refuses without naming a question refuses all of them. `toDecisionModel` does the same when the language model refuses. ## Reference | Option | Description | | --- | --- | | `input` | Required. The content to judge, as a string or an object. | | `questions` | Required. An object that maps a question ID to a question. | | `providerOptions` | Options for one adapter. The sections below list them. | | `signal` | An `AbortSignal`. | | Question field | Used by | Description | | --- | --- | --- | | `type` | All | `boolean`, `choice`, or `score`. | | `instructions` | All | Required. A string or an object. | | `choices` | `choice` | Required. A list of `{ value, description? }`. | | `levels` | `score` | Required. A list of `{ label, description? }`, from the lowest level to the highest. | | Answer | Fields | | --- | --- | | `boolean` | `probability`. | | `choice` | `choice`, and when the service gives them, `confidence` and `probabilities`, a list of `{ value, probability }`. | | `score` | `score`, and when the service gives them, `confidence` and `probabilities`, an object that maps a level number to its probability. | `usage` can have `inputTokens`, `outputTokens`, and `totalTokens`. A field is absent when the service reports no value. ### Adapters Every adapter takes `{ baseURL, model, apiKey?, headers?, fetch? }` and returns a `DecisionModel`. | Adapter | Request | Provider options | | --- | --- | --- | | `decisions()` | `POST decisions` | `providerOptions.decisions.overrideInput` | | `systemone()` | `POST systemone` | `providerOptions.systemone.images` | | `toDecisionModel(model, options?)` | One request to a `LanguageModel`. | None. The adapter ignores the options of other adapters. | `systemone()` sends each score level as one string: the `label`, or `label: description` when the level has a description. `overrideInput` replaces `input` for `decisions()`. It takes a string, or a list of user messages that can hold `input_text` and `input_image` content. An empty string also replaces the input. `images` adds pictures to a `systemone()` request. Each image is a URL, a data URL, or an object `{ base64, content_type }`. The content type is `image/jpeg`, `image/png`, or `image/webp`. ### Errors | Code | Meaning | | --- | --- | | `decision-refusal` | The service refused at least one question. `questionIds` lists them. | | `invalid-response` | `toDecisionModel` got a generation that did not complete, one that has tool calls, or an answer outside its question: a probability outside 0 to 1, a score outside the levels, or a value that is not in `choices`. | | `truncated-stream` | The text stream of `toDecisionModel` ended without a terminal event. | HTTP failures throw `HttpError`, and network failures throw `network-error`. No adapter retries a request. To write your own decision model, read [Write a custom model](/advanced/custom-models). --- --- url: /embed.md --- # Embeddings `@xsai/embed` turns text into vectors. It needs a service with an `embeddings` endpoint. ```sh pnpm add @xsai/embed ``` ```ts import { embed, embeddings } from '@xsai/embed' const model = embeddings({ apiKey: process.env.OPENAI_API_KEY, baseURL: 'https://api.openai.com/v1/', model: 'YOUR_EMBEDDING_MODEL_ID', }) const { embedding } = await embed(model, { input: 'A quiet forest.' }) console.log(embedding.length) ``` `embed` takes one string and returns `{ embedding, usage? }`. `embedMany` takes an array of strings and returns `{ embeddings, usage? }`, with one vector for each input in the same order. ```ts import type { EmbeddingModel } from '@xsai/embed' declare const model: EmbeddingModel // ---cut--- import { embedMany } from '@xsai/embed' const { embeddings } = await embedMany(model, { input: ['A quiet forest.', 'A busy city.'], }) ``` `embedMany` sends all inputs in one request. It does not split large batches or retry, so batch the input yourself when the service limits it. ## Reference | Option | Description | | --- | --- | | `input` | A string for `embed`, or a string array for `embedMany`. | | `providerOptions.embeddings.dimensions` | The number of output dimensions. The endpoint decides the support and the limits. | | `signal` | An `AbortSignal`. | `embeddings({ baseURL, model, apiKey?, headers?, fetch? })` sends `POST embeddings`. The adapter sorts response entries by `index` before it returns the vectors. `usage` has `inputTokens` and `totalTokens`, and it is absent when the service reports none. `embed` throws `invalid-response` if the model returns no embeddings. HTTP failures throw `HttpError`, and network failures throw `network-error`. --- --- url: /image.md --- # Images `@xsai/image` generates images from a prompt. It needs a service with an `images/generations` endpoint that returns base64 data. ```sh pnpm add @xsai/image ``` ```ts import { writeFile } from 'node:fs/promises' import { generateImage, generations } from '@xsai/image' const model = generations({ apiKey: process.env.OPENAI_API_KEY, baseURL: 'https://api.openai.com/v1/', model: 'YOUR_IMAGE_MODEL_ID', }) const { image } = await generateImage(model, { input: 'A small cabin in a quiet forest.' }) await writeFile('cabin.png', new Uint8Array(await image.arrayBuffer())) ``` `generateImage` returns `{ image, images }`. `image` is a `Blob` with the first picture, and its `type` is the detected media type, for example `image/png`. The adapter reads PNG, JPEG, GIF, WebP, and AVIF. ## Reference | Option | Description | | --- | --- | | `input` | Required. The prompt. | | `n` | The number of images. The endpoint decides the default and the limit. | | `size` | A string like `1024x1024`. The endpoint decides which sizes it accepts. | | `providerOptions.generations` | `background`, `outputCompression`, `outputFormat`, and `quality`. | | `signal` | An `AbortSignal`. | | `providerOptions.generations` field | Values | | --- | --- | | `background` | `auto`, `opaque`, or `transparent`. | | `outputCompression` | A number that the provider accepts. | | `outputFormat` | `jpeg`, `png`, or `webp`. | | `quality` | `auto`, `high`, `low`, `max`, `medium`, or `xhigh`. | `generations({ baseURL, model, apiKey?, headers?, fetch? })` sends `POST images/generations`. The adapter does not download image URLs from a response. If the service returns revised prompts, they are in `providerMetadata.generations.images`, in the order of the images. An empty image list, invalid base64, or an unknown image format throws `invalid-response`. HTTP failures throw `HttpError`, and network failures throw `network-error`. --- --- url: /model.md --- # Models `@xsai/model` lists the models that a service offers and reads their metadata. It does not send inference requests. It needs a service with `GET models`. ```sh pnpm add @xsai/model ``` ```ts import { listModels, models } from '@xsai/model' const catalog = models({ apiKey: process.env.OPENAI_API_KEY, baseURL: 'https://api.openai.com/v1/', }) const entries = await listModels(catalog) console.log(entries.map(entry => entry.id)) ``` Unlike the other factories, `models()` takes no `model` option. Use `retrieveModel(catalog, { id })` to read one entry. An ID in the list does not guarantee that the model works with every xsAI operation. ## Reference `listModels(catalog, options?)` returns `Promise`, in the order of the service. `retrieveModel(catalog, { id, providerOptions?, signal? })` returns `Promise`. An entry has a string `id` and optional `providerMetadata`. The adapter maps `created` and `owned_by` to `providerMetadata.models.created` and `providerMetadata.models.ownedBy`. `models({ baseURL, apiKey?, headers?, fetch? })` sends `GET models` for a list and `GET models/{id}` for one entry, with the ID URL-encoded. It adds no pagination, retries, or caching. HTTP failures throw `HttpError`, and network failures throw `network-error`. You can also write a `ModelCatalog` yourself. It has `list(options?)` and `retrieve(options)`, and each can return a value or a promise. --- --- url: /advanced/custom-models.md --- # Write a custom model Every xsAI operation takes a model as its first argument, and a model is a plain function. The HTTP adapters, such as `responses()` and `embeddings()`, are functions of this kind. Write your own to test code without a service, to wrap a protocol that has no adapter, or to serve a local backend. | Package | Model type | Receives | Returns | Used by | | --- | --- | --- | --- | --- | | `@xsai/text` | `LanguageModel` | `LanguageModelOptions` | `ReadableStream` | `generateText`, `streamText`, `loop` | | `@xsai/audio` | `SpeechModel` | `SpeechModelOptions` | `Response` with audio | `generateSpeech`, `streamSpeech` | | `@xsai/audio` | `TranscriptionModel` | `TranscriptionModelOptions` | `ReadableStream` | `generateTranscription`, `streamTranscription` | | `@xsai/decide` | `DecisionModel` | `DecisionModelOptions` | `{ answers, usage }` | `decide` | | `@xsai/embed` | `EmbeddingModel` | `EmbeddingModelOptions` | `{ embeddings, usage? }` | `embed`, `embedMany` | | `@xsai/image` | `ImageModel` | `ImageModelOptions` | `{ images, providerMetadata? }` | `generateImage` | | `@xsai/model` | `ModelCatalog` | An object with `list` and `retrieve` | `ModelCatalogEntry` values | `listModels`, `retrieveModel` | Each function can return its value directly or in a promise. Each takes an optional `signal`, so a model that does asynchronous work must stop when the signal aborts. When the input or the output is invalid, throw an `XSAIError` such as `invalid-input` or `invalid-response`. ## Language model A language model returns a stream of [`TextEvent`](/text/events) values. This one answers with a fixed text. ```ts import type { LanguageModel, TextEvent } from '@xsai/text' import { generateText } from '@xsai/text' const model: LanguageModel = () => new ReadableStream({ start: (controller) => { controller.enqueue({ type: 'step.start' }) controller.enqueue({ contentType: 'text', index: 0, type: 'content.start' }) controller.enqueue({ delta: 'Hello.', index: 0, type: 'text.delta' }) controller.enqueue({ content: { text: 'Hello.', type: 'text' }, index: 0, type: 'content.end' }) controller.enqueue({ message: { content: [{ text: 'Hello.', type: 'text' }], role: 'assistant' }, reason: 'stop', status: 'completed', type: 'step.end', }) controller.close() }, }) const { text } = await generateText(model, { input: 'Hi.' }) console.log(text) ``` Follow three rules: 1. Emit exactly one `step.end` for each request, including a request that produces no text. 2. Give each Part a `content.start`, its deltas, and a `content.end`, all with the same `index`. 3. Report a failure with a `step.end` that has `status: 'failed'` and an `error`. A stream that ends without `step.end` makes the operation reject with `truncated-stream`. More than one `step.end` in one request is a `protocol-error`. ## Other models The other models are shorter, because their results have no events to order. Each example below runs without a service. ::: code-group ```ts [Speech] import type { SpeechModel } from '@xsai/audio' import { generateSpeech } from '@xsai/audio' const model: SpeechModel = ({ input }) => new Response(new TextEncoder().encode(input), { headers: { 'content-type': 'audio/mpeg' } }) const audio = await generateSpeech(model, { input: 'Hello.', voice: 'test' }) console.log(audio.type, audio.size) ``` ```ts [Transcription] import type { TranscriptionEvent, TranscriptionModel } from '@xsai/audio' import { generateTranscription } from '@xsai/audio' const model: TranscriptionModel = () => new ReadableStream({ start: (controller) => { controller.enqueue({ type: 'transcription.start' }) controller.enqueue({ delta: 'Hello.', type: 'transcription.text.delta' }) controller.enqueue({ text: 'Hello.', type: 'transcription.end' }) controller.close() }, }) const { text } = await generateTranscription(model, { audio: new Blob([]) }) console.log(text) ``` ```ts [Decisions] import type { DecisionModel } from '@xsai/decide' import { decide } from '@xsai/decide' const model: DecisionModel = ({ questions }) => ({ answers: Object.fromEntries(Object.keys(questions).map(id => [id, { probability: 0.5, type: 'boolean' as const }])), usage: {}, }) const { answers } = await decide(model, { input: 'A quiet forest.', questions: { calm: { instructions: 'Is the text calm?', type: 'boolean' } }, }) console.log(answers.calm.probability) ``` ```ts [Embeddings] import type { EmbeddingModel } from '@xsai/embed' import { embed } from '@xsai/embed' const model: EmbeddingModel = ({ input }) => ({ embeddings: (Array.isArray(input) ? input : [input]).map(text => [text.length, 0]), }) const { embedding } = await embed(model, { input: 'A quiet forest.' }) console.log(embedding) ``` ```ts [Images] import type { ImageModel } from '@xsai/image' import { generateImage } from '@xsai/image' const model: ImageModel = () => ({ images: [new Blob([new Uint8Array(8)], { type: 'image/png' })], }) const { image } = await generateImage(model, { input: 'A cabin.' }) console.log(image.type, image.size) ``` ```ts [Models] import type { ModelCatalog } from '@xsai/model' import { listModels, retrieveModel } from '@xsai/model' const catalog: ModelCatalog = { list: () => [{ id: 'local-small' }, { id: 'local-large' }], retrieve: ({ id }) => ({ id }), } console.log(await listModels(catalog)) console.log(await retrieveModel(catalog, { id: 'local-small' })) ``` ::: `generateTranscription` keeps the last `transcription.end` event as its result. It rejects with `truncated-stream` if the stream has none. `embed` rejects with `invalid-response` when `embeddings` is empty, and `generateImage` does the same when `images` is empty. To give a custom model typed options of its own, read [Module augmentation](/advanced/module-augmentation). --- --- url: /advanced/module-augmentation.md --- # Module augmentation Many xsAI types have a slot for provider-specific data, such as `providerOptions` on a request or `providerMetadata` on a result. Each slot is an empty interface that packages fill through TypeScript module augmentation. The adapters use it, and you can use it to type the options and metadata of your own model. ## How the adapters use it `@xsai/text-responses` declares this, in its own source: ```ts import type { ResponsesProviderOptions } from '@xsai/text-responses' declare module '@xsai/text' { interface LanguageModelProviderOptions { responses?: ResponsesProviderOptions } } ``` TypeScript merges the declaration with the empty `LanguageModelProviderOptions` in `@xsai/text`. After you import `@xsai/text-responses`, `providerOptions: { responses: { store: true } }` type-checks. A package for another protocol adds its own key, so the keys never overlap. ## Add options for a model This example adds a `region` option for a model called `example`. The model reads it from `providerOptions.example`. ```ts import type { LanguageModel, TextEvent } from '@xsai/text' import { generateText } from '@xsai/text' const textStream = (text: string) => new ReadableStream({ start: (controller) => { controller.enqueue({ type: 'step.start' }) controller.enqueue({ content: { text, type: 'text' }, index: 0, type: 'content.end' }) controller.enqueue({ message: { content: [{ text, type: 'text' }], role: 'assistant' }, status: 'completed', type: 'step.end' }) controller.close() }, }) // ---cut--- declare module '@xsai/text' { interface LanguageModelProviderOptions { example?: { region?: 'eu' | 'us' } } } const example = (): LanguageModel => ({ providerOptions }) => textStream(`Served from ${providerOptions?.example?.region ?? 'us'}.`) const { text } = await generateText(example(), { input: 'Hi.', providerOptions: { example: { region: 'eu' } }, }) console.log(text) ``` The compiler now checks the namespace. A misspelled field is an error: ```ts // @errors: 2561 import type { LanguageModel } from '@xsai/text' declare const model: LanguageModel declare module '@xsai/text' { interface LanguageModelProviderOptions { example?: { region?: 'eu' | 'us' } } } await model({ input: 'Hi.', providerOptions: { example: { regoin: 'eu' } } }) ``` ## Add metadata to a result Metadata slots work the same way. This example adds `citations` to every text Part. ```ts import type { LanguageModel } from '@xsai/text' import { generateText } from '@xsai/text' declare const model: LanguageModel // ---cut--- declare module '@xsai/text' { interface PartProviderMetadata { example?: { citations?: string[] } } } const { message } = await generateText(model, { input: 'Hi.' }) if (typeof message.content !== 'string') { for (const part of message.content) { if (part.type === 'text') console.log(part.providerMetadata?.example?.citations) } } ``` ## Add an error code `XSAIErrorCauseMap` in `@xsai/shared` maps each error code to the type of its `cause`. `undefined` forbids a cause, `unknown` makes it optional, and any other type requires it. ```ts import { XSAIError } from '@xsai/shared' declare module '@xsai/shared' { interface XSAIErrorCauseMap { 'quota-exceeded': { limit: number } } } throw new XSAIError('quota-exceeded', 'The daily quota is used up.', { cause: { limit: 100 } }) ``` ## Extension points | Package | Interface | Slot | | --- | --- | --- | | `@xsai/text` | `LanguageModelProviderOptions` | `providerOptions` of a text request. | | `@xsai/text` | `PartProviderMetadata` | `providerMetadata` of a Part. | | `@xsai/text` | `MessageProviderMetadata` | `providerMetadata` of a message. | | `@xsai/audio` | `SpeechModelProviderOptions` | `providerOptions` of a speech request. | | `@xsai/audio` | `TranscriptionModelProviderOptions` | `providerOptions` of a transcription request. | | `@xsai/audio` | `TranscriptionSegmentProviderMetadata`, `TranscriptionWordProviderMetadata` | `providerMetadata` of a segment or a word. | | `@xsai/decide` | `DecisionModelProviderOptions` | `providerOptions` of a decision request. | | `@xsai/embed` | `EmbeddingModelProviderOptions` | `providerOptions` of an embedding request. | | `@xsai/image` | `ImageModelProviderOptions`, `ImageModelResultProviderMetadata` | `providerOptions` of a request, and `providerMetadata` of a result. | | `@xsai/model` | `ModelCatalogProviderOptions`, `ModelCatalogEntryProviderMetadata` | `providerOptions` of a request, and `providerMetadata` of an entry. | | `@xsai/shared` | `XSAIErrorCauseMap` | Error codes and their causes. | ## Rules 1. Augment the package that declares the interface, as in the table. 2. The file must be a module, so it needs at least one `import` or `export`. Without one, `declare module` replaces the package types instead of extending them. 3. Make each property optional, so code that does not use your model still compiles. 4. Use a key that is unique to your package, such as the name of its factory. Two packages that declare the same key with different types make the build fail. 5. An augmentation applies to the whole project, not to one model. Every model type accepts the key, and a model ignores keys that it does not read. 6. If you publish a package, export the augmentation from its entry point, so it applies when a user imports your package. --- --- url: /shared.md --- # Shared `@xsai/shared` holds the HTTP options, request helper, and error types that every xsAI package uses. Adapters send requests through `sendRequest`, so every package fails in the same way. A non-success status throws `HttpError` with `status`, `body`, and `headers`. A rejected `fetch` throws an `XSAIError` with the code `network-error`, and the original error is in `cause`. ## Handle an HTTP error ```sh pnpm add @xsai/shared ``` ```ts import { HttpError, sendRequest } from '@xsai/shared' try { await sendRequest({ method: 'GET', path: 'models' }, { baseURL: 'https://example.com/v1/', fetch: async () => new Response('Try again later', { status: 429 }), }) } catch (error) { if (HttpError.isInstance(error)) console.log(error.status, error.body) else throw error } ``` This example replaces `fetch` with a local response, so it needs no service. It prints `429 Try again later`. Use `HttpError.isInstance(error)` and `XSAIError.isInstance(error)` to narrow an unknown error. xsAI does not retry requests. For a rate limit, read `retry-after` from `error.headers` and wait before you call again. Read request IDs from `error.headers` too, because your service support team needs them to trace a failure. ## Reference Import these exports from `@xsai/shared`. The adapters use them, and you need them when you write an adapter or customize requests. ### HTTP options `HttpOptions` requires `baseURL: string | URL` and `model: string`. It has the optional fields `apiKey`, `headers`, and `fetch`. Model catalogs and `sendRequest` use `Omit`. `apiKey` becomes a bearer token in `sendRequest`, and an adapter can use another header instead. `headers` is a record of strings, and undefined values are left out. `fetch` has the type `(request: Request) => Promise`. It receives a complete `Request`. `requestURL(path, baseURL)` adds a trailing slash to the root and then resolves the path. Absolute paths and absolute URLs follow the normal URL rules. ### sendRequest `sendRequest(options, httpOptions)` makes one request and returns a `Response` with a body. It does not retry. | Field | Description | | --- | --- | | `path` | Required. A path or URL. | | `method` | `GET` or `POST`. Defaults to `POST`. | | `body` | `FormData` or an object. Objects are sent as JSON. | | `headers` | Replaces all headers that the helper builds. | | `signal` | An `AbortSignal`. | Without `headers`, the helper merges `httpOptions.headers`, the authentication header, and the content type. For `FormData`, it removes `Content-Type` so the runtime can add the multipart boundary. | Condition | Result | | --- | --- | | Non-success status | Throws `HttpError` after it reads the body. | | Success with no body | Throws `invalid-response`. | | `fetch` rejects | Throws `network-error`, with the original error in `cause`. | | Signal aborted | Throws the abort reason. | JSON parsing and later stream errors happen outside this helper. ### Errors `XSAIError(code, message, options?)` extends `Error` with a typed `code`. `XSAIError.isInstance(error)` narrows an unknown value. Packages add their own codes by extending `XSAIErrorCauseMap`. | Code | Meaning | | --- | --- | | `http-error` | A non-success HTTP status. | | `network-error` | `fetch` rejected. A cause is required. | | `invalid-input` | The operation cannot accept the input. | | `invalid-response` | The response breaks the contract of the operation. | | `truncated-stream` | A required terminal event is missing. | | `decision-refusal` | A decision model refused a question. `@xsai/decide` adds this code. | | `model-error` | A text adapter reports a model failure. | | `protocol-error` | A text stream breaks its protocol. | `HttpError({ status, body, headers })` extends `XSAIError<'http-error'>`. Its message is `HTTP {status}: {body}`, and its fields hold the status, the text body, and the `Headers`. ### SSE and utility types `EventSourceParserStream` and `EventSourceMessage` are re-exported from `eventsource-parser/stream`. Pipe decoded SSE text through the stream to get parsed messages. It does not translate provider events into xsAI events. `Promisable` is `T | Promise`. Custom models use it for functions that return synchronously or asynchronously. --- --- url: /xsschema.md --- # xsschema `xsschema` converts schemas to JSON Schema and validates data with them. The two jobs are separate. Validation works with any library that implements [Standard Schema](https://standardschema.dev). Conversion needs native Standard JSON Schema support or a converter for the library. ```sh pnpm add xsschema zod ``` ```ts import { toJsonSchema, validate } from 'xsschema' import * as z from 'zod' const schema = z.object({ name: z.string() }) console.log(await toJsonSchema(schema)) console.log(await validate(schema, { name: 'Ada' })) ``` xsAI accepts schemas from libraries with native Standard JSON Schema support directly, for example in [`tool()`](/text/tools). Use xsschema for libraries without it. ```ts import { toJsonSchema } from 'xsschema' import * as z from 'zod' const inputSchema = await toJsonSchema(z.object({ city: z.string() })) ``` ## Reference `toJsonSchema(schema)` returns `Promise`. If the schema exposes `~standard.jsonSchema`, the function asks it for the input schema with target `draft-07`. Otherwise, it loads the converter for `~standard.vendor`. It rejects when the converter package is missing or the library cannot express the schema. `validate(schema, input)` returns a promise for the output type of the schema. If validation reports issues, it throws an `Error` with the issues as JSON. It returns the transformed value when the schema transforms its input. `jsonSchema(schema)` returns the JSON Schema object that you pass in, unchanged. It validates neither data nor the schema. `strictJsonSchema(schema)` returns a copy with `additionalProperties: false`, applied recursively to directly nested object properties. It does not visit array items, unions, or references, and it does not make properties required. | Library | Required packages | | --- | --- | | Native Standard JSON Schema | The library itself. | | Zod 4 and Zod Mini | `zod`. | | Zod 3 | `zod` and `zod-to-json-schema`. | | Valibot | `valibot` and `@valibot/to-json-schema`. | | ArkType | `arktype`. | | Effect Schema | `effect`. | | Sury | `sury`. | Support for a library does not mean that JSON Schema can express all of its features. No function in this package sends a request or takes an `AbortSignal`. The types are `Schema` (`StandardSchemaV1`), `SchemaWithJson` (`StandardJSONSchemaV1`), and `JsonSchema` (`JSONSchema7`). `Infer` and `InferIn` are deprecated aliases of the Standard Schema inference types. ### Errors If conversion reports a missing package, install the converter from the table and run it again. If the library has no native support and no converter, conversion rejects. Use a supported library, or pass a JSON Schema directly. Validation still works with any Standard Schema library. --- --- url: /index.md --- ```ts import { responses, streamText } from 'xsai' const model = responses({ apiKey: process.env.OPENAI_API_KEY, baseURL: 'https://api.openai.com/v1/', model: 'gpt-6-luna', }) const { stream } = streamText(model, { input: 'Write a haiku about small things.', }) for await (const event of stream) console.log(event) ``` ```ts import { tool } from '@xsai/text' import * as z from 'zod' const weather = tool({ description: 'Get the current temperature for a city.', execute: ({ city, unit }) => `It is 21 degrees ${unit} in ${city}.`, // ^? inputSchema: z.object({ city: z.string(), unit: z.enum(['celsius', 'fahrenheit']), }), name: 'get_weather', }) ```