diff --git a/.agent/self-learning/lessons/2026-06-23-authoring-kiira-doc-snippets.md b/.agent/self-learning/lessons/2026-06-23-authoring-kiira-doc-snippets.md index b1cf7dfd82..5ee5c64644 100644 --- a/.agent/self-learning/lessons/2026-06-23-authoring-kiira-doc-snippets.md +++ b/.agent/self-learning/lessons/2026-06-23-authoring-kiira-doc-snippets.md @@ -35,9 +35,14 @@ is a means (keep examples honest), not the end (a green check). - an adapter → `import { openaiText } from '@tanstack/ai-openai'; const adapter = openaiText('gpt-5.5')`. - tools → define inline with `toolDefinition({ name, description, inputSchema })`. - any other arbitrary placeholder (db, env, a client, a token) → import it from - a relative module: `import { db } from './db'`. kiira ignores unresolved - _relative_ imports, so the name is a usable `any` and the reader sees they - must supply it. + a relative module: `import { db } from './db'`. kiira hides the "cannot find + module" error for _relative_ imports, and the reader sees they must supply it. + **But the binding is TypeScript's error type, not `any`:** every other error + in a call that uses it is silently dropped. `chat({ ..., tools: serverTools })` + with `serverTools` from `./tools` passes even with no `threadId`. So never pass + a relative-import value into a TanStack call (tools, schemas, agents, + messages, options) in a fence that must prove the call is right. Define it + inline or in an earlier fence of the same file instead. 2. **No invisible "ambient" fixture for `messages`/`message`.** Same reason as #1 — the reader sees a variable appear from nowhere. Use real visible setup. diff --git a/.agents/skills/add-example-tutorial/SKILL.md b/.agents/skills/add-example-tutorial/SKILL.md index 9d837628cb..85eec6dc2c 100644 --- a/.agents/skills/add-example-tutorial/SKILL.md +++ b/.agents/skills/add-example-tutorial/SKILL.md @@ -41,7 +41,7 @@ Add other files only when this tutorial needs them (a key form, a tool, a store) ## 3. Tutorial page -Load `docs`. Run its persona and tone gates unless this conversation already chose them. +Load `docs`. State the readers and the tone it picks, then keep going. Open with the problem the reader has, why it matters, and how this tutorial solves it (one short block). Then walk through steps. Each step teaches one piece of that solution and says why that piece exists. diff --git a/.agents/skills/docs/SKILL.md b/.agents/skills/docs/SKILL.md index 72b5815c31..b35a535e1d 100644 --- a/.agents/skills/docs/SKILL.md +++ b/.agents/skills/docs/SKILL.md @@ -1,6 +1,6 @@ --- name: docs -description: Use when writing, editing, or organizing documentation, when planning what docs a feature needs, and whenever planning or implementing a new feature or change in a repo (docs ship with the code). Also use when tempted to write docs without showing the discovered readers to the user, without asking for tone, or without loading simple-english and i-have-adhd. Triggers on "write docs for X", "document this feature", "add a guide", "update the docs", "reorganize the docs", "plan feature X", "implement X", or /docs. +description: Use when writing, editing, or organizing documentation, when planning what docs a feature needs, and whenever planning or implementing a new feature or change in a repo (docs ship with the code). Also use when tempted to write docs without naming the readers, without picking a tone, or without loading simple-english and i-have-adhd. Triggers on "write docs for X", "document this feature", "add a guide", "update the docs", "reorganize the docs", "plan feature X", "implement X", or /docs. --- # docs @@ -11,19 +11,19 @@ Write docs a real person wants to read. Short, plain, built around someone tryin Work in two phases. First plan the story: who reads this, what they want, and how many pages it should be. Then write. -The plan is not private. The user must see the readers and must choose the tone before any page is written. +Do not ask the user to approve the plan or the tone. Pick sane defaults, state them in one line each, and keep going. The user can correct you after. ```text Find docs → read neighbors → Phase 1: readers + pages - → PERSONA GATE (show the list, then stop) - → TONE GATE (ask, then stop) [when writing pages] + → state the readers + pages (one line each, no question) + → state the tone (match neighbors, no question) [when writing pages] → load simple-english + i-have-adhd [when writing pages] → Phase 2: write ``` -A doc-impact list in a feature plan still runs the persona gate. It does not run the tone gate or load the writing skills until pages are actually written. +A doc-impact list in a feature plan still names the reader for each page. It does not pick a tone or load the writing skills until pages are actually written. ## When to run @@ -48,7 +48,7 @@ If either file is missing, stop and tell the user. Do not write the pages withou How they compose: -- This skill owns who the page is for, the page split, problem-then-fix, show-don't-tell, no history leak, forbidden glyphs, and neighbor structure. +- This skill owns who the page is for, the page split, the call-first opener, show-don't-tell, no history leak, forbidden glyphs, and neighbor structure. - `simple-english` owns the sentences. Use pragmatic mode. Run its self-check before you call a page done. - `i-have-adhd` owns the shape of the page: next action first, numbered steps, lists capped at 5 (split must vs later, or split the page), no preamble, a visible win at the end. @@ -59,8 +59,8 @@ When `i-have-adhd` is loaded from this skill, apply it to the pages only. Do not Before writing anything, find where docs live. 1. Look for a docs folder. Check `docs/` first, then `documentation/`, `content/docs/`, `site/`, `website/docs/`. -2. Found nothing? Ask the user where docs should go. Do not guess and do not create a folder on a hunch. -3. Open 2 or 3 existing pages near where the new content belongs. Read them for copy, structure, frontmatter fields, and components. Note the apparent tone as a *candidate* for the tone gate. Do not adopt it yet. +2. Found nothing? Put the page in the README or the closest existing docs file, and say where you put it. Do not create a new docs folder on a hunch. +3. Open 2 or 3 existing pages near where the new content belongs. Read them for copy, structure, frontmatter fields, and components. Note their tone. That is the tone you write in. 4. Note which components the site already uses (steps, tabs, callouts, cards, accordions, code groups, and so on). Different sites have different ones. 5. Reuse those components to tell the story. If the site has a steps component, use it for walkthroughs. If it has tabs, use them for framework variations. If the site has none, use plain markdown. Never invent a component the site does not have. @@ -100,8 +100,6 @@ If there is no page like the one you are about to write, read the closest one yo Matching neighbors is about **copy, structure, frontmatter, and components**: whether headings are sentence case, how much setup a section gets, which components carry the walkthrough, how code samples are introduced, what the frontmatter fields are. -It is **not** a substitute for the tone gate. Neighbors can suggest a default. They cannot answer for the user. - It is **not** permission to copy a neighbor's bad habits. Everything in [Forbidden](#forbidden) and every rule in this skill still applies to the content you write, no matter what the surrounding pages do. An existing page full of em dashes does not license one more. So: never reason "the other pages do it, so I will too" about a rule this skill states. If existing pages break a rule and you think the whole set should be brought in line, that is a separate cleanup to raise with the user, not something to settle by quietly matching the violation. @@ -117,40 +115,11 @@ Do this before you write a word of content. The page split comes out of this step. Do not skip it. -### Gate: show the personas - - -The user must see the list. Listing readers in a thought, a todo, or a buried plan is not this gate. - -After step 4, send a message that contains only: - -1. Each reader, one user story, and the page you will write for them. -2. One question: confirm, drop a reader, or add one. - -If the harness has an AskQuestion (or similar) tool, use it for that question. If it does not, use a numbered list. - -Then pick one path: - -**Default: stop.** This message is the entire turn. End the turn. Do not write files. Do not start Phase 2. Do not say you will proceed unless they object. - -**Skip the wait: continue in this same turn.** Use this path only when one of these is true: +### State the readers -- This conversation already has the user's confirmation of this persona list. -- The user said "use sane defaults", "just write it", or "don't ask questions". Still SHOW the list in this turn, then continue. -- The change is a tiny copy edit: typo, broken link, code-fence language, or a factual fix. No new page, no new section, no rewrite of a journey. +After step 4, put the readers and their pages in your message to the user, one line per reader: the user story and the page. Do not ask a question and do not wait. Continue to Phase 2 in the same turn. -On this path, do not end the turn. After you show the list (or after a tiny copy edit, with no list), continue to the next step. - -These are not skips: - -- "The readers are obvious." -- "The user asked for docs, so they do not want a question." -- "I already named them in the plan." -- "There is only one reader." -- "This is part of implementing a feature, keep going." - -Writing docs is why this gate exists. It is not a reason to skip it. - +The user reads the list and corrects you if a reader is wrong. A list that only lives in your thinking or a todo does not count. Skip the list only for a tiny copy edit (typo, broken link, code-fence language, factual fix). ## Less is more: split, do not cram @@ -173,47 +142,15 @@ Good: a small set of linked pages Each page is short and does one thing. The overview stitches them into a story. -## Gate: ask for tone - -This gate sits between Phase 1 and Phase 2. Run it before you write any page. Do not nest it inside Phase 2. - - -This gate runs before any page content is written. It does not run for a doc-impact list that is only a plan. - -Neighbors can suggest a default. They cannot answer for the user. - -Send a message that contains only: - -1. The tone you inferred from neighboring pages, in one line (how formal, how much setup, second person or not). -2. One question with options: use that tone, more casual, more formal, or the user names another. +## Pick the tone -If the harness has an AskQuestion (or similar) tool, use it. If it does not, use a numbered list. +Use the tone of the neighboring pages. State it in one line (how formal, how much setup, second person or not) and continue. Do not ask. -Then pick one path: - -**Default: stop.** This message is the entire turn. End the turn. Do not write page content. - -**Skip the wait: continue in this same turn.** Use this path only when one of these is true: - -- This conversation already has the user's tone choice, including an explicit "match the existing pages". -- The user said "use sane defaults", "just write it", or "don't ask questions". Still STATE the inferred tone in one line, then continue. -- Tiny copy edit, same carve-out as the persona gate. - -On this path, do not end the turn. After you state the tone (or after a tiny copy edit, with no question), continue to Phase 2. - -These are not skips: - -- "I can tell from the neighbors." -- "The site already has a voice." -- "Tone does not matter for a reference page." -- "I'll match neighbors and mention it later." - +If the user already named a tone in this conversation, use theirs. ## Phase 2: write like a human -Do not write page content until the persona gate has passed, the tone gate has passed, and `simple-english` plus `i-have-adhd` are loaded. - -The tone gate is the heading above this one. Run that gate first. Then load the two skills. Then write. +Do not write page content until you have stated the readers and the tone, and `simple-english` plus `i-have-adhd` are loaded. Legibility is the goal, above everything else. @@ -224,28 +161,29 @@ Legibility is the goal, above everything else. - Second person, action first. Start with what the reader has now and what they will have at the end. No "In this guide we will explore..." openings. Just start. - Shape the page with `i-have-adhd`: the first line is something the reader can do, multi-step work is numbered, a list longer than 5 is split, the page ends on a visible win. -### Lead with the problem, then solve it - -Every page opens with the problem the reader came for, in their own words, before any API. Name the situation they are stuck in. Then say in a sentence or two how the feature solves it. Only after that do you go into the technical parts and the code. +### Open with the call -A reader who sees the problem first knows in seconds whether they are on the right page. A reader who hits an API signature first has to reverse-engineer what it is even for. - -The first line of the how is the next action (from `i-have-adhd`). The problem still comes first so they know they are on the right page. +The first one or two sentences name the call and what the reader gets from it. A reader who lands from search knows in one line whether this page has their answer. ```text Bad: - Call useInterrupts() and pass a resolver. The resolver runs once per - pending item inside a transaction... + Some tool calls shouldn't run without a human saying yes: moving money, + deleting data. An interrupt pauses the run for that decision... Good: - Some tool calls shouldn't run without a human saying yes: moving money, - deleting data. An interrupt pauses the run for that decision, then picks up - where it left off. Here is how to gate a tool behind an approval: + Set `needsApproval: true` on a tool. The run pauses before the tool runs, + and continues after a person approves it. - [code] +Good: + `chat()` returns each token while the model writes the reply. `useChat` + renders those tokens. Call `stop()` to cancel the run. ``` -The order for a guide is problem, one-line fix, then the how (steps, snippets, API). Keep the problem to a couple of sentences, not a background essay. The code shows how it is solved, so do not narrate the solution in prose first. +The opener is the page's only summary. The next line starts the steps or the code. Each idea in the opener is said once on the page, so the paragraph after it adds something new. + +When the reader must know a limit to pick the right page, put it in the opener: "`chat()` with `outputSchema` returns one typed object. It does not stream or run tools." + +The order for a guide is the call, then the steps and code. The code shows how it works, so the prose between snippets stays short. ### Show, do not tell @@ -309,10 +247,9 @@ If a list grows past five items, split it. Put "do now" first. Put the rest on a ### Page shape for a guide -Problem, fix, steps, done. +Call, steps, done. -- Open with the problem the reader has, in their words, and what they will have at the end. -- Say in a sentence how the feature solves it. +- Open with the call and what the reader gets from it, in one or two sentences. - Walk through steps they can follow and test as they go, with the code doing the explaining. - End when they reach the goal. Show what now works. Do not close with a vague "next steps" dump. @@ -397,7 +334,8 @@ Then run the `simple-english` self-check on the same added text. | You catch yourself | Do instead | |---|---| -| Opening with an API signature or config before the problem | Name the problem the reader has first, then the one-line fix, then the code. | +| Opening with a scene, a background story, or "In this guide" | Open with the call and its result in one or two sentences, then the steps. | +| A second paragraph that restates the opener | Cut it. The paragraph after the opener starts the steps or adds a new fact. | | Writing three paragraphs before any code | Cut to one sentence plus a snippet. Show it. | | Writing a sentence that names two or more things and explains each | Make it a bulleted list, one item per bullet. | | A paragraph with commas or semicolons separating a set of options/stores/flags | Same fix: that is a list, format it as one. | @@ -407,9 +345,9 @@ Then run the `simple-english` self-check on the same added text. | Reaching for an em dash | Rewrite with a comma, period, or parentheses. | | "The surrounding pages do X, so I will too" where X breaks a rule here | Match neighbors on structure only. Follow this skill on everything it covers, and raise the existing violations as their own cleanup. | | Using a big word (utilize, leverage, facilitate) | Swap in the plain word. `simple-english` owns this. | -| One page for everyone | Name the readers. Give each a path or a page. Show the list. Stop. | +| One page for everyone | Name the readers. Give each a path or a page. State the list. | | Posting a snippet "they can adapt" | Make it complete and runnable. | -| Guessing where docs go | Find the docs folder, or ask. Read neighbors first. | +| Guessing where docs go | Find the docs folder. Read neighbors first. | | Calling a feature done with no doc change | If behavior changed, docs change too. Write them before you finish. | | Planning a feature without a doc-impact list | Add the list of new and changed docs to the plan. Name the reader for each page. | | "Unlike the old X, this now..." / "we renamed X to Y" | The reader never saw X. Describe only what ships now. | @@ -417,8 +355,5 @@ Then run the `simple-english` self-check on the same added text. | Warning readers not to do a thing they never knew was possible (e.g. "don't pass locks as a store") | Delete it. If they never saw the old arrangement, the warning only confuses. Describe the thing in its own terms. | | Inventing a component the site lacks | Use only components the site already has. | | Stacking `npm` / `pnpm` / `yarn` in one install fence | Use ``. | -| Skipping the persona message because readers are obvious | Show the list. Stop. Obvious is not a skip. | -| Skipping the tone question because neighbors have a voice | Neighbors are the proposed default. Ask. Then stop. | | Writing pages without loading simple-english and i-have-adhd | Load both. If either is missing, stop. | -| Treating "write the docs" as permission to skip the gates | Writing docs is why the gates exist. | -| Listing personas inside a larger plan and continuing | That is not the gate. The gate is a dedicated message that ends the turn. | +| Asking the user to confirm readers or tone, then ending the turn | Do not ask. State them in one line each and keep writing. | diff --git a/.changeset/v1-release.md b/.changeset/v1-release.md new file mode 100644 index 0000000000..3fa6440d11 --- /dev/null +++ b/.changeset/v1-release.md @@ -0,0 +1,96 @@ +--- +'@tanstack/ai-acp': major +'@tanstack/ai-angular': major +'@tanstack/ai-anthropic': major +'@tanstack/ai-bedrock': major +'@tanstack/ai-byteplus': major +'@tanstack/ai-claude-code': major +'@tanstack/ai-client': major +'@tanstack/ai-cloudflare': major +'@tanstack/ai-code-mode-snippets': major +'@tanstack/ai-code-mode': major +'@tanstack/ai-codex': major +'@tanstack/ai-cohere': major +'@tanstack/ai-compaction': major +'@tanstack/ai-computer-local': major +'@tanstack/ai-computer-playwright': major +'@tanstack/ai-computer-sandbox': major +'@tanstack/ai-computer-use': major +'@tanstack/ai-devtools-core': major +'@tanstack/ai-durable-stream': major +'@tanstack/ai-elevenlabs': major +'@tanstack/ai-event-client': major +'@tanstack/ai-fal': major +'@tanstack/ai-gemini': major +'@tanstack/ai-grok-build': major +'@tanstack/ai-grok': major +'@tanstack/ai-groq': major +'@tanstack/ai-isolate-cloudflare': major +'@tanstack/ai-isolate-daytona': major +'@tanstack/ai-isolate-e2b': major +'@tanstack/ai-isolate-node': major +'@tanstack/ai-isolate-quickjs-bun': major +'@tanstack/ai-isolate-quickjs': major +'@tanstack/ai-llmgateway': major +'@tanstack/ai-lovable': major +'@tanstack/ai-mcp': major +'@tanstack/ai-memory': major +'@tanstack/ai-mistral': major +'@tanstack/ai-octane': major +'@tanstack/ai-ollama': major +'@tanstack/ai-ollaya': major +'@tanstack/ai-openai': major +'@tanstack/ai-opencode': major +'@tanstack/ai-openrouter': major +'@tanstack/ai-perplexity': major +'@tanstack/ai-persistence': major +'@tanstack/ai-preact': major +'@tanstack/ai-react': major +'@tanstack/ai-reactor': major +'@tanstack/ai-remix': major +'@tanstack/ai-sandbox-blaxel': major +'@tanstack/ai-sandbox-boxd': major +'@tanstack/ai-sandbox-cloudflare': major +'@tanstack/ai-sandbox-daytona': major +'@tanstack/ai-sandbox-docker': major +'@tanstack/ai-sandbox-e2b': major +'@tanstack/ai-sandbox-local-process': major +'@tanstack/ai-sandbox-railway': major +'@tanstack/ai-sandbox-sprites': major +'@tanstack/ai-sandbox-upstash-box': major +'@tanstack/ai-sandbox-vercel': major +'@tanstack/ai-sandbox': major +'@tanstack/ai-skills': major +'@tanstack/ai-solid': major +'@tanstack/ai-svelte': major +'@tanstack/ai-typesafe': major +'@tanstack/ai-utils': major +'@tanstack/ai-vercel-gateway': major +'@tanstack/ai-vertex': major +'@tanstack/ai-vue': major +'@tanstack/ai-worldlabs': major +'@tanstack/ai': major +'@tanstack/openai-base': major +'@tanstack/preact-ai-devtools': major +'@tanstack/react-ai-devtools': major +'@tanstack/solid-ai-devtools': major +'@tanstack/svelte-ai-devtools': major +--- + +TanStack AI 1.0.0. This release removes every API that was deprecated in 0.x. Read the upgrade guide before you upgrade: https://tanstack.com/ai/latest/docs/migration/v1 + +- Removed all `@deprecated` exports, options, fields, and type aliases. Each one has a replacement that already shipped in 0.x. +- Removed `addToolApprovalResponse()` from `ChatClient` and every chat hook. Resolve the bound `tool-approval` interrupt with `resolveInterrupt` instead. The devtools event `tools:approval:responded` is also removed. +- Removed the old wire formats: the CUSTOM `approval-requested` and `tool-input-available` events, the `[DONE]` SSE line, the `data` copy of `forwardedProps`, pre-binding interrupt descriptors and their `approval_required` and `client_tool_input` reasons, and the STEP events that adapters sent next to `REASONING_*` events. Use a 1.0 client with a 1.0 server. +- Removed the `toolName` alias from `TOOL_CALL_START` chunks. Read `toolCallName` instead. +- `threadId` is now required on `useChat` and every other chat hook, `ChatClient`, the generation hooks and clients, `chat()`, `summarize()`, the generation functions, `codeModeWithSnippets()`, `selectRelevantSnippets()`, `CompactionContext`, and `SummarizeInput`. TanStack AI no longer makes a thread id for you. Pass a stable id for each conversation. `createChatHook` factory options no longer take `threadId`: pass it on each hook call. Octane generation hooks take `threadId` instead of `id`. `@tanstack/ai-sandbox` removes `resolveDurableThreadId` and `DurableThreadIdRequiredError`. +- The published `@tanstack/ai-octane/ui` types are now strict. `UI.Messages`, `UI.Message`, `UI.Part`, and `UI.Interrupts` type their render children. `ToolProps` types `part.input` and `part.output` for the named tool. `toolsComponents` and `interruptsComponents` are required when the chat options declare tools or named interrupts. Code that relied on the old loose types can fail to compile. +- A connection adapter's `connect()` and `send()` need the run context (with `threadId`) as their fourth argument. `ChatClient` and the generation clients pass it. +- `@tanstack/ai-sandbox`: `createToolBridgeCore()`, `startHostToolBridge()`, a `ToolBridgeProvisioner`, and `executeHostTool()` take a required `threadId`. Bridged tools get it in their context. +- `@tanstack/ai-sandbox`: the harness tool bridge and `executeHostTool()` refuse a tool with `needsApproval`. Before, the tool ran with no approval. Remove `needsApproval`, or run the tool with an HTTP adapter. +- `stream()` and `rpcStream()` pass the AG-UI `RunAgentInput` for the run as the fourth factory argument, the same body the HTTP adapters send. Read it on the server with `chatParamsFromRequestBody()` or `generationParamsFromBody()`, so `threadId`, `runId`, and interrupt `resume` reach a server function. `RunAgentInput` is exported from `@tanstack/ai/client`. +- A generation `fetcher` gets `threadId` and `runId` in its second argument, next to `signal` and `headers`. Pass them to the server generation call. +- `generationParamsFromBody()` and `generationParamsFromRequest()` read the generation input from `forwardedProps`. A server that reads `body.data` gets `undefined`. Read `body.forwardedProps` instead. They also require `threadId` and `runId` and return them as strings, the same as `chatParamsFromRequestBody()` and the AG-UI `RunAgentInput`. A body that holds only the input is rejected. +- Removed the readers for data saved in old 0.x formats (old Cloudflare run-log records, a bare `UIMessage[]` from client persistence, artifact records without `blobKey`). +- Removed provider models that are shut down. +- `@tanstack/ai-react-ui`, `@tanstack/ai-solid-ui`, and `@tanstack/ai-vue-ui` get no 1.0 release. Import from `@tanstack/ai-react/ui`, `@tanstack/ai-solid/ui`, or `@tanstack/ai-vue/ui`. diff --git a/.claude/skills/add-example-tutorial/SKILL.md b/.claude/skills/add-example-tutorial/SKILL.md index 9d837628cb..85eec6dc2c 100644 --- a/.claude/skills/add-example-tutorial/SKILL.md +++ b/.claude/skills/add-example-tutorial/SKILL.md @@ -41,7 +41,7 @@ Add other files only when this tutorial needs them (a key form, a tool, a store) ## 3. Tutorial page -Load `docs`. Run its persona and tone gates unless this conversation already chose them. +Load `docs`. State the readers and the tone it picks, then keep going. Open with the problem the reader has, why it matters, and how this tutorial solves it (one short block). Then walk through steps. Each step teaches one piece of that solution and says why that piece exists. diff --git a/.claude/skills/docs/SKILL.md b/.claude/skills/docs/SKILL.md index 72b5815c31..b35a535e1d 100644 --- a/.claude/skills/docs/SKILL.md +++ b/.claude/skills/docs/SKILL.md @@ -1,6 +1,6 @@ --- name: docs -description: Use when writing, editing, or organizing documentation, when planning what docs a feature needs, and whenever planning or implementing a new feature or change in a repo (docs ship with the code). Also use when tempted to write docs without showing the discovered readers to the user, without asking for tone, or without loading simple-english and i-have-adhd. Triggers on "write docs for X", "document this feature", "add a guide", "update the docs", "reorganize the docs", "plan feature X", "implement X", or /docs. +description: Use when writing, editing, or organizing documentation, when planning what docs a feature needs, and whenever planning or implementing a new feature or change in a repo (docs ship with the code). Also use when tempted to write docs without naming the readers, without picking a tone, or without loading simple-english and i-have-adhd. Triggers on "write docs for X", "document this feature", "add a guide", "update the docs", "reorganize the docs", "plan feature X", "implement X", or /docs. --- # docs @@ -11,19 +11,19 @@ Write docs a real person wants to read. Short, plain, built around someone tryin Work in two phases. First plan the story: who reads this, what they want, and how many pages it should be. Then write. -The plan is not private. The user must see the readers and must choose the tone before any page is written. +Do not ask the user to approve the plan or the tone. Pick sane defaults, state them in one line each, and keep going. The user can correct you after. ```text Find docs → read neighbors → Phase 1: readers + pages - → PERSONA GATE (show the list, then stop) - → TONE GATE (ask, then stop) [when writing pages] + → state the readers + pages (one line each, no question) + → state the tone (match neighbors, no question) [when writing pages] → load simple-english + i-have-adhd [when writing pages] → Phase 2: write ``` -A doc-impact list in a feature plan still runs the persona gate. It does not run the tone gate or load the writing skills until pages are actually written. +A doc-impact list in a feature plan still names the reader for each page. It does not pick a tone or load the writing skills until pages are actually written. ## When to run @@ -48,7 +48,7 @@ If either file is missing, stop and tell the user. Do not write the pages withou How they compose: -- This skill owns who the page is for, the page split, problem-then-fix, show-don't-tell, no history leak, forbidden glyphs, and neighbor structure. +- This skill owns who the page is for, the page split, the call-first opener, show-don't-tell, no history leak, forbidden glyphs, and neighbor structure. - `simple-english` owns the sentences. Use pragmatic mode. Run its self-check before you call a page done. - `i-have-adhd` owns the shape of the page: next action first, numbered steps, lists capped at 5 (split must vs later, or split the page), no preamble, a visible win at the end. @@ -59,8 +59,8 @@ When `i-have-adhd` is loaded from this skill, apply it to the pages only. Do not Before writing anything, find where docs live. 1. Look for a docs folder. Check `docs/` first, then `documentation/`, `content/docs/`, `site/`, `website/docs/`. -2. Found nothing? Ask the user where docs should go. Do not guess and do not create a folder on a hunch. -3. Open 2 or 3 existing pages near where the new content belongs. Read them for copy, structure, frontmatter fields, and components. Note the apparent tone as a *candidate* for the tone gate. Do not adopt it yet. +2. Found nothing? Put the page in the README or the closest existing docs file, and say where you put it. Do not create a new docs folder on a hunch. +3. Open 2 or 3 existing pages near where the new content belongs. Read them for copy, structure, frontmatter fields, and components. Note their tone. That is the tone you write in. 4. Note which components the site already uses (steps, tabs, callouts, cards, accordions, code groups, and so on). Different sites have different ones. 5. Reuse those components to tell the story. If the site has a steps component, use it for walkthroughs. If it has tabs, use them for framework variations. If the site has none, use plain markdown. Never invent a component the site does not have. @@ -100,8 +100,6 @@ If there is no page like the one you are about to write, read the closest one yo Matching neighbors is about **copy, structure, frontmatter, and components**: whether headings are sentence case, how much setup a section gets, which components carry the walkthrough, how code samples are introduced, what the frontmatter fields are. -It is **not** a substitute for the tone gate. Neighbors can suggest a default. They cannot answer for the user. - It is **not** permission to copy a neighbor's bad habits. Everything in [Forbidden](#forbidden) and every rule in this skill still applies to the content you write, no matter what the surrounding pages do. An existing page full of em dashes does not license one more. So: never reason "the other pages do it, so I will too" about a rule this skill states. If existing pages break a rule and you think the whole set should be brought in line, that is a separate cleanup to raise with the user, not something to settle by quietly matching the violation. @@ -117,40 +115,11 @@ Do this before you write a word of content. The page split comes out of this step. Do not skip it. -### Gate: show the personas - - -The user must see the list. Listing readers in a thought, a todo, or a buried plan is not this gate. - -After step 4, send a message that contains only: - -1. Each reader, one user story, and the page you will write for them. -2. One question: confirm, drop a reader, or add one. - -If the harness has an AskQuestion (or similar) tool, use it for that question. If it does not, use a numbered list. - -Then pick one path: - -**Default: stop.** This message is the entire turn. End the turn. Do not write files. Do not start Phase 2. Do not say you will proceed unless they object. - -**Skip the wait: continue in this same turn.** Use this path only when one of these is true: +### State the readers -- This conversation already has the user's confirmation of this persona list. -- The user said "use sane defaults", "just write it", or "don't ask questions". Still SHOW the list in this turn, then continue. -- The change is a tiny copy edit: typo, broken link, code-fence language, or a factual fix. No new page, no new section, no rewrite of a journey. +After step 4, put the readers and their pages in your message to the user, one line per reader: the user story and the page. Do not ask a question and do not wait. Continue to Phase 2 in the same turn. -On this path, do not end the turn. After you show the list (or after a tiny copy edit, with no list), continue to the next step. - -These are not skips: - -- "The readers are obvious." -- "The user asked for docs, so they do not want a question." -- "I already named them in the plan." -- "There is only one reader." -- "This is part of implementing a feature, keep going." - -Writing docs is why this gate exists. It is not a reason to skip it. - +The user reads the list and corrects you if a reader is wrong. A list that only lives in your thinking or a todo does not count. Skip the list only for a tiny copy edit (typo, broken link, code-fence language, factual fix). ## Less is more: split, do not cram @@ -173,47 +142,15 @@ Good: a small set of linked pages Each page is short and does one thing. The overview stitches them into a story. -## Gate: ask for tone - -This gate sits between Phase 1 and Phase 2. Run it before you write any page. Do not nest it inside Phase 2. - - -This gate runs before any page content is written. It does not run for a doc-impact list that is only a plan. - -Neighbors can suggest a default. They cannot answer for the user. - -Send a message that contains only: - -1. The tone you inferred from neighboring pages, in one line (how formal, how much setup, second person or not). -2. One question with options: use that tone, more casual, more formal, or the user names another. +## Pick the tone -If the harness has an AskQuestion (or similar) tool, use it. If it does not, use a numbered list. +Use the tone of the neighboring pages. State it in one line (how formal, how much setup, second person or not) and continue. Do not ask. -Then pick one path: - -**Default: stop.** This message is the entire turn. End the turn. Do not write page content. - -**Skip the wait: continue in this same turn.** Use this path only when one of these is true: - -- This conversation already has the user's tone choice, including an explicit "match the existing pages". -- The user said "use sane defaults", "just write it", or "don't ask questions". Still STATE the inferred tone in one line, then continue. -- Tiny copy edit, same carve-out as the persona gate. - -On this path, do not end the turn. After you state the tone (or after a tiny copy edit, with no question), continue to Phase 2. - -These are not skips: - -- "I can tell from the neighbors." -- "The site already has a voice." -- "Tone does not matter for a reference page." -- "I'll match neighbors and mention it later." - +If the user already named a tone in this conversation, use theirs. ## Phase 2: write like a human -Do not write page content until the persona gate has passed, the tone gate has passed, and `simple-english` plus `i-have-adhd` are loaded. - -The tone gate is the heading above this one. Run that gate first. Then load the two skills. Then write. +Do not write page content until you have stated the readers and the tone, and `simple-english` plus `i-have-adhd` are loaded. Legibility is the goal, above everything else. @@ -224,28 +161,29 @@ Legibility is the goal, above everything else. - Second person, action first. Start with what the reader has now and what they will have at the end. No "In this guide we will explore..." openings. Just start. - Shape the page with `i-have-adhd`: the first line is something the reader can do, multi-step work is numbered, a list longer than 5 is split, the page ends on a visible win. -### Lead with the problem, then solve it - -Every page opens with the problem the reader came for, in their own words, before any API. Name the situation they are stuck in. Then say in a sentence or two how the feature solves it. Only after that do you go into the technical parts and the code. +### Open with the call -A reader who sees the problem first knows in seconds whether they are on the right page. A reader who hits an API signature first has to reverse-engineer what it is even for. - -The first line of the how is the next action (from `i-have-adhd`). The problem still comes first so they know they are on the right page. +The first one or two sentences name the call and what the reader gets from it. A reader who lands from search knows in one line whether this page has their answer. ```text Bad: - Call useInterrupts() and pass a resolver. The resolver runs once per - pending item inside a transaction... + Some tool calls shouldn't run without a human saying yes: moving money, + deleting data. An interrupt pauses the run for that decision... Good: - Some tool calls shouldn't run without a human saying yes: moving money, - deleting data. An interrupt pauses the run for that decision, then picks up - where it left off. Here is how to gate a tool behind an approval: + Set `needsApproval: true` on a tool. The run pauses before the tool runs, + and continues after a person approves it. - [code] +Good: + `chat()` returns each token while the model writes the reply. `useChat` + renders those tokens. Call `stop()` to cancel the run. ``` -The order for a guide is problem, one-line fix, then the how (steps, snippets, API). Keep the problem to a couple of sentences, not a background essay. The code shows how it is solved, so do not narrate the solution in prose first. +The opener is the page's only summary. The next line starts the steps or the code. Each idea in the opener is said once on the page, so the paragraph after it adds something new. + +When the reader must know a limit to pick the right page, put it in the opener: "`chat()` with `outputSchema` returns one typed object. It does not stream or run tools." + +The order for a guide is the call, then the steps and code. The code shows how it works, so the prose between snippets stays short. ### Show, do not tell @@ -309,10 +247,9 @@ If a list grows past five items, split it. Put "do now" first. Put the rest on a ### Page shape for a guide -Problem, fix, steps, done. +Call, steps, done. -- Open with the problem the reader has, in their words, and what they will have at the end. -- Say in a sentence how the feature solves it. +- Open with the call and what the reader gets from it, in one or two sentences. - Walk through steps they can follow and test as they go, with the code doing the explaining. - End when they reach the goal. Show what now works. Do not close with a vague "next steps" dump. @@ -397,7 +334,8 @@ Then run the `simple-english` self-check on the same added text. | You catch yourself | Do instead | |---|---| -| Opening with an API signature or config before the problem | Name the problem the reader has first, then the one-line fix, then the code. | +| Opening with a scene, a background story, or "In this guide" | Open with the call and its result in one or two sentences, then the steps. | +| A second paragraph that restates the opener | Cut it. The paragraph after the opener starts the steps or adds a new fact. | | Writing three paragraphs before any code | Cut to one sentence plus a snippet. Show it. | | Writing a sentence that names two or more things and explains each | Make it a bulleted list, one item per bullet. | | A paragraph with commas or semicolons separating a set of options/stores/flags | Same fix: that is a list, format it as one. | @@ -407,9 +345,9 @@ Then run the `simple-english` self-check on the same added text. | Reaching for an em dash | Rewrite with a comma, period, or parentheses. | | "The surrounding pages do X, so I will too" where X breaks a rule here | Match neighbors on structure only. Follow this skill on everything it covers, and raise the existing violations as their own cleanup. | | Using a big word (utilize, leverage, facilitate) | Swap in the plain word. `simple-english` owns this. | -| One page for everyone | Name the readers. Give each a path or a page. Show the list. Stop. | +| One page for everyone | Name the readers. Give each a path or a page. State the list. | | Posting a snippet "they can adapt" | Make it complete and runnable. | -| Guessing where docs go | Find the docs folder, or ask. Read neighbors first. | +| Guessing where docs go | Find the docs folder. Read neighbors first. | | Calling a feature done with no doc change | If behavior changed, docs change too. Write them before you finish. | | Planning a feature without a doc-impact list | Add the list of new and changed docs to the plan. Name the reader for each page. | | "Unlike the old X, this now..." / "we renamed X to Y" | The reader never saw X. Describe only what ships now. | @@ -417,8 +355,5 @@ Then run the `simple-english` self-check on the same added text. | Warning readers not to do a thing they never knew was possible (e.g. "don't pass locks as a store") | Delete it. If they never saw the old arrangement, the warning only confuses. Describe the thing in its own terms. | | Inventing a component the site lacks | Use only components the site already has. | | Stacking `npm` / `pnpm` / `yarn` in one install fence | Use ``. | -| Skipping the persona message because readers are obvious | Show the list. Stop. Obvious is not a skip. | -| Skipping the tone question because neighbors have a voice | Neighbors are the proposed default. Ask. Then stop. | | Writing pages without loading simple-english and i-have-adhd | Load both. If either is missing, stop. | -| Treating "write the docs" as permission to skip the gates | Writing docs is why the gates exist. | -| Listing personas inside a larger plan and continuing | That is not the gate. The gate is a dedicated message that ends the turn. | +| Asking the user to confirm readers or tone, then ending the turn | Do not ask. State them in one line each and keep writing. | diff --git a/.grok/skills/add-example-tutorial/SKILL.md b/.grok/skills/add-example-tutorial/SKILL.md index 9d837628cb..85eec6dc2c 100644 --- a/.grok/skills/add-example-tutorial/SKILL.md +++ b/.grok/skills/add-example-tutorial/SKILL.md @@ -41,7 +41,7 @@ Add other files only when this tutorial needs them (a key form, a tool, a store) ## 3. Tutorial page -Load `docs`. Run its persona and tone gates unless this conversation already chose them. +Load `docs`. State the readers and the tone it picks, then keep going. Open with the problem the reader has, why it matters, and how this tutorial solves it (one short block). Then walk through steps. Each step teaches one piece of that solution and says why that piece exists. diff --git a/.grok/skills/docs/SKILL.md b/.grok/skills/docs/SKILL.md index 72b5815c31..b35a535e1d 100644 --- a/.grok/skills/docs/SKILL.md +++ b/.grok/skills/docs/SKILL.md @@ -1,6 +1,6 @@ --- name: docs -description: Use when writing, editing, or organizing documentation, when planning what docs a feature needs, and whenever planning or implementing a new feature or change in a repo (docs ship with the code). Also use when tempted to write docs without showing the discovered readers to the user, without asking for tone, or without loading simple-english and i-have-adhd. Triggers on "write docs for X", "document this feature", "add a guide", "update the docs", "reorganize the docs", "plan feature X", "implement X", or /docs. +description: Use when writing, editing, or organizing documentation, when planning what docs a feature needs, and whenever planning or implementing a new feature or change in a repo (docs ship with the code). Also use when tempted to write docs without naming the readers, without picking a tone, or without loading simple-english and i-have-adhd. Triggers on "write docs for X", "document this feature", "add a guide", "update the docs", "reorganize the docs", "plan feature X", "implement X", or /docs. --- # docs @@ -11,19 +11,19 @@ Write docs a real person wants to read. Short, plain, built around someone tryin Work in two phases. First plan the story: who reads this, what they want, and how many pages it should be. Then write. -The plan is not private. The user must see the readers and must choose the tone before any page is written. +Do not ask the user to approve the plan or the tone. Pick sane defaults, state them in one line each, and keep going. The user can correct you after. ```text Find docs → read neighbors → Phase 1: readers + pages - → PERSONA GATE (show the list, then stop) - → TONE GATE (ask, then stop) [when writing pages] + → state the readers + pages (one line each, no question) + → state the tone (match neighbors, no question) [when writing pages] → load simple-english + i-have-adhd [when writing pages] → Phase 2: write ``` -A doc-impact list in a feature plan still runs the persona gate. It does not run the tone gate or load the writing skills until pages are actually written. +A doc-impact list in a feature plan still names the reader for each page. It does not pick a tone or load the writing skills until pages are actually written. ## When to run @@ -48,7 +48,7 @@ If either file is missing, stop and tell the user. Do not write the pages withou How they compose: -- This skill owns who the page is for, the page split, problem-then-fix, show-don't-tell, no history leak, forbidden glyphs, and neighbor structure. +- This skill owns who the page is for, the page split, the call-first opener, show-don't-tell, no history leak, forbidden glyphs, and neighbor structure. - `simple-english` owns the sentences. Use pragmatic mode. Run its self-check before you call a page done. - `i-have-adhd` owns the shape of the page: next action first, numbered steps, lists capped at 5 (split must vs later, or split the page), no preamble, a visible win at the end. @@ -59,8 +59,8 @@ When `i-have-adhd` is loaded from this skill, apply it to the pages only. Do not Before writing anything, find where docs live. 1. Look for a docs folder. Check `docs/` first, then `documentation/`, `content/docs/`, `site/`, `website/docs/`. -2. Found nothing? Ask the user where docs should go. Do not guess and do not create a folder on a hunch. -3. Open 2 or 3 existing pages near where the new content belongs. Read them for copy, structure, frontmatter fields, and components. Note the apparent tone as a *candidate* for the tone gate. Do not adopt it yet. +2. Found nothing? Put the page in the README or the closest existing docs file, and say where you put it. Do not create a new docs folder on a hunch. +3. Open 2 or 3 existing pages near where the new content belongs. Read them for copy, structure, frontmatter fields, and components. Note their tone. That is the tone you write in. 4. Note which components the site already uses (steps, tabs, callouts, cards, accordions, code groups, and so on). Different sites have different ones. 5. Reuse those components to tell the story. If the site has a steps component, use it for walkthroughs. If it has tabs, use them for framework variations. If the site has none, use plain markdown. Never invent a component the site does not have. @@ -100,8 +100,6 @@ If there is no page like the one you are about to write, read the closest one yo Matching neighbors is about **copy, structure, frontmatter, and components**: whether headings are sentence case, how much setup a section gets, which components carry the walkthrough, how code samples are introduced, what the frontmatter fields are. -It is **not** a substitute for the tone gate. Neighbors can suggest a default. They cannot answer for the user. - It is **not** permission to copy a neighbor's bad habits. Everything in [Forbidden](#forbidden) and every rule in this skill still applies to the content you write, no matter what the surrounding pages do. An existing page full of em dashes does not license one more. So: never reason "the other pages do it, so I will too" about a rule this skill states. If existing pages break a rule and you think the whole set should be brought in line, that is a separate cleanup to raise with the user, not something to settle by quietly matching the violation. @@ -117,40 +115,11 @@ Do this before you write a word of content. The page split comes out of this step. Do not skip it. -### Gate: show the personas - - -The user must see the list. Listing readers in a thought, a todo, or a buried plan is not this gate. - -After step 4, send a message that contains only: - -1. Each reader, one user story, and the page you will write for them. -2. One question: confirm, drop a reader, or add one. - -If the harness has an AskQuestion (or similar) tool, use it for that question. If it does not, use a numbered list. - -Then pick one path: - -**Default: stop.** This message is the entire turn. End the turn. Do not write files. Do not start Phase 2. Do not say you will proceed unless they object. - -**Skip the wait: continue in this same turn.** Use this path only when one of these is true: +### State the readers -- This conversation already has the user's confirmation of this persona list. -- The user said "use sane defaults", "just write it", or "don't ask questions". Still SHOW the list in this turn, then continue. -- The change is a tiny copy edit: typo, broken link, code-fence language, or a factual fix. No new page, no new section, no rewrite of a journey. +After step 4, put the readers and their pages in your message to the user, one line per reader: the user story and the page. Do not ask a question and do not wait. Continue to Phase 2 in the same turn. -On this path, do not end the turn. After you show the list (or after a tiny copy edit, with no list), continue to the next step. - -These are not skips: - -- "The readers are obvious." -- "The user asked for docs, so they do not want a question." -- "I already named them in the plan." -- "There is only one reader." -- "This is part of implementing a feature, keep going." - -Writing docs is why this gate exists. It is not a reason to skip it. - +The user reads the list and corrects you if a reader is wrong. A list that only lives in your thinking or a todo does not count. Skip the list only for a tiny copy edit (typo, broken link, code-fence language, factual fix). ## Less is more: split, do not cram @@ -173,47 +142,15 @@ Good: a small set of linked pages Each page is short and does one thing. The overview stitches them into a story. -## Gate: ask for tone - -This gate sits between Phase 1 and Phase 2. Run it before you write any page. Do not nest it inside Phase 2. - - -This gate runs before any page content is written. It does not run for a doc-impact list that is only a plan. - -Neighbors can suggest a default. They cannot answer for the user. - -Send a message that contains only: - -1. The tone you inferred from neighboring pages, in one line (how formal, how much setup, second person or not). -2. One question with options: use that tone, more casual, more formal, or the user names another. +## Pick the tone -If the harness has an AskQuestion (or similar) tool, use it. If it does not, use a numbered list. +Use the tone of the neighboring pages. State it in one line (how formal, how much setup, second person or not) and continue. Do not ask. -Then pick one path: - -**Default: stop.** This message is the entire turn. End the turn. Do not write page content. - -**Skip the wait: continue in this same turn.** Use this path only when one of these is true: - -- This conversation already has the user's tone choice, including an explicit "match the existing pages". -- The user said "use sane defaults", "just write it", or "don't ask questions". Still STATE the inferred tone in one line, then continue. -- Tiny copy edit, same carve-out as the persona gate. - -On this path, do not end the turn. After you state the tone (or after a tiny copy edit, with no question), continue to Phase 2. - -These are not skips: - -- "I can tell from the neighbors." -- "The site already has a voice." -- "Tone does not matter for a reference page." -- "I'll match neighbors and mention it later." - +If the user already named a tone in this conversation, use theirs. ## Phase 2: write like a human -Do not write page content until the persona gate has passed, the tone gate has passed, and `simple-english` plus `i-have-adhd` are loaded. - -The tone gate is the heading above this one. Run that gate first. Then load the two skills. Then write. +Do not write page content until you have stated the readers and the tone, and `simple-english` plus `i-have-adhd` are loaded. Legibility is the goal, above everything else. @@ -224,28 +161,29 @@ Legibility is the goal, above everything else. - Second person, action first. Start with what the reader has now and what they will have at the end. No "In this guide we will explore..." openings. Just start. - Shape the page with `i-have-adhd`: the first line is something the reader can do, multi-step work is numbered, a list longer than 5 is split, the page ends on a visible win. -### Lead with the problem, then solve it - -Every page opens with the problem the reader came for, in their own words, before any API. Name the situation they are stuck in. Then say in a sentence or two how the feature solves it. Only after that do you go into the technical parts and the code. +### Open with the call -A reader who sees the problem first knows in seconds whether they are on the right page. A reader who hits an API signature first has to reverse-engineer what it is even for. - -The first line of the how is the next action (from `i-have-adhd`). The problem still comes first so they know they are on the right page. +The first one or two sentences name the call and what the reader gets from it. A reader who lands from search knows in one line whether this page has their answer. ```text Bad: - Call useInterrupts() and pass a resolver. The resolver runs once per - pending item inside a transaction... + Some tool calls shouldn't run without a human saying yes: moving money, + deleting data. An interrupt pauses the run for that decision... Good: - Some tool calls shouldn't run without a human saying yes: moving money, - deleting data. An interrupt pauses the run for that decision, then picks up - where it left off. Here is how to gate a tool behind an approval: + Set `needsApproval: true` on a tool. The run pauses before the tool runs, + and continues after a person approves it. - [code] +Good: + `chat()` returns each token while the model writes the reply. `useChat` + renders those tokens. Call `stop()` to cancel the run. ``` -The order for a guide is problem, one-line fix, then the how (steps, snippets, API). Keep the problem to a couple of sentences, not a background essay. The code shows how it is solved, so do not narrate the solution in prose first. +The opener is the page's only summary. The next line starts the steps or the code. Each idea in the opener is said once on the page, so the paragraph after it adds something new. + +When the reader must know a limit to pick the right page, put it in the opener: "`chat()` with `outputSchema` returns one typed object. It does not stream or run tools." + +The order for a guide is the call, then the steps and code. The code shows how it works, so the prose between snippets stays short. ### Show, do not tell @@ -309,10 +247,9 @@ If a list grows past five items, split it. Put "do now" first. Put the rest on a ### Page shape for a guide -Problem, fix, steps, done. +Call, steps, done. -- Open with the problem the reader has, in their words, and what they will have at the end. -- Say in a sentence how the feature solves it. +- Open with the call and what the reader gets from it, in one or two sentences. - Walk through steps they can follow and test as they go, with the code doing the explaining. - End when they reach the goal. Show what now works. Do not close with a vague "next steps" dump. @@ -397,7 +334,8 @@ Then run the `simple-english` self-check on the same added text. | You catch yourself | Do instead | |---|---| -| Opening with an API signature or config before the problem | Name the problem the reader has first, then the one-line fix, then the code. | +| Opening with a scene, a background story, or "In this guide" | Open with the call and its result in one or two sentences, then the steps. | +| A second paragraph that restates the opener | Cut it. The paragraph after the opener starts the steps or adds a new fact. | | Writing three paragraphs before any code | Cut to one sentence plus a snippet. Show it. | | Writing a sentence that names two or more things and explains each | Make it a bulleted list, one item per bullet. | | A paragraph with commas or semicolons separating a set of options/stores/flags | Same fix: that is a list, format it as one. | @@ -407,9 +345,9 @@ Then run the `simple-english` self-check on the same added text. | Reaching for an em dash | Rewrite with a comma, period, or parentheses. | | "The surrounding pages do X, so I will too" where X breaks a rule here | Match neighbors on structure only. Follow this skill on everything it covers, and raise the existing violations as their own cleanup. | | Using a big word (utilize, leverage, facilitate) | Swap in the plain word. `simple-english` owns this. | -| One page for everyone | Name the readers. Give each a path or a page. Show the list. Stop. | +| One page for everyone | Name the readers. Give each a path or a page. State the list. | | Posting a snippet "they can adapt" | Make it complete and runnable. | -| Guessing where docs go | Find the docs folder, or ask. Read neighbors first. | +| Guessing where docs go | Find the docs folder. Read neighbors first. | | Calling a feature done with no doc change | If behavior changed, docs change too. Write them before you finish. | | Planning a feature without a doc-impact list | Add the list of new and changed docs to the plan. Name the reader for each page. | | "Unlike the old X, this now..." / "we renamed X to Y" | The reader never saw X. Describe only what ships now. | @@ -417,8 +355,5 @@ Then run the `simple-english` self-check on the same added text. | Warning readers not to do a thing they never knew was possible (e.g. "don't pass locks as a store") | Delete it. If they never saw the old arrangement, the warning only confuses. Describe the thing in its own terms. | | Inventing a component the site lacks | Use only components the site already has. | | Stacking `npm` / `pnpm` / `yarn` in one install fence | Use ``. | -| Skipping the persona message because readers are obvious | Show the list. Stop. Obvious is not a skip. | -| Skipping the tone question because neighbors have a voice | Neighbors are the proposed default. Ask. Then stop. | | Writing pages without loading simple-english and i-have-adhd | Load both. If either is missing, stop. | -| Treating "write the docs" as permission to skip the gates | Writing docs is why the gates exist. | -| Listing personas inside a larger plan and continuing | That is not the gate. The gate is a dedicated message that ends the turn. | +| Asking the user to confirm readers or tone, then ending the turn | Do not ask. State them in one line each and keep writing. | diff --git a/CLAUDE.md b/CLAUDE.md index 826f2b6a6e..877e909bfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,7 +172,6 @@ The `@tanstack/ai` package provides core functions: - **`chat()`** - Chat completion with streaming, tools, and agent loops - **`embedding()`** - Generate embeddings - **`summarize()`** - Summarize text -- Legacy adapters (monolithic, deprecated) use `openai()`, `anthropic()`, etc. #### 3. Isomorphic Tool System @@ -228,7 +227,6 @@ Each framework integration uses the headless `ai-client` under the hood. - **`index.ts`** - Exports tree-shakeable adapters (openaiText, openaiEmbed, etc.) - **`adapters/`** - Individual adapter implementations (text.ts, embed.ts, summarize.ts, image.ts) - **`model-meta.ts`** - Model metadata for type safety (provider options per model) -- **`openai-adapter.ts`** - Legacy monolithic adapter (deprecated) ## Development Workflow diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bfe3bdc4aa..10e7f5a23e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -133,7 +133,7 @@ Add tests covering the code you changed, so each metric is back at 60% or more. Two known limitations: - Uncovered `.tsx` files can't be remapped by the coverage provider and are dropped from the report with a `Failed to parse ... Excluding it from coverage` warning. `.tsx` files that tests _do_ load are measured normally, so the UI packages read higher than their real coverage. -- `ai-react-ui`, `ai-solid-ui`, `ai-vue-ui`, `preact-ai-devtools`, `react-ai-devtools`, `solid-ai-devtools`, and `svelte-ai-devtools` have no tests, so they have no `test:coverage` script. The collect step fails when a measured package reports 0 statements. Add the script back when the package gets tests. +- `preact-ai-devtools`, `react-ai-devtools`, `solid-ai-devtools`, and `svelte-ai-devtools` have no tests, so they have no `test:coverage` script. The collect step fails when a measured package reports 0 statements. Add the script back when the package gets tests. A **new package that defines `test:coverage`** shows as `new` and cannot fail the comparison. Packages without that script are not measured. diff --git a/README.md b/README.md index de16e634f7..40f036e06e 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ export async function POST(request: Request) { const stream = chat({ adapter: openaiText('gpt-5.2'), messages: body.messages, + threadId: body.threadId, }) return toServerSentEventsResponse(stream) @@ -203,6 +204,7 @@ const Person = z.object({ const person = await chat({ adapter: openaiText('gpt-5.2'), messages: [{ role: 'user', content: 'Ada Lovelace, 36' }], + threadId: 'thread-1', outputSchema: Person, }) ``` @@ -249,15 +251,15 @@ on. ## Framework Packages -| Package | What it provides | -| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| [`@tanstack/ai-client`](https://tanstack.com/ai/latest/docs/api/ai-client) | Headless chat, realtime, and generation clients | -| [`@tanstack/ai-react`](https://tanstack.com/ai/latest/docs/api/ai-react) | React hooks including `useChat`, `useRealtimeChat`, and generation hooks | -| [`@tanstack/ai-solid`](https://tanstack.com/ai/latest/docs/api/ai-solid) | Solid hooks for chat and generations | -| [`@tanstack/ai-vue`](https://tanstack.com/ai/latest/docs/api/ai-vue) | Vue composables for chat and generations | -| [`@tanstack/ai-svelte`](https://tanstack.com/ai/latest/docs/api/ai-svelte) | Svelte 5 factories for chat and generations | -| [`@tanstack/ai-preact`](https://tanstack.com/ai/latest/docs/api/ai-preact) | Preact hooks for chat | -| `@tanstack/ai-react/ui`, `@tanstack/ai-solid/ui`, `@tanstack/ai-vue/ui`, `@tanstack/ai-svelte/ui` | Headless UI components for chat interfaces. `@tanstack/ai-react-ui`, `@tanstack/ai-solid-ui`, and `@tanstack/ai-vue-ui` are deprecated. | +| Package | What it provides | +| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| [`@tanstack/ai-client`](https://tanstack.com/ai/latest/docs/api/ai-client) | Headless chat, realtime, and generation clients | +| [`@tanstack/ai-react`](https://tanstack.com/ai/latest/docs/api/ai-react) | React hooks including `useChat`, `useRealtimeChat`, and generation hooks | +| [`@tanstack/ai-solid`](https://tanstack.com/ai/latest/docs/api/ai-solid) | Solid hooks for chat and generations | +| [`@tanstack/ai-vue`](https://tanstack.com/ai/latest/docs/api/ai-vue) | Vue composables for chat and generations | +| [`@tanstack/ai-svelte`](https://tanstack.com/ai/latest/docs/api/ai-svelte) | Svelte 5 factories for chat and generations | +| [`@tanstack/ai-preact`](https://tanstack.com/ai/latest/docs/api/ai-preact) | Preact hooks for chat | +| `@tanstack/ai-react/ui`, `@tanstack/ai-solid/ui`, `@tanstack/ai-vue/ui`, `@tanstack/ai-svelte/ui` | Headless UI components for chat interfaces. | ## Advanced Docs diff --git a/codemods/README.md b/codemods/README.md index c207f2ea4c..2a7ac8bec6 100644 --- a/codemods/README.md +++ b/codemods/README.md @@ -2,7 +2,7 @@ [jscodeshift](https://github.com/facebook/jscodeshift) transforms for migrating between API versions of `@tanstack/ai` and friends. -Each codemod lives in its own subdirectory and is named after the migration it covers. Codemods are **opt-in modernizations** — the deprecated APIs they replace continue to work, so you can run them at your own pace. +Each codemod lives in its own subdirectory and is named after the migration it covers. In 1.0 the old names that these codemods replace are removed, so run the codemods as part of the upgrade. ## Available codemods diff --git a/codemods/ag-ui-compliance/README.md b/codemods/ag-ui-compliance/README.md index dbfc27077f..72e46c5a46 100644 --- a/codemods/ag-ui-compliance/README.md +++ b/codemods/ag-ui-compliance/README.md @@ -2,7 +2,7 @@ Migrates client-side TanStack AI code from the legacy field names to the AG-UI–compliant ones introduced in the AG-UI client/server compliance release. -> **Heads up — nothing breaks if you skip this.** The legacy names continue to work via deprecation bridges. Run this codemod when you want to clean up your codebase and remove deprecation warnings. See [`docs/migration/ag-ui-compliance.md`](../../docs/migration/ag-ui-compliance.md) for the full migration story. +> **Heads up: 1.0 removes the legacy names.** Run this codemod before you upgrade to 1.0. See [`docs/migration/v1.md`](../../docs/migration/v1.md) for the full 1.0 upgrade and [`docs/migration/ag-ui-compliance.md`](../../docs/migration/ag-ui-compliance.md) for the wire change. ## What it does @@ -20,7 +20,7 @@ Each rename is gated by an **import-source check** — the codemod only rewrites - **Server-side `body.data.X` rewrites.** Detecting which `body.data.foo` reads belong to a TanStack AI route handler vs. unrelated code requires more context than a syntactic codemod can reliably provide. Migrate these by hand using the recipes in [`docs/migration/ag-ui-compliance.md`](../../docs/migration/ag-ui-compliance.md). - **Re-exports and aliases.** If you re-export `useChat` from a barrel file (`export { useChat } from '@tanstack/ai-react'`), call sites that import the re-export won't be matched. Update the import to come directly from the framework package, or run the codemod against the barrel file too. -- **`chat({ conversationId: })` value-source rewrites.** The codemod renames the property _key_ `conversationId` → `threadId` but does NOT rewrite the value expression. If your server reads from a now-stale source (commonly `body.forwardedProps?.conversationId`, which the upgraded client no longer auto-emits), audit the value after the codemod runs and migrate to `body.threadId` (the AG-UI top-level wire field), `params.threadId` from `chatParamsFromRequest`, or omit `threadId` entirely so the runtime auto-generates one. +- **`chat({ conversationId: })` value-source rewrites.** The codemod renames the property _key_ `conversationId` → `threadId` but does NOT rewrite the value expression. If your server reads from a now-stale source (commonly `body.forwardedProps?.conversationId`, which the upgraded client no longer auto-emits), audit the value after the codemod runs and migrate to `body.threadId` (the AG-UI top-level wire field), or `params.threadId` from `chatParamsFromRequest`. `threadId` is required in 1.0 and nothing generates one, so pass a stable `threadId` for each conversation. ## Running it diff --git a/codemods/ag-ui-compliance/transform.ts b/codemods/ag-ui-compliance/transform.ts index d4924f7a90..cb0ebe9d8a 100644 --- a/codemods/ag-ui-compliance/transform.ts +++ b/codemods/ag-ui-compliance/transform.ts @@ -1,12 +1,12 @@ /** * jscodeshift transform: AG-UI client compliance migration * - * Renames the deprecated client-side fields introduced by the AG-UI - * compliance release of `@tanstack/ai`. Each rename is gated by an + * Renames the client-side fields that the AG-UI compliance release of + * `@tanstack/ai` replaced, and that 1.0 removed. Each rename is gated by an * import-source check so we don't touch unrelated code that happens * to share a property name. * - * Transforms (all opt-in — the deprecated names keep working): + * Transforms (the old names are removed in 1.0): * * 1. `useChat({ body })` → `useChat({ forwardedProps })` * 2. `new ChatClient({ body })` → `new ChatClient({ forwardedProps })` diff --git a/docs/adapters/acp-compatible.md b/docs/adapters/acp-compatible.md index b421de3cde..a930ec8770 100644 --- a/docs/adapters/acp-compatible.md +++ b/docs/adapters/acp-compatible.md @@ -1,7 +1,7 @@ --- title: ACP-Compatible Harness id: acp-compatible-harness -description: "Plug any Agent Client Protocol (ACP) coding agent into a TanStack AI sandbox with one generic harness adapter — no dedicated package required." +description: "Run any coding agent that speaks the Agent Client Protocol (ACP) inside a TanStack AI sandbox, with one generic harness adapter and no dedicated package." keywords: - tanstack ai - acp @@ -12,311 +12,224 @@ keywords: - custom adapter --- -Coding-agent CLIs that speak the [Agent Client Protocol](https://agentclientprotocol.com) (ACP) — `grok`, `gemini --acp`, and others — expose a long-lived JSON-RPC session you can drive from a sandbox. Instead of a dedicated package per agent, `acpCompatible` builds a `chat()` adapter for **any** ACP-compliant CLI: configure how to launch it once, select a model per call, and pass it into a sandbox. +`acpCompatible` builds a `chat()` adapter for an ACP agent CLI that has no `@tanstack/ai-*` package. You pass the start command. The adapter runs the agent in a sandbox. -It is the harness equivalent of the [OpenAI-Compatible adapter](./openai-compatible). Use it when your agent speaks ACP but has no `@tanstack/ai-*` package. If a dedicated harness adapter exists ([Grok Build](./grok-build), and others), prefer it — those carry curated per-model metadata and vendor-specific behavior. +It is the harness version of the [OpenAI-Compatible adapter](./openai-compatible). Does a dedicated harness adapter exist for your agent, such as [Grok Build](./grok-build)? Use that one. It knows the models and the special behavior of its agent. -## Authentication +You need: -The default `authMode` is `'api-key'`. Set `'host'` when the agent should use -a CLI login on the machine. The sandbox type does not pick this. See -[Harness Auth](../sandbox/auth). +- A sandbox provider. The samples use Docker, so Docker must run on your machine. +- An agent CLI that runs an ACP server over stdio, installed inside the sandbox. The samples use the `grok` CLI. +- The API key of that agent, or a CLI login on the machine. -```ts -import { acpCompatibleText } from "@tanstack/ai-acp" - -acpCompatibleText("composer-2.5", { - name: "acp", - command: ({ model }) => `grok agent -m '${model}' --always-approve stdio`, - authMethodId: "xai.api_key", -}) - -acpCompatibleText("composer-2.5", { - name: "acp", - command: ({ model }) => `grok agent -m '${model}' --always-approve stdio`, - authMode: "host", -}) -``` +## 1. Install -- `'api-key'` (default): call `authenticate` with `authMethodId`. -- `'host'`: skip ACP `authenticate`. Use the CLI login on the machine. + -Pass `outputSchema` on the same `chat()` call. `acpCompatible` adds the schema to the prompt and parses the last assistant text. Native harness tools still run on that turn. Read the typed object from `await chat()`, from `useChat().final`, or from the assistant `structured-output` part. See [Harness Agents](../structured-outputs/harnesses). +react: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build +vue: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build +solid: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build +svelte: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build +preact: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build +angular: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build +vanilla: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build +octane: @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-build -## Installation + -`acpCompatible` ships in `@tanstack/ai-acp`. You drive it inside a sandbox, so install the sandbox package and a provider too: +The samples import `GROK_CLI_INSTALL_COMMAND` from `@tanstack/ai-grok-build`, to install the `grok` CLI in the sandbox. For another agent, use its own install command and skip that package. - +## 2. Set the API key -react: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker -vue: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker -solid: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker -svelte: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker -preact: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker -angular: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker -vanilla: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker -octane: @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +Put the key of your agent in the environment of your server. For the `grok` CLI: - +```bash +XAI_API_KEY=xai-... +``` + +The sandbox in step 3 gives this key to the agent as a workspace secret. Then the adapter signs in through ACP `authenticate`, with the auth method that you name in `authMethodId`. For Grok, it is `xai.api_key`. -## Basic Usage +Did the machine already log in to the agent CLI? Set `authMode: 'host'`. The adapter then skips ACP `authenticate`. See [Harness Auth](../sandbox/auth). -Configure the harness once with `acpCompatible({ name, command })`, then select a model per call. `command` builds the shell command that launches the agent's ACP server over **stdio** inside the sandbox: +## 3. Run the agent from the server + +Define the sandbox once. It clones your repo into a Docker container and installs the agent CLI: ```ts -import { chat } from '@tanstack/ai' -import { acpCompatible } from '@tanstack/ai-acp' +// sandbox.ts +import { GROK_CLI_INSTALL_COMMAND } from "@tanstack/ai-grok-build"; import { createSecrets, defineSandbox, defineWorkspace, githubRepo, - withSandbox, -} from '@tanstack/ai-sandbox' -import { dockerSandbox } from '@tanstack/ai-sandbox-docker' -import { messages } from './chat-context' - -// Configure the "pi" agent harness once: -const pi = acpCompatible({ - name: 'pi', - command: ({ model, harnessCwd }) => `pi --acp -m ${model} --cwd ${harnessCwd}`, - authMethodId: 'pi-api-key', // when the harness advertises an ACP auth method - refusalMessage: 'Pi refused the request.', -}) - -const sandbox = defineSandbox({ - id: 'pi-agent', - provider: dockerSandbox({ image: 'node:22' }), - workspace: defineWorkspace({ - source: githubRepo({ repo: 'owner/app' }), - setup: ['npm install -g pi-cli'], // install the agent CLI into the image - secrets: createSecrets({ PI_API_KEY: process.env.PI_API_KEY ?? '' }), - }), -}) - -const stream = chat({ - adapter: pi('pi-fast'), - messages, - middleware: [withSandbox(sandbox)], -}) -``` - -You get the full ACP flow for free: sandbox resolution, `chat()`-tool → MCP bridging, session resume, permission handling, abort, and AG-UI event translation. - -## One-Shot Usage - -For a single model, skip the harness-factory and build the adapter inline with `acpCompatibleText`: - -```ts -import { chat } from '@tanstack/ai' -import { acpCompatibleText } from '@tanstack/ai-acp' -import { withSandbox } from '@tanstack/ai-sandbox' -import { sandbox } from './sandbox' -import { messages } from './chat-context' +} from "@tanstack/ai-sandbox"; +import { dockerSandbox } from "@tanstack/ai-sandbox-docker"; -const stream = chat({ - adapter: acpCompatibleText('pi-fast', { - name: 'pi', - command: ({ model }) => `pi --acp -m ${model}`, +export const sandbox = defineSandbox({ + id: "acp-agent", + provider: dockerSandbox({ image: "node:22" }), + workspace: defineWorkspace({ + source: githubRepo({ repo: "owner/app" }), + setup: [GROK_CLI_INSTALL_COMMAND], + secrets: createSecrets({ + XAI_API_KEY: process.env.XAI_API_KEY ?? "", + }), }), - messages, - middleware: [withSandbox(sandbox)], -}) +}); ``` -## Typed output - -Pass `outputSchema` on the same `chat()` call. The agent runs its native tools. Then the adapter parses the last assistant text as JSON. +Then build the adapter with `acpCompatibleText` and pass it to `chat()`. `command` returns the shell command that starts the ACP server of the agent over stdio: ```ts -import { chat, toServerSentEventsResponse } from '@tanstack/ai' -import { acpCompatibleText } from '@tanstack/ai-acp' -import { withSandbox } from '@tanstack/ai-sandbox' -import { z } from 'zod' -import { sandbox } from './sandbox' -import { messages } from './chat-context' +// api/agent.ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { acpCompatibleText } from "@tanstack/ai-acp"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; -const ReportSchema = z.object({ - name: z.string(), - oneLiner: z.string(), -}) +export async function POST(request: Request) { + const { messages, threadId, runId } = await chatParamsFromRequest(request); -export async function POST() { const stream = chat({ - adapter: acpCompatibleText('pi-fast', { - name: 'pi', - command: ({ model }) => `pi --acp -m ${model}`, + adapter: acpCompatibleText("composer-2.5", { + name: "acp", + command: ({ model }) => `grok agent -m '${model}' --always-approve stdio`, + authMethodId: "xai.api_key", }), messages, - outputSchema: ReportSchema, - stream: true, + threadId, + runId, middleware: [withSandbox(sandbox)], - }) - return toServerSentEventsResponse(stream) + }); + + return toServerSentEventsResponse(stream); } ``` -On the client, walk `messages[].parts` for tool calls and reasoning. Read the typed object from the `structured-output` part or from `useChat().final`. +Replace `owner/app` with your repo. -```tsx -import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' -import { z } from 'zod' +## 4. Render the agent's work -const ReportSchema = z.object({ - name: z.string(), - oneLiner: z.string(), -}) +The client is the same as for any chat. Render the text, and show each tool that the agent ran: -function Report() { - const { messages, final } = useChat({ - connection: fetchServerSentEvents('/api/report'), - outputSchema: ReportSchema, - }) +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; + +export function CodingAgent() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/agent"), + threadId: "support-chat", + }); return ( - <> +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > {messages.map((message) => message.parts.map((part, index) => { - if (part.type === 'tool-call') { - return

{part.name}

+ if (part.type === "text") { + return

{part.content}

; } - if (part.type === 'structured-output') { - const report = part.data ?? part.partial - return report?.name ?

{report.name}

: null + if (part.type === "tool-call") { + return {part.name}; } - return null + return null; }), )} - {final ?

{final.oneLiner}

: null} - - ) + setInput(event.target.value)} /> +
+ ); } ``` -See [Harness Agents](../structured-outputs/harnesses) for the full part list and the repo-report example. +Ask it "Fix the failing test in utils.test.ts". You see the agent's text, plus a line for each tool it used. The agent edited the files inside the container. + +## How it works + +`acpCompatible` gives you the full ACP flow: -## Typed models & options +- It finds the sandbox from `withSandbox(...)` and starts the agent there. +- It bridges your `chat()` tools into the agent over MCP. +- It handles session resume, permission requests, and abort. +- It turns the ACP updates of the agent into `chat()` stream chunks. -Like `openaiCompatible`, you can declare the harness's **models** and its -per-call **options** so the whole thing is type-checked. `models` constrains the -factory's argument; `modelOptions` is a type-only brand (`{} as { … }`, unused at -runtime) describing what `chat({ modelOptions })` accepts. Declared options are -merged with the base ACP options and handed to `command` / `openTransport` as -`ctx.modelOptions`, so you can turn them into CLI flags: +## Configure an agent once + +For more than one model, configure the agent once with `acpCompatible`. You get a factory. Then pick the model on each call: ```ts -import { acpCompatible } from '@tanstack/ai-acp' +// pi-harness.ts +import { acpCompatible } from "@tanstack/ai-acp"; -const pi = acpCompatible({ - name: 'pi', - models: ['pi-fast', 'pi-pro'], - modelOptions: {} as { reasoningEffort?: 'low' | 'high' }, +export const pi = acpCompatible({ + name: "pi", + models: ["pi-fast", "pi-pro"], + modelOptions: {} as { reasoningEffort?: "low" | "high" }, command: ({ model, harnessCwd, modelOptions }) => `pi --acp -m ${model} --cwd ${harnessCwd}` + - (modelOptions?.reasoningEffort ? ` --effort ${modelOptions.reasoningEffort}` : ''), -}) - -pi('pi-pro') // ok -// pi('pi-ultra') // type error — not in `models` + (modelOptions?.reasoningEffort + ? ` --effort ${modelOptions.reasoningEffort}` + : ""), + authMethodId: "pi-api-key", + refusalMessage: "Pi refused the request.", +}); ``` ```ts -import { chat } from '@tanstack/ai' -import { withSandbox } from '@tanstack/ai-sandbox' -import { pi } from './pi-harness' -import { sandbox } from './sandbox' -import { messages } from './chat-context' +import { chat } from "@tanstack/ai"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { pi } from "./pi-harness"; +import { sandbox } from "./sandbox"; const stream = chat({ - adapter: pi('pi-pro'), - modelOptions: { reasoningEffort: 'high' }, // typed against the declared options - messages, + adapter: pi("pi-pro"), + modelOptions: { reasoningEffort: "high" }, + messages: [{ role: "user", content: "Fix the failing test." }], + threadId: "thread-1", middleware: [withSandbox(sandbox)], -}) +}); ``` -The base options are always available on `modelOptions` regardless of what you -declare: `sessionId` (resume), `cwd`, `authMode`, `authMethodId`, and -`permissionMode`. - -## Configuration - -| Field | Purpose | -| --- | --- | -| `name` (required) | Harness label, log prefix, and the `.session-id` CUSTOM event name. | -| `models` | The model ids this harness accepts — declaring them makes `harness('id')` type-safe (unknown ids are rejected). Omit to accept any string. | -| `modelOptions` | Type-only brand for the per-call options accepted via `chat({ modelOptions })`. Declare with `{} as { … }`; merged with the base options and exposed on `ctx.modelOptions` in `command` / `openTransport`. | -| `command` | Build the **stdio** launch command from `{ model, cwd, harnessCwd, sandbox, env, modelOptions, signal }`. Required unless `openTransport` is given. | -| `skillsDir` | The harness's skills directory (relative to the workspace root, e.g. `'.pi/skills'`) — its native convention, like Claude Code's `.claude/skills`. `withSandbox` workspace `gitSkill`s are linked here. Omit and gitSkills are left unlinked (warned). | -| `openTransport` | Open any `AcpSessionTransport` yourself (e.g. boot a `serve` process and connect over WebSocket). Overrides `command`. | -| `cwd` | Working directory inside the sandbox (default `/workspace`). | -| `env` | Extra environment variables for the harness process. | -| `authMode` | `'api-key'` (default) uses `authMethodId`. `'host'` skips ACP `authenticate`. See [Harness Auth](../sandbox/auth). | -| `authMethodId` | ACP auth method to select before the session starts. Ignored when `authMode` is `'host'`. | -| `permissionMode` | `'default'` \| `'acceptEdits'` \| `'bypassPermissions'` (default). | -| `permissions` | `'headless'` (auto-resolve, default) or `'interactive'` (emit approval-requested events for `ask` prompts). | -| `onPermissionRequest` | Custom permission handler; overrides `permissions`/`permissionMode`. | -| `refusalMessage` | `RUN_ERROR` message when the harness refuses a request. | -| `planEventName` | Emit ACP `plan` updates as a CUSTOM event under this name. | -| `emitDiff` | Emit the post-run `git diff` of `cwd` as a `file.changed` CUSTOM event (off by default). | -| `onExtNotification` | Handle vendor `_x/…` JSON-RPC notifications. | -| `buildPrompt` | Override how chat history maps to the harness prompt. | - -This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the harness process sends the model requests. - -## WebSocket and Custom Transports - -Some harnesses run an ACP server you reach over WebSocket rather than stdio (the `grok agent serve` pattern). Open the transport yourself with `openTransport` — it receives the same context and returns an `AcpSessionTransport`. Put all teardown in the returned transport's `dispose`: - -```ts -import { acpCompatible, startAcpServerInSandbox } from '@tanstack/ai-acp' - -const myAgent = acpCompatible({ - name: 'my-agent', - openTransport: async ({ sandbox, model, harnessCwd, signal }) => { - const server = await startAcpServerInSandbox(sandbox, { - port: 9100, - cwd: harnessCwd, - command: `my-agent serve --bind 0.0.0.0:9100 -m ${model}`, - readyMarker: 'listening', - buildWsUrl: ({ channel, port }) => - `${channel.url.replace(/^http/i, 'ws')}:${port}`, - ...(signal ? { signal } : {}), - }) - const ws = await server.connect(signal) - return { - kind: 'stream', - stream: ws.stream, - dispose: async () => { - ws.close() - await server.dispose() - }, - } - }, -}) -``` +- `models` makes the factory type-safe. `pi("pi-ultra")` is a type error. Without `models`, the factory takes any string. +- `modelOptions` is a type-only brand. The value `{} as { ... }` is not used at run time. It types what `chat({ modelOptions })` takes. +- Your declared options join the base options. `command` and `openTransport` get them as `ctx.modelOptions`, so you can turn them into CLI flags. +- The base options are always there: `sessionId`, `cwd`, `authMode`, `authMethodId`, and `permissionMode`. ## Permissions -Inside a sandbox the sandbox itself is the security boundary, so the default `'headless'` strategy with `permissionMode: 'bypassPermissions'` lets the agent edit files and run commands without prompting. To surface tool approvals to a client instead, switch to `'interactive'`: +Inside a sandbox, the sandbox is the security boundary. So by default, the adapter answers each permission request of the agent with `permissionMode: 'bypassPermissions'`. The agent edits files and runs commands with no prompt. + +To send tool approvals to a client, switch to `'interactive'`: ```ts -import { acpCompatible } from '@tanstack/ai-acp' +import { acpCompatible } from "@tanstack/ai-acp"; -const pi = acpCompatible({ - name: 'pi', +export const pi = acpCompatible({ + name: "pi", command: ({ model }) => `pi --acp -m ${model}`, - permissions: 'interactive', // emit approval-requested events for `ask` prompts - permissionMode: 'acceptEdits', // still auto-approve file edits -}) + permissions: "interactive", // send approval-requested events for `ask` prompts + permissionMode: "acceptEdits", // still allow file edits with no prompt +}); ``` -`chat()`-provided tools bridged into the agent are always auto-approved, regardless of mode. +- With `'interactive'`, an `ask` prompt sends an approval-requested event. The client approves, then runs the turn again. +- Your `chat()` tools that the adapter bridges into the agent are always allowed, in every mode. +- To write your own rules, pass `onPermissionRequest`. It overrides `permissions` and `permissionMode`. ## Tool choice -You want the agent to see just one of your tools, or none of them. Pass `toolChoice` to `chat()`. It limits only the tools that the adapter bridges into the agent. ACP has no field to turn off the built-in tools of the agent or to force a tool call, so the agent decides when it calls a tool. +You want the agent to see only one of your tools, or none of them. Pass `toolChoice` to `chat()`. It limits only the tools that the adapter bridges into the agent. ACP has no field to turn off the built-in tools of the agent or to force a tool call. So the agent decides when it calls a tool. | Value | What the adapter does | | --- | --- | @@ -329,84 +242,226 @@ Each value except `'auto'` logs a warning the first time an adapter instance get For the values on other adapters, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool). -## Session Resume +## Session resume -On every run the adapter emits the harness session id as a CUSTOM event named `.session-id` (e.g. `pi.session-id`). Thread that id back through `modelOptions.sessionId` on the next call and the harness resumes the session — only the trailing user message is sent, since the agent already holds the prior context: +On each run, the adapter sends the session id of the agent as a custom event named `.session-id`, for example `pi.session-id`. Send the id back in `modelOptions.sessionId` on the next call, and the agent continues the session: ```ts -import { chat, chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai' -import { withSandbox } from '@tanstack/ai-sandbox' -import { pi } from './pi-harness' // the configured `acpCompatible(...)` factory -import { sandbox } from './sandbox' +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { pi } from "./pi-harness"; +import { sandbox } from "./sandbox"; export async function POST(request: Request) { - const params = await chatParamsFromRequest(request) + const params = await chatParamsFromRequest(request); const sessionId = - typeof params.forwardedProps.sessionId === 'string' + typeof params.forwardedProps.sessionId === "string" ? params.forwardedProps.sessionId - : undefined + : undefined; const stream = chat({ - adapter: pi('pi-fast'), + adapter: pi("pi-fast"), messages: params.messages, + threadId: params.threadId, middleware: [withSandbox(sandbox)], modelOptions: { sessionId }, - }) + }); + + return toServerSentEventsResponse(stream); +} +``` + +On a resumed session, the adapter sends only the last user message. The agent already has the rest. For the client half, see [Resume a session](./claude-code#resume-a-session) on the Claude Code page. Use your `.session-id` as the event name. + +## Typed output + +You want a typed object after the agent works. Pass `outputSchema` on the same `chat()` call. The agent runs its own tools. Then the adapter adds the schema to the prompt and parses the last assistant text as JSON. + +```ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { acpCompatibleText } from "@tanstack/ai-acp"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { z } from "zod"; +import { sandbox } from "./sandbox"; + +const ReportSchema = z.object({ + name: z.string(), + oneLiner: z.string(), +}); + +export async function POST(request: Request) { + const { messages, threadId } = await chatParamsFromRequest(request); + + const stream = chat({ + adapter: acpCompatibleText("pi-fast", { + name: "pi", + command: ({ model }) => `pi --acp -m ${model}`, + }), + messages, + threadId, + outputSchema: ReportSchema, + stream: true, + middleware: [withSandbox(sandbox)], + }); + + return toServerSentEventsResponse(stream); +} +``` + +On the client, walk `messages[].parts` for tool calls and reasoning. Read the typed object from the `structured-output` part or from `useChat().final`: + +```tsx +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; +import { z } from "zod"; - return toServerSentEventsResponse(stream) +const ReportSchema = z.object({ + name: z.string(), + oneLiner: z.string(), +}); + +export function Report() { + const { messages, final } = useChat({ + connection: fetchServerSentEvents("/api/report"), + threadId: "support-chat", + outputSchema: ReportSchema, + }); + + return ( + <> + {messages.map((message) => + message.parts.map((part, index) => { + if (part.type === "tool-call") { + return

{part.name}

; + } + if (part.type === "structured-output") { + const report = part.data ?? part.partial; + return report?.name ?

{report.name}

: null; + } + return null; + }), + )} + {final ?

{final.oneLiner}

: null} + + ); } ``` -## Workspace skills +You can also read the object from `await chat()` when you do not stream. See [Harness Agents](../structured-outputs/harnesses) for the full part list and the repo-report example. + +## Models and options + +`acpCompatible` has no built-in model list. The model is any string that your agent takes, or one of the `models` that you declare. -When you provision a [workspace](../sandbox/workspace) via `withSandbox`, -`acpCompatible` projects its skills into the harness — each kind lands where that -harness expects it: +### Configuration -| Workspace input | How `acpCompatible` projects it | +Pass these to `acpCompatible` or as the second argument of `acpCompatibleText`: + +| Field | What it does | +| --- | --- | +| `name` (required) | The harness label, the log prefix, and the name of the `.session-id` custom event. | +| `command` | Builds the stdio launch command from `{ model, cwd, harnessCwd, sandbox, env, modelOptions, signal }`. Required unless you pass `openTransport`. | +| `openTransport` | Opens any `AcpSessionTransport` yourself, for example a WebSocket. It overrides `command`. See [WebSocket and custom transports](#websocket-and-custom-transports). | +| `models` | The model ids that the harness takes. With this list, unknown ids are a type error. | +| `modelOptions` | Type-only brand for the per-call options of `chat({ modelOptions })`. Declare it with `{} as { ... }`. | +| `cwd` | The working directory inside the sandbox. Default `/workspace`. | +| `env` | More environment variables for the agent process. | +| `authMode` | `'api-key'` (default) uses `authMethodId`. `'host'` skips ACP `authenticate`. See [Harness Auth](../sandbox/auth). | +| `authMethodId` | The ACP auth method to select before the session starts. Ignored when `authMode` is `'host'`. | +| `permissionMode` | `'default'`, `'acceptEdits'`, or `'bypassPermissions'` (default). | +| `permissions` | `'headless'` (default) answers each request. `'interactive'` sends approval-requested events for `ask` prompts. | +| `onPermissionRequest` | Your own permission handler. It overrides `permissions` and `permissionMode`. | + +More options, for special cases: + +| Field | What it does | | --- | --- | -| `mcpSkill(name, config)` | Passed to the agent over **ACP natively** via `newSession`'s `mcpServers` (secrets/bearer headers resolved). No config file — that's the ACP advantage over file-based harnesses. | -| `gitSkill({ repo })` | Cloned during bootstrap, then linked into your declared [`skillsDir`](#configuration) (e.g. `.pi/skills`). Omit `skillsDir` and it's left unlinked (with a warning). | -| `fileSkill({ path, content })` | Written into the workspace root during bootstrap (provider-agnostic). | -| `instructions` | Written to `AGENTS.md` (and symlinks) during bootstrap. | -| `agentSkill(name)`, `plugins` | No generic ACP primitive — warned and skipped. Provide a `gitSkill` or MCP server instead. | +| `skillsDir` | The skills directory of the agent, relative to the workspace root, for example `'.pi/skills'`. Workspace `gitSkill`s are linked here. Without it, they stay unlinked and the adapter logs a warning. | +| `refusalMessage` | The `RUN_ERROR` message when the agent refuses a request. | +| `planEventName` | Send ACP `plan` updates as a custom event with this name. | +| `emitDiff` | Send the `git diff` of `cwd` after the run as a `file.changed` custom event. Off by default. Needs a git repo at `cwd`. | +| `onExtNotification` | Handles vendor `_x/...` JSON-RPC notifications. It must not throw. | +| `buildPrompt` | Changes how the chat history becomes the prompt of the agent. | + +This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the agent process sends the model requests. + +## Advanced + +### WebSocket and custom transports + +Some agents run an ACP server that you reach over WebSocket, not stdio (the `grok agent serve` pattern). Open the transport yourself with `openTransport`. It gets the same context as `command` and returns an `AcpSessionTransport`. Put all teardown in the `dispose` of the transport: -`secrets` declared on the workspace are injected into the agent's environment at -create/resume (never persisted to snapshots), so the harness CLI picks them up -like any env var. +```ts +import { acpCompatible, startAcpServerInSandbox } from "@tanstack/ai-acp"; + +export const myAgent = acpCompatible({ + name: "my-agent", + openTransport: async ({ sandbox, model, harnessCwd, signal }) => { + const server = await startAcpServerInSandbox(sandbox, { + port: 9100, + cwd: harnessCwd, + command: `my-agent serve --bind 0.0.0.0:9100 -m ${model}`, + readyMarker: "listening", + buildWsUrl: ({ channel, port }) => + `${channel.url.replace(/^http/i, "ws")}:${port}`, + ...(signal ? { signal } : {}), + }); + const ws = await server.connect(signal); + return { + kind: "stream", + stream: ws.stream, + dispose: async () => { + ws.close(); + await server.dispose(); + }, + }; + }, +}); +``` + +### Workspace skills + +When you set up a [workspace](../sandbox/workspace) with `withSandbox`, `acpCompatible` puts its skills where the agent expects them: + +| Workspace input | What `acpCompatible` does | +| --- | --- | +| `mcpSkill(name, config)` | Passes the server to the agent over ACP, in the `mcpServers` of `newSession`, with secrets and bearer headers resolved. No config file is needed. | +| `gitSkill({ repo })` | Clones the repo during bootstrap, then links it into your [`skillsDir`](#configuration). Without `skillsDir`, it stays unlinked and the adapter logs a warning. | +| `fileSkill({ path, content })` | Writes the file into the workspace root during bootstrap. | +| `instructions` | Writes them to `AGENTS.md` (and symlinks) during bootstrap. | +| `agentSkill(name)`, `plugins` | Skipped with a warning, because ACP has no generic way to pass them. Use a `gitSkill` or an MCP server. | -## Protocol coverage +The workspace `secrets` go into the environment of the agent on create and on resume. They are never saved to snapshots. The agent CLI reads them like any environment variable. -`acpCompatible` implements the **client / orchestration** side of ACP — enough to -drive an agent through a full prompt turn, not the entire protocol surface. It is -a compliant *minimal* client: everything it doesn't implement is either -capability-gated (so advertising non-support is the spec-defined behavior) or a -rendering choice, never a violation. +### Protocol coverage -**Covered:** +`acpCompatible` implements the client side of ACP: enough to drive an agent through a full prompt turn. It is a minimal client that follows the spec. Each part it does not implement is either gated by a capability, so it advertises no support, or a rendering choice. -- `initialize` handshake — sends `clientInfo` + the protocol version, negotiates the version, advertises capabilities. -- `authenticate` (when the agent advertises auth methods), `session/new`, `session/load` (resume), `session/prompt`, `session/cancel`. -- `session/request_permission` with all four option kinds, mapped by [permission mode](#permissions). -- All streamed `session/update`s that carry turn output: `agent_message_chunk`, `agent_thought_chunk` (→ reasoning), `tool_call` / `tool_call_update`, `plan`. -- All five stop reasons (`end_turn`, `max_tokens`, `max_turn_requests`, `refusal`, `cancelled`). +It covers: -**Surfaced as `CUSTOM` stream events** (the AG-UI chat-event protocol has no -first-class event for non-text assistant output, so these ride on `CUSTOM`): +- The `initialize` handshake. It sends `clientInfo` and the protocol version, agrees on the version, and advertises capabilities. +- `authenticate` (when the agent advertises auth methods), `session/new`, `session/load` (resume), `session/prompt`, and `session/cancel`. +- `session/request_permission` with all four option kinds, mapped by the [permission mode](#permissions). +- The streamed `session/update`s that carry turn output: `agent_message_chunk`, `agent_thought_chunk` (as reasoning), `tool_call`, `tool_call_update`, and `plan`. +- All five stop reasons: `end_turn`, `max_tokens`, `max_turn_requests`, `refusal`, and `cancelled`. -- `.session-id` — the harness session id, for [resume](#session-resume). -- `.message-content` — non-text agent content (`image` / `audio` / `resource` / `resource_link` blocks). Its `value` is `{ content: }`. Non-text **tool** content (diffs, terminal, images) is preserved inside the `TOOL_CALL_RESULT` payload. -- the plan event, when you set `planEventName`. +Some output arrives as `CUSTOM` stream events, because the AG-UI chat events have no event for non-text assistant output: -**Not implemented (by design):** +- `.session-id`: the session id of the agent, for [resume](#session-resume). +- `.message-content`: non-text agent content (`image`, `audio`, `resource`, or `resource_link` blocks). Its `value` is `{ content: }`. Non-text tool content (diffs, terminal output, images) stays inside the `TOOL_CALL_RESULT` payload. +- The plan event, when you set `planEventName`. -- `fs/read_text_file`, `fs/write_text_file`, `terminal/*` — advertised as unsupported. The agent runs inside the sandbox with direct filesystem and shell access, so it never delegates these back to the client. -- Sending **multimodal prompts** — prompts are sent as text. (Agent multimodal *output* is surfaced via `message-content` above.) -- Incremental `usage_update` (final turn usage is reported instead), `available_commands_update`, `current_mode_update`, and experimental features (elicitation, NES, providers, session modes/config). +It does not implement: -## Next Steps +- `fs/read_text_file`, `fs/write_text_file`, and `terminal/*`. It advertises them as unsupported. The agent runs inside the sandbox with direct file and shell access, so it never sends these back to the client. +- Multimodal prompts. Prompts go to the agent as text. Multimodal output of the agent arrives as `message-content`, shown above. +- Incremental `usage_update` (the final usage of the turn arrives instead), `available_commands_update`, `current_mode_update`, and the experimental features (elicitation, NES, providers, session modes and config). -- [Sandbox Overview](../sandbox/overview) — how harnesses run inside a sandbox -- [Grok Build Adapter](./grok-build) — a first-class ACP harness adapter -- [Sandbox Tools](../sandbox/tools) — bridge your app's tools into the agent -- [OpenAI-Compatible Adapter](./openai-compatible) — the same idea for model providers +To find agents, see the [ACP agents list](https://agentclientprotocol.com/get-started/agents). For how harnesses fit with sandboxes, see [Harnesses](../sandbox/harnesses). diff --git a/docs/adapters/anthropic-advanced.md b/docs/adapters/anthropic-advanced.md new file mode 100644 index 0000000000..598b4652ab --- /dev/null +++ b/docs/adapters/anthropic-advanced.md @@ -0,0 +1,560 @@ +--- +title: "Anthropic: Advanced" +id: anthropic-advanced +order: 2 +description: "Advanced Anthropic setup in TanStack AI: Bearer and OAuth tokens, gateways, Claude on Vertex, the max_tokens default, prompt caching, every provider tool, and the API reference." +keywords: + - tanstack ai + - anthropic + - claude + - claude on vertex + - oauth + - prompt caching + - provider tools + - adapter +--- + +This page has the details of the Anthropic adapter. To get a first chat working, start at [Anthropic](./anthropic). + +## Bearer and OAuth tokens + +Got a Bearer token, not an API key? Pass it as the credential and set `auth: "bearer"`. The adapter sends `Authorization: Bearer ` and no `x-api-key` header: + +```typescript +import { chat } from "@tanstack/ai"; +import { createAnthropicChat } from "@tanstack/ai-anthropic"; + +const adapter = createAnthropicChat("claude-sonnet-5-5", process.env.MY_CLAUDE_TOKEN!, { + auth: "bearer", +}); + +const stream = chat({ + adapter, + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", +}); +``` + +`auth` takes one of three values: + +- `"api-key"`: sends the `x-api-key` header. This is the default. +- `"bearer"`: sends `Authorization: Bearer ` and no `x-api-key` header. +- `"oauth"`: sends the token like `"bearer"`, plus the Claude Code extras below. + +A Claude OAuth token contains `sk-ant-oat`. For such a token, the adapter picks `"oauth"`, so you can leave `auth` out. In OAuth mode, every request also gets: + +- the Claude Code identity system block, before your own system prompts. +- the Claude Code CLI headers (`user-agent` and `x-app`). +- the `claude-code-20250219` and `oauth-2025-04-20` betas. + +An explicit `auth` always wins. To send an `sk-ant-oat` token as a plain Bearer token, without the extras, set `auth: "bearer"`. + +Is the token in your environment? Then `anthropicText` finds it and picks `auth` for you. See [Set the API key](./anthropic#2-set-the-api-key) for the order of the variables: + +```typescript +import { chat } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; + +// ANTHROPIC_AUTH_TOKEN=my-token in your environment +const stream = chat({ + adapter: anthropicText("claude-sonnet-5-5"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", +}); +``` + +An `auth` in the config wins over the kind that the adapter picks. To call Claude models through a GitHub Copilot plan, see [GitHub Copilot](./openai-compatible-advanced#claude-models). + +## Configuration + +```typescript +import { createAnthropicChat, type AnthropicTextConfig } from "@tanstack/ai-anthropic"; + +const config: Omit = { + baseURL: "https://api.anthropic.com", // Optional, for custom endpoints +}; + +const adapter = createAnthropicChat("claude-sonnet-5-5", process.env.ANTHROPIC_API_KEY!, config); +``` + +Need a header on each request, or a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). An adapter with your own client (Claude on Vertex, or `createAnthropicChatWithClient`) ignores it, because the adapter cannot reach the fetch of that client. + +## Replay thinking through a gateway + +Your gateway speaks the Anthropic protocol, but it returns thinking with no signature. By default, the adapter does not send unsigned thinking back, so the next turn loses it. Turn on `allowEmptySignature`: + +```typescript +import { anthropicText } from "@tanstack/ai-anthropic"; + +const gateway = anthropicText("claude-sonnet-5-5", { + baseURL: "https://gateway.example.com", + provider: "my-gateway", + allowEmptySignature: true, +}); + +console.log(gateway.provider); // "my-gateway" +``` + +- `allowEmptySignature` is `false` by default. It sends unsigned thinking back only for history from the same source. +- Redacted thinking still needs its encrypted data. +- `provider` names the gateway. History from the gateway then counts as a different source from Anthropic itself. See [Keep saved history when you switch](../advanced/runtime-adapter-switching#keep-saved-history-when-you-switch). + +## Claude on Vertex + +Use `@tanstack/ai-anthropic/vertex` when Claude must run on Vertex AI. That is the path for regional endpoints and Google Cloud credentials. + +Install the Vertex SDK next to the Anthropic adapter: + + + +react: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +vue: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +solid: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +svelte: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +preact: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +angular: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +vanilla: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +octane: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk + + + +```typescript +import { chat } from "@tanstack/ai"; +import { anthropicVertexText } from "@tanstack/ai-anthropic/vertex"; + +const stream = chat({ + adapter: anthropicVertexText("claude-sonnet-5", { + project: "my-project", + location: "europe-west1", + }), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", +}); +``` + +- `anthropicVertexText` accepts only the Claude models in the Vertex catalog (`ANTHROPIC_VERTEX_CHAT_MODELS`). It does not accept ids that only Anthropic has, such as `claude-opus-5-fast`. +- `project` and `location` use the same names as `@tanstack/ai-vertex`, so one auth object works for Gemini and Claude. +- If you omit `project`, Application Default Credentials can still fill it. +- `location` is required. Pass it on the factory, or set `GOOGLE_CLOUD_LOCATION`, `GOOGLE_VERTEX_LOCATION`, or `CLOUD_ML_REGION`. + +Gemini on Vertex is in [`@tanstack/ai-vertex`](./vertex). + +## Custom Anthropic client + +Already have a client that speaks the Anthropic protocol? Use `createAnthropicChatWithClient`. The adapter only needs `beta.messages.create`. Message mapping, streaming, tools, media, usage, and structured output stay on the same TanStack path. + + + +react: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +vue: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +solid: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +svelte: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +preact: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +angular: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +vanilla: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk +octane: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk + + + +```typescript +import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"; +import { createAnthropicChatWithClient } from "@tanstack/ai-anthropic"; + +const client = new AnthropicVertex({ + projectId: "my-project", + region: "europe-west1", +}); + +const adapter = createAnthropicChatWithClient("claude-sonnet-5", client); +``` + +The client must implement the Anthropic Beta Messages protocol. Model and feature support of that endpoint is your job. + +## `max_tokens` default + +The Messages API of Anthropic requires `max_tokens` on every request, so the adapter always sends a value. + +- When you do not set `modelOptions.max_tokens`, it is the full output limit of the model (`max_output_tokens` from the model metadata, for example 64K for Sonnet and 128K for Opus). +- For a model that this package does not know, the adapter uses a safe constant. +- `max_tokens` is a limit, not a reservation. Anthropic bills the tokens that the model makes, so this default costs nothing extra. A low default can cut a response short (`stop_reason: "max_tokens"`). +- Set `max_tokens` only to cap the output below the model limit. +- If a response stops at the default limit, the adapter logs a warning. You see it with [debug logging](../advanced/debug-logging) on. + +A streamed response that stops at `max_tokens` ends in a `RUN_ERROR` with `code: 'max_tokens'`. Anthropic bills the tokens of that call, so this `RUN_ERROR` carries the `usage` of the call. The `onUsage` middleware hook fires only for `RUN_FINISHED`. To count these tokens too, read `usage` from the `RUN_ERROR` chunk, in the stream or in an `onChunk` middleware. + +One exception: structured output (`chat({ outputSchema })`) on models that use the non-streaming finalization path caps this default at about 21K tokens. The Anthropic SDK rejects a non-streaming request whose `max_tokens` can go past its 10-minute timeout. Streaming chat does not have this cap. To raise the structured output limit toward the true maximum of a model, stream the response. + +## Prompt caching + +`chat()` caches Claude prompts by default. It adds `cache_control` markers, at most 4, to: + +- the last start tool. That is the tool before the deferred placeholder, or the last tool. +- the last block of the last user message, or of the last system message in the conversation. +- the system blocks. + +Pass `promptCache: 'long'` for the 1-hour cache, or `promptCache: 'none'` to send no markers. For the key and the cost, see [Prompt Caching](../advanced/prompt-caching). + +To put the markers in other places, set `cache_control` in the `metadata` of a message part, a system prompt, or a tool. One marker of your own turns the automatic markers off for that request: + +```typescript +import { chat } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; + +const stream = chat({ + adapter: anthropicText("claude-opus-5-5"), + messages: [ + { + role: "user", + content: [ + { + type: "text", + content: "What is the capital of France?", + metadata: { + cache_control: { + type: "ephemeral", + }, + }, + }, + ], + }, + ], + threadId: "thread-1", +}); +``` + +- `modelOptions.cache_control` also turns the automatic markers off. +- `usage.promptTokens` counts the full input, cached tokens included. The cache reads and writes are also on `usage.promptTokensDetails`. See [Token usage](../chat/stream-events#token-usage). + +### Tools and prompts added during a conversation + +On `claude-opus-4-8`, `claude-opus-5`, `claude-opus-5-5`, `claude-fable-5`, and `claude-fable-5-1`, a tool or a system prompt that you add between model calls goes into the conversation. The cached start of the request stays the same. + +What the adapter sends on these models: + +- `system` keeps the system prompts of the first call. +- A request with tools gets the `mid-conversation-tool-changes-2026-07-01` beta and one placeholder tool, `__tanstack_deferred_placeholder__`. The model never calls it. +- An added tool goes at the end of `tools` with `defer_loading: true`. A `system` message names it in a `tool_addition` block. +- An added system prompt goes into a `system` message, before the next assistant message or at the end. +- With a provider tool such as `webSearchTool()`, `tools` is the full list and has no placeholder. + +The automatic tool marker stays on the last tool of the first call, so the marked start does not move. + +The channels are on by default with the API of Anthropic. A custom `baseURL`, a custom `fetch`, `ANTHROPIC_BASE_URL`, or your own client turns them off. Set `midConversationChannels` to choose: + +```typescript +import { anthropicText } from "@tanstack/ai-anthropic"; + +export const fullLists = anthropicText("claude-opus-5-5", { + midConversationChannels: false, +}); + +export const throughProxy = anthropicText("claude-opus-5-5", { + baseURL: "https://llm-proxy.example.com", + midConversationChannels: true, +}); +``` + +If that endpoint sends the request and the `anthropic-beta` header on to Anthropic without changes, set `true`. See [Mid-Conversation Changes](../advanced/mid-conversation-changes). + +## Provider tools + +Anthropic has tools of its own, next to the function tools that you define. Import them from `@tanstack/ai-anthropic/tools` and pass them into `chat({ tools: [...] })`. + +For the concept, a comparison table, and type gating, see [Provider Tools](../tools/provider-tools.md). + +### `webSearchTool` + +Claude runs the native web search of Anthropic, with inline citations. + +- Limit the search with `allowed_domains` or `blocked_domains`. You cannot use both. +- Set `max_uses` to cap the cost of each turn. + +```typescript +import { chat } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; +import { webSearchTool } from "@tanstack/ai-anthropic/tools"; + +const stream = chat({ + adapter: anthropicText("claude-opus-5-5"), + messages: [{ role: "user", content: "What's new in AI this week?" }], + threadId: "thread-1", + tools: [ + webSearchTool({ + name: "web_search", + type: "web_search_20250305", + max_uses: 2, + }), + ], +}); +``` + +**Supported models:** every registered Claude model. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `webFetchTool` + +Claude gets the content of one URL. If the model must read a specific page, not run a search, use it. It needs no arguments. Pass a config object to change the defaults. + +```typescript +import { chat } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; +import { webFetchTool } from "@tanstack/ai-anthropic/tools"; + +const stream = chat({ + adapter: anthropicText("claude-sonnet-4-6"), + messages: [{ role: "user", content: "Summarise https://example.com" }], + threadId: "thread-1", + tools: [webFetchTool()], +}); +``` + +**Supported models:** Claude Sonnet 4.x and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `codeExecutionTool` + +Gives Claude a sandbox that runs code. Claude can run Python, look at data, and return the results inline. Pick the version string of the API revision that you want. + +```typescript +import { chat } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; +import { codeExecutionTool } from "@tanstack/ai-anthropic/tools"; + +const stream = chat({ + adapter: anthropicText("claude-sonnet-4-6"), + messages: [{ role: "user", content: "Plot a histogram of [1,2,2,3,3,3]" }], + threadId: "thread-1", + tools: [ + codeExecutionTool({ name: "code_execution", type: "code_execution_20250825" }), + ], +}); +``` + +**Supported models:** Claude Sonnet 4.x and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +#### Attaching hosted skills + +Pass a `skills` array as the second argument to load skill bundles that the provider manages into the sandbox. The adapter moves them into the `container.skills` parameter of the API and adds the required beta headers for you. + +```typescript +import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; +import { codeExecutionTool } from "@tanstack/ai-anthropic/tools"; + +export async function POST(request: Request) { + const { messages, threadId } = await request.json(); + + const stream = chat({ + adapter: anthropicText("claude-sonnet-4-5"), + messages, + threadId, + tools: [ + codeExecutionTool( + { type: "code_execution_20250825", name: "code_execution" }, + { + skills: [{ type: "anthropic", skill_id: "pptx", version: "latest" }], + }, + ), + ], + }); + + return toServerSentEventsResponse(stream); +} +``` + +For the skill shape, the limits, the scope, and the OpenAI equivalent, see [Provider Skills](../tools/provider-skills.md). + +### `computerUseTool` + +Claude can look at a screen and use the mouse and the keyboard. Your app runs each action and sends back a screenshot. `createComputerUse` from `@tanstack/ai-computer-use` does this on a Playwright browser or an E2B desktop. Each thread gets its own machine: + +```typescript +import { chat } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; +import { createComputerUse } from "@tanstack/ai-computer-use"; +import { playwrightComputer } from "@tanstack/ai-computer-playwright"; + +declare const threadId: string; + +const computerUse = createComputerUse({ + driver: playwrightComputer({ startUrl: "https://tanstack.com" }), +}); + +const stream = chat({ + adapter: anthropicText("claude-opus-5-5"), + messages: [{ role: "user", content: "Find the TanStack AI docs" }], + threadId, + tools: computerUse.tools, + middleware: [computerUse.middleware], + interrupts: [computerUse.interrupt], +}); +``` + +The adapter sends the `computer` tool to Claude as the newest native computer tool that your model takes. + +- For the setup, the threads, and how to close the machines, see [Computer use](../computer-use/computer-use.md). +- For your own machine, see [Custom driver](../computer-use/custom-driver.md). + +#### The raw tool + +`computerUseTool` from `@tanstack/ai-anthropic/tools` is the native tool with no `execute`. Use it when your app runs the actions itself. Pick the version that your model takes: + +| Version | Models | +| --- | --- | +| `computer_toolset_20260801` | Claude Opus 5.5, Sonnet 5.5, Haiku 5.5, Fable 5.1, Fable 5, Opus 5, Sonnet 5, Opus 4.8 | +| `computer_20251124` | Claude Fable 5.1, Fable 5, Opus 5, Sonnet 5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 4.6, Opus 4.5 | +| `computer_20250124` | Claude Sonnet 4.5, Haiku 4.5, Opus 4.1 | + +```typescript +import { computerUseTool } from "@tanstack/ai-anthropic/tools"; + +const computer = computerUseTool({ type: "computer_toolset_20260801" }); +``` + +The earlier versions also take a name and the screen size: + +```typescript +import { computerUseTool } from "@tanstack/ai-anthropic/tools"; + +const computer = computerUseTool({ + type: "computer_20251124", + name: "computer", + display_width_px: 1280, + display_height_px: 800, +}); +``` + +Add an `execute` that runs the action and returns a result: + +- Every version calls `execute` with the action in `input.action`. With the toolset, Claude calls each action as its own tool. The adapter gives you each call as one `computer` call. +- Return a screenshot as an image part for the `screenshot` action, and `"OK"` for the other actions. +- Send screenshots that fit the image limits of the model. The toolset refuses a large screenshot. It does not make it smaller. +- The toolset turns on `zoom` by default. Return an image for the `zoom` action, or turn it off with `configs: { zoom: { enabled: false } }`. +- The adapter sends the beta header that an earlier version needs. The toolset needs no beta header. + +Claude gives coordinates in the pixels of your screenshots. + +A version that your model does not take is a type error. `claude-opus-5-fast` takes no provider tools. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `bashTool` + +Gives Claude a bash shell session that stays open. Claude can run commands, install packages, or change files on the host. Pick the type string of your API revision. + +```typescript +import { chat } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; +import { bashTool } from "@tanstack/ai-anthropic/tools"; + +const stream = chat({ + adapter: anthropicText("claude-sonnet-4-6"), + messages: [{ role: "user", content: "List all TypeScript files in src/" }], + threadId: "thread-1", + tools: [bashTool({ name: "bash", type: "bash_20250124" })], +}); +``` + +**Supported models:** Claude Sonnet 3.5 and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `textEditorTool` + +Gives Claude a text editor to view and change files, with the `str_replace`, `create`, `view`, and `undo_edit` commands. Pick the type string of the API revision that you use. + +```typescript +import { chat } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; +import { textEditorTool } from "@tanstack/ai-anthropic/tools"; + +const stream = chat({ + adapter: anthropicText("claude-sonnet-4-6"), + messages: [{ role: "user", content: "Fix the bug in src/index.ts" }], + threadId: "thread-1", + tools: [ + textEditorTool({ type: "text_editor_20250124", name: "str_replace_editor" }), + ], +}); +``` + +**Supported models:** Claude Sonnet 3.5 and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `memoryTool` + +Claude stores and reads facts across turns with the managed memory service of Anthropic. Call it with no arguments to use the default configuration. + +```typescript +import { chat } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; +import { memoryTool } from "@tanstack/ai-anthropic/tools"; + +const stream = chat({ + adapter: anthropicText("claude-sonnet-4-6"), + messages: [{ role: "user", content: "Remember that I prefer metric units" }], + threadId: "thread-1", + tools: [memoryTool()], +}); +``` + +**Supported models:** Claude Sonnet 4.x and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `customTool` + +Makes a tool with an inline schema, without `toolDefinition()`. If you need full control of the schema shape, or want to add `cache_control`, use it. `customTool` returns a plain `Tool`, so every chat model accepts it. + +```typescript +import { chat } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; +import { customTool } from "@tanstack/ai-anthropic/tools"; +import { z } from "zod"; + +const stream = chat({ + adapter: anthropicText("claude-sonnet-4-6"), + messages: [{ role: "user", content: "Look up user 42" }], + threadId: "thread-1", + tools: [ + customTool( + "lookup_user", + "Look up a user by ID and return their profile", + z.object({ userId: z.number() }), + ), + ], +}); +``` + +**Supported models:** all current Claude models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +## API reference + +Each factory pair has the same shape. The short factory (`anthropicText`, `anthropicSummarize`) reads the credential from the [environment](./anthropic#2-set-the-api-key). The `create*` factory takes an explicit credential and never reads one from the environment. Both take `model` as the first argument. + +### `anthropicText(model, config?)` / `createAnthropicChat(model, apiKey, config?)` + +Creates an Anthropic chat adapter. + +- `model`: a Claude model id, for example `"claude-sonnet-5-5"` or `"claude-opus-5-5"`. +- `apiKey`: an API key, a Bearer token, or a Claude OAuth token (`createAnthropicChat` only). +- `config?.baseURL`: a custom base URL. Optional. +- `config?.auth`: how to send the credential: `"api-key"`, `"bearer"`, or `"oauth"`. Optional. See [Bearer and OAuth tokens](#bearer-and-oauth-tokens). +- `config?.allowEmptySignature`: send unsigned thinking back to the same source. Optional, default `false`. See [Replay thinking through a gateway](#replay-thinking-through-a-gateway). +- `config?.provider`: the source name for a gateway. Optional, default `"anthropic"`. + +`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). + +### `anthropicVertexText(model, config?)` + +Creates an Anthropic chat adapter on Vertex. Import it from `@tanstack/ai-anthropic/vertex`. + +- `model`: a Claude model id from the Vertex catalog. +- `config.project`: the GCP project id. Optional if ADC can find it. +- `config.location`: the Vertex region. You can also set `GOOGLE_CLOUD_LOCATION`. + +### `createAnthropicChatWithClient(model, client)` + +Creates an Anthropic chat adapter with a client that you give. + +- `model`: a Claude model id. +- `client`: a client with `beta.messages.create`. + +### `anthropicSummarize(model, config?)` / `createAnthropicSummarize(model, apiKey, config?)` + +Creates an Anthropic summarization adapter. + +### `anthropicFiles` / `createAnthropicFiles` + +Creates a Files API adapter. See [Files API](../advanced/files-api). diff --git a/docs/adapters/anthropic.md b/docs/adapters/anthropic.md index f0bfd88b17..2c944b079d 100644 --- a/docs/adapters/anthropic.md +++ b/docs/adapters/anthropic.md @@ -2,7 +2,7 @@ title: Anthropic id: anthropic-adapter order: 2 -description: "Use Anthropic Claude models with TanStack AI — Claude Fable 5, Claude Sonnet 5, Claude Opus, and more via the @tanstack/ai-anthropic adapter." +description: "Use Anthropic Claude models in TanStack AI with @tanstack/ai-anthropic: Claude Opus, Sonnet, Haiku, and Fable for chat, thinking, prompt caching, and tools." keywords: - tanstack ai - anthropic @@ -14,9 +14,9 @@ keywords: - llm --- -The Anthropic adapter provides access to Claude models, including Claude Fable 5, Claude Sonnet 5, Claude Opus 4.8, and more. +The Anthropic adapter calls Claude for chat. It supports thinking, automatic prompt caching, and tools that Anthropic runs. -## Installation +## 1. Install @@ -31,397 +31,96 @@ octane: @tanstack/ai-anthropic -## Basic Usage +## 2. Set the API key -```typescript -import { chat } from "@tanstack/ai"; -import { anthropicText } from "@tanstack/ai-anthropic"; +Put the key in the environment of your server: -const stream = chat({ - adapter: anthropicText("claude-sonnet-4-6"), - messages: [{ role: "user", content: "Hello!" }], -}); +```bash +ANTHROPIC_API_KEY=sk-ant-... ``` -## Basic Usage - Custom API Key +`anthropicText`, `anthropicSummarize`, and `anthropicFiles` use the first of these variables that is set: -```typescript -import { chat } from "@tanstack/ai"; -import { createAnthropicChat } from "@tanstack/ai-anthropic"; - -const adapter = createAnthropicChat("claude-sonnet-4-6", process.env.ANTHROPIC_API_KEY!, { - // ... your config options -}); - -const stream = chat({ - adapter, - messages: [{ role: "user", content: "Hello!" }], -}); -``` - -## Bearer and OAuth tokens +1. `ANTHROPIC_AUTH_TOKEN`: sent as a Bearer token. An `sk-ant-oat` token here turns on OAuth mode. +2. `ANTHROPIC_OAUTH_TOKEN`: sent as a Bearer token, in OAuth mode. +3. `ANTHROPIC_API_KEY`: sent as `x-api-key`. -Got a Bearer token instead of an API key? Pass it as the credential and set `auth: "bearer"`. The adapter sends `Authorization: Bearer ` and no `x-api-key` header: +If none of them is set, these factories throw. To pass the credential in code, use `createAnthropicChat`. The `create*` factories never read a credential from the environment: ```typescript -import { chat } from "@tanstack/ai"; import { createAnthropicChat } from "@tanstack/ai-anthropic"; -const adapter = createAnthropicChat("claude-sonnet-5-5", process.env.MY_CLAUDE_TOKEN!, { - auth: "bearer", -}); - -const stream = chat({ - adapter, - messages: [{ role: "user", content: "Hello!" }], -}); +const adapter = createAnthropicChat("claude-sonnet-5-5", process.env.ANTHROPIC_API_KEY!); ``` -`auth` takes one of three values: - -- `"api-key"`: sends the `x-api-key` header. This is the default. -- `"bearer"`: sends `Authorization: Bearer ` and no `x-api-key` header. -- `"oauth"`: sends the token like `"bearer"`, plus the Claude Code extras below. - -Got a Claude OAuth token? It contains `sk-ant-oat`, and the adapter picks `"oauth"` for you, so you can leave `auth` out. In OAuth mode, every request also gets: - -- the Claude Code identity system block, before your own system prompts. -- the Claude Code CLI headers (`user-agent` and `x-app`). -- the `claude-code-20250219` and `oauth-2025-04-20` betas. - -An explicit `auth` always wins. To send an `sk-ant-oat` token as a plain Bearer token, without the extras, set `auth: "bearer"`. +Got a Bearer or OAuth token? See [Bearer and OAuth tokens](./anthropic-advanced#bearer-and-oauth-tokens). -Is the token in your environment? Then `anthropicText` finds it and picks `auth` for you. See [Environment Variables](#environment-variables) for the order: +## 3. Stream a chat from the server ```typescript -import { chat } from "@tanstack/ai"; -import { anthropicText } from "@tanstack/ai-anthropic"; - -// ANTHROPIC_AUTH_TOKEN=my-token in your environment -const stream = chat({ - adapter: anthropicText("claude-sonnet-5-5"), - messages: [{ role: "user", content: "Hello!" }], -}); -``` - -To call Claude models through a GitHub Copilot plan, see [GitHub Copilot](./openai-compatible#claude-models). - -## Configuration - -```typescript -import { createAnthropicChat, type AnthropicTextConfig } from "@tanstack/ai-anthropic"; - -const config: Omit = { - baseURL: "https://api.anthropic.com", // Optional, for custom endpoints -}; - -const adapter = createAnthropicChat("claude-sonnet-4-6", process.env.ANTHROPIC_API_KEY!, config); -``` - -Need a header on each request, or a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). An adapter with your own client (Claude on Vertex, or `createAnthropicChatWithClient`) ignores it, because the adapter cannot reach the fetch of that client. - -## Replay thinking through a gateway - -Your gateway speaks the Anthropic protocol, but it returns thinking with no signature. By default the adapter does not send unsigned thinking back, so the next turn loses it. Turn on `allowEmptySignature`: - -```typescript -import { anthropicText } from "@tanstack/ai-anthropic"; - -const gateway = anthropicText("claude-sonnet-5-5", { - baseURL: "https://gateway.example.com", - provider: "my-gateway", - allowEmptySignature: true, -}); - -console.log(gateway.provider); // "my-gateway" -``` - -- `allowEmptySignature` is `false` by default. It sends unsigned thinking back only for history from the same source. -- Redacted thinking still needs its encrypted data. -- `provider` names the gateway. History from the gateway then counts as a different source from Anthropic itself. See [Keep saved history when you switch](../advanced/runtime-adapter-switching#keep-saved-history-when-you-switch). - -## Claude on Vertex - -Use `@tanstack/ai-anthropic/vertex` when Claude must run on Vertex AI. That -is the path for regional endpoints and Google Cloud credentials. - -Install the Vertex SDK next to the Anthropic adapter: - - - -react: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk -vue: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk -solid: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk -svelte: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk -preact: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk -angular: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk -vanilla: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk -octane: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk - - - -```typescript -import { chat } from "@tanstack/ai"; -import { anthropicVertexText } from "@tanstack/ai-anthropic/vertex"; - -const stream = chat({ - adapter: anthropicVertexText("claude-sonnet-5", { - project: "my-project", - location: "europe-west1", - }), - messages: [{ role: "user", content: "Hello!" }], -}); -``` - -`anthropicVertexText` accepts only the Claude models in the Vertex catalog. -It does not accept Anthropic-only ids such as `claude-opus-5-fast`. - -`project` and `location` use the same names as `@tanstack/ai-vertex`, so one -auth object works for Gemini and Claude. - -If you omit `project`, Application Default Credentials can still fill it. -`location` is required. You can pass it on the factory or set -`GOOGLE_CLOUD_LOCATION`, `GOOGLE_VERTEX_LOCATION`, or `CLOUD_ML_REGION`. - -Gemini on Vertex lives in [`@tanstack/ai-vertex`](./vertex). - -## Custom Anthropic client - -Use `createAnthropicChatWithClient` when you already have an -Anthropic-compatible client. The adapter only needs `beta.messages.create`. -Message mapping, streaming, tools, media, usage, and structured output stay -on the same TanStack path. - - - -react: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk -vue: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk -solid: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk -svelte: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk -preact: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk -angular: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk -vanilla: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk -octane: @tanstack/ai-anthropic @anthropic-ai/vertex-sdk - - - -```typescript -import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"; -import { createAnthropicChatWithClient } from "@tanstack/ai-anthropic"; - -const client = new AnthropicVertex({ - projectId: "my-project", - region: "europe-west1", -}); - -const adapter = createAnthropicChatWithClient("claude-sonnet-5", client); -``` - -The injected client must implement the Anthropic Beta Messages protocol. -Endpoint-specific model and feature support stays the caller's job. - -## Example: Chat Completion - -```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; import { anthropicText } from "@tanstack/ai-anthropic"; export async function POST(request: Request) { - const { messages } = await request.json(); + const { messages, threadId, runId } = await chatParamsFromRequest(request); const stream = chat({ - adapter: anthropicText("claude-sonnet-4-6"), + adapter: anthropicText("claude-sonnet-5-5"), messages, + threadId, + runId, }); return toServerSentEventsResponse(stream); } ``` -## Example: With Tools - -```typescript -import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai"; -import { anthropicText } from "@tanstack/ai-anthropic"; -import { z } from "zod"; - -const searchDatabaseDef = toolDefinition({ - name: "search_database", - description: "Search the database", - inputSchema: z.object({ - query: z.string(), - }), -}); +## 4. Render the chat -const searchDatabase = searchDatabaseDef.server(async ({ query }) => { - // Search database - return { results: [] }; -}); +The client is the same for every provider. Point `useChat` at your route: -export async function POST(request: Request) { - const { messages } = await request.json(); +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; - const stream = chat({ - adapter: anthropicText("claude-sonnet-4-6"), - messages, - tools: [searchDatabase], +export function Chat() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", }); - return toServerSentEventsResponse(stream); + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => ( +

+ {message.parts + .map((part) => (part.type === "text" ? part.content : "")) + .join("")} +

+ ))} + setInput(event.target.value)} /> +
+ ); } ``` -## Model Options - -Anthropic supports various provider-specific options. Sampling parameters live here too — `temperature`, `top_p`, and `max_tokens` — rather than as root-level props on `chat()`: - -```typescript -import { chat } from "@tanstack/ai"; -import { anthropicText } from "@tanstack/ai-anthropic"; - -const stream = chat({ - adapter: anthropicText("claude-sonnet-4-6"), - messages: [{ role: "user", content: "Hello!" }], - modelOptions: { - max_tokens: 4096, - temperature: 0.7, - top_p: 0.9, - top_k: 40, - stop_sequences: ["END"], - }, -}); -``` - -> If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, see [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). - -#### `max_tokens` default - -Anthropic's Messages API _requires_ `max_tokens` on every request, so the adapter always sends a value. When you don't set `modelOptions.max_tokens`, it defaults to the selected model's full output ceiling (`max_output_tokens` from the model metadata — e.g. 64K for Sonnet, 128K for Opus), falling back to a safe constant for unrecognized models. `max_tokens` is a ceiling, not a reservation — billing is on tokens actually generated — so this default costs nothing extra and avoids the silent mid-response truncation (`stop_reason: "max_tokens"`) that a low default would cause. Set `max_tokens` explicitly only when you want to _cap_ output below the model ceiling. If a response is truncated while using the default cap, the adapter logs a warning (visible with [debug logging](../advanced/debug-logging) enabled). - -A streamed response that stops at `max_tokens` ends in a `RUN_ERROR` with `code: 'max_tokens'`. Anthropic bills the tokens of that call, so this `RUN_ERROR` carries the `usage` of the call. The `onUsage` middleware hook fires only for `RUN_FINISHED`. To count these tokens too, read `usage` from the `RUN_ERROR` chunk, in the stream or in an `onChunk` middleware. - -One exception: structured output (`chat({ outputSchema })`) on models that use the non-streaming finalization path clamps this default to ~21K tokens. The Anthropic SDK rejects a non-streaming request whose `max_tokens` could exceed its 10-minute timeout, so the full ceiling can't be used there. Streaming chat is unaffected. To raise the structured-output ceiling toward a model's true max, stream the response. - -### Reasoning - -Want Claude to think harder, or not at all? Set `reasoning` on `chat()`. The adapter turns the level into the right thinking fields for the model. - -```typescript -import { chat } from "@tanstack/ai"; -import { anthropicText } from "@tanstack/ai-anthropic"; - -const stream = chat({ - adapter: anthropicText("claude-opus-5-5"), - messages: [{ role: "user", content: "Plan a database migration." }], - reasoning: "xhigh", -}); -``` - -What goes on the wire: - -- `off` sends `thinking: { type: "disabled" }` on the models with `off` in the table below. On a model without `off`, such as `claude-fable-5`, the types reject it, and at run time it moves to the lowest level that the model has. -- Claude 4.6 and later get adaptive thinking and `output_config.effort`. With an `outputSchema`, the adapter adds `output_config.format` next to the effort. -- Budget models get `thinking: { type: "enabled", budget_tokens }`. The level picks the budget, or you set it with `budgetTokens`. The adapter raises `max_tokens` when it is below the budget. -- `summary: false` sends `display: "omitted"`, so the thinking text does not stream. - -The levels of each model: - -| Model | Levels | `budgetTokens` | -| --- | --- | --- | -| `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-fable-5-1`, `claude-fable-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7` | `low` to `max` | no | -| `claude-haiku-5-5`, `claude-sonnet-5` | `off`, `low` to `max` | no | -| `claude-opus-5-fast` | `off`, `low` to `max` | yes | -| `claude-opus-4-6`, `claude-sonnet-4-6` | `low`, `medium`, `high`, `max` | yes | -| `claude-opus-4-5` | `low`, `medium`, `high` | yes | -| `claude-haiku-4-5`, `claude-sonnet-4-5`, `claude-opus-4-1` | `off` to `high` | yes | - -A budget model with `budgetTokens`: - -```typescript -import { chat } from "@tanstack/ai"; -import { anthropicText } from "@tanstack/ai-anthropic"; - -const stream = chat({ - adapter: anthropicText("claude-haiku-4-5"), - messages: [{ role: "user", content: "Check this proof." }], - reasoning: { level: "high", budgetTokens: 8000 }, -}); -``` - -Claude Sonnet 5, Claude Opus 4.7 and later, and the 5-generation models reject the sampling options (`temperature`, `top_p`, `top_k`). The types take them only on the models that accept them. See [Reasoning](../chat/reasoning) for the levels and the nearest-level rule. - -Structured output forces a tool, and Claude cannot think while a tool is forced. So the structured output call sends no thinking. - -### Prompt Caching - -`chat()` caches Claude prompts by default. It adds `cache_control` markers to the last start tool, the last block of the last user or mid-conversation system message, and the system blocks. At most 4 markers. The last start tool is the tool before the deferred placeholder, or the last tool. Pass `promptCache: 'long'` for the 1-hour cache, or `promptCache: 'none'` to send no markers. For the key and the cost, see [Prompt Caching](../advanced/prompt-caching). - -Want the markers in other places? Set `cache_control` in the `metadata` of a message part, a system prompt, or a tool. One marker of your own turns the automatic markers off for that request: - -```typescript -import { chat } from "@tanstack/ai"; -import { anthropicText } from "@tanstack/ai-anthropic"; - -const stream = chat({ - adapter: anthropicText("claude-opus-5-5"), - messages: [ - { - role: "user", - content: [ - { - type: "text", - content: "What is the capital of France?", - metadata: { - cache_control: { - type: "ephemeral", - }, - }, - }, - ], - }, - ], -}); -``` - -`modelOptions.cache_control` also turns the automatic markers off. - -`usage.promptTokens` counts the full input, cached tokens included. The cache reads and writes are also on `usage.promptTokensDetails`. See [Token usage](../chat/stream-events#token-usage). - -#### Tools and prompts added during a conversation - -On `claude-opus-4-8`, `claude-opus-5`, `claude-opus-5-5`, `claude-fable-5`, and `claude-fable-5-1`, a tool or a system prompt that you add between model calls goes into the conversation. The cached start of the request stays the same. - -What the adapter sends on these models: +For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat). -- `system` keeps the system prompts of the first call. -- A request with tools gets the `mid-conversation-tool-changes-2026-07-01` beta and one placeholder tool, `__tanstack_deferred_placeholder__`. The model never calls it. -- An added tool goes at the end of `tools` with `defer_loading: true`. A `system` message names it in a `tool_addition` block. -- An added system prompt goes into a `system` message, before the next assistant message or at the end. -- With a provider tool such as `webSearchTool()`, `tools` is the full list and has no placeholder. - -The automatic tool marker stays on the last tool of the first call, so the marked start does not move. - -The channels are on by default with Anthropic's own API. A custom `baseURL`, a custom `fetch`, `ANTHROPIC_BASE_URL`, or your own client turns them off. Set `midConversationChannels` to choose: - -```typescript -import { anthropicText } from "@tanstack/ai-anthropic"; - -export const fullLists = anthropicText("claude-opus-5-5", { - midConversationChannels: false, -}); - -export const throughProxy = anthropicText("claude-opus-5-5", { - baseURL: "https://llm-proxy.example.com", - midConversationChannels: true, -}); -``` - -If that endpoint sends the request and the `anthropic-beta` header on to Anthropic as they are, set `true`. See [Mid-Conversation Changes](../advanced/mid-conversation-changes). +You now have a streaming Claude chat. [Tools](../tools/tools) and [Structured Outputs](../structured-outputs/overview) work the same as with every adapter. ## Summarization -Anthropic supports text summarization: - ```typescript import { summarize } from "@tanstack/ai"; import { anthropicSummarize } from "@tanstack/ai-anthropic"; @@ -429,6 +128,7 @@ import { anthropicSummarize } from "@tanstack/ai-anthropic"; const result = await summarize({ adapter: anthropicSummarize("claude-sonnet-4-6"), text: "Your long text to summarize...", + threadId: "thread-1", maxLength: 100, style: "concise", // "concise" | "bullet-points" | "paragraph" }); @@ -436,89 +136,17 @@ const result = await summarize({ console.log(result.summary); ``` -## Environment Variables - -Set one of these. `anthropicText`, `anthropicSummarize`, and `anthropicFiles` use the first one that is set: - -1. `ANTHROPIC_AUTH_TOKEN`: sent as a Bearer token. An `sk-ant-oat` token here turns on OAuth mode. -2. `ANTHROPIC_OAUTH_TOKEN`: sent as a Bearer token, in OAuth mode. -3. `ANTHROPIC_API_KEY`: sent as `x-api-key`. - -```bash -ANTHROPIC_API_KEY=sk-ant-... -``` +## Files -If none of them is set, these factories throw. An `auth` in the config wins over the kind that the adapter picks. See [Bearer and OAuth tokens](#bearer-and-oauth-tokens) for what OAuth mode adds. +`anthropicFiles` uploads media to the Anthropic Files API, so a message can point to the file. See [Files API](../advanced/files-api). -The `create*` factories (`createAnthropicChat`, `createAnthropicSummarize`, `createAnthropicFiles`) never read a credential from the environment. They use only the credential that you pass. +## No image generation -## API Reference +Anthropic does not make images. For images, use [OpenAI](./openai) or [Gemini](./gemini). -Every factory pair has the same shape. The short factory (`anthropicText`, `anthropicSummarize`) reads the credential from the [environment](#environment-variables). `createAnthropicChat` / `createAnthropicSummarize` take an explicit credential and never read one from the environment. Both take `model` as the first argument. For Claude on Vertex, use `anthropicVertexText` from `@tanstack/ai-anthropic/vertex`. For any other custom transport, `createAnthropicChatWithClient` accepts an Anthropic-compatible Messages client. +## Provider tools -### `anthropicText(model, config?)` / `createAnthropicChat(model, apiKey, config?)` - -Creates an Anthropic chat adapter. - -`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). - -**Parameters:** - -- `model` - Claude model id (e.g. `"claude-sonnet-5"`, `"claude-fable-5"`, `"claude-opus-4-8"`) -- `apiKey` - API key, Bearer token, or Claude OAuth token (`createAnthropicChat` only) -- `config?.baseURL` - Custom base URL (optional) -- `config?.auth` - How to send the credential: `"api-key"`, `"bearer"`, or `"oauth"` (optional, see [Bearer and OAuth tokens](#bearer-and-oauth-tokens)) -- `config?.allowEmptySignature` - Send unsigned thinking back to the same source (optional, default `false`, see [Replay thinking through a gateway](#replay-thinking-through-a-gateway)) -- `config?.provider` - The source name for a gateway (optional, default `"anthropic"`) - -### `anthropicVertexText(model, config?)` - -Creates an Anthropic chat adapter on Vertex. Import it from -`@tanstack/ai-anthropic/vertex`. - -**Parameters:** - -- `model` - Claude model id -- `config.project` - GCP project id (optional if ADC can resolve it) -- `config.location` - Vertex region (or set `GOOGLE_CLOUD_LOCATION`) - -### `createAnthropicChatWithClient(model, client)` - -Creates an Anthropic chat adapter using an injected client. - -**Parameters:** - -- `model` - Claude model id -- `client` - Client exposing `beta.messages.create` - -### `anthropicSummarize(model, config?)` / `createAnthropicSummarize(model, apiKey, config?)` - -Creates an Anthropic summarization adapter. - -## Limitations - -- **Image Generation**: Anthropic does not support image generation. Use OpenAI or Gemini for image generation. - -## Next Steps - -- [Getting Started](../getting-started/quick-start) - Learn the basics -- [Tools Guide](../tools/tools) - Learn about tools -- [Other Adapters](./openai) - Explore other providers - -## Provider Tools - -Anthropic exposes several native tools beyond user-defined function calls. -Import them from `@tanstack/ai-anthropic/tools` and pass them into -`chat({ tools: [...] })`. - -> For the full concept, a comparison matrix, and type-gating details, see -> [Provider Tools](../tools/provider-tools.md). - -### `webSearchTool` - -Enables Claude to run Anthropic's native web search with inline citations. -Scope the search with `allowed_domains` or `blocked_domains` (mutually -exclusive); set `max_uses` to cap per-turn cost. +Anthropic runs some tools itself, such as web search and a code sandbox. Import them from `@tanstack/ai-anthropic/tools` and add them to `tools`: ```typescript import { chat } from "@tanstack/ai"; @@ -526,8 +154,9 @@ import { anthropicText } from "@tanstack/ai-anthropic"; import { webSearchTool } from "@tanstack/ai-anthropic/tools"; const stream = chat({ - adapter: anthropicText("claude-opus-4-7"), + adapter: anthropicText("claude-sonnet-5-5"), messages: [{ role: "user", content: "What's new in AI this week?" }], + threadId: "thread-1", tools: [ webSearchTool({ name: "web_search", @@ -538,242 +167,117 @@ const stream = chat({ }); ``` -**Supported models:** every registered Claude model. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). +Tools that Anthropic runs: -### `webFetchTool` +- `webSearchTool`: search the web, with citations. +- `webFetchTool`: read one URL. +- `codeExecutionTool`: run code in a sandbox, also with hosted skills. +- `memoryTool`: keep facts across turns. -Lets Claude fetch the contents of a URL directly, useful when you want the -model to read a specific page rather than run a search. Takes no required -arguments — pass an optional config object to override defaults. +Tools that your app runs when Claude asks: -```typescript -import { chat } from "@tanstack/ai"; -import { anthropicText } from "@tanstack/ai-anthropic"; -import { webFetchTool } from "@tanstack/ai-anthropic/tools"; +- `bashTool`: run shell commands. +- `textEditorTool`: view and change files. +- `computerUseTool`: control a virtual desktop. +- `customTool`: a tool with an inline schema. -const stream = chat({ - adapter: anthropicText("claude-sonnet-4-6"), - messages: [{ role: "user", content: "Summarise https://example.com" }], - tools: [webFetchTool()], -}); -``` +Each tool, with a full sample, is on [Anthropic: Advanced](./anthropic-advanced#provider-tools). For which models take which tool, see [Provider Tools](../tools/provider-tools.md). -**Supported models:** Claude Sonnet 4.x and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). +## Models and options -### `codeExecutionTool` +### Model options -Gives Claude a sandboxed code-execution environment so it can run Python -snippets, analyse data, and return results inline. Choose the version string -that matches your desired API revision. +Provider options go in `modelOptions`, with the Anthropic names: ```typescript import { chat } from "@tanstack/ai"; import { anthropicText } from "@tanstack/ai-anthropic"; -import { codeExecutionTool } from "@tanstack/ai-anthropic/tools"; const stream = chat({ - adapter: anthropicText("claude-sonnet-4-6"), - messages: [{ role: "user", content: "Plot a histogram of [1,2,2,3,3,3]" }], - tools: [ - codeExecutionTool({ name: "code_execution", type: "code_execution_20250825" }), - ], + adapter: anthropicText("claude-sonnet-5-5"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + modelOptions: { + max_tokens: 4096, + stop_sequences: ["END"], + }, }); ``` -**Supported models:** Claude Sonnet 4.x and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -#### Attaching hosted skills +- The sampling options `temperature`, `top_p`, and `top_k` also go in `modelOptions`. Claude Sonnet 5, Claude Opus 4.7 and later, and the 5-generation models reject them. The types take them only on the models that accept them, for example `claude-sonnet-4-6`. +- When you do not set `max_tokens`, the adapter sends the full output limit of the model. See [The `max_tokens` default](./anthropic-advanced#max_tokens-default). +- Sampling options do not go at the root of `chat()`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). -Pass a `skills` array as the second argument to load provider-managed skill -bundles into the sandbox. The adapter auto-lifts them into the API's -`container.skills` param and adds the required beta headers for you. - -```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; -import { anthropicText } from "@tanstack/ai-anthropic"; -import { codeExecutionTool } from "@tanstack/ai-anthropic/tools"; - -export async function POST(request: Request) { - const { messages } = await request.json(); - - const stream = chat({ - adapter: anthropicText("claude-sonnet-4-5"), - messages, - tools: [ - codeExecutionTool( - { type: "code_execution_20250825", name: "code_execution" }, - { - skills: [{ type: "anthropic", skill_id: "pptx", version: "latest" }], - }, - ), - ], - }); - - return toServerSentEventsResponse(stream); -} -``` - -For the full reference — skill shape, constraints, scope, and the OpenAI -equivalent — see [Provider Skills](../tools/provider-skills.md). - -### `computerUseTool` +### Reasoning -Claude can look at a screen and use the mouse and the keyboard. Your app runs each action and sends back a screenshot. `createComputerUse` from `@tanstack/ai-computer-use` does this on a Playwright browser or an E2B desktop. Each thread gets its own machine: +Want Claude to think harder, or not at all? Set `reasoning` on `chat()`. The adapter turns the level into the right thinking fields for the model. ```typescript import { chat } from "@tanstack/ai"; import { anthropicText } from "@tanstack/ai-anthropic"; -import { createComputerUse } from "@tanstack/ai-computer-use"; -import { playwrightComputer } from "@tanstack/ai-computer-playwright"; - -declare const threadId: string; - -const computerUse = createComputerUse({ - driver: playwrightComputer({ startUrl: "https://tanstack.com" }), -}); const stream = chat({ adapter: anthropicText("claude-opus-5-5"), - messages: [{ role: "user", content: "Find the TanStack AI docs" }], - threadId, - tools: computerUse.tools, - middleware: [computerUse.middleware], - interrupts: [computerUse.interrupt], -}); -``` - -The adapter sends the `computer` tool to Claude as the newest native computer tool that your model takes. - -- For the setup, the threads, and how to close the machines, see [Computer use](../computer-use/computer-use.md). -- For your own machine, see [Custom driver](../computer-use/custom-driver.md). - -#### The raw tool - -`computerUseTool` from `@tanstack/ai-anthropic/tools` is the native tool with no `execute`. Use it when your app runs the actions itself. Pick the version that your model takes: - -| Version | Models | -| --- | --- | -| `computer_toolset_20260801` | Claude Opus 5.5, Sonnet 5.5, Haiku 5.5, Fable 5.1, Fable 5, Opus 5, Sonnet 5, Opus 4.8 | -| `computer_20251124` | Claude Fable 5.1, Fable 5, Opus 5, Sonnet 5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 4.6, Opus 4.5 | -| `computer_20250124` | Claude Sonnet 4.5, Haiku 4.5, Opus 4.1 | - -```typescript -import { computerUseTool } from "@tanstack/ai-anthropic/tools"; - -const computer = computerUseTool({ type: "computer_toolset_20260801" }); -``` - -The earlier versions also take a name and the screen size: - -```typescript -import { computerUseTool } from "@tanstack/ai-anthropic/tools"; - -const computer = computerUseTool({ - type: "computer_20251124", - name: "computer", - display_width_px: 1280, - display_height_px: 800, + messages: [{ role: "user", content: "Plan a database migration." }], + threadId: "thread-1", + reasoning: "xhigh", }); ``` -Add an `execute` that runs the action and returns a result: - -- Every version calls `execute` with the action in `input.action`. With the toolset, Claude calls each action as its own tool. The adapter gives you each call as one `computer` call. -- Return a screenshot as an image part for the `screenshot` action, and `"OK"` for the other actions. -- Send screenshots that fit the image limits of the model. The toolset refuses a large screenshot. It does not make it smaller. -- The toolset turns on `zoom` by default. Return an image for the `zoom` action, or turn it off with `configs: { zoom: { enabled: false } }`. -- The adapter sends the beta header that an earlier version needs. The toolset needs no beta header. - -Claude gives coordinates in the pixels of your screenshots. - -A version that your model does not take is a type error. `claude-opus-5-fast` takes no provider tools. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `bashTool` - -Provides Claude with a persistent bash shell session, letting it run arbitrary -commands, install packages, or manipulate files on the host. Choose the type -string that matches your API revision. +What goes to Anthropic: -```typescript -import { chat } from "@tanstack/ai"; -import { anthropicText } from "@tanstack/ai-anthropic"; -import { bashTool } from "@tanstack/ai-anthropic/tools"; - -const stream = chat({ - adapter: anthropicText("claude-sonnet-4-6"), - messages: [{ role: "user", content: "List all TypeScript files in src/" }], - tools: [bashTool({ name: "bash", type: "bash_20250124" })], -}); -``` +- `off` sends `thinking: { type: "disabled" }` on the models with `off` in the table below. On a model without `off`, such as `claude-fable-5`, the types reject it. At run time, it moves to the lowest level that the model has. +- Claude 4.6 and later get adaptive thinking and `output_config.effort`. With an `outputSchema`, the adapter adds `output_config.format` next to the effort. +- Budget models get `thinking: { type: "enabled", budget_tokens }`. The level picks the budget, or you set it with `budgetTokens`. If `max_tokens` is below the budget, the adapter raises it. +- `summary: false` sends `display: "omitted"`, so the thinking text does not stream. -**Supported models:** Claude Sonnet 3.5 and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). +The levels of each model: -### `textEditorTool` +| Model | Levels | `budgetTokens` | +| --- | --- | --- | +| `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-fable-5-1`, `claude-fable-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7` | `low` to `max` | no | +| `claude-haiku-5-5`, `claude-sonnet-5` | `off`, `low` to `max` | no | +| `claude-opus-5-fast` | `off`, `low` to `max` | yes | +| `claude-opus-4-6`, `claude-sonnet-4-6` | `low`, `medium`, `high`, `max` | yes | +| `claude-opus-4-5` | `low`, `medium`, `high` | yes | +| `claude-haiku-4-5`, `claude-sonnet-4-5`, `claude-opus-4-1` | `off` to `high` | yes | -Gives Claude a structured text-editor interface for viewing and modifying files -using `str_replace`, `create`, `view`, and `undo_edit` commands. Choose the -type string for the API revision you target. +A budget model with `budgetTokens`: ```typescript import { chat } from "@tanstack/ai"; import { anthropicText } from "@tanstack/ai-anthropic"; -import { textEditorTool } from "@tanstack/ai-anthropic/tools"; const stream = chat({ - adapter: anthropicText("claude-sonnet-4-6"), - messages: [{ role: "user", content: "Fix the bug in src/index.ts" }], - tools: [ - textEditorTool({ type: "text_editor_20250124", name: "str_replace_editor" }), - ], + adapter: anthropicText("claude-haiku-4-5"), + messages: [{ role: "user", content: "Check this proof." }], + threadId: "thread-1", + reasoning: { level: "high", budgetTokens: 8000 }, }); ``` -**Supported models:** Claude Sonnet 3.5 and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `memoryTool` - -Enables Claude to store and retrieve information across conversation turns -using Anthropic's managed memory service. Call with no arguments to use -default configuration. +Structured output forces a tool, and Claude cannot think while a tool is forced. So the structured output call sends no thinking. See [Reasoning](../chat/reasoning) for the levels and the nearest-level rule. -```typescript -import { chat } from "@tanstack/ai"; -import { anthropicText } from "@tanstack/ai-anthropic"; -import { memoryTool } from "@tanstack/ai-anthropic/tools"; +### Models -const stream = chat({ - adapter: anthropicText("claude-sonnet-4-6"), - messages: [{ role: "user", content: "Remember that I prefer metric units" }], - tools: [memoryTool()], -}); -``` +Newest first. The package exports the full list as `ANTHROPIC_MODELS`. -**Supported models:** Claude Sonnet 4.x and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). +| Family | Models | +| --- | --- | +| Claude 5.5 | `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5` | +| Claude 5 | `claude-fable-5-1`, `claude-fable-5`, `claude-opus-5`, `claude-opus-5-fast`, `claude-sonnet-5` | +| Claude 4 | `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-sonnet-4-5`, `claude-haiku-4-5`, `claude-opus-4-1` | -### `customTool` +`adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). -Creates a tool with an inline JSON Schema input definition instead of going -through `toolDefinition()`. Useful when you need fine-grained control over the -schema shape or want to add `cache_control`. Unlike branded provider tools, -`customTool` returns a plain `Tool` and is accepted by any chat model. +## Advanced -```typescript -import { chat } from "@tanstack/ai"; -import { anthropicText } from "@tanstack/ai-anthropic"; -import { customTool } from "@tanstack/ai-anthropic/tools"; -import { z } from "zod"; +These topics are on [Anthropic: Advanced](./anthropic-advanced): -const stream = chat({ - adapter: anthropicText("claude-sonnet-4-6"), - messages: [{ role: "user", content: "Look up user 42" }], - tools: [ - customTool( - "lookup_user", - "Look up a user by ID and return their profile", - z.object({ userId: z.number() }), - ), - ], -}); -``` +- Bearer and OAuth tokens, gateways, and your own Anthropic client. +- Claude on Vertex AI. +- The `max_tokens` default and prompt caching. +- Every provider tool, with a full sample. +- The API reference. -**Supported models:** all current Claude models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). +To call Claude through a GitHub Copilot plan, see [GitHub Copilot](./openai-compatible-advanced#claude-models). diff --git a/docs/adapters/bedrock.md b/docs/adapters/bedrock.md index 6f1ab9e57a..3edacf2446 100644 --- a/docs/adapters/bedrock.md +++ b/docs/adapters/bedrock.md @@ -2,7 +2,7 @@ title: Amazon Bedrock id: bedrock-adapter order: 7 -description: "Use Amazon Bedrock with TanStack AI — the Converse API is the default, reaching Claude, Nova, Llama, Mistral, DeepSeek, and more. Opt into OpenAI-compatible Chat Completions or Responses for open-weight and gpt-oss models. Supports streaming, tools, reasoning, and API-key or SigV4 auth." +description: "Use Amazon Bedrock in TanStack AI with @tanstack/ai-bedrock: Claude, Nova, Llama, Mistral, DeepSeek, and gpt-oss through the Converse API or the OpenAI-compatible APIs, with API-key or SigV4 auth." keywords: - tanstack ai - amazon bedrock @@ -19,18 +19,9 @@ keywords: - adapter --- -The Bedrock adapter connects TanStack AI to [Amazon Bedrock](https://aws.amazon.com/bedrock/) with three API paths: +The Bedrock adapter calls Claude, Nova, Llama, Mistral, DeepSeek, and gpt-oss in your AWS account. It supports streaming, tools, reasoning, and embeddings. -- **Converse** (default) — Bedrock's model-agnostic API built on `@aws-sdk/client-bedrock-runtime`. Reaches the broad chat catalog including Anthropic Claude, Amazon Nova, Meta Llama, Mistral, DeepSeek, Cohere, AI21, and OpenAI gpt-oss models. -- **Chat Completions** (`api: 'chat'`) — Bedrock's OpenAI-compatible Chat Completions endpoint. Reaches open-weight models only (gpt-oss, DeepSeek V3.x, Gemma, Qwen, Mistral open models, GLM, etc.). Does NOT reach Claude, Nova, or Llama. -- **Responses** (`api: 'responses'`) — Bedrock's OpenAI-compatible Responses API, mantle-only. Currently gpt-oss and Gemma 4. - -All paths support streaming and client-side tool calling. Reasoning output is -surfaced when the model emits it (e.g. DeepSeek R1, gpt-oss); on the Converse -path, request-side enablement of extended thinking (e.g. a Claude thinking -budget) is not yet wired, so only models that reason by default surface it. - -## Installation +## 1. Install @@ -45,206 +36,352 @@ octane: @tanstack/ai-bedrock -No additional packages are required. SigV4 authentication is handled by `@aws-sdk/client-bedrock-runtime`, which is a direct dependency. +You need no other package. `@aws-sdk/client-bedrock-runtime` is a direct dependency, and it does the SigV4 signing. -## Quick Start (Converse — default) +## 2. Set the credentials -The default `bedrockText` call uses the Converse API and reaches the broad model catalog: +Bedrock takes an API key or your AWS credentials. Pick one. -```typescript ignore -// ignore: iterating a chat() stream and reading chunk.type/chunk.delta needs the -// AG-UI base event fields, which come from @ag-ui/core. It's a transitive dep of -// @tanstack/ai, so kiira (resolving @tanstack/ai from source under the dist->src -// heuristic) can't follow it and those base fields drop off StreamChunk. The code -// is correct (the same pattern is used throughout ai-client); see -// getting-started/quick-start-server for the type-checked consumption shape. -import { bedrockText } from '@tanstack/ai-bedrock' -import { chat } from '@tanstack/ai' +**API key.** Create a key in the AWS Console. See the [Bedrock API keys guide](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html). Then set one of these variables: -const adapter = bedrockText('us.anthropic.claude-haiku-4-5-20251001-v1:0', { - region: 'us-east-1', -}) +```bash +BEDROCK_API_KEY=your-bedrock-api-key +# or the legacy name: +AWS_BEARER_TOKEN_BEDROCK=your-bedrock-api-key +``` -for await (const chunk of chat({ - adapter, - messages: [{ role: 'user', content: 'What is the capital of France?' }], -})) { - if (chunk.type === 'TEXT_MESSAGE_CONTENT') process.stdout.write(chunk.delta ?? '') +**SigV4 (AWS credential chain).** Use this for IAM roles, instance profiles, or `~/.aws/credentials`. With no API key in the environment, the adapter uses SigV4: + +```bash +AWS_ACCESS_KEY_ID=... +AWS_SECRET_ACCESS_KEY=... +AWS_SESSION_TOKEN=... # optional, for temporary credentials +``` + +With `auth: 'auto'` (the default), the adapter uses the first of these that it finds: + +1. An explicit `apiKey` passed to the factory. +2. The `BEDROCK_API_KEY` environment variable. +3. The `AWS_BEARER_TOKEN_BEDROCK` environment variable. +4. SigV4 through the standard AWS credential chain. + +Also enable access to the model in the [Amazon Bedrock console](https://console.aws.amazon.com/bedrock/home#/modelaccess). The models that you can use depend on your account and your region. + +## 3. Stream a chat from the server + +```typescript +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { bedrockText } from "@tanstack/ai-bedrock"; + +export async function POST(request: Request) { + const { messages, threadId, runId } = await chatParamsFromRequest(request); + + const stream = chat({ + adapter: bedrockText("us.anthropic.claude-sonnet-4-5-20250929-v1:0", { + region: "us-east-1", + }), + messages, + threadId, + runId, + }); + + return toServerSentEventsResponse(stream); } ``` -Equivalent to passing `{ api: 'converse' }` explicitly. Returns a `bedrock-converse` adapter. +`bedrockText` uses the Converse API by default. Converse reaches the full chat catalog of Bedrock. For the other two APIs, see [Choose the API](#choose-the-api). + +## 4. Render the chat + +The client is the same for every provider. Point `useChat` at your route: + +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; + +export function Chat() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", + }); + + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => ( +

+ {message.parts + .map((part) => (part.type === "text" ? part.content : "")) + .join("")} +

+ ))} + setInput(event.target.value)} /> +
+ ); +} +``` -## Authentication +For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat). -Bedrock supports two authentication modes. +You now have a streaming chat on Bedrock. Client-side [tools](../tools/tools) work on all three APIs. Structured output and multimodal input (text, images, and documents) depend on the model. -### API Key +## Embeddings -Bedrock issues API keys from the AWS Console. See the [Bedrock API keys guide](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html) for instructions. +`bedrockEmbedding` calls the Titan and Cohere embedding models through `InvokeModel`: -Set one of the following environment variables and the adapter picks it up automatically: +```typescript +import { embed } from "@tanstack/ai"; +import { bedrockEmbedding } from "@tanstack/ai-bedrock"; -```bash -BEDROCK_API_KEY=your-bedrock-api-key -# or the legacy name: -AWS_BEARER_TOKEN_BEDROCK=your-bedrock-api-key +const result = await embed({ + adapter: bedrockEmbedding("amazon.titan-embed-text-v2:0"), + input: ["a red guitar", "a blue drum kit"], + dimensions: 512, // 256 | 512 | 1024 +}); + +console.log(result.embeddings[0]?.vector); ``` -### SigV4 (AWS credential chain) +Titan Multimodal embeds text and images, alone or joined into one vector. To join parts, nest them in an array (the same `Array` shape as the `content` of a chat message). The outer array is the list of items, so this embeds one joined item: + +```typescript +import { embed } from "@tanstack/ai"; +import { bedrockEmbedding } from "@tanstack/ai-bedrock"; -For workloads using IAM roles, instance profiles, or `~/.aws/credentials`, set `auth: 'sigv4'` (or leave it as `'auto'` with no API key in the environment). SigV4 works out of the box via `@aws-sdk/client-bedrock-runtime` — no additional packages required. +const productPhoto = "iVBORw0KGgo..."; // base64 image data -```bash -AWS_ACCESS_KEY_ID=... -AWS_SECRET_ACCESS_KEY=... -AWS_SESSION_TOKEN=... # optional, for temporary credentials +const result = await embed({ + adapter: bedrockEmbedding("amazon.titan-embed-image-v1"), + input: [ + [ + { type: "text", content: "a red guitar" }, + { + type: "image", + source: { + type: "data", + value: productPhoto, + mimeType: "image/png", + }, + }, + ], + ], + dimensions: 1024, // 256 | 384 | 1024 +}); +``` + +Cohere Embed v3 on Bedrock is text only. It sends items in batches and requires `inputType`: + +```typescript +import { embed } from "@tanstack/ai"; +import { bedrockEmbedding } from "@tanstack/ai-bedrock"; + +const result = await embed({ + adapter: bedrockEmbedding("cohere.embed-english-v3"), + input: ["a red guitar", "a blue drum kit"], + modelOptions: { inputType: "search_document" }, +}); ``` -### Auth resolution order (`auth: 'auto'`, the default) +- Titan models have no batch API. A batch of N items runs as N `InvokeModel` calls, with a small limit on calls at the same time. +- Titan Multimodal does not fetch remote image URLs. Pass base64 data (or a `data:` URI). -1. Explicit `apiKey` passed to the factory -2. `BEDROCK_API_KEY` environment variable -3. `AWS_BEARER_TOKEN_BEDROCK` environment variable -4. SigV4 via the standard AWS credential chain +See the [Embeddings guide](../embeddings.md). -## Configuration +## Models and options -`BedrockClientConfig` accepts the following options: +### Choose the API -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `api` | `'converse' \| 'chat' \| 'responses'` | `'converse'` | Bedrock API to use | -| `region` | `string` | `'us-east-1'` | AWS region string (e.g. `'us-west-2'`) | -| `auth` | `'apikey' \| 'sigv4' \| 'auto'` | `'auto'` | Authentication mode | -| `apiKey` | `string` | — | Explicit API key (overrides env vars) | -| `baseURL` | `string` | — | Override the computed base URL entirely | -| `defaultHeaders` | `Record` | none | Headers sent with every request | -| `endpoint` | `'runtime' \| 'mantle'` | `'runtime'` | Bedrock endpoint to target (Chat Completions path only) | +Bedrock has three chat APIs. Set `api` on the factory: + +| `api` | Adapter name | Models | SDK | +|---|---|---|---| +| `'converse'` (default) | `bedrock-converse` | The full catalog: Claude, Nova, Llama, Mistral, DeepSeek, Cohere, AI21, gpt-oss, and more | `@aws-sdk/client-bedrock-runtime` | +| `'chat'` | `bedrock` | Open-weight models only: gpt-oss, DeepSeek V3.x, Gemma, Qwen, Mistral open models, GLM, and similar. No Claude, Nova, or Llama | `openai` (OpenAI-compatible Chat Completions) | +| `'responses'` | `bedrock-responses` | gpt-oss and Gemma 4. Mantle only | `openai` (OpenAI-compatible Responses) | -The `endpoint` option only applies when `api: 'chat'`. The `runtime` endpoint (`bedrock-runtime`) hosts the broad open-weight catalog; `mantle` is an alternative. On mantle, the adapter reads the path from the Bedrock API compatibility config (`/openai/v1` for Gemma 4, `/v1` for other models). An explicit `baseURL` still wins. The Responses API always targets mantle. +Use Converse unless you need the OpenAI wire format. For which models work with which API, see the [AWS API compatibility matrix](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html). Samples for `'chat'` and `'responses'` are in [Advanced](#chat-completions-api). -## Behind a proxy +### Reasoning -Route every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them. +Set `reasoning` on `chat()`. What goes to Bedrock depends on the API: + +- Converse (the default): only Claude gets thinking. A budget model gets `thinking` with a budget in `additionalModelRequestFields`, and `maxTokens` grows past the budget. `off` sends nothing. +- Chat Completions (`api: 'chat'`): `reasoning_effort`. +- Responses (`api: 'responses'`): `reasoning.effort`. ```typescript -import { createBedrockText } from "@tanstack/ai-bedrock"; +import { chat } from "@tanstack/ai"; +import { bedrockText } from "@tanstack/ai-bedrock"; -const adapter = createBedrockText("us.amazon.nova-pro-v1:0", process.env.BEDROCK_API_KEY!, { - baseURL: "https://gateway.example.com/aws-bedrock", - defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` }, +const stream = chat({ + adapter: bedrockText("us.anthropic.claude-haiku-4-5-20251001-v1:0", { + region: "us-east-1", + }), + messages: [{ role: "user", content: "Check this proof." }], + threadId: "thread-1", + reasoning: { level: "high", budgetTokens: 8000 }, }); ``` -All three Bedrock APIs accept these options. On the Converse API the headers are added before SigV4 signing, so a signed request still includes them. +The levels of each model: -Need more than a fixed header, such as a log of each model call? The Chat Completions API (`api: 'chat'`) and the Responses API (`api: 'responses'`) support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). The Converse API ignores it, because the AWS SDK does not send its requests through `fetch`. +- `us.anthropic.claude-haiku-4-5-*`, `us.anthropic.claude-sonnet-4-5-*`: `off`, `high`, with `budgetTokens`. +- `openai.gpt-oss-120b-1:0`, `openai.gpt-oss-20b-1:0`: `low`, `medium`, `high`. +- The `google.gemma-4` models: `off`, `high`. -## Converse API (default) +The other models take no `reasoning`. `us.deepseek.r1-v1:0` always thinks, and has no levels. When a model thinks by default, such as DeepSeek R1 or gpt-oss, the adapter streams its thinking. -`bedrockText(model)` or `bedrockText(model, { api: 'converse' })` returns a `bedrock-converse` adapter backed by `@aws-sdk/client-bedrock-runtime`. This is Bedrock's model-agnostic conversational API and is the recommended path for most use cases. +On Converse, structured output forces a tool, and Claude cannot think while a tool is forced. So the structured output call sends no thinking. -**Model scope:** Anthropic Claude, Amazon Nova, Meta Llama, Mistral, DeepSeek, Cohere, AI21, OpenAI gpt-oss, and other models accessible in your account. See [Model availability](#model-availability) below. +### Models -```typescript -import { bedrockText } from '@tanstack/ai-bedrock' -import { chat } from '@tanstack/ai' +The package ships a catalog of confirmed model ids (`src/model-catalog.generated.ts`). The maintainer script `scripts/fetch-bedrock-models.ts` refreshes it with `ListFoundationModels`. -// Claude via Converse -const claudeAdapter = bedrockText('us.anthropic.claude-haiku-4-5-20251001-v1:0', { - region: 'us-east-1', -}) +| Group | Model ids | +| --- | --- | +| Anthropic Claude | `us.anthropic.claude-sonnet-4-5-20250929-v1:0`, `us.anthropic.claude-haiku-4-5-20251001-v1:0`, `us.anthropic.claude-3-7-sonnet-20250219-v1:0`, `us.anthropic.claude-3-5-sonnet-20241022-v2:0`, `us.anthropic.claude-3-5-haiku-20241022-v1:0` | +| Amazon Nova | `us.amazon.nova-pro-v1:0`, `us.amazon.nova-lite-v1:0`, `us.amazon.nova-micro-v1:0` | +| Meta Llama | `us.meta.llama4-maverick-17b-instruct-v1:0`, `us.meta.llama3-3-70b-instruct-v1:0` | +| Other chat | `openai.gpt-oss-120b-1:0`, `openai.gpt-oss-20b-1:0`, `us.mistral.pixtral-large-2502-v1:0`, `us.deepseek.r1-v1:0`, `google.gemma-4-31b`, `google.gemma-4-26b-a4b`, `google.gemma-4-e2b` | +| Embeddings | `amazon.titan-embed-text-v2:0`, `amazon.titan-embed-image-v1`, `cohere.embed-english-v3`, `cohere.embed-multilingual-v3` | -// Amazon Nova via Converse -const novaAdapter = bedrockText('us.amazon.nova-pro-v1:0', { - region: 'us-east-1', -}) +`adapter.inputModalities` lists the input kinds of a model in the catalog. If this package does not know the model id, `bedrockText` and `createBedrockText` throw. `createBedrockChat`, `createBedrockResponsesText`, and `createBedrockConverse` return `undefined` for that id. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). -// Meta Llama via Converse -const llamaAdapter = bedrockText('us.meta.llama4-maverick-17b-instruct-v1:0', { - region: 'us-east-1', -}) -``` +## Advanced + +### Configuration -### Explicit API key (Converse) +`BedrockClientConfig` takes these options: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `api` | `'converse' \| 'chat' \| 'responses'` | `'converse'` | The Bedrock API to use | +| `region` | `string` | `'us-east-1'` | AWS region (for example `'us-west-2'`) | +| `auth` | `'apikey' \| 'sigv4' \| 'auto'` | `'auto'` | Authentication mode | +| `apiKey` | `string` | none | Explicit API key (wins over the environment variables) | +| `baseURL` | `string` | none | Replaces the computed base URL | +| `defaultHeaders` | `Record` | none | Headers sent with every request | +| `endpoint` | `'runtime' \| 'mantle'` | `'runtime'` | The Bedrock endpoint (Chat Completions only) | + +About `endpoint`: + +- It applies only when `api: 'chat'`. +- `runtime` (`bedrock-runtime`) hosts the broad open-weight catalog. `mantle` is the other choice. +- On mantle, the adapter reads the path from the Bedrock API compatibility config: `/openai/v1` for Gemma 4, `/v1` for other models. An explicit `baseURL` still wins. +- The Responses API always uses mantle. + +With an explicit API key: ```typescript -import { createBedrockText } from '@tanstack/ai-bedrock' +import { createBedrockText } from "@tanstack/ai-bedrock"; const adapter = createBedrockText( - 'us.anthropic.claude-haiku-4-5-20251001-v1:0', - 'your-bedrock-api-key', - { region: 'us-west-2' }, -) + "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "your-bedrock-api-key", + { region: "us-west-2" }, +); +``` + +### Behind a proxy + +Send every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them. + +```typescript +import { createBedrockText } from "@tanstack/ai-bedrock"; + +const adapter = createBedrockText("us.amazon.nova-pro-v1:0", process.env.BEDROCK_API_KEY!, { + baseURL: "https://gateway.example.com/aws-bedrock", + defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` }, +}); ``` +- All three Bedrock APIs accept these options. On the Converse API, the adapter adds the headers before SigV4 signing, so a signed request still includes them. +- Need more than a fixed header, such as a log of each model call? The Chat Completions API (`api: 'chat'`) and the Responses API (`api: 'responses'`) support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). The Converse API ignores it, because the AWS SDK does not send its requests through `fetch`. + ### Prompt caching `chat()` adds cache points for Claude models by default: one after the system prompt, and one at the end of the last user message. Pass `promptCache: 'long'` for the 1-hour cache, or `promptCache: 'none'` to turn it off. Other models get no automatic cache point. For the cost, see [Prompt Caching](../advanced/prompt-caching). -To pick the places yourself, add a `cachePoint`. One cache point of your own turns the automatic ones off for that request. Later requests can read matching tokens at the reduced cache rate. Bedrock bills cache misses at the standard input rate. +To pick the places yourself, add a `cachePoint`. One cache point of your own turns the automatic ones off for that request. Later requests can read matching tokens at the lower cache rate. Bedrock bills cache misses at the standard input rate. -Explicit prompt caching is model-dependent. Use `cachePoint` only with a model that AWS lists as supporting it. The minimum checkpoint size and the TTL options also vary by model. +Explicit prompt caching depends on the model. Use `cachePoint` only with a model that AWS lists as supporting it. The minimum checkpoint size and the TTL options also change by model. -Set `metadata.cachePoint` on the item that ends the stable part of your prompt. The adapter places a `cachePoint` block right after it: +Set `metadata.cachePoint` on the item that ends the stable part of your prompt. The adapter puts a `cachePoint` block right after it: ```typescript -import { bedrockText } from '@tanstack/ai-bedrock' -import { chat } from '@tanstack/ai' +import { bedrockText } from "@tanstack/ai-bedrock"; +import { chat } from "@tanstack/ai"; const stream = chat({ - adapter: bedrockText('us.anthropic.claude-sonnet-4-5-20250929-v1:0', { - region: 'us-east-1', + adapter: bedrockText("us.anthropic.claude-sonnet-4-5-20250929-v1:0", { + region: "us-east-1", }), systemPrompts: [ { - content: 'Long, stable instructions...', - metadata: { cachePoint: { type: 'default' } }, + content: "Long, stable instructions...", + metadata: { cachePoint: { type: "default" } }, }, ], messages: [ // An older turn. Its cachePoint was removed when the new turn was added. - { role: 'user', content: 'Summarize the report.' }, - { role: 'assistant', content: 'The report says...' }, + { role: "user", content: "Summarize the report." }, + { role: "assistant", content: "The report says..." }, { - role: 'user', + role: "user", content: [ { - type: 'text', - content: 'What changed since yesterday?', + type: "text", + content: "What changed since yesterday?", // Only the newest message has a checkpoint. // It caches the conversation up to here for the next round. - metadata: { cachePoint: { type: 'default' } }, + metadata: { cachePoint: { type: "default" } }, }, ], }, ], -}) + threadId: "thread-1", +}); ``` Keep one message checkpoint, on the newest message. Before you send the next turn, remove `cachePoint` from the older message and add it to the new message. The metadata stays in the message history. If you do not remove it, the checkpoints add up, and the request can go over the Bedrock limit of four. -Tools take the same metadata. Pass `metadata: { cachePoint: { type: 'default' } }` to `toolDefinition()` for the last tool in the list to cache the tool definitions. +Tools take the same metadata. To cache the tool definitions, pass `metadata: { cachePoint: { type: 'default' } }` to `toolDefinition()` for the last tool in the list. -- Bedrock accepts up to four checkpoints per request. -- Add `ttl: '1h'` to keep an entry for one hour. The default is 5 minutes, and a read refreshes the timer. Bedrock processes tool checkpoints first, then system prompt checkpoints, and then message checkpoints. If a request mixes both TTLs, place every `1h` checkpoint before the first `5m` checkpoint in that combined order. -- A checkpoint below the model's minimum size is ignored. The request still succeeds, and nothing is cached. +- Bedrock accepts up to four checkpoints in one request. +- Add `ttl: '1h'` to keep an entry for one hour. The default is 5 minutes, and a read starts the timer again. +- Bedrock reads tool checkpoints first, then system prompt checkpoints, then message checkpoints. If a request mixes both TTLs, put every `1h` checkpoint before the first `5m` checkpoint in that order. +- Bedrock ignores a checkpoint below the minimum size of the model. The request still succeeds, and nothing goes into the cache. ### Token usage -`onUsage` and `RUN_FINISHED.usage` report Bedrock's counts as `promptTokens`, `completionTokens`, and `totalTokens`. `promptTokens` is the full input, cached tokens included. When a request hits or writes a prompt cache, the cache parts are also on `promptTokensDetails.cachedTokens` and `promptTokensDetails.cacheWriteTokens`. To read these counts, see [Token usage](../chat/stream-events#token-usage). +`onUsage` and `RUN_FINISHED.usage` report the counts of Bedrock as `promptTokens`, `completionTokens`, and `totalTokens`. -## Chat Completions API (`api: 'chat'`) +- `promptTokens` is the full input, cached tokens included. +- When a request reads or writes a prompt cache, the cache parts are also on `promptTokensDetails.cachedTokens` and `promptTokensDetails.cacheWriteTokens`. -Set `api: 'chat'` to use Bedrock's OpenAI-compatible Chat Completions endpoint. Returns a `bedrock` adapter. +To read these counts, see [Token usage](../chat/stream-events#token-usage). -**Model scope:** Open-weight models only — gpt-oss, DeepSeek V3.x, Gemma, Qwen, Mistral open models, GLM, and similar. Claude, Nova, and Llama are NOT available on this endpoint. See the [AWS API compatibility matrix](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html) for the current list. +### Chat Completions API + +Set `api: 'chat'` to use the OpenAI-compatible Chat Completions endpoint of Bedrock. It returns a `bedrock` adapter. Only open-weight models are on this endpoint. ```typescript ignore -// ignore: see the Converse quick-start above — iterating a chat() stream and -// reading chunk.type/chunk.delta needs @ag-ui/core base fields kiira can't -// resolve transitively through @tanstack/ai source. +// ignore: iterating a chat() stream and reading chunk.type/chunk.delta needs the +// AG-UI base event fields, which come from @ag-ui/core. It's a transitive dep of +// @tanstack/ai, so kiira (resolving @tanstack/ai from source under the dist->src +// heuristic) can't follow it and those base fields drop off StreamChunk. The code +// is correct (the same pattern is used throughout ai-client); see +// getting-started/quick-start-server for the type-checked consumption shape. import { bedrockText } from '@tanstack/ai-bedrock' import { chat } from '@tanstack/ai' @@ -256,21 +393,21 @@ const adapter = bedrockText('openai.gpt-oss-20b-1:0', { for await (const chunk of chat({ adapter, messages: [{ role: 'user', content: 'What is the capital of France?' }], + threadId: 'thread-1', })) { if (chunk.type === 'TEXT_MESSAGE_CONTENT') process.stdout.write(chunk.delta ?? '') } ``` -## Responses API (`api: 'responses'`) - -Set `api: 'responses'` to use Bedrock's OpenAI-compatible Responses API. Returns a `bedrock-responses` adapter. This API is mantle-only. +### Responses API -**Model scope:** Currently gpt-oss and Gemma 4. The Responses API is stateful — pass `previous_response_id` and `store` through `modelOptions` to continue a conversation server-side. +Set `api: 'responses'` to use the OpenAI-compatible Responses API of Bedrock. It returns a `bedrock-responses` adapter. This API is mantle only. -**Reasoning:** Reasoning deltas stream as thinking content. Mantle-served models may still emit the pre-July-2025 event name `response.reasoning.delta` (removed from the OpenAI spec in favor of `response.reasoning_text.delta`); the adapter recognizes both and maps them identically. +- The Responses API is stateful. To continue a conversation on the server, pass `previous_response_id` and `store` in `modelOptions`. +- Reasoning deltas stream as thinking content. Models on mantle can still send the old event name `response.reasoning.delta`, which the OpenAI spec replaced with `response.reasoning_text.delta`. The adapter reads both names the same way. ```typescript ignore -// ignore: see the Converse quick-start above — iterating a chat() stream and +// ignore: see the Chat Completions sample above. Iterating a chat() stream and // reading chunk.type/chunk.delta needs @ag-ui/core base fields kiira can't // resolve transitively through @tanstack/ai source. import { bedrockText } from '@tanstack/ai-bedrock' @@ -284,154 +421,24 @@ const adapter = bedrockText('openai.gpt-oss-120b-1:0', { for await (const chunk of chat({ adapter, messages: [{ role: 'user', content: 'Summarize the Bedrock pricing page.' }], + threadId: 'thread-1', })) { if (chunk.type === 'TEXT_MESSAGE_CONTENT') process.stdout.write(chunk.delta ?? '') } ``` -## Reasoning - -Set `reasoning` on `chat()`. What goes on the wire depends on the API: - -- Converse (the default): only Claude gets thinking. A budget model gets `thinking` with a budget in `additionalModelRequestFields`, and `maxTokens` grows past the budget. `off` sends nothing. -- Chat Completions (`api: 'chat'`): `reasoning_effort`. -- Responses (`api: 'responses'`): `reasoning.effort`. - -```typescript -import { chat } from "@tanstack/ai"; -import { bedrockText } from "@tanstack/ai-bedrock"; - -const stream = chat({ - adapter: bedrockText("us.anthropic.claude-haiku-4-5-20251001-v1:0", { - region: "us-east-1", - }), - messages: [{ role: "user", content: "Check this proof." }], - reasoning: { level: "high", budgetTokens: 8000 }, -}); -``` - -The levels of each model: - -- `us.anthropic.claude-haiku-4-5-*`, `us.anthropic.claude-sonnet-4-5-*`: `off`, `high`, with `budgetTokens`. -- `openai.gpt-oss-120b-1:0`, `openai.gpt-oss-20b-1:0`: `low`, `medium`, `high`. -- The `google.gemma-4` models: `off`, `high`. - -The other models take no `reasoning`. `us.deepseek.r1-v1:0` always thinks, and has no levels. - -On Converse, structured output forces a tool, and Claude cannot think while a tool is forced. So the structured output call sends no thinking. - -## Embeddings - -Generate embedding vectors with Titan or Cohere embedding models via `InvokeModel`: - -```typescript -import { embed } from "@tanstack/ai"; -import { bedrockEmbedding } from "@tanstack/ai-bedrock"; - -const result = await embed({ - adapter: bedrockEmbedding("amazon.titan-embed-text-v2:0"), - input: ["a red guitar", "a blue drum kit"], - dimensions: 512, // 256 | 512 | 1024 -}); - -console.log(result.embeddings[0]?.vector); -``` - -Titan Multimodal embeds text and images — alone or fused into a single vector. Fuse parts by nesting them in an array (the same `Array` shape a chat message's `content` uses); the outer array is the item list, so this embeds one fused item: - -```typescript -import { embed } from "@tanstack/ai"; -import { bedrockEmbedding } from "@tanstack/ai-bedrock"; - -const productPhoto = "iVBORw0KGgo..."; // base64 image data - -const result = await embed({ - adapter: bedrockEmbedding("amazon.titan-embed-image-v1"), - input: [ - [ - { type: "text", content: "a red guitar" }, - { - type: "image", - source: { - type: "data", - value: productPhoto, - mimeType: "image/png", - }, - }, - ], - ], - dimensions: 1024, // 256 | 384 | 1024 -}); -``` - -Cohere Embed v3 on Bedrock is also supported (text-only, batched, requires `inputType`): - -```typescript -import { embed } from "@tanstack/ai"; -import { bedrockEmbedding } from "@tanstack/ai-bedrock"; - -const result = await embed({ - adapter: bedrockEmbedding("cohere.embed-english-v3"), - input: ["a red guitar", "a blue drum kit"], - modelOptions: { inputType: "search_document" }, -}); -``` - -> Titan models have no batch API — a batch of N items runs as N `InvokeModel` -> calls under a small concurrency cap. Titan Multimodal does not fetch remote -> image URLs; pass base64 data (or a `data:` URI). - -See the [Embeddings guide](../embeddings.md) for the full API. - -## Model Availability - -The adapter ships with a hand-seeded snapshot catalog (`src/model-catalog.generated.ts`) of confirmed model IDs. This catalog can be refreshed by the maintainer script `scripts/fetch-bedrock-models.ts`, which calls `ListFoundationModels` with AWS credentials. - -**Actual model availability depends on your AWS account's model access configuration and the region you are targeting.** Enable model access in the [Amazon Bedrock console](https://console.aws.amazon.com/bedrock/home#/modelaccess) before use. - -For the full list of models and which API endpoints they support, see the [AWS API compatibility matrix](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html). - -## Supported Capabilities - -- Streaming chat completions -- Client-side tool calling -- Reasoning output (extended thinking surfaced when the model emits it; request-side enablement on Converse is not yet wired) -- Multimodal input (text, images, documents — model-dependent) -- JSON schema / structured output - -## API Reference - -### `bedrockText(model, config?)` - -Creates a Bedrock adapter using environment-variable auth. - -`adapter.inputModalities` lists the input kinds of a model in the catalog. If this package does not know the model id, `bedrockText` and `createBedrockText` throw. `createBedrockChat`, `createBedrockResponsesText`, and `createBedrockConverse` return `undefined` for that id. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). - -- `model` — Model ID (e.g. `'us.anthropic.claude-haiku-4-5-20251001-v1:0'`) -- `config.api` — `'converse'` (default), `'chat'`, or `'responses'` -- `config.region` — AWS region string (default `'us-east-1'`) -- `config.auth` — `'auto'` (default), `'apikey'`, or `'sigv4'` -- `config.apiKey` — Explicit API key (overrides env vars) -- `config.baseURL` — Override base URL -- `config.endpoint` — `'runtime'` (default) or `'mantle'` (Chat Completions path only) - -Returns a chat adapter for use with `chat()` or `generate()`. - -| `api` value | Adapter name | Underlying SDK | -|---|---|---| -| `'converse'` (default) | `bedrock-converse` | `@aws-sdk/client-bedrock-runtime` | -| `'chat'` | `bedrock` | `openai` (OpenAI-compatible) | -| `'responses'` | `bedrock-responses` | `openai` (OpenAI-compatible) | +### API reference -### `createBedrockText(model, apiKey, config?)` +| Factory | Creates | +| --- | --- | +| `bedrockText(model, config?)` | Chat adapter with auth from the environment. Returns the adapter of the `api` that you pick | +| `createBedrockText(model, apiKey, config?)` | Chat adapter with an explicit API key. Skips the environment lookup | +| `bedrockEmbedding(model, config?)` / `createBedrockEmbedding(model, apiKey, config?)` | Embedding adapter for Titan and Cohere models | -Creates a Bedrock adapter with an explicit API key, bypassing the environment-variable lookup. +`config` takes `api`, `region`, `auth`, `apiKey`, `baseURL`, `defaultHeaders`, and `endpoint`. See [Configuration](#configuration). -## Next Steps +More from AWS: -- [Amazon Bedrock API keys](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html) — Create and manage API keys -- [Amazon Bedrock model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) — Enable models in your account -- [AWS API compatibility matrix](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html) — Which models work with which APIs -- [Converse API reference](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html) — Native Converse API docs -- [Streaming Guide](../chat/streaming) — Learn about streaming responses -- [Tools Guide](../tools/tools) — Learn about tool calling +- [Amazon Bedrock API keys](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html): create and manage API keys. +- [Amazon Bedrock model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html): enable models in your account. +- [Converse API reference](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html): the native Converse API docs. diff --git a/docs/adapters/byteplus-advanced.md b/docs/adapters/byteplus-advanced.md new file mode 100644 index 0000000000..3eee7bc5f7 --- /dev/null +++ b/docs/adapters/byteplus-advanced.md @@ -0,0 +1,421 @@ +--- +title: "BytePlus: Advanced" +id: byteplus-advanced +order: 8 +description: "Advanced BytePlus setup in TanStack AI: regions, reasoning and encrypted_content, structured output, Seedance options per model, Seedream reference images, Seed Speech details, and the API reference." +keywords: + - tanstack ai + - byteplus + - modelark + - seedance + - seedream + - seed speech + - encrypted_content + - adapter +--- + +This page has the details of the BytePlus adapter. To get a first chat working, start at [BytePlus](./byteplus). + +## Regions + +Each Ark key works in one region only. A key from one region does not authenticate in another region. The default base URL is the Asia-Pacific south-east endpoint, `https://ark.ap-southeast.bytepluses.com/api/v3`. To use another region, set `baseURL`: + +```typescript +import { createBytePlusText } from "@tanstack/ai-byteplus"; + +const adapter = createBytePlusText("dola-seed-2-1-turbo-260628", process.env.ARK_API_KEY!, { + baseURL: "https://ark.eu-west.bytepluses.com/api/v3", +}); +``` + +The BytePlus docs say that the EU endpoint has chat and images only. Seedance video is in Asia-Pacific only. + +### BYOK + +The two products use two keys, so [BYOK](../advanced/byok) has two providers. Import both from `@tanstack/ai-byteplus/byok`: + +- `byteplusByok`: the Ark key, for chat, video, and images. +- `byteplusVoiceByok`: the Seed Speech key, for text-to-speech and transcription. + +## Reasoning and `encrypted_content` + +Seed models think by default. The thinking text streams as `reasoning_content` deltas. The adapter sends it as reasoning, so `useChat` shows it apart from the answer. + +What `reasoning` sends to Ark: + +- `off` sends `thinking: { type: "disabled" }` and no effort. +- The other levels send `thinking: { type: "enabled" }` and `reasoning_effort`. +- `deepseek-v3-2-251201` is the one model that does not think by default. + +Four models also send an `encrypted_content` value with their reasoning: `dola-seed-2-1-turbo-260628`, `seed-2-0-lite-260428`, `seed-2-0-mini-260428`, and `seed-2-0-pro-260328`. This value is a signature of the reasoning. BytePlus asks you to send it back, with no change, on the assistant message of the next turn. + +The adapter does this for you. It reads the value from the stream and stores it as the `signature` of the thinking part. On the next request, it sends the value back. Anthropic thinking signatures work the same way. + +- If you store and replay the history yourself, keep the `signature` of the thinking parts. Without it, you lose the reasoning cache hit. The request still works. +- A structured-output turn does not keep the value. Again, you lose the cache hit, and the request still works. + +## Structured output + +Ten of the 18 chat models accept `response_format: { type: "json_schema" }`: + +- `dola-seed-2-1-turbo-260628` +- `seed-2-0-pro-260328`, `seed-2-0-lite-260228`, `seed-2-0-mini-260215` +- `seed-1-8-251228`, `seed-1-6-250915`, `seed-1-6-250615` +- `seed-1-6-flash-250715`, `seed-1-6-flash-250615` +- `glm-5-2-260617` + +```typescript +import { chat } from "@tanstack/ai"; +import { byteplusText } from "@tanstack/ai-byteplus"; +import { z } from "zod"; + +const RecipeSchema = z.object({ + name: z.string().meta({ description: "Name of the dish" }), + minutes: z.number().meta({ description: "Total cooking time in minutes" }), + ingredients: z.array(z.string()), +}); + +const recipe = await chat({ + adapter: byteplusText("dola-seed-2-1-turbo-260628"), + messages: [{ role: "user", content: "Give me a recipe for carbonara" }], + threadId: "thread-1", + outputSchema: RecipeSchema, +}); + +console.log(recipe.name, recipe.minutes); +``` + +On the other eight models, the adapter stops with an error: + +- `chat({ outputSchema })` throws. +- The streaming form sends a `RUN_ERROR` that names the models that work. + +There is no JSON-mode fallback. Ark also rejects `response_format: { type: "json_object" }` on these eight models. + +Two results of live tests that the BytePlus tables do not show: + +- `seed-2-0-lite-260428` rejects a JSON schema. For typed output, use `seed-2-0-lite-260228` or `dola-seed-2-1-turbo-260628`. +- `glm-4-7-251222` accepts a schema and then ignores it. It answers in prose with a `200`. The adapter does not allow a schema on this model, so you do not get output that you cannot parse. + +To build a model picker, use the exported `BYTEPLUS_STRUCTURED_OUTPUT_CHAT_MODELS`. + +## Seedance video + +### Run a Seedance job from the client + +Let the server poll the job. Set `stream: true` and send the events to the browser: + +```typescript +import { + generateVideo, + generationParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { byteplusVideo } from "@tanstack/ai-byteplus"; + +export async function POST(request: Request) { + const { input, threadId } = await generationParamsFromRequest("video", request); + if (typeof input.prompt !== "string") { + return new Response("Send a text prompt.", { status: 400 }); + } + + const stream = generateVideo({ + adapter: byteplusVideo("dreamina-seedance-2-5-260628"), + prompt: input.prompt, + threadId, + size: "16:9_720p", + duration: 5, + stream: true, + pollingInterval: 5000, + }); + + return toServerSentEventsResponse(stream); +} +``` + +On the client, `useGenerateVideo` tracks the job: + +```tsx +import { fetchServerSentEvents, useGenerateVideo } from "@tanstack/ai-react"; + +export function SeedanceGenerator() { + const { generate, result, videoStatus, isLoading, error } = useGenerateVideo({ + connection: fetchServerSentEvents("/api/generate/video"), + threadId: "video-studio", + }); + + return ( +
+ + + {isLoading &&

Status: {videoStatus?.status ?? "starting"}

} + {error &&

Error: {error.message}

} + {result &&
+ ); +} +``` + +The job record stays for seven days. The video URL expires 24 hours after the job completes. + +### Seedance options per model + +Seedance options are different for each model. Ark rejects an option that does not apply with a `400`, for example: "the specified parameter `draft` is not supported for model seedance-1-0-pro in t2v, must be empty". + +| Option | Models that accept it | +| --- | --- | +| `service_tier` (`"default"` or `"flex"`) | Seedance 1.x only | +| `camera_fixed` | Seedance 1.x only | +| `frames` (`25 + 4n`, from `29` to `289`) | `seedance-1-0-pro-250528`, `seedance-1-0-pro-fast-251015` | +| `draft` (a cheap preview) | `seedance-1-5-pro-251215` only | +| `priority` (`0` to `9`) | Seedance 2.5 and the `dreamina-seedance-2-0-*` models | +| `duration: -1` (the model picks the length) | Seedance 2.5, Seedance 2.0, `seedance-1-5-pro-251215` | +| `output_format` (`"mp4"` or `"mov"`) | Seedance 2.5 (default `mp4`) | +| `seed`, `watermark`, `generate_audio`, `return_last_frame`, `callback_url` | All models | + +- `watermark` is `false` by default for video. For Seedream images, it is `true`. +- All models accept `generate_audio`. Only Seedance 2.5, Seedance 2.0, and `seedance-1-5-pro-251215` add an audio track. + +Resolutions also change by model: + +- No Seedance model has a 2K tier. +- Only `dreamina-seedance-2-0-260128` has `4k`. +- Seedance 2.5 accepts `480p`, `720p`, and `1080p`. +- `seedance-1-0-pro-fast-251015` accepts `1080p`. + +The adapter finds a combination that a model does not support before it sends the request, and throws a clear error. + +Reference media uses the shared [role hints](../media/video-generation#role-hints): `start_frame`, `end_frame`, and `reference`. + +- Seedance 2.5 and Seedance 2.0 take reference images, video, and audio. +- Seedance 2.5 also accepts audio-only reference input. It takes up to 30 images, 10 videos, and 10 audio clips. Seedance 2.0 takes 9, 3, and 3. +- The 1.x models take start and end frames only. `seedance-1-0-pro-fast-251015` takes a start frame only. + +### Seedance 2.5 + +`dreamina-seedance-2-5-260628` is the newest Seedance model. Use it for long clips (up to 30 seconds), audio-only reference input, and `output_format: "mov"`. + +```typescript +import { generateVideo } from "@tanstack/ai"; +import { byteplusVideo } from "@tanstack/ai-byteplus"; + +const { jobId } = await generateVideo({ + adapter: byteplusVideo("dreamina-seedance-2-5-260628"), + prompt: "a guitar being played in a store", + threadId: "thread-1", + size: "16:9_1080p", + duration: 10, + modelOptions: { + generate_audio: true, + priority: 5, + output_format: "mp4", + }, +}); + +console.log(jobId); +``` + +| Capability | Seedance 2.5 | +| --- | --- | +| Duration | 4 to 30 seconds, or `-1` (the model picks; required for video edit tasks) | +| Resolution | `480p`, `720p`, `1080p` (default `720p`). No 4k. | +| Reference media | 1 to 30 images, 0 to 10 videos, 0 to 10 audio clips. Audio only is allowed. | +| First and last frame | Yes | +| `priority`, `generate_audio` | Yes | +| `service_tier`, `camera_fixed`, `frames`, `draft` | No | + +The account can need to activate the model, or buy a resource pack, in the Ark Console. Until then, a call fails with `404 ModelNotOpen`. + +`byteplusVideo()` accepts any string, so you can use an id that BytePlus releases before this package lists it. For an unknown id, the adapter skips its local checks and lets Ark validate the request. Known ids keep their checks. + +### Seedance through BytePlus or fal + +You can also reach Seedance through [`@tanstack/ai-fal`](./fal). This adapter talks to BytePlus directly, so you get: + +- BytePlus billing and rate limits. +- The model ids of BytePlus. +- All the Seedance fields above (`camera_fixed`, `draft`, `priority`, reference media roles). fal has a smaller, shared set. + +Use the account that you already have. You do not need both packages for Seedance. + +## Seedream reference images + +To edit an image, or to guide the result with images, put image parts in the `prompt` array. Seedream takes up to 14 reference images, and `dola-seedream-5-0-pro-260628` takes up to 10: + +```typescript +import { generateImage } from "@tanstack/ai"; +import { byteplusImage } from "@tanstack/ai-byteplus"; + +const result = await generateImage({ + adapter: byteplusImage("seedream-5-0-260128"), + prompt: [ + { type: "text", content: "Put this guitar on a concert stage" }, + { + type: "image", + source: { type: "url", value: "https://example.com/guitar.png" }, + }, + ], + threadId: "thread-1", +}); + +console.log(result.images.length); +``` + +Two Seedream behaviors to know: + +- The adapter never sets `watermark` for you, so the BytePlus default (`true`) applies. +- Seedream has no `n` option. When you ask for more than one image, the adapter uses the group-image mode (`sequential_image_generation: "auto"` with `max_images`). The model then decides how many images the prompt needs. A request for four can return two. The adapter logs a warning when this occurs. + +## Seed Speech + +### Voice ids + +Seed Speech has no top-level `speaker` field. The voice goes inside a `references` entry. The adapter turns `voice` into `references: [{ speaker: "en_female_stokie_uranus_bigtts" }]` for you. + +`modelOptions.references` replaces that entry. It does not merge with it. So if you send `references` for voice cloning, the adapter drops `voice`. To keep a stock voice with your clips, add a `speaker` entry yourself. + +The end of a voice id tells you its generation: + +- `_uranus_bigtts`: TTS 2.0 voices, the newest generation. +- `_mars_bigtts` and `_moon_bigtts`: TTS 1.0 voices. +- `*_emo_v2_*`: TTS 1.0 voices that also accept emotion tags. + +The full list is in the [BytePlus voice list](https://docs.byteplus.com/en/docs/byteplusvoice/voicelist). It changes more often than this package, so the adapter accepts any string. `speaker` also takes a voice that you cloned with Voice Replication. + +### Speech options + +Use `modelOptions` for these: + +- `ogg_opus` output and an exact `sample_rate`. +- `references`: voice cloning, with up to three 30-second audio clips. Refer to them from the text as `@Audio1` to `@Audio3`. +- `watermark`, `speech_rate`, and the raw `enable_subtitle` timings. + +```typescript +import { generateSpeech } from "@tanstack/ai"; +import { byteplusSpeech, type BytePlusTTSResult } from "@tanstack/ai-byteplus"; + +const result: BytePlusTTSResult = await generateSpeech({ + adapter: byteplusSpeech("seed-audio-1.0"), + text: "welcome to the guitar store", + threadId: "thread-1", + modelOptions: { + format: "ogg_opus", + sample_rate: 48000, + speech_rate: 20, + enable_subtitle: true, + }, +}); + +for (const sentence of result.subtitle?.sentences ?? []) { + console.log(sentence.text, sentence.start_time); +} +``` + +### Timings + +The shared `timestamps: true` option is easier than `modelOptions.enable_subtitle`. It sets the same flag. Then it maps the timings to `result.alignment` (per word) and `result.segments` (per sentence), in seconds: + +```typescript +import { generateSpeech } from "@tanstack/ai"; +import { byteplusSpeech } from "@tanstack/ai-byteplus"; + +const result = await generateSpeech({ + adapter: byteplusSpeech("seed-audio-1.0"), + text: "welcome to the guitar store", + threadId: "thread-1", + timestamps: true, +}); + +console.log(result.alignment?.unit); // "word" +console.log(result.alignment?.endSeconds.at(-1)); // where speech stops +``` + +### Dialogue + +Seed Audio 1.0 makes a dialogue with several voices in one request. Pass `turns`, and the adapter builds the `references` array and the role prompt for you. A request can have up to three different voices: + +```typescript +import { generateSpeech } from "@tanstack/ai"; +import { byteplusSpeech } from "@tanstack/ai-byteplus"; + +// Replace SECOND_VOICE with another voice id from the BytePlus voice list. +const SECOND_VOICE = "your-second-voice-id"; + +const result = await generateSpeech({ + adapter: byteplusSpeech("seed-audio-1.0"), + turns: [ + { text: "Do you sell picks?", voice: "en_female_stokie_uranus_bigtts" }, + { text: "By the till.", voice: SECOND_VOICE }, + ], + timestamps: true, + threadId: "thread-1", +}); + +for (const segment of result.segments ?? []) { + console.log(segment.startSeconds, segment.text); +} +``` + +### Watermarks + +For speech, `watermark` is an object. It has two markers, and both are off by default: + +- `aigc_watermark`: adds a sound marker at the end of the clip. +- `aigc_metadata`: writes the source of the audio into the audio header. It writes nothing unless `enable` is `true`. + +```typescript +import { generateSpeech } from "@tanstack/ai"; +import { byteplusSpeech } from "@tanstack/ai-byteplus"; + +const result = await generateSpeech({ + adapter: byteplusSpeech("seed-audio-1.0"), + text: "welcome to the guitar store", + threadId: "thread-1", + modelOptions: { + watermark: { + aigc_watermark: true, + aigc_metadata: { enable: true, content_producer: "guitar-store" }, + }, + }, +}); + +console.log(result.format); +``` + +`watermark: true` is short for `{ aigc_watermark: true }`. Images and video use a boolean `watermark`. Only speech takes the object. + +### Limits + +- One request makes 120 seconds of audio at most. The limit applies to `originalDuration`, the length before `speech_rate`. BytePlus also bills on this length. +- The raw `subtitle` timings are in milliseconds. `duration` and `originalDuration` are in seconds. `alignment` and `segments` are always in seconds. +- The `url` on the result expires about two hours after generation. Keep the base64 `audio`, not the link. + +## Transcription options + +Set these in `modelOptions` of `byteplusTranscription`: + +- `enable_itn`: writes spoken numbers and dates as digits. +- `enable_punc`: adds punctuation. +- `enable_ddc`: removes filler words. +- `enable_speaker_info`: adds speaker labels, which come back as `segment.speaker`. + +## API reference + +Each factory pair has the same shape. The short factory reads the key from the environment. The `create*` factory takes an explicit API key. Both take `model` as the first argument. + +| Factories | Creates | Key | +| --- | --- | --- | +| `byteplusText(model, config?)` / `createBytePlusText(model, apiKey, config?)` | Chat adapter | `ARK_API_KEY` or `BYTEPLUS_API_KEY` | +| `byteplusVideo(model, config?)` / `createBytePlusVideo(model, apiKey, config?)` | Seedance video adapter (experimental) | `ARK_API_KEY` | +| `byteplusImage(model, config?)` / `createBytePlusImage(model, apiKey, config?)` | Seedream image adapter | `ARK_API_KEY` | +| `byteplusSpeech(model, config?)` / `createBytePlusSpeech(model, apiKey, config?)` | Seed Speech text-to-speech adapter | `BYTEPLUS_VOICE_API_KEY` | +| `byteplusTranscription(model, config?)` / `createBytePlusTranscription(model, apiKey, config?)` | Seed Speech transcription adapter | `BYTEPLUS_VOICE_API_KEY` | + +- `config.baseURL` sets the region endpoint of the Ark adapters. +- `adapter.inputModalities` lists the input kinds of the selected chat model. It is `undefined` for a model that this package does not know. +- BytePlus has no provider tools. Define your tools with `toolDefinition()`. Ark uses the OpenAI tool-call format, so the usual [tools flow](../tools/tools.md) works. diff --git a/docs/adapters/byteplus.md b/docs/adapters/byteplus.md index 7d00c036db..fbbf712ae8 100644 --- a/docs/adapters/byteplus.md +++ b/docs/adapters/byteplus.md @@ -2,7 +2,7 @@ title: BytePlus id: byteplus-adapter order: 8 -description: "Use BytePlus ModelArk with TanStack AI — Seed chat models with reasoning, Seedance video generation, Seedream image generation, and Seed Speech text-to-speech and transcription via @tanstack/ai-byteplus." +description: "Use BytePlus ModelArk in TanStack AI with @tanstack/ai-byteplus: Seed chat models with reasoning, Seedance video, Seedream images, and Seed Speech text-to-speech and transcription." keywords: - tanstack ai - byteplus @@ -20,14 +20,9 @@ keywords: - adapter --- -The BytePlus adapter connects TanStack AI to [BytePlus](https://www.byteplus.com/), ByteDance's international model platform, across four generation kinds: +`@tanstack/ai-byteplus` calls Seed chat models with reasoning, plus Seedance video, Seedream images, and Seed Speech. -- **Chat** — the Seed model family (plus GLM, DeepSeek and gpt-oss) on ModelArk's OpenAI-compatible `/chat/completions` endpoint, with reasoning on by default. -- **Video** — Seedance, an asynchronous task API. -- **Image** — Seedream. -- **Speech** — Seed Speech text-to-speech and transcription, which live on a **different host with a different API key** (see [Two products, two keys](#two-products-two-keys)). - -## Installation +## 1. Install @@ -42,591 +37,281 @@ octane: @tanstack/ai-byteplus -## Two products, two keys - -BytePlus splits these models across two products, and they do not share credentials: - -| Adapters | Product | Env var | Auth header | -| --- | --- | --- | --- | -| `byteplusText`, `byteplusVideo`, `byteplusImage` | ModelArk (Ark) | `ARK_API_KEY` (falls back to `BYTEPLUS_API_KEY`) | `Authorization: Bearer` | -| `byteplusSpeech`, `byteplusTranscription` | Seed Speech | `BYTEPLUS_VOICE_API_KEY` | `X-Api-Key` | +## 2. Set the API keys -BYOK uses two slugs because these are two keys: `byteplusByok` (Ark) and `byteplusVoiceByok` (Seed Speech), both from `@tanstack/ai-byteplus/byok`. +BytePlus sells these models as two products, and each product has its own key. Put both keys in the environment of your server: ```bash -# ModelArk: chat, Seedance video, Seedream image +# ModelArk: chat, Seedance video, Seedream images ARK_API_KEY=... -# Seed Speech: TTS and transcription — a separate product key +# Seed Speech: text-to-speech and transcription BYTEPLUS_VOICE_API_KEY=... ``` -Passing an Ark key to the speech adapters fails with `45000010 Invalid X-Api-Key`. +- `byteplusText`, `byteplusVideo`, and `byteplusImage` read `ARK_API_KEY`. If it is not set, they read `BYTEPLUS_API_KEY`. +- `byteplusSpeech` and `byteplusTranscription` read `BYTEPLUS_VOICE_API_KEY`. An Ark key fails here with `45000010 Invalid X-Api-Key`. -Ark keys are also **region-isolated**: the default base URL is the Asia-Pacific south-east endpoint (`https://ark.ap-southeast.bytepluses.com/api/v3`), and a key issued for one region will not authenticate against another. Point the adapter elsewhere with `baseURL`: +To pass the key in code, use the `create*` factory of the same adapter: ```typescript -import { createBytePlusText } from '@tanstack/ai-byteplus' -import { arkApiKey } from './config' +import { createBytePlusText } from "@tanstack/ai-byteplus"; -const adapter = createBytePlusText('dola-seed-2-1-turbo-260628', arkApiKey, { - baseURL: 'https://ark.eu-west.bytepluses.com/api/v3', -}) +const adapter = createBytePlusText("dola-seed-2-1-turbo-260628", process.env.ARK_API_KEY!); ``` -Per the BytePlus docs the EU endpoint serves chat and image only — Seedance video is Asia-Pacific only. - -## Chat - -The adapter carries the model, so there is no separate `model` option. +Ark keys work in one region only. The default endpoint is Asia-Pacific south-east. For a key from another region, see [Regions](./byteplus-advanced#regions). -**Server** — an endpoint that streams the reply back over SSE: +## 3. Stream a chat from the server ```typescript -import { chat, toServerSentEventsResponse } from '@tanstack/ai' -import { byteplusText } from '@tanstack/ai-byteplus' +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { byteplusText } from "@tanstack/ai-byteplus"; export async function POST(request: Request) { - const { messages } = await request.json() + const { messages, threadId, runId } = await chatParamsFromRequest(request); const stream = chat({ - adapter: byteplusText('dola-seed-2-1-turbo-260628'), + adapter: byteplusText("dola-seed-2-1-turbo-260628"), messages, - }) + threadId, + runId, + }); - return toServerSentEventsResponse(stream) + return toServerSentEventsResponse(stream); } ``` -**Client** — the same `useChat` hook as every other provider: +## 4. Render the chat + +The client is the same for every provider. Point `useChat` at your route: ```tsx -import { useState } from 'react' -import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; export function Chat() { - const [input, setInput] = useState('') - - const { messages, sendMessage, isLoading } = useChat({ - connection: fetchServerSentEvents('/api/chat'), - }) + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", + }); return ( -
+
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > {messages.map((message) => ( -
- {message.role} - {message.parts.map((part, index) => - part.type === 'text' ?

{part.content}

: null, - )} -
+

+ {message.parts + .map((part) => (part.type === "text" ? part.content : "")) + .join("")} +

))} - - { - event.preventDefault() - if (!input.trim() || isLoading) return - sendMessage(input) - setInput('') - }} - > - setInput(e.target.value)} /> - -
-
- ) + setInput(event.target.value)} /> + + ); } ``` -### Model options - -Ark's chat endpoint is OpenAI-compatible, so sampling parameters keep their OpenAI snake_case names and live in `modelOptions`. `repetition_penalty` and `service_tier` are the Ark-only additions. The thinking level goes in `reasoning`: - -```typescript -import { chat, toServerSentEventsResponse } from '@tanstack/ai' -import { byteplusText } from '@tanstack/ai-byteplus' - -export async function POST(request: Request) { - const { messages } = await request.json() - - const stream = chat({ - adapter: byteplusText('dola-seed-2-1-turbo-260628'), - messages, - modelOptions: { - temperature: 0.7, - top_p: 0.9, - max_tokens: 2048, - }, - reasoning: 'medium', - }) - - return toServerSentEventsResponse(stream) -} -``` +For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat). -`max_tokens` and `max_completion_tokens` are mutually exclusive. The types cannot express this, and Ark returns a `400` when you send both. - -`service_tier: 'flex'` routes the request to the cheaper offline batch queue with no latency guarantee. - -Need a header on each request, or a log of each model call? The chat adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). - -## Reasoning and `encrypted_content` - -Seed models reason by default. Reasoning arrives as its own stream of `reasoning_content` deltas and is surfaced as reasoning content rather than answer text, so `useChat` renders it separately from the reply. Turn it off per request, on a model that can stop thinking: - -```typescript -import { chat, toServerSentEventsResponse } from '@tanstack/ai' -import { byteplusText } from '@tanstack/ai-byteplus' - -export async function POST(request: Request) { - const { messages } = await request.json() - - const stream = chat({ - adapter: byteplusText('glm-5-2-260617'), - messages, - reasoning: 'off', - }) - - return toServerSentEventsResponse(stream) -} -``` - -`off` sends `thinking: { type: 'disabled' }` and no effort. The other levels send `thinking: { type: 'enabled' }` and `reasoning_effort`. `deepseek-v3-2-251201` is the one model that does not think by default. - -The levels of each model: - -- `dola-seed-2-1-turbo-260628` and the `seed-*` models: `minimal` to `high`. They cannot stop thinking. -- `glm-5-2-260617`: `off` to `high`. `glm-4-7-251222`, `deepseek-v3-2-251201`: `off`, `high`. -- `deepseek-v4-pro-260425`, `deepseek-v4-flash-260425`: `off`, `high`, `max`. -- `gpt-oss-120b-250805`: `low`, `medium`, `high`. - -The four "thinking summary" models — `dola-seed-2-1-turbo-260628`, `seed-2-0-lite-260428`, `seed-2-0-mini-260428` and `seed-2-0-pro-260328` — also emit an opaque `encrypted_content` blob alongside the reasoning trace. It is a signature over that trace, and BytePlus's docs ask for it back verbatim on the assistant message in the next turn. - -**The adapter round-trips it for you**, over the same seam Anthropic's thinking signatures use: the blob is captured off the stream and attached to the reasoning step as its `signature`, which lands on the thinking part of the assistant message and is echoed back on the next request. Two consequences worth knowing: - -- If you persist and replay conversation history yourself, keep the thinking parts' `signature` — dropping it costs you the reasoning-cache hit. It is not fatal: a live probe confirmed Ark accepts a turn whose assistant message omits `encrypted_content`. -- A structured-output turn doesn't capture the blob, for the same reason — again a lost cache hit, not a failed request. - -## Structured output - -Ten of the eighteen chat models accept `response_format: { type: 'json_schema' }`: - -`dola-seed-2-1-turbo-260628`, `seed-2-0-pro-260328`, `seed-2-0-lite-260228`, `seed-2-0-mini-260215`, `seed-1-8-251228`, `seed-1-6-250915`, `seed-1-6-250615`, `seed-1-6-flash-250715`, `seed-1-6-flash-250615`, `glm-5-2-260617`. - -```typescript -import { chat } from '@tanstack/ai' -import { byteplusText } from '@tanstack/ai-byteplus' -import { z } from 'zod' - -const RecipeSchema = z.object({ - name: z.string().meta({ description: 'Name of the dish' }), - minutes: z.number().meta({ description: 'Total cooking time in minutes' }), - ingredients: z.array(z.string()), -}) - -const recipe = await chat({ - adapter: byteplusText('dola-seed-2-1-turbo-260628'), - messages: [{ role: 'user', content: 'Give me a recipe for carbonara' }], - outputSchema: RecipeSchema, -}) - -console.log(recipe.name, recipe.minutes) -``` - -On the other eight models the adapter **fails loudly** rather than degrading: `chat({ outputSchema })` throws, and the streaming form emits a `RUN_ERROR` naming the models that do work. There is no JSON-mode fallback to fall back *to* — Ark rejects `response_format: { type: 'json_object' }` on every one of them too. - -Two traps worth knowing, both found by probing the live API rather than reading the tables: - -- **The published capability tables are wrong in both directions.** `seed-2-0-lite-260428` — the obvious default model — is one of the eight that reject a JSON schema. Reach for `seed-2-0-lite-260228` or `dola-seed-2-1-turbo-260628` when you need typed output. -- **`glm-4-7-251222` accepts a schema and then ignores it**, answering in prose with a `200`. It is deliberately excluded from the supported list, so the adapter rejects it up front instead of handing you unparseable output. - -`BYTEPLUS_STRUCTURED_OUTPUT_CHAT_MODELS` is exported if you want to gate a model picker on it. +You now have a streaming chat on BytePlus. Seed models think before they answer, and `useChat` shows that thinking as separate thinking parts. [Tools](../tools/tools) work the same as with every adapter. BytePlus has no provider tools of its own. ## Video generation (Seedance) > Video generation is an [experimental feature](../media/video-generation). -Seedance is an asynchronous task API: `generateVideo()` opens the job and returns a `jobId`, and the video URL arrives with the terminal status. +Seedance runs as a job. `generateVideo()` starts the job and returns a `jobId`. Poll the job until it has a video URL: ```typescript -import { generateVideo, getVideoJobStatus } from '@tanstack/ai' -import { byteplusVideo } from '@tanstack/ai-byteplus' +import { generateVideo, getVideoJobStatus } from "@tanstack/ai"; +import { byteplusVideo } from "@tanstack/ai-byteplus"; -const adapter = byteplusVideo('dreamina-seedance-2-0-260128') +const adapter = byteplusVideo("dreamina-seedance-2-5-260628"); const { jobId } = await generateVideo({ adapter, - prompt: 'a guitar being played in a store', - size: '16:9_720p', + prompt: "a guitar being played in a store", + threadId: "thread-1", + size: "16:9_720p", duration: 5, -}) - -let status = await getVideoJobStatus({ adapter, jobId }) -while (status.status === 'pending' || status.status === 'processing') { - await new Promise((resolve) => setTimeout(resolve, 5000)) - status = await getVideoJobStatus({ adapter, jobId }) -} - -console.log(status.status === 'completed' ? status.url : status.error) -``` - -**Generated video URLs expire 24 hours after the task completes** (the task record itself is kept for seven days), so download anything you intend to keep — see [Keeping generated files](../persistence/keep-generated-files). - -`size` is a `ratio` or `ratio_resolution` template (`'16:9'`, `'16:9_720p'`). Ratios are `16:9`, `9:16`, `4:3`, `3:4`, `1:1`, `21:9` and `adaptive`. - -### Driving the whole lifecycle from the client - -**Server** — hand the polling to the core with `stream: true` and pipe the chunks out: - -```typescript -import { generateVideo, toServerSentEventsResponse } from '@tanstack/ai' -import { byteplusVideo } from '@tanstack/ai-byteplus' +}); -export async function POST(request: Request) { - const { prompt } = await request.json() - - const stream = generateVideo({ - adapter: byteplusVideo('dreamina-seedance-2-0-260128'), - prompt, - size: '16:9_720p', - duration: 5, - stream: true, - pollingInterval: 5000, - }) - - return toServerSentEventsResponse(stream) +let status = await getVideoJobStatus({ adapter, jobId }); +while (status.status === "pending" || status.status === "processing") { + await new Promise((resolve) => setTimeout(resolve, 5000)); + status = await getVideoJobStatus({ adapter, jobId }); } -``` - -**Client** — `useGenerateVideo` tracks the job for you: - -```tsx -import { fetchServerSentEvents, useGenerateVideo } from '@tanstack/ai-react' -export function SeedanceGenerator() { - const { generate, result, videoStatus, isLoading, error } = useGenerateVideo({ - connection: fetchServerSentEvents('/api/generate/video'), - }) - - return ( -
- - - {isLoading &&

Status: {videoStatus?.status ?? 'starting…'}

} - {error &&

Error: {error.message}

} - {result &&
- ) -} +console.log(status.status === "completed" ? status.url : status.error); ``` -### Per-model options - -Seedance options are model-specific, and **Ark rejects an inapplicable field with a `400` rather than ignoring it** — "the specified parameter `draft` is not supported for model seedance-1-0-pro in t2v, must be empty". The applicable set: +- The video URL expires 24 hours after the job completes. If you want to keep the video, download it. See [Keeping generated files](../persistence/keep-generated-files). +- `size` is a ratio (`"16:9"`) or a ratio with a resolution (`"16:9_720p"`). The ratios are `16:9`, `9:16`, `4:3`, `3:4`, `1:1`, `21:9`, and `adaptive`. +- The Seedance 2 models can need activation in the Ark Console first. Until then, a call fails with `404 ModelNotOpen`. -| Option | Models that accept it | -| --- | --- | -| `service_tier` (`'default'` \| `'flex'`) | Seedance 1.x only — Seedance 2.5 and the 2.0 family reject it | -| `camera_fixed` | Seedance 1.x only | -| `frames` (fractional-second output, `25 + 4n` in `[29, 289]`) | `seedance-1-0-pro-250528`, `seedance-1-0-pro-fast-251015` | -| `draft` (cheap low-fidelity preview) | `seedance-1-5-pro-251215` only | -| `priority` (`0`–`9`) | Seedance 2.5 and the `dreamina-seedance-2-0-*` family | -| `duration: -1` (model picks the length) | Seedance 2.5, 2.0 and `seedance-1-5-pro-251215` | -| `output_format` (`'mp4'` \| `'mov'`) | Seedance 2.5 (default `mp4`) | -| `seed`, `watermark`, `generate_audio`, `return_last_frame`, `callback_url` | every model | - -`watermark` defaults to `false` for video (the opposite of Seedream images). `generate_audio` is accepted everywhere but only Seedance 2.5, 2.0 and 1.5-pro actually produce an audio track. - -Resolutions are per model too: there is **no 2K tier on any Seedance model**, `4k` exists only on `dreamina-seedance-2-0-260128`, Seedance 2.5 accepts **480p/720p/1080p**, and `seedance-1-0-pro-fast-251015` does accept `1080p` despite older prose listing it as 480p/720p only. An unsupported combination is caught locally with a clear error before the request goes out. - -Reference media follows the shared [role hints](../media/video-generation#role-hints) — `start_frame`, `end_frame` and `reference`. Seedance 2.5 and the 2.0 family take full multimodal references (reference images, video and audio); Seedance 2.5 additionally accepts **audio-only** reference input and up to 30 reference images / 10 videos / 10 audio clips (2.0 is 9 / 3 / 3). The 1.x models take start/end frames only, and `seedance-1-0-pro-fast-251015` takes a start frame only. - -### Seedance 2.5 - -Seedance 2.5 (`dreamina-seedance-2-5-260628`) is a first-class model in this package. It is the current multimodal flagship for longer clips (up to **30 seconds**), audio-only reference input, and `output_format: 'mov'`. Like the 2.0 series it may still require model activation / a resource pack in the Ark Console before the account can call it (`404 ModelNotOpen` until then). - -```typescript -import { generateVideo } from '@tanstack/ai' -import { byteplusVideo } from '@tanstack/ai-byteplus' - -const { jobId } = await generateVideo({ - adapter: byteplusVideo('dreamina-seedance-2-5-260628'), - prompt: 'a guitar being played in a store', - size: '16:9_1080p', - duration: 10, - modelOptions: { - generate_audio: true, - priority: 5, - output_format: 'mp4', - }, -}) -``` - -| Capability | Seedance 2.5 | -| --- | --- | -| Duration | 4–30s, or `-1` (model chooses; required for video-editing tasks) | -| Resolution | `480p`, `720p`, `1080p` (default `720p`) — no 4k | -| Reference media | images 1–30, videos 0–10, audio 0–10; **audio-only allowed** | -| First + last frame | yes | -| `priority` / `generate_audio` | yes | -| `service_tier` / `camera_fixed` / `frames` / `draft` | no | - -`byteplusVideo()` still accepts any string for ids BytePlus ships between package releases. An unknown id relaxes local guards so Ark validates the request; known ids (including 2.5) keep their documented narrowing. - -### Seedance here vs. Seedance via fal - -Seedance is also reachable through [`@tanstack/ai-fal`](./fal), which proxies it alongside hundreds of other hosted models. This adapter talks to BytePlus directly, which means BytePlus billing and rate limits, model ids in BytePlus's own naming, and the first-class Seedance request fields above (`camera_fixed`, `draft`, `priority`, reference-media roles) rather than fal's normalized subset. Pick whichever matches the account you already have; there is no reason to install both just for Seedance. +To run the job from a React component, and for the options of each model, see [BytePlus: Advanced](./byteplus-advanced#run-a-seedance-job-from-the-client). ## Image generation (Seedream) ```typescript -import { generateImage } from '@tanstack/ai' -import { byteplusImage } from '@tanstack/ai-byteplus' +import { generateImage } from "@tanstack/ai"; +import { byteplusImage } from "@tanstack/ai-byteplus"; const result = await generateImage({ - adapter: byteplusImage('dola-seedream-5-0-pro-260628'), - prompt: 'a guitar being played in a store', - size: '2K', + adapter: byteplusImage("dola-seedream-5-0-pro-260628"), + prompt: "a guitar being played in a store", + threadId: "thread-1", + size: "2K", modelOptions: { watermark: false }, -}) - -console.log(result.images[0]?.url) -``` +}); -`size` takes either a token (`1K`, `2K`, `4K`) or explicit pixels (`2048x2048`) — never a mix of the two. Pass image parts in the `prompt` array to edit or condition on existing images (up to 14 references, 10 on `dola-seedream-5-0-pro-260628`): - -```typescript -import { generateImage } from '@tanstack/ai' -import { byteplusImage } from '@tanstack/ai-byteplus' - -const result = await generateImage({ - adapter: byteplusImage('seedream-5-0-260128'), - prompt: [ - { type: 'text', content: 'Put this guitar on a concert stage' }, - { - type: 'image', - source: { type: 'url', value: 'https://example.com/guitar.png' }, - }, - ], -}) - -console.log(result.images.length) +console.log(result.images[0]?.url); ``` -Two behaviours surprise people: - -- **`watermark` defaults to `true`.** BytePlus stamps "AI generated" into the bottom-right corner unless you pass `watermark: false`. The adapter never sets the field implicitly, so the provider default applies. -- **`numberOfImages` is an upper bound, not a count.** Seedream has no `n` parameter; asking for more than one image maps onto its group-image mode (`sequential_image_generation: 'auto'` with `max_images`), where the model decides how many images the prompt actually warrants. A request for four can come back with two, and the adapter logs a warning when it does. +- `watermark` is `true` by default. BytePlus then writes "AI generated" in the bottom-right corner. Set `watermark: false` to stop this. +- `size` is a token (`1K`, `2K`, `4K`) or pixels (`2048x2048`). Do not mix the two. +- `numberOfImages` is a maximum, not an exact count. The model decides how many images to return. +- Result URLs expire after 24 hours. To get the bytes in the response, set `response_format: "b64_json"` in `modelOptions`. -Result URLs expire after 24 hours. Pass `response_format: 'b64_json'` in `modelOptions` to get the bytes inline instead. +To edit an existing image, see [Seedream reference images](./byteplus-advanced#seedream-reference-images). ## Text-to-speech (Seed Speech) -Seed Speech uses `BYTEPLUS_VOICE_API_KEY`, not the Ark key. +This adapter uses `BYTEPLUS_VOICE_API_KEY`: ```typescript -import { generateSpeech } from '@tanstack/ai' -import { byteplusSpeech } from '@tanstack/ai-byteplus' +import { generateSpeech } from "@tanstack/ai"; +import { byteplusSpeech } from "@tanstack/ai-byteplus"; const result = await generateSpeech({ - adapter: byteplusSpeech('seed-audio-1.0'), - text: 'welcome to the guitar store', - voice: 'en_female_stokie_uranus_bigtts', - format: 'mp3', -}) - -console.log(result.contentType, result.audio.length) -``` - -Seed Speech has **no top-level `speaker` field** — the voice travels inside a -`references` entry, so the adapter turns `voice` into -`references: [{ speaker: 'en_female_stokie_uranus_bigtts' }]` for you. The -consequence: `modelOptions.references` **replaces** that entry rather than -merging with it, so a request that passes `references` for voice cloning -silently drops `voice`. Include a `speaker` member yourself if you still want a -stock voice alongside your clips. - -The suffix on a voice id tells you which generation you are asking for: - -- `_uranus_bigtts` — TTS 2.0 voices, the current generation. -- `_mars_bigtts` / `_moon_bigtts` — TTS 1.0 voices. -- `*_emo_v2_*` — TTS 1.0 voices that additionally accept emotion tags. - -The full roster lives in the [BytePlus voice list](https://docs.byteplus.com/en/docs/byteplusvoice/voicelist) and changes far more often than this package ships, so any string is accepted. - -`speaker` also takes a voice you cloned through Voice Replication, not only a stock id. That field is the join between replication and synthesis. - -Output formats are `wav`, `mp3`, `pcm` and `ogg_opus`. Reach for `modelOptions` for `ogg_opus`, for an explicit `sample_rate`, for `references` (voice cloning, up to three 30-second audio clips, addressed from the text as `@Audio1` to `@Audio3`), for `watermark`, or for word-level timings: - -```typescript -import { generateSpeech } from '@tanstack/ai' -import { byteplusSpeech, type BytePlusTTSResult } from '@tanstack/ai-byteplus' - -const result: BytePlusTTSResult = await generateSpeech({ - adapter: byteplusSpeech('seed-audio-1.0'), - text: 'welcome to the guitar store', - modelOptions: { - format: 'ogg_opus', - sample_rate: 48000, - speech_rate: 20, - enable_subtitle: true, - }, -}) - -for (const sentence of result.subtitle?.sentences ?? []) { - console.log(sentence.text, sentence.start_time) -} + adapter: byteplusSpeech("seed-audio-1.0"), + text: "welcome to the guitar store", + threadId: "thread-1", + voice: "en_female_stokie_uranus_bigtts", + format: "mp3", +}); + +console.log(result.contentType, result.audio.length); ``` -Prefer the cross-provider `timestamps: true` over `modelOptions.enable_subtitle` -unless you want the raw BytePlus block. It sets the same flag and maps the -response onto `result.alignment` (per word) and `result.segments` (per -sentence), already converted to seconds: +- Formats: `wav`, `mp3`, `pcm`, and `ogg_opus`. +- One request makes 120 seconds of audio at most. +- `timestamps: true` gives you word timings, and `turns` makes a dialogue with up to three voices. -```typescript -import { generateSpeech } from '@tanstack/ai' -import { byteplusSpeech } from '@tanstack/ai-byteplus' - -const result = await generateSpeech({ - adapter: byteplusSpeech('seed-audio-1.0'), - text: 'welcome to the guitar store', - timestamps: true, -}) +The voice ids, voice cloning, timings, dialogue, and watermarks are in [Seed Speech](./byteplus-advanced#seed-speech). The client half is the standard [`useGenerateSpeech` hook](../media/text-to-speech#quick-setup). -console.log(result.alignment?.unit) // 'word' -console.log(result.alignment?.endSeconds.at(-1)) // where speech stops -``` +## Transcription (Seed Speech ASR) -Seed Audio 1.0 also synthesizes multi-role dialogue in one pass. Pass `turns` -and the adapter builds the `references` array and the role-structured prompt -for you, up to **three distinct voices** per request: +This adapter also uses `BYTEPLUS_VOICE_API_KEY`. The call is synchronous, so you get the transcript back with no polling: ```typescript -import { generateSpeech } from '@tanstack/ai' -import { byteplusSpeech } from '@tanstack/ai-byteplus' +import { generateTranscription } from "@tanstack/ai"; +import { byteplusTranscription } from "@tanstack/ai-byteplus"; -// Replace SECOND_VOICE with another voice id from the BytePlus voice list. -const SECOND_VOICE = 'your-second-voice-id' - -const result = await generateSpeech({ - adapter: byteplusSpeech('seed-audio-1.0'), - turns: [ - { text: 'Do you sell picks?', voice: 'en_female_stokie_uranus_bigtts' }, - { text: 'By the till.', voice: SECOND_VOICE }, - ], - timestamps: true, -}) +const result = await generateTranscription({ + adapter: byteplusTranscription("seed-asr"), + audio: "https://example.com/recording.mp3", + modelOptions: { enable_punc: true, enable_speaker_info: true }, + threadId: "thread-1", +}); +console.log(result.text); for (const segment of result.segments ?? []) { - console.log(segment.startSeconds, segment.text) + console.log(segment.speaker, segment.text); } ``` -### Watermarking speech +`audio` takes a `File`, base64, a data URL, or a public URL. The limit is two hours and 100 MB. For the other options, see [Transcription options](./byteplus-advanced#transcription-options). -Seed Audio takes an object here, not a flag. Two markers, both off by default: +## Models and options -- `aigc_watermark`: appends an audible rhythm marker to the end of the clip. -- `aigc_metadata`: writes provenance into the audio header. It writes nothing unless `enable` is `true`. +### Model options + +Ark speaks the OpenAI chat format, so the sampling options keep their OpenAI names. They go in `modelOptions`: ```typescript -import { generateSpeech } from '@tanstack/ai' -import { byteplusSpeech } from '@tanstack/ai-byteplus' +import { chat } from "@tanstack/ai"; +import { byteplusText } from "@tanstack/ai-byteplus"; -const result = await generateSpeech({ - adapter: byteplusSpeech('seed-audio-1.0'), - text: 'welcome to the guitar store', +const stream = chat({ + adapter: byteplusText("dola-seed-2-1-turbo-260628"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", modelOptions: { - watermark: { - aigc_watermark: true, - aigc_metadata: { enable: true, content_producer: 'guitar-store' }, - }, + temperature: 0.7, + top_p: 0.9, + max_tokens: 2048, }, -}) +}); ``` -`watermark: true` is shorthand for `{ aigc_watermark: true }`, so the audible marker is what a boolean gives you. Images and video keep their own boolean `watermark`. Only speech takes the object. +- Send `max_tokens` or `max_completion_tokens`, not both. Ark returns a `400` when you send both. The types cannot catch this. +- `repetition_penalty` and `service_tier` are Ark options. `service_tier: "flex"` sends the request to a cheaper batch queue with no latency guarantee. -Three things to plan around: - -- **Synthesis is capped at 120 seconds of output.** The cap applies to `originalDuration` (the length before `speech_rate` is applied), which is also what BytePlus bills on. -- **Subtitle timings are milliseconds** on the raw `subtitle` block, while `duration` and `originalDuration` are seconds. `alignment` and `segments` are always seconds. -- The `url` on the result expires roughly two hours after generation — persist the base64 `audio`, not the link. - -The client half is the standard [`useGenerateSpeech` hook](../media/text-to-speech#streaming-mode-server-route--client-hook); nothing about it is BytePlus-specific. +Need a header on each request, or a log of each model call? The chat adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). -## Transcription (Seed Speech ASR) +### Reasoning -Also on the voice key. The endpoint is synchronous — audio in, transcript out, no polling: +Seed models think by default. Set how hard they think with `reasoning` on `chat()`: ```typescript -import { generateTranscription } from '@tanstack/ai' -import { byteplusTranscription } from '@tanstack/ai-byteplus' -import { audioFile } from './audio' - -const result = await generateTranscription({ - adapter: byteplusTranscription('seed-asr'), - audio: audioFile, - modelOptions: { enable_punc: true, enable_speaker_info: true }, -}) - -console.log(result.text) -for (const segment of result.segments ?? []) { - console.log(segment.speaker, segment.text) -} +import { chat } from "@tanstack/ai"; +import { byteplusText } from "@tanstack/ai-byteplus"; + +const stream = chat({ + adapter: byteplusText("glm-5-2-260617"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + reasoning: "off", +}); ``` -Audio can be a `File`, base64, a data URL or a public URL, up to two hours and 100 MB. `enable_itn` renders spoken numbers and dates as digits, `enable_punc` inserts punctuation, `enable_ddc` strips fillers, and `enable_speaker_info` attaches speaker labels that surface as `segment.speaker`. - -## Supported models - -- **Chat** — `dola-seed-2-1-turbo-260628`, `seed-2-0-lite-260428`, `seed-2-0-mini-260428`, `seed-2-0-pro-260328`, `seed-2-0-lite-260228`, `seed-2-0-mini-260215`, `seed-2-0-code-preview-260328`, `seed-1-8-251228`, `seed-1-6-250915`, `seed-1-6-250615`, `seed-1-6-flash-250715`, `seed-1-6-flash-250615`, `glm-5-2-260617`, `glm-4-7-251222`, `deepseek-v4-pro-260425`, `deepseek-v4-flash-260425`, `deepseek-v3-2-251201`, `gpt-oss-120b-250805`. -- **Video** — `dreamina-seedance-2-5-260628`, `dreamina-seedance-2-0-260128`, `dreamina-seedance-2-0-fast-260128`, `dreamina-seedance-2-0-mini-260615`, `seedance-1-5-pro-251215`, `seedance-1-0-pro-250528`, `seedance-1-0-pro-fast-251015`. -- **Image** — `dola-seedream-5-0-pro-260628`, `seedream-5-0-260128`, `seedream-5-0-lite-260128`, `seedream-4-5-251128`, `seedream-4-0-250828`. -- **Speech** — `seed-audio-1.0` (TTS) and `seed-asr` (transcription). - -BytePlus retires model ids aggressively and its published lists include ids that no longer resolve, so this package ships only dated ids that answered a live request. `BYTEPLUS_CHAT_MODELS`, `BYTEPLUS_VIDEO_MODELS`, `BYTEPLUS_IMAGE_MODELS`, `BYTEPLUS_TTS_MODELS` and `BYTEPLUS_TRANSCRIPTION_MODELS` are the authoritative lists and are exported for building model pickers. - -## API Reference - -Every factory has an environment-variable form and an explicit-key form. - -### `byteplusText(model, config?)` / `createBytePlusText(model, apiKey, config?)` - -Chat adapter for the Seed, GLM, DeepSeek and gpt-oss models. Reads `ARK_API_KEY` (or `BYTEPLUS_API_KEY`). `config.baseURL` overrides the region endpoint. - -`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). +The levels of each model: -### `byteplusVideo(model, config?)` / `createBytePlusVideo(model, apiKey, config?)` +- `dola-seed-2-1-turbo-260628` and the `seed-*` models: `minimal` to `high`. They cannot stop thinking. +- `glm-5-2-260617`: `off` to `high`. +- `glm-4-7-251222`, `deepseek-v3-2-251201`: `off`, `high`. +- `deepseek-v4-pro-260425`, `deepseek-v4-flash-260425`: `off`, `high`, `max`. +- `gpt-oss-120b-250805`: `low`, `medium`, `high`. -Seedance video adapter (experimental). Reads `ARK_API_KEY`. +What goes over the wire, and how the adapter keeps the `encrypted_content` of the reasoning, is in [Reasoning and `encrypted_content`](./byteplus-advanced#reasoning-and-encrypted_content). -### `byteplusImage(model, config?)` / `createBytePlusImage(model, apiKey, config?)` +### Structured output -Seedream image adapter. Reads `ARK_API_KEY`. +Ten of the 18 chat models support `chat({ outputSchema })`. Use `dola-seed-2-1-turbo-260628` or `seed-2-0-lite-260228`. On the other models, the call fails with an error that names the models that work. See [Structured output](./byteplus-advanced#structured-output). -### `byteplusSpeech(model, config?)` / `createBytePlusSpeech(model, apiKey, config?)` +### Models -Seed Speech TTS adapter. Reads **`BYTEPLUS_VOICE_API_KEY`**. +Newest first. The package exports the full lists: `BYTEPLUS_CHAT_MODELS`, `BYTEPLUS_VIDEO_MODELS`, `BYTEPLUS_IMAGE_MODELS`, `BYTEPLUS_TTS_MODELS`, and `BYTEPLUS_TRANSCRIPTION_MODELS`. -### `byteplusTranscription(model, config?)` / `createBytePlusTranscription(model, apiKey, config?)` +| Activity | Factory | Models | +| --- | --- | --- | +| Chat | `byteplusText` | `dola-seed-2-1-turbo-260628`, `seed-2-0-lite-260428`, `seed-2-0-mini-260428`, `seed-2-0-pro-260328`, `seed-2-0-lite-260228`, `seed-2-0-mini-260215`, `seed-2-0-code-preview-260328`, `seed-1-8-251228`, `seed-1-6-250915`, `seed-1-6-250615`, `seed-1-6-flash-250715`, `seed-1-6-flash-250615`, `glm-5-2-260617`, `glm-4-7-251222`, `deepseek-v4-pro-260425`, `deepseek-v4-flash-260425`, `deepseek-v3-2-251201`, `gpt-oss-120b-250805` | +| Video | `byteplusVideo` | `dreamina-seedance-2-5-260628`, `dreamina-seedance-2-0-260128`, `dreamina-seedance-2-0-fast-260128`, `dreamina-seedance-2-0-mini-260615`, `seedance-1-5-pro-251215`, `seedance-1-0-pro-250528`, `seedance-1-0-pro-fast-251015` | +| Images | `byteplusImage` | `dola-seedream-5-0-pro-260628`, `seedream-5-0-260128`, `seedream-5-0-lite-260128`, `seedream-4-5-251128`, `seedream-4-0-250828` | +| Speech | `byteplusSpeech` | `seed-audio-1.0` | +| Transcription | `byteplusTranscription` | `seed-asr` | -Seed Speech transcription adapter. Reads **`BYTEPLUS_VOICE_API_KEY`**. +BytePlus retires model ids often, and its published lists contain ids that do not work. This package lists only dated ids that answered a live request. -## Provider Tools +`adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). -BytePlus does not expose provider-specific tool factories. Define your own with `toolDefinition()` from `@tanstack/ai` — Ark's tool calling is the standard OpenAI shape and works with the usual [tools flow](../tools/tools.md). +## Advanced -## Next Steps +These topics are on [BytePlus: Advanced](./byteplus-advanced): -- [Video Generation](../media/video-generation) — the full jobs/polling flow and the `useGenerateVideo` hook -- [Image Generation](../media/image-generation) — image-conditioned generation and role hints -- [Structured Outputs](../structured-outputs/one-shot) — schemas, validation and streaming -- [Other Adapters](./openai) — explore other providers +- Regions, and the two BYOK providers. +- Reasoning, `encrypted_content`, and structured output. +- Seedance from the client, the options of each model, and Seedance 2.5. +- Seedream reference images, Seed Speech details, and transcription options. +- The API reference. diff --git a/docs/adapters/claude-code.md b/docs/adapters/claude-code.md index ce41f96c36..35d0002beb 100644 --- a/docs/adapters/claude-code.md +++ b/docs/adapters/claude-code.md @@ -2,7 +2,7 @@ title: Claude Code id: claude-code-adapter order: 11 -description: "Use Claude Code as a chat backend in TanStack AI — agent harness with local tool execution, stateful coding sessions, and tool bridging via @tanstack/ai-claude-code." +description: "Run Anthropic's Claude Code agent inside a sandbox from TanStack AI. It reads, edits, and runs code in a real repo and streams each step to your UI." keywords: - tanstack ai - claude code @@ -14,131 +14,185 @@ keywords: - adapter --- -The Claude Code adapter runs [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (via the `@anthropic-ai/claude-agent-sdk`) as a chat backend. Unlike HTTP provider adapters, this is a **harness adapter**: Claude Code runs its own agent loop and executes its own tools — bash, file reads and edits, glob/grep search, web search — locally on your server. Each `chat()` call runs one full harness turn; the harness's tool activity streams back as already-resolved tool-call events your UI can render. +The Claude Code adapter runs the `claude` CLI in a sandbox and streams each step to `chat()`. The agent reads files, edits them, and runs commands in a repo. -> **Server-only.** The harness spawns the Claude Code runtime as a subprocess, so this adapter only works in a Node.js server environment — never in the browser. Treat it like giving Claude a shell on the machine it runs on, and configure permissions accordingly. +You need: -## Installation +- A sandbox provider. The samples use Docker, so Docker must run on your machine. +- An Anthropic API key, or a `claude login` on the machine. +- The `claude` CLI inside the sandbox. A setup step in the sandbox installs it. + +Do you only want a Claude chat, with no repo and no shell? Use the [Anthropic adapter](./anthropic). + +## 1. Install -react: @tanstack/ai-claude-code -vue: @tanstack/ai-claude-code -solid: @tanstack/ai-claude-code -svelte: @tanstack/ai-claude-code -preact: @tanstack/ai-claude-code -angular: @tanstack/ai-claude-code -vanilla: @tanstack/ai-claude-code -octane: @tanstack/ai-claude-code +react: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +vue: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +solid: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +svelte: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +preact: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +angular: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +vanilla: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +octane: @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-docker -A runnable demo lives at [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare) — pick Claude Code, Codex, or Grok Build in the UI, with session resume, the harness tool timeline, and tool bridging, wired into a TanStack Start app on Workers. For the same wiring on plain Node with durable, refresh-surviving runs (this adapter on Docker), see [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web). +## 2. Set the API key -## Authentication +Put the key in the environment of your server: -Your laptop can already have `claude login`. A CI runner only has -`ANTHROPIC_API_KEY`. The default `authMode` is `'api-key'`. Set `'host'` -when you want `claude login`. See [Harness Auth](../sandbox/auth). - -```ts -import { claudeCodeText } from "@tanstack/ai-claude-code" - -claudeCodeText("claude-opus-4-8") -claudeCodeText("claude-opus-4-8", { authMode: "host" }) +```bash +ANTHROPIC_API_KEY=sk-ant-... ``` -- `'api-key'` (default): inject `ANTHROPIC_API_KEY` (or pass `apiKey`). -- `'host'`: use `claude login`. Do not inject `ANTHROPIC_API_KEY`. +The sandbox in step 3 gives this key to the `claude` process as a workspace secret. -## Basic Usage +Did the machine already run `claude login`? Then you can use that login and no key. See [Harness Auth](../sandbox/auth). -```typescript -import { chat } from "@tanstack/ai"; -import { claudeCodeText } from "@tanstack/ai-claude-code"; +## 3. Run the agent from the server -const stream = chat({ - adapter: claudeCodeText("claude-opus-4-8", { - cwd: "/path/to/project", - permissionMode: "acceptEdits", +Define the sandbox once. It clones your repo into a Docker container and installs the `claude` CLI: + +```ts +// sandbox.ts +import { + createSecrets, + defineSandbox, + defineWorkspace, + githubRepo, +} from "@tanstack/ai-sandbox"; +import { dockerSandbox } from "@tanstack/ai-sandbox-docker"; + +export const sandbox = defineSandbox({ + id: "claude-code-agent", + provider: dockerSandbox({ image: "node:22" }), + workspace: defineWorkspace({ + source: githubRepo({ repo: "owner/app" }), + setup: [ + "npm install -g @anthropic-ai/claude-code --include=optional && claude --version", + ], + secrets: createSecrets({ + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? "", + }), }), - messages: [{ role: "user", content: "Fix the failing test in utils.test.ts" }], }); ``` -`RUN_FINISHED.usage.promptTokens` counts the full input, cached tokens included. See [Token usage](../chat/stream-events#token-usage). +Then pass the adapter and the sandbox to `chat()` in your route: -## Configuration +```ts +// api/agent.ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { claudeCodeText } from "@tanstack/ai-claude-code"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; -| Option | Description | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cwd` | Working directory for the harness session. Defaults to `process.cwd()`. | -| `permissionMode` | Claude Code permission mode (`'default'`, `'acceptEdits'`, `'bypassPermissions'`, `'plan'`, `'dontAsk'`, `'auto'`). See the permissions note below. | -| `allowedTools` | Built-in tools the harness may use without prompting (e.g. `['Read', 'Grep', 'Bash(npm test:*)']`). | -| `disallowedTools` | Built-in tools removed from the harness entirely. | -| `maxTurns` | Maximum harness-internal turns per run. | -| `systemPromptMode` | `'append'` (default) keeps Claude Code's preset system prompt and appends your `systemPrompts`; `'replace'` sends yours as the entire prompt. | -| `mcpServers` | Extra MCP servers passed through to the harness untouched. | -| `authMode` | `'api-key'` (default) injects `ANTHROPIC_API_KEY`. `'host'` uses `claude login`. Also valid on `modelOptions`. See [Harness Auth](../sandbox/auth). | -| `apiKey` | Anthropic API key for the harness subprocess. | -| `env` | Extra environment variables for the harness subprocess. | -| `pathToClaudeCodeExecutable` | Use a specific Claude Code executable instead of the SDK's bundled one. | -| `streamPartials` | Emit true token-level deltas for text and tool-call arguments (default `true`). When `false`, both emit whole from the complete message. | -| `canUseTool` | Custom permission handler; replaces the adapter's default handler. | -| `settingSources` | Claude Code settings tiers to load. Default `['project']`: the workspace's `CLAUDE.md`, `.claude/skills`, and `.mcp.json` apply, and the host's `~/.claude` (plugins, hooks, skills) is not loaded. Pass `['user', 'project', 'local']` for CLI-equivalent behavior, or `[]` to load no settings. | +export async function POST(request: Request) { + const { messages, threadId, runId } = await chatParamsFromRequest(request); -**Permissions on headless servers.** Without an explicit `permissionMode` or `canUseTool`, the adapter installs a safe default handler: bridged TanStack tools always run, and any built-in tool call that would normally prompt a human is denied with guidance instead of hanging the request. To let the harness edit files or run commands, set `permissionMode: 'acceptEdits'` / `'bypassPermissions'`, or enumerate `allowedTools`. + const stream = chat({ + adapter: claudeCodeText("claude-opus-4-8"), + messages, + threadId, + runId, + middleware: [withSandbox(sandbox)], + }); -## Reasoning + return toServerSentEventsResponse(stream); +} +``` -Set `reasoning` on `chat()`. The adapter turns it into a Claude Code setting: +Replace `owner/app` with your repo. -```typescript -import { chat } from "@tanstack/ai"; -import { claudeCodeText } from "@tanstack/ai-claude-code"; +## 4. Render the agent's work -const stream = chat({ - adapter: claudeCodeText("claude-opus-4-8"), - messages: [{ role: "user", content: "Refactor the auth module" }], - reasoning: "xhigh", -}); +The client is the same as for any chat. Render the text, and show each tool that the agent ran: + +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; + +export function CodingAgent() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/agent"), + threadId: "support-chat", + }); + + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => + message.parts.map((part, index) => { + if (part.type === "text") { + return

{part.content}

; + } + if (part.type === "tool-call") { + return {part.name}; + } + return null; + }), + )} + setInput(event.target.value)} /> +
+ ); +} ``` -- `claude-opus-4-8`, `claude-opus-4-7`, `opus`: `--effort`, `low` to `max`. -- `claude-opus-4-6`, `claude-sonnet-4-6`, `sonnet`: `--effort`, `low`, `medium`, `high`, `max`. -- `claude-haiku-4-5`, `haiku`: `MAX_THINKING_TOKENS`, `off` to `high`. The level picks the budget, or you set it with `budgetTokens`. `off` sends `0`. +Ask it "Fix the failing test in utils.test.ts". You see the agent's text, plus a line for each tool it used: `Read`, `Edit`, `Bash`, and more. The agent edited the files inside the container. -## Stateful Sessions +## How it works -Claude Code sessions are stateful — the harness keeps the full working context (files read, commands run, conclusions reached) between turns. The adapter surfaces the session id of every run as a custom stream event named `claude-code.session-id`; thread it back via `modelOptions.sessionId` to resume the session. When resuming, only the latest user message is sent — the harness already holds the prior context. +- `claudeCodeText` declares that it needs a sandbox. `withSandbox(sandbox)` gives it one. Without a sandbox, `chat()` fails at the call site. +- Each `chat()` call runs one full agent turn. Claude Code runs its own loop and its own tools. +- The tools of the agent stream back as `tool-call` parts with the results attached. Your code does not run them. +- The agent runs with `permissionMode: 'bypassPermissions'` by default. The sandbox is the safety boundary. To limit what the agent can do, add a sandbox [Policy](../sandbox/policy). +- When the run ends, the adapter sends a `file.changed` custom event with the `git diff` of the workspace. -The `claude-code.session-id` event payload also includes `skills`, an array of skills loaded during SDK initialization (or an empty array when none are reported). +To learn more about sandboxes, see [Sandboxes](../sandbox/overview). -Server endpoint: +## Resume a session -```typescript +Claude Code keeps its working context between turns: the files it read, the commands it ran, and what it found. The adapter sends the session id of each run as a custom event named `claude-code.session-id`. Send it back in `modelOptions.sessionId` to continue the session. + +On the server, read the id from the request: + +```ts import { chat, chatParamsFromRequest, toServerSentEventsResponse, } from "@tanstack/ai"; import { claudeCodeText } from "@tanstack/ai-claude-code"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; export async function POST(request: Request) { const params = await chatParamsFromRequest(request); - // Extra fields the client puts in the connection `body` arrive here. + // The client sends the id in `forwardedProps`. const sessionId = typeof params.forwardedProps.sessionId === "string" ? params.forwardedProps.sessionId : undefined; const stream = chat({ - adapter: claudeCodeText("claude-opus-4-8", { - cwd: "/path/to/project", - permissionMode: "acceptEdits", - }), + adapter: claudeCodeText("claude-opus-4-8"), messages: params.messages, + threadId: params.threadId, + runId: params.runId, + middleware: [withSandbox(sandbox)], modelOptions: { sessionId }, }); @@ -146,20 +200,19 @@ export async function POST(request: Request) { } ``` -Client (React) — capture the session id from the custom event and send it back on subsequent requests: +On the client, keep the id from the custom event and pass it to `forwardedProps`. `useChat` sends the latest value with each request: -```typescript +```tsx import { useState } from "react"; -import { useChat } from "@tanstack/ai-react"; -import { fetchServerSentEvents } from "@tanstack/ai-client"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; -function CodingAssistant() { +export function useCodingAgent() { const [sessionId, setSessionId] = useState(undefined); - const { messages, sendMessage } = useChat({ - connection: fetchServerSentEvents("/api/chat", () => ({ - body: { sessionId }, - })), + return useChat({ + connection: fetchServerSentEvents("/api/agent"), + forwardedProps: { sessionId }, + threadId: "support-chat", onCustomEvent: (name, value) => { if ( name === "claude-code.session-id" && @@ -172,26 +225,23 @@ function CodingAssistant() { } }, }); - - // ... render messages; harness tool activity (Bash, Edit, Read, ...) - // arrives as regular tool-call parts with their results attached. } ``` -Sessions are stored on the machine that ran them (`~/.claude/projects/`), so resuming only works on the same server instance. Pass `modelOptions: { forkSession: true }` alongside `sessionId` to branch a session instead of continuing it. +- On a resumed session, the adapter sends only the latest user message. The agent already has the rest. +- The event value also has `skills`: the skills that Claude Code loaded when it started. It is an empty array when Claude Code reports none. +- Claude Code saves the session inside the sandbox. Resume works only when the next call runs in the same sandbox. See [Lifecycle & Snapshots](../sandbox/lifecycle). -## Tools +## Give the agent your own tools -Two kinds of tools flow through this adapter: +The agent has its own tools for files and the shell. It cannot reach your database or your secrets. Pass your server tools to `chat()`, and the adapter bridges them into the sandbox: -1. **Built-in harness tools** (`Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep`, `WebSearch`, ...) are executed by Claude Code itself. Their activity streams back as tool-call events with results already attached, so `useChat` UIs render them with no extra wiring — but your code never executes them. - -2. **Your TanStack tools** are bridged *into* the harness as an in-process MCP server. Define them as usual with `toolDefinition().server()`; the model sees them as `mcp__tanstack__` and the adapter strips the prefix on the way back out, so events match the names you registered. - -```typescript group=tools +```ts group=tools import { z } from "zod"; import { chat, toolDefinition } from "@tanstack/ai"; import { claudeCodeText } from "@tanstack/ai-claude-code"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; const lookupTicket = toolDefinition({ name: "lookup_ticket", @@ -204,22 +254,30 @@ const lookupTicket = toolDefinition({ const stream = chat({ adapter: claudeCodeText("claude-opus-4-8"), messages: [{ role: "user", content: "What's the status of ticket T-123?" }], + threadId: "thread-1", tools: [lookupTicket], + middleware: [withSandbox(sandbox)], }); ``` -**Client-side and approval-gated tools are not supported.** The harness executes tools inside a live subprocess, which cannot pause across HTTP requests to wait for a browser round-trip or a human approval. Passing a tool without a server `execute()` implementation — or one marked `needsApproval` — fails fast with a descriptive error. Run those tools outside the harness with a regular provider adapter. +- Each tool runs on your server, with its closures and secrets. The result goes back into the sandbox. +- The agent sees the tool as `mcp__tanstack__lookup_ticket`. The adapter removes the prefix, so the events use the name you gave. +- Only server tools work here. A client tool has no server `execute()`, so the agent cannot run it. + +For how the bridge reaches your server from each provider, see [Sandbox Tools](../sandbox/tools). ## Tool choice -You want Claude Code to answer in text only, or to use just one of your tools. Pass `toolChoice` to `chat()`: +You want Claude Code to answer in text only, or to use only one of your tools. Pass `toolChoice` to `chat()`: -```typescript group=tools +```ts group=tools const onlyLookup = chat({ adapter: claudeCodeText("claude-opus-4-8"), messages: [{ role: "user", content: "What's the status of ticket T-123?" }], + threadId: "thread-1", tools: [lookupTicket], toolChoice: { type: "tool", name: "lookup_ticket" }, + middleware: [withSandbox(sandbox)], }); ``` @@ -230,78 +288,166 @@ const onlyLookup = chat({ | `{ type: 'tool', name }` | Turns off the built-in tools and bridges only the named tool. Claude Code can call only this tool, but it does not have to. | | `'required'` | No change. Claude Code cannot force a tool call, so the adapter logs a warning the first time. | -For `'none'` and a named tool, the adapter passes `--tools ""` and `--strict-mcp-config`. Then other MCP servers, for example from the workspace or a settings file, do not load. - For the values on other adapters, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool). -## Structured Output - -Pass `outputSchema` on `chat()`. Claude Code runs one harness turn, uses its native tools, and returns a typed object. The schema JSON is passed to `--json-schema` as inline JSON (the CLI rejects a file path). Tool activity and prose stream as usual. The object arrives as `structured-output.complete`, including when Claude delivers it through its built-in `StructuredOutput` tool. +## Structured output -By default, the adapter loads only project settings (`--setting-sources project`). Workspace projections (instructions, skills, MCP config) are project-scoped, so they apply. The host's `~/.claude` is not loaded, so a local-process run does not pick up your personal skills and hooks. A cloned repo's `.claude/settings.json` and hooks apply to the run. Set `settingSources` to change this. The adapter does not pass `--bare`, because that flag ignores a host `claude login`. - -On local-process, Claude uses your host `claude login`. On Docker, pass `ANTHROPIC_API_KEY` in the process env. A container has no host login. +You want a typed report after the agent reads the repo. Pass `outputSchema` on the same `chat()` call: ```ts -import { chat } from "@tanstack/ai" -import { claudeCodeText } from "@tanstack/ai-claude-code" -import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox" -import { dockerSandbox } from "@tanstack/ai-sandbox-docker" -import { z } from "zod" +import { chat } from "@tanstack/ai"; +import { claudeCodeText } from "@tanstack/ai-claude-code"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { z } from "zod"; +import { sandbox } from "./sandbox"; const Report = z.object({ summary: z.string(), filesChanged: z.array(z.string()), -}) - -const sandbox = defineSandbox({ - id: "repo-report", - provider: dockerSandbox({ image: "node:22" }), -}) +}); const report = await chat({ adapter: claudeCodeText("claude-opus-4-8"), messages: [{ role: "user", content: "Review this repo." }], + threadId: "thread-1", outputSchema: Report, middleware: [withSandbox(sandbox)], -}) +}); -report.summary +console.log(report.summary); ``` On the client, pass the same schema to `useChat` and read `final`. `partial` stays empty until the end. ```tsx -import { fetchServerSentEvents, useChat } from "@tanstack/ai-react" -import { z } from "zod" +import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"; +import { z } from "zod"; const Report = z.object({ summary: z.string(), filesChanged: z.array(z.string()), -}) +}); -function ReportView() { +export function ReportView() { const { final, isLoading } = useChat({ connection: fetchServerSentEvents("/api/repo-report"), + threadId: "support-chat", outputSchema: Report, - }) + }); - if (isLoading) return

The agent is inspecting the repo.

- if (!final) return null - return

{final.summary}

+ if (isLoading) return

The agent is inspecting the repo.

; + if (!final) return null; + return

{final.summary}

; } ``` -If you only need to extract JSON from a prompt and do not need a sandbox, use `@tanstack/ai-anthropic`. That path is faster. +- Claude Code runs one turn with its own tools. Tool activity and text stream as usual. +- The adapter passes the schema to `--json-schema` as inline JSON. The CLI does not take a file path. +- The object arrives as `structured-output.complete`. This also works when Claude sends it through its built-in `StructuredOutput` tool. + +Do you only need JSON from a prompt, with no repo? Use `@tanstack/ai-anthropic`. That is faster. For the full walkthrough, see [Harness Agents](../structured-outputs/harnesses). + +## Models and options + +### Models + +| Model | Reasoning levels | What `reasoning` sets | +| --- | --- | --- | +| `claude-opus-4-8`, `claude-opus-4-7`, `opus` | `low`, `medium`, `high`, `xhigh`, `max` | `--effort` | +| `claude-opus-4-6`, `claude-sonnet-4-6`, `sonnet` | `low`, `medium`, `high`, `max` | `--effort` | +| `claude-haiku-4-5`, `haiku` | `off`, `minimal`, `low`, `medium`, `high` | `MAX_THINKING_TOKENS` | + +The package exports this list as `CLAUDE_CODE_MODELS`. The adapter also takes any other Claude model id as a string. + +### Reasoning + +Set `reasoning` on `chat()`: + +```ts +import { chat } from "@tanstack/ai"; +import { claudeCodeText } from "@tanstack/ai-claude-code"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; + +const stream = chat({ + adapter: claudeCodeText("claude-opus-4-8"), + messages: [{ role: "user", content: "Refactor the auth module" }], + threadId: "thread-1", + reasoning: "xhigh", + middleware: [withSandbox(sandbox)], +}); +``` + +For `claude-haiku-4-5`, the level picks a token budget. You can also set the budget with `budgetTokens`. `off` sends `0`. See [Reasoning](../chat/reasoning). + +### Adapter options + +Pass these as the second argument of `claudeCodeText`: + +| Option | What it does | +| --- | --- | +| `cwd` | The working directory inside the sandbox. Default `/workspace`. | +| `permissionMode` | `'default'`, `'acceptEdits'`, `'bypassPermissions'`, or `'plan'`. Default `'bypassPermissions'`. | +| `allowedTools` | Built-in tools that the agent can use (`--allowedTools`), for example `['Read', 'Grep', 'Bash(npm test:*)']`. | +| `disallowedTools` | Built-in tools that the agent cannot use (`--disallowedTools`). | +| `addDirs` | More directories that the agent can access (`--add-dir`). | +| `maxTurns` | The maximum number of agent turns in one run. | +| `settingSources` | Which Claude Code settings to load. Default `['project']`. See [Settings sources](#settings-sources). | +| `systemPromptMode` | `'append'` (default) adds your `systemPrompts` to the Claude Code prompt. `'replace'` sends only yours. | +| `authMode` | `'api-key'` (default) gives `ANTHROPIC_API_KEY` to the process. `'host'` uses `claude login`. See [Harness Auth](../sandbox/auth). | +| `claudeExecutable` | The path or name of the `claude` executable in the sandbox. Default `claude`. | +| `streamPartials` | Stream text and tool arguments token by token. Default `true`. With `false`, each arrives whole. | +| `env` | More environment variables for the `claude` process. | +| `emitDiff` | Send the `file.changed` event with the `git diff` after the run. Default `true`. | + +### Per-call options + +Pass these in `modelOptions` on `chat()`. They override the adapter options for one call: + +- `sessionId`: resume a session. See [Resume a session](#resume-a-session). +- `cwd`, `maxTurns`, `permissionMode`, `allowedTools`, `disallowedTools`. +- `authMode`: `'host'` or `'api-key'`. + +## Advanced + +### Permissions and policy + +A headless run has no person to answer a permission prompt. So the default `permissionMode` is `'bypassPermissions'`, and the sandbox is the boundary. + +- To allow fewer actions, set `permissionMode`, `allowedTools`, or `disallowedTools`. +- To allow, ask, or deny each command and capability, add a sandbox [Policy](../sandbox/policy). The adapter maps the policy to Claude Code flags and answers the permission prompts of Claude Code from it. +- If the policy says `ask`, the adapter sends an approval-requested event and denies the action. The client approves, then runs the turn again. + +### Settings sources + +By default, the adapter loads only the project settings (`--setting-sources project`): + +- The workspace `CLAUDE.md`, `.claude/skills`, and `.mcp.json` apply to the run. +- A cloned repo's `.claude/settings.json` and hooks also apply. +- The host `~/.claude` (your personal plugins, hooks, and skills) does not load. This matters on the local-process provider. + +For the same behavior as the CLI, pass `settingSources: ['user', 'project', 'local']`. To load no settings, pass `[]`. + +The adapter does not pass `--bare`, because that flag ignores a host `claude login`. + +### How tool choice changes the CLI flags + +For `'none'` and a named tool, the adapter passes `--tools ""` and `--strict-mcp-config`. Then other MCP servers, for example from the workspace or a settings file, do not load. + +### Token usage + +`RUN_FINISHED.usage.promptTokens` counts the full input, cached tokens included. See [Token usage](../chat/stream-events#token-usage). -Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses). +### Limitations -## Limitations +- **A sandbox is required.** Every call needs `withSandbox(...)`. +- **The agent owns the loop.** TanStack agent-loop strategies and per-iteration middleware do not apply inside a turn. Use `maxTurns` to limit the turn. +- **No sampling options.** Options such as `temperature` do not exist here. +- **Sessions stay in the sandbox.** Resume needs the same sandbox. +- **Cold starts.** Each call starts a CLI turn, so the first token arrives later than with an HTTP adapter. +- **No `wrapFetch`.** This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the `claude` process sends the model requests. -- **Server-only (Node).** The harness spawns a subprocess; Windows support is untested. -- **The harness owns the agent loop.** TanStack's agent-loop strategies and per-iteration middleware don't apply inside a harness turn; `maxTurns` is the equivalent control. -- **No sampling controls.** `temperature`-style options don't exist here. -- **Sessions are machine-local.** Resume requires hitting the same server instance. -- **Cold starts.** Each call spawns a harness turn; expect higher first-token latency than HTTP adapters. +### Example apps -This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the harness process sends the model requests. +- [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare): a TanStack Start app on Workers. You pick Claude Code, Codex, or Grok Build in the UI. It shows session resume, the tool timeline, and tool bridging. +- [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web): this adapter on Docker, with durable runs that continue after a page refresh. diff --git a/docs/adapters/cloudflare.md b/docs/adapters/cloudflare.md index 9abf504050..7f0fc20247 100644 --- a/docs/adapters/cloudflare.md +++ b/docs/adapters/cloudflare.md @@ -1,7 +1,7 @@ --- title: Cloudflare id: cloudflare-adapter -description: "Run Workers AI chat, embeddings, images, speech, transcription, and evaluate from a Cloudflare Worker or any server, and route other providers through AI Gateway, with TanStack AI." +description: "Run Workers AI chat, embeddings, images, speech, transcription, and evaluate from a Cloudflare Worker or any server, and send other providers through AI Gateway, with TanStack AI." keywords: - tanstack ai - cloudflare @@ -14,9 +14,13 @@ keywords: - jev --- -You have a Cloudflare Worker and want an AI route without managing API keys. Or you have a server elsewhere and want to call Workers AI models. Or you already use OpenAI or Anthropic and want AI Gateway caching and logs in front of them. This adapter does all three. +The Cloudflare adapter calls Workers AI, and it can put AI Gateway in front of another provider. -## Installation +- You have a Cloudflare Worker and want an AI route with no API keys to manage. +- You have a server somewhere else and want to call Workers AI models. +- You use OpenAI or Anthropic and want AI Gateway caching and logs in front of them. + +## 1. Install @@ -31,11 +35,9 @@ octane: @tanstack/ai-cloudflare -## Chat from a Worker - -Pass `env.AI` as the binding. No token, no account id. +## 2. Connect to Cloudflare -1. Add the binding to `wrangler.jsonc`: +Inside a Worker, use the `env.AI` binding. You need no token and no account id. Add the binding to `wrangler.jsonc`: ```jsonc { @@ -43,10 +45,25 @@ Pass `env.AI` as the binding. No token, no account id. } ``` -2. Stream a chat response from your Worker: +On any other server, the adapter calls the Cloudflare REST API. Put your account id and an API token with Workers AI access in the environment of your server: + +```bash +CLOUDFLARE_ACCOUNT_ID=your-account-id +CLOUDFLARE_API_TOKEN=your-api-token +``` + +Get the account id from `wrangler whoami`. Create the token in the Cloudflare dashboard, under Workers AI, then Use REST API. + +## 3. Stream a chat from the server + +From a Worker, pass `env.AI` as the binding: ```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; import { createCloudflareText } from "@tanstack/ai-cloudflare"; import type { Ai } from "@cloudflare/workers-types"; @@ -56,13 +73,15 @@ interface Env { export default { async fetch(request: Request, env: Env) { - const { messages } = await request.json(); + const { messages, threadId, runId } = await chatParamsFromRequest(request); const stream = chat({ adapter: createCloudflareText("@cf/zai-org/glm-5.3-flash", { binding: env.AI, }), messages, + threadId, + runId, }); return toServerSentEventsResponse(stream); @@ -70,36 +89,86 @@ export default { }; ``` -That is a working AI route. Run `wrangler dev` and post `{ "messages": [...] }` to it. +Run `wrangler dev` to start the route. -## Chat from anywhere else +From any other server, `cloudflareText` reads `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`: -Outside a Worker, the adapter talks to the Cloudflare REST API. You need your account id and an API token with Workers AI access. +```typescript +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { cloudflareText } from "@tanstack/ai-cloudflare"; + +export async function POST(request: Request) { + const { messages, threadId, runId } = await chatParamsFromRequest(request); + + const stream = chat({ + adapter: cloudflareText("@cf/zai-org/glm-5.3-flash"), + messages, + threadId, + runId, + }); + + return toServerSentEventsResponse(stream); +} +``` + +To pass the values in code, use `createCloudflareText`: ```typescript -import { chat } from "@tanstack/ai"; import { createCloudflareText } from "@tanstack/ai-cloudflare"; const adapter = createCloudflareText("@cf/zai-org/glm-5.3-flash", { accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, apiKey: process.env.CLOUDFLARE_API_TOKEN!, }); - -const stream = chat({ - adapter, - messages: [{ role: "user", content: "Hello!" }], -}); ``` -`cloudflareText(model)` does the same and reads `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` from the environment for you. +## 4. Render the chat + +The client is the same for every provider. Point `useChat` at your route: + +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; -The text and summarize REST config also accepts the OpenAI SDK client options (`baseURL`, `defaultHeaders`, `fetch`, `timeout`, `maxRetries`). The other adapters call the native `/ai/run` endpoint and take `fetch` only. +export function Chat() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", + }); + + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => ( +

+ {message.parts + .map((part) => (part.type === "text" ? part.content : "")) + .join("")} +

+ ))} + setInput(event.target.value)} /> +
+ ); +} +``` + +For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat). -Need a header on each request, or a log of each model call? The chat adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call) over REST. In binding mode, the wrapper still runs. The binding reads only the request body, so it drops the URL and the headers that the wrapper sets. +You now have a streaming chat on Workers AI. [Tools](../tools/tools) and [Structured Outputs](../structured-outputs/overview) work the same as with every adapter. ## Route through AI Gateway -AI Gateway gives you caching, logs, rate limits, and one bill across providers. Add `gateway` to any config to send requests through it. Use `"default"` for the gateway Cloudflare creates for your account. +AI Gateway gives you caching, logs, rate limits, and one bill for all providers. To send requests through it, add `gateway` to any config. Use `"default"` for the gateway that Cloudflare makes for your account: ```typescript import { createCloudflareText } from "@tanstack/ai-cloudflare"; @@ -117,7 +186,7 @@ export function makeAdapter(env: Env) { } ``` -With a gateway you can also run third-party models by their catalog id, billed through Cloudflare: +With a gateway, you can also run third-party models by their catalog id. Cloudflare bills them: ```typescript import { createCloudflareText } from "@tanstack/ai-cloudflare"; @@ -131,7 +200,7 @@ const adapter = createCloudflareText("openai/gpt-5.5", { ### Keep your existing provider adapter -Already using `@tanstack/ai-openai` with your own OpenAI key? `cloudflareGateway()` returns the `baseURL` and headers that point that adapter at your gateway. The rest of your code stays the same. +Do you use `@tanstack/ai-openai` with your own OpenAI key? `cloudflareGateway()` returns the `baseURL` and the headers that point that adapter at your gateway. The rest of your code stays the same: ```typescript import { createOpenaiChat } from "@tanstack/ai-openai"; @@ -143,22 +212,27 @@ const gateway = cloudflareGateway("openai", { cacheTtl: 300, }); -const adapter = createOpenaiChat("gpt-5.5", process.env.OPENAI_API_KEY!, { +const adapter = createOpenaiChat("gpt-6.1-sol", process.env.OPENAI_API_KEY!, { baseURL: gateway.baseURL, defaultHeaders: gateway.headers, }); ``` -The first argument is the provider slug from your gateway dashboard (`openai`, `anthropic`, `groq`, and so on). If the gateway has authentication turned on, pass `cfApiKey` too. That token needs the `AI Gateway Run` permission. +- The first argument is the provider slug from your gateway dashboard, for example `openai`, `anthropic`, or `groq`. +- If the gateway has authentication on, also pass `cfApiKey`. That token needs the `AI Gateway Run` permission. -The `ts-react-chat` example does this. Set `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`, and `CLOUDFLARE_AI_GATEWAY_ID` in its `.env`, and the Cloudflare, OpenAI, Anthropic, and Groq models in its picker all go through your gateway. +The `ts-react-chat` example does this. Set `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`, and `CLOUDFLARE_AI_GATEWAY_ID` in its `.env`. Then the Cloudflare, OpenAI, Anthropic, and Groq models in its picker all go through your gateway. ### Claude and GPT from a Worker -Inside a Worker, the AI Gateway `anthropic/...` and `openai/...` models use their provider's own API, not Chat Completions. Keep the provider adapter, and give it `cloudflareBindingFetch` as its `fetch`. The binding signs the requests, so the Worker needs no provider key: +Inside a Worker, the AI Gateway `anthropic/...` and `openai/...` models use the API of their own provider, not Chat Completions. Keep the provider adapter, and give it `cloudflareBindingFetch` as its `fetch`. The binding signs the requests, so the Worker needs no provider key: ```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; import { createAnthropicChat } from "@tanstack/ai-anthropic"; import { cloudflareBindingFetch } from "@tanstack/ai-cloudflare"; import type { Ai } from "@cloudflare/workers-types"; @@ -169,7 +243,7 @@ interface Env { export default { async fetch(request: Request, env: Env) { - const { messages } = await request.json(); + const { messages, threadId, runId } = await chatParamsFromRequest(request); // The SDK needs a key value. The binding does not use it. const adapter = createAnthropicChat("claude-opus-5-5", "cloudflare-binding", { @@ -180,7 +254,7 @@ export default { }), }); - const stream = chat({ adapter, messages, reasoning: "high" }); + const stream = chat({ adapter, messages, threadId, runId, reasoning: "high" }); return toServerSentEventsResponse(stream); }, }; @@ -188,33 +262,22 @@ export default { - `vendor: "anthropic"` sends Anthropic Messages requests to `anthropic/`. Use it with `createAnthropicChat`. - `vendor: "openai"` sends OpenAI Responses requests to `openai/`. Use it with `createOpenaiChat`. -- The adapter keeps all of its options: `chat({ reasoning })`, tools, `cache_control` for prompt caching, and Anthropic betas, which go out as the `anthropic-beta` header. +- The adapter keeps all of its options: `chat({ reasoning })`, tools, `cache_control` for prompt caching, and Anthropic betas (sent as the `anthropic-beta` header). - For `@cf/...` models and other gateway vendors, keep `createCloudflareText` with `binding: env.AI`. -An adapter with a gateway `baseURL` (`cloudflareGateway()`) or with `cloudflareBindingFetch` sends the full tools and system prompts on every call, also on a model with a [mid-conversation channel](../advanced/mid-conversation-changes). AI Gateway then gets the same request as on any other model. To send tools and prompts added during a conversation through the channel, set `midConversationChannels: true`: +## Bring your own key -```typescript -import { createAnthropicChat } from "@tanstack/ai-anthropic"; -import { cloudflareBindingFetch } from "@tanstack/ai-cloudflare"; -import type { Ai } from "@cloudflare/workers-types"; +Two different things have this name. Both work. -export function gatewayClaude(env: { AI: Ai }) { - return createAnthropicChat("claude-opus-5-5", "cloudflare-binding", { - fetch: cloudflareBindingFetch({ - binding: env.AI, - vendor: "anthropic", - gateway: { id: "default" }, - }), - midConversationChannels: true, - }); -} -``` +### Cloudflare credentials of a user, from the browser -## Bring your own key +A user brings two values: the API token, and the account that it belongs to. -Two different things go by this name. Both work. +- `cloudflareByok` and `cloudflareAccountByok` are two entries in the TanStack BYOK store. Each one has its own env fallback. +- `cloudflareByok` declares the account id as a companion. Register both, and a send for `cloudflare` carries both headers. +- Import them from `@tanstack/ai-cloudflare/byok`, not from the main entry. -**A user's Cloudflare credentials from the browser.** A user brings two values: the API token and the account it belongs to. `cloudflareByok` and `cloudflareAccountByok` are two entries in the TanStack BYOK store, each with its own env fallback. `cloudflareByok` declares the account id as a companion, so register both and a send for `cloudflare` carries both headers. Import them from `@tanstack/ai-cloudflare/byok`, not from the main entry. +On the client: ```typescript import { defineByok, defaultByokStorage } from "@tanstack/ai-client/byok"; @@ -232,7 +295,11 @@ export const byok = defineByok({ On the relay, read both values in one call: ```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; import { createCloudflareText } from "@tanstack/ai-cloudflare"; import { cloudflareAccountByok, @@ -248,13 +315,15 @@ export async function POST(request: Request) { if (!apiKey) return byokMissing(cloudflareByok); if (!accountId) return byokMissing(cloudflareAccountByok); - const { messages } = await request.json(); + const { messages, threadId, runId } = await chatParamsFromRequest(request); const stream = chat({ adapter: createCloudflareText("@cf/zai-org/glm-5.3-flash", { accountId, apiKey, }), messages, + threadId, + runId, }); return toServerSentEventsResponse(stream); } @@ -262,84 +331,13 @@ export async function POST(request: Request) { See [Bring Your Own Key](../advanced/byok) for the client store and the save dialog. -**Your provider keys stored in AI Gateway.** Add an OpenAI or Anthropic key under Provider Keys in the gateway dashboard, alias `default`. From then on, a `provider/model` id with `gateway: { id }` uses that key instead of Unified Billing. No code changes. - -## Models - -Any id from the [Workers AI catalog](https://developers.cloudflare.com/workers-ai/models/) works, and so does any `provider/model` id from the [AI Gateway catalog](https://developers.cloudflare.com/ai/models/) when a gateway is set. Ids in the bundled catalog get editor autocomplete. - -## Tools and structured output - -Tools and `outputSchema` work the same as with every other adapter: - -```typescript -import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai"; -import { createCloudflareText } from "@tanstack/ai-cloudflare"; -import { z } from "zod"; -import type { Ai } from "@cloudflare/workers-types"; - -interface Env { - AI: Ai; -} - -const getWeather = toolDefinition({ - name: "get_weather", - description: "Get the current weather", - inputSchema: z.object({ location: z.string() }), -}).server(async ({ location }) => { - return { location, temperature: 21, conditions: "sunny" }; -}); - -export default { - async fetch(request: Request, env: Env) { - const { messages } = await request.json(); - - const stream = chat({ - adapter: createCloudflareText("@cf/zai-org/glm-5.3-flash", { - binding: env.AI, - }), - messages, - tools: [getWeather], - }); - - return toServerSentEventsResponse(stream); - }, -}; -``` - -## Model options - -Sampling controls go in `modelOptions`, and the thinking level goes in `reasoning`. Reasoning models stream their thinking as `reasoning_content`, which shows up as `REASONING_*` events. - -```typescript -import { chat } from "@tanstack/ai"; -import { cloudflareText } from "@tanstack/ai-cloudflare"; - -const stream = chat({ - adapter: cloudflareText("@cf/zai-org/glm-5.3-flash"), - messages: [{ role: "user", content: "Summarize this in one sentence." }], - modelOptions: { - temperature: 0.3, - max_tokens: 512, - }, - reasoning: "low", -}); -``` - -### Reasoning +### Your provider keys stored in AI Gateway -`reasoning` goes out as `reasoning_effort`. `off` sends `null`, which turns thinking off. Some models and their levels: - -- `@cf/zai-org/glm-5.3`, `@cf/zai-org/glm-5.3-flash`: `low`, `high`, `max`. -- `@cf/deepseek-ai/deepseek-v4-pro-0813`, `@cf/deepseek-ai/deepseek-v4-flash-0731`: `off`, `low`, `high`, `max`. -- `@cf/openai/gpt-oss-120b`, `@cf/openai/gpt-oss-20b`: `low`, `medium`, `high`. - -The types take the levels of each model. A model with no reasoning data takes no `reasoning`. +Add an OpenAI or Anthropic key under Provider Keys in the gateway dashboard, with the alias `default`. Then a `provider/model` id with `gateway: { id }` uses that key, not Unified Billing. You change no code. ## Evaluate -Use `cloudflareDecider('typesafe/jev')` with `decide()`. -Pass `gateway: { id: 'default' }` on the adapter config to send the run through AI Gateway. +Sometimes you need an answer that your code can branch on, not chat text. Use `cloudflareDecider("typesafe/jev")` with `decide()`. To send the run through AI Gateway, add `gateway: { id: "default" }` to the adapter config. From a Worker, pass the binding: @@ -387,8 +385,7 @@ export async function evaluateTicket(env: Env) { } ``` -From any other server, `cloudflareDecider` reads `CLOUDFLARE_ACCOUNT_ID` and -`CLOUDFLARE_API_TOKEN`: +From any other server, `cloudflareDecider` reads `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`: ```typescript import { decide, boolean } from "@tanstack/ai"; @@ -407,14 +404,19 @@ const result = await decide({ console.log(result.refund.value); ``` -See the [Evaluate guide](../evaluate/evaluate) for question helpers, the result -shape, abort, and middleware. +See the [Evaluate guide](../evaluate/evaluate) for the question helpers, the result shape, abort, and middleware. ## Summarize, embed, and media -Every activity takes the same config as chat: `{ binding }` inside a Worker, `{ accountId, apiKey }` elsewhere, plus an optional `gateway`. The examples below use the env-reading factories. +Every activity takes the same config as chat: + +- Inside a Worker: `{ binding }`. +- On any other server: `{ accountId, apiKey }`. +- Both take an optional `gateway`. -Summarize: +The samples below use the factories that read the environment. + +### Summarize ```typescript import { summarize } from "@tanstack/ai"; @@ -423,11 +425,14 @@ import { cloudflareSummarize } from "@tanstack/ai-cloudflare"; const result = await summarize({ adapter: cloudflareSummarize("@cf/zai-org/glm-5.3-flash"), text: "Long article text...", + threadId: "thread-1", stream: false, }); + +console.log(result.summary); ``` -Embeddings: +### Embeddings ```typescript import { embed } from "@tanstack/ai"; @@ -437,10 +442,11 @@ const result = await embed({ adapter: cloudflareEmbedding("@cf/baai/bge-m3"), input: ["a story about a llama", "a story about a cloud"], }); -// result.embeddings[0].vector + +console.log(result.embeddings[0]?.vector); ``` -Images: +### Images ```typescript import { generateImage } from "@tanstack/ai"; @@ -449,12 +455,14 @@ import { cloudflareImage } from "@tanstack/ai-cloudflare"; const result = await generateImage({ adapter: cloudflareImage("@cf/black-forest-labs/flux-1-schnell"), prompt: "a red bicycle on a beach", + threadId: "thread-1", modelOptions: { steps: 4 }, }); -// result.images[0].b64Json + +console.log(result.images[0]?.b64Json); ``` -Text to speech: +### Text to speech ```typescript import { generateSpeech } from "@tanstack/ai"; @@ -463,12 +471,14 @@ import { cloudflareTTS } from "@tanstack/ai-cloudflare"; const result = await generateSpeech({ adapter: cloudflareTTS("@cf/deepgram/aura-2-en"), text: "Hello from the edge.", + threadId: "thread-1", voice: "luna", }); -// result.audio is base64 MP3 + +console.log(result.audio); // base64 MP3 ``` -Transcription: +### Transcription ```typescript import { generateTranscription } from "@tanstack/ai"; @@ -478,19 +488,93 @@ import { audioBuffer } from "./audio"; const result = await generateTranscription({ adapter: cloudflareTranscription("@cf/openai/whisper-large-v3-turbo"), audio: audioBuffer, + threadId: "thread-1", }); -// result.text, result.segments, result.words + +console.log(result.text, result.segments, result.words); ``` -Whisper models take the audio as base64. `@cf/deepgram/nova-3` takes the raw bytes. The adapter handles both, so pass a `File`, `Blob`, `ArrayBuffer`, or base64 string. +Pass the audio as a `File`, a `Blob`, an `ArrayBuffer`, or a base64 string. Whisper models take base64, and `@cf/deepgram/nova-3` takes the raw bytes. The adapter sends each model the format that it needs. -## Environment variables +## Models and options -For the REST path: +### Models -```bash -CLOUDFLARE_ACCOUNT_ID=your-account-id -CLOUDFLARE_API_TOKEN=your-api-token +These ids work: + +- Any id from the [Workers AI catalog](https://developers.cloudflare.com/workers-ai/models/). +- Any `provider/model` id from the [AI Gateway catalog](https://developers.cloudflare.com/ai/models/), when a gateway is set. + +Ids in the bundled catalog get editor autocomplete. + +### Model options + +Sampling options go in `modelOptions`. The thinking level goes in `reasoning`: + +```typescript +import { chat } from "@tanstack/ai"; +import { cloudflareText } from "@tanstack/ai-cloudflare"; + +const stream = chat({ + adapter: cloudflareText("@cf/zai-org/glm-5.3-flash"), + messages: [{ role: "user", content: "Summarize this in one sentence." }], + threadId: "thread-1", + modelOptions: { + temperature: 0.3, + max_tokens: 512, + }, + reasoning: "low", +}); ``` -Get the account id from `wrangler whoami`. Create the token in the Cloudflare dashboard under Workers AI, then Use REST API. +### Reasoning + +`reasoning` goes out as `reasoning_effort`. `off` sends `null`, which turns thinking off. Reasoning models stream their thinking as `reasoning_content`, which you get as `REASONING_*` events. + +Some models and their levels: + +| Model | Levels | +| --- | --- | +| `@cf/zai-org/glm-5.3`, `@cf/zai-org/glm-5.3-flash` | `low`, `high`, `max` | +| `@cf/deepseek-ai/deepseek-v4-pro-0813`, `@cf/deepseek-ai/deepseek-v4-flash-0731` | `off`, `low`, `high`, `max` | +| `@cf/qwen/qwen3.8-27b` | `off`, `low`, `medium`, `xhigh` | +| `@cf/openai/gpt-oss-120b`, `@cf/openai/gpt-oss-20b` | `low`, `medium`, `high` | + +The types know the levels of each model. A model with no reasoning data takes no `reasoning`. See [Reasoning](../chat/reasoning). + +## Advanced + +### Client options on the REST path + +- The text and summarize adapters also take the OpenAI SDK client options: `baseURL`, `defaultHeaders`, `fetch`, `timeout`, and `maxRetries`. +- The other adapters call the native `/ai/run` endpoint and take only `fetch`. + +### Change the HTTP requests + +Need a header on each request, or a log of each model call? The chat adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call) on the REST path. In binding mode, the wrapper still runs. But the binding reads only the request body, so it drops the URL and the headers that the wrapper sets. + +### Tools and prompts added during a conversation + +Two kinds of adapter send the full tools and system prompts on every call: + +- An adapter with a gateway `baseURL` from `cloudflareGateway()`. +- An adapter with `cloudflareBindingFetch`. + +This is also true on a model with a [mid-conversation channel](../advanced/mid-conversation-changes). So AI Gateway gets the same request as on any other model. To send tools and prompts added during a conversation through the channel, set `midConversationChannels: true`: + +```typescript +import { createAnthropicChat } from "@tanstack/ai-anthropic"; +import { cloudflareBindingFetch } from "@tanstack/ai-cloudflare"; +import type { Ai } from "@cloudflare/workers-types"; + +export function gatewayClaude(env: { AI: Ai }) { + return createAnthropicChat("claude-opus-5-5", "cloudflare-binding", { + fetch: cloudflareBindingFetch({ + binding: env.AI, + vendor: "anthropic", + gateway: { id: "default" }, + }), + midConversationChannels: true, + }); +} +``` diff --git a/docs/adapters/codex.md b/docs/adapters/codex.md index e9c3f90299..e41da7982e 100644 --- a/docs/adapters/codex.md +++ b/docs/adapters/codex.md @@ -2,7 +2,7 @@ title: Codex id: codex-adapter order: 12 -description: "Use OpenAI Codex as a chat backend in TanStack AI — agent harness with local tool execution, stateful coding sessions, and tool bridging via @tanstack/ai-codex." +description: "Run OpenAI's Codex agent inside a sandbox from TanStack AI. It runs shell commands and edits files in a real repo, and streams each step to your UI." keywords: - tanstack ai - codex @@ -14,129 +14,182 @@ keywords: - adapter --- -The Codex adapter runs [OpenAI Codex](https://developers.openai.com/codex) (via the `@openai/codex-sdk`) as a chat backend. Unlike HTTP provider adapters, this is a **harness adapter**: Codex runs its own agent loop and executes its own tools — shell commands, file changes, web search — locally on your server, inside its sandbox. Each `chat()` call runs one full harness turn; the harness's tool activity streams back as already-resolved tool-call events your UI can render. +The Codex adapter runs the `codex` CLI in a sandbox and streams each step to `chat()`. The agent runs shell commands and edits files in a repo. -> **Server-only.** The harness spawns the Codex runtime (bundled with the SDK) as a subprocess, so this adapter only works in a Node.js server environment — never in the browser. The sandbox mode is the safety boundary; configure it deliberately. +You need: -## Installation +- A sandbox provider. The samples use Docker, so Docker must run on your machine. +- A `CODEX_API_KEY`, or a `codex login` on the machine. +- The `codex` CLI inside the sandbox. A setup step in the sandbox installs it. + +Do you only want a GPT chat, with no repo and no shell? Use the [OpenAI adapter](./openai). + +## 1. Install -react: @tanstack/ai-codex -vue: @tanstack/ai-codex -solid: @tanstack/ai-codex -svelte: @tanstack/ai-codex -preact: @tanstack/ai-codex -angular: @tanstack/ai-codex -vanilla: @tanstack/ai-codex -octane: @tanstack/ai-codex +react: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +vue: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +solid: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +svelte: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +preact: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +angular: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +vanilla: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +octane: @tanstack/ai-codex @tanstack/ai-sandbox @tanstack/ai-sandbox-docker -A runnable demo lives at [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare) — pick Claude Code, Codex, or Grok Build in the UI, with session resume, the harness tool timeline, and tool bridging, wired into a TanStack Start app on Workers. For the same wiring on plain Node with durable, refresh-surviving runs (Claude Code on Docker), see [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web) — swapping in this adapter is a one-line change (`src/sandbox-agent.ts`). +## 2. Set the API key -## Authentication +Put your OpenAI API key in the environment of your server, with the name `CODEX_API_KEY`: -Your laptop can already have `codex login`. A CI runner only has -`CODEX_API_KEY`. The default `authMode` is `'api-key'`. Set `'host'` when -you want `codex login`. See [Harness Auth](../sandbox/auth). - -```ts -import { codexText } from "@tanstack/ai-codex" - -codexText("gpt-5.5") -codexText("gpt-5.5", { authMode: "host" }) +```bash +CODEX_API_KEY=sk-... ``` -- `'api-key'` (default): expect `CODEX_API_KEY` (or pass `apiKey`). -- `'host'`: use `codex login`. Do not inject `CODEX_API_KEY`. +The sandbox in step 3 gives this key to the `codex` process as a workspace secret. `codex exec` signs in with `CODEX_API_KEY`. A bare `OPENAI_API_KEY` is not enough. -## Basic Usage +Did the machine already run `codex login`? Then you can use that login and no key. See [Harness Auth](../sandbox/auth). -```typescript -import { chat } from "@tanstack/ai"; -import { codexText } from "@tanstack/ai-codex"; +## 3. Run the agent from the server -const stream = chat({ - adapter: codexText("gpt-5.1-codex", { - cwd: "/path/to/project", - sandboxMode: "workspace-write", +Define the sandbox once. It clones your repo into a Docker container and installs the `codex` CLI: + +```ts +// sandbox.ts +import { + createSecrets, + defineSandbox, + defineWorkspace, + githubRepo, +} from "@tanstack/ai-sandbox"; +import { dockerSandbox } from "@tanstack/ai-sandbox-docker"; + +export const sandbox = defineSandbox({ + id: "codex-agent", + provider: dockerSandbox({ image: "node:22" }), + workspace: defineWorkspace({ + source: githubRepo({ repo: "owner/app" }), + setup: ["npm install -g @openai/codex --include=optional && codex --version"], + secrets: createSecrets({ + CODEX_API_KEY: process.env.CODEX_API_KEY ?? "", + }), }), - messages: [{ role: "user", content: "Fix the failing test in utils.test.ts" }], }); ``` -## Configuration - -| Option | Description | -| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `cwd` | Working directory for the harness session. Defaults to `process.cwd()`. | -| `sandboxMode` | Codex sandbox: `'read-only'`, `'workspace-write'`, or `'danger-full-access'`. Default is `'workspace-write'` on local-process and Docker. Default is `'danger-full-access'` on Daytona and Cloudflare, because those providers cannot create a nested bubblewrap namespace. Isolation is then the outer VM plus `defineSandboxPolicy`. | -| `approvalPolicy` | Codex approval policy. Defaults to `'never'` — headless runs have no approval UI, so anything else can stall a turn. | -| `modelReasoningEffort` | The default effort when a call sets no `reasoning`: `'minimal'` \| `'low'` \| `'medium'` \| `'high'`. | -| `skipGitRepoCheck` | Skip the harness's git-repo safety check. Defaults to `true` (server adapters routinely point at scratch directories). | -| `networkAccessEnabled` | Allow network access inside the `workspace-write` sandbox. | -| `webSearchMode` | `'disabled'` \| `'cached'` \| `'live'`. | -| `additionalDirectories`| Extra writable directories beyond `cwd`. | -| `authMode` | `'api-key'` (default) expects `CODEX_API_KEY`. `'host'` uses `codex login`. See [Harness Auth](../sandbox/auth). | -| `apiKey` | OpenAI API key for the harness subprocess. | -| `baseUrl` | Override the Codex backend base URL. | -| `codexPathOverride` | Use a specific codex executable instead of the SDK's bundled binary. | -| `env` | Environment variables for the subprocess. When set, `process.env` is **not** inherited (Codex SDK semantics). | -| `config` | Extra `--config key=value` overrides passed to the Codex CLI (e.g. additional `mcp_servers` entries). | - -Per-call overrides go through `modelOptions`: `sessionId`, `sandboxMode`, -`approvalPolicy`, `workingDirectory`, `skipGitRepoCheck`, and `authMode`. -Set the effort per call with `reasoning` on `chat()`. See [Reasoning](#reasoning). - -## Reasoning +Then pass the adapter and the sandbox to `chat()` in your route: -Set `reasoning` on `chat()`. Codex gets it as `model_reasoning_effort`: - -```typescript -import { chat } from "@tanstack/ai"; +```ts +// api/agent.ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; import { codexText } from "@tanstack/ai-codex"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; -const stream = chat({ - adapter: codexText("gpt-5.3-codex"), - messages: [{ role: "user", content: "Fix the failing test in utils.test.ts" }], - reasoning: "xhigh", -}); +export async function POST(request: Request) { + const { messages, threadId, runId } = await chatParamsFromRequest(request); + + const stream = chat({ + adapter: codexText("gpt-5.3-codex"), + messages, + threadId, + runId, + middleware: [withSandbox(sandbox)], + }); + + return toServerSentEventsResponse(stream); +} ``` -- `gpt-5.3-codex`: `off`, `low` to `xhigh`. `gpt-5.2-codex`: `low` to `xhigh`. -- `gpt-5.1-codex`, `gpt-5.1-codex-mini`: `low`, `medium`, `high`. `gpt-5.1`: `off`, `low`, `medium`, `high`. -- A call without `reasoning` uses `modelReasoningEffort` from the config. +Replace `owner/app` with your repo. -## Stateful Sessions +## 4. Render the agent's work -Codex threads are stateful — the harness keeps the full working context (files read, commands run, conclusions reached) between turns. The adapter surfaces the thread id of every fresh run as a custom stream event named `codex.session-id`; thread it back via `modelOptions.sessionId` to resume. When resuming, only the latest user message is sent — the harness already holds the prior context. +The client is the same as for any chat. Render the text, and show each tool that the agent ran: -Server endpoint: +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; -```typescript +export function CodingAgent() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/agent"), + threadId: "support-chat", + }); + + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => + message.parts.map((part, index) => { + if (part.type === "text") { + return

{part.content}

; + } + if (part.type === "tool-call") { + return {part.name}; + } + return null; + }), + )} + setInput(event.target.value)} /> +
+ ); +} +``` + +Ask it "Fix the failing test in utils.test.ts". You see the agent's text, plus a line for each tool it used: `command_execution`, `file_change`, and more. The agent edited the files inside the container. + +## How it works + +- `codexText` declares that it needs a sandbox. `withSandbox(sandbox)` gives it one. Without a sandbox, `chat()` fails at the call site. +- Each `chat()` call runs one full agent turn. Codex runs its own loop and its own tools. +- The tools of the agent stream back as `tool-call` parts with the results attached. Your code does not run them. +- Codex also has its own inner sandbox. `sandboxMode` sets it. The outer TanStack sandbox is the real boundary. + +To learn more about sandboxes, see [Sandboxes](../sandbox/overview). + +## Resume a session + +Codex keeps its working context between turns: the files it read, the commands it ran, and what it found. The adapter sends the thread id of each new run as a custom event named `codex.session-id`. Send it back in `modelOptions.sessionId` to continue the thread. + +On the server, read the id from the request: + +```ts import { chat, chatParamsFromRequest, toServerSentEventsResponse, } from "@tanstack/ai"; import { codexText } from "@tanstack/ai-codex"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; export async function POST(request: Request) { const params = await chatParamsFromRequest(request); - // Extra fields the client puts in the connection `body` arrive here. + // The client sends the id in `forwardedProps`. const sessionId = typeof params.forwardedProps.sessionId === "string" ? params.forwardedProps.sessionId : undefined; const stream = chat({ - adapter: codexText("gpt-5.1-codex", { - cwd: "/path/to/project", - sandboxMode: "workspace-write", - }), + adapter: codexText("gpt-5.3-codex"), messages: params.messages, + threadId: params.threadId, + runId: params.runId, + middleware: [withSandbox(sandbox)], modelOptions: { sessionId }, }); @@ -144,20 +197,19 @@ export async function POST(request: Request) { } ``` -Client (React) — capture the session id from the custom event and send it back on subsequent requests: +On the client, keep the id from the custom event and pass it to `forwardedProps`. `useChat` sends the latest value with each request: -```typescript +```tsx import { useState } from "react"; -import { useChat } from "@tanstack/ai-react"; -import { fetchServerSentEvents } from "@tanstack/ai-client"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; -function CodingAssistant() { +export function useCodingAgent() { const [sessionId, setSessionId] = useState(undefined); - const { messages, sendMessage } = useChat({ - connection: fetchServerSentEvents("/api/chat", () => ({ - body: { sessionId }, - })), + return useChat({ + connection: fetchServerSentEvents("/api/agent"), + forwardedProps: { sessionId }, + threadId: "support-chat", onCustomEvent: (name, value) => { if ( name === "codex.session-id" && @@ -170,26 +222,22 @@ function CodingAssistant() { } }, }); - - // ... render messages; harness tool activity (command_execution, - // file_change, ...) arrives as regular tool-call parts with results. } ``` -Sessions are stored on the machine that ran them (`~/.codex/sessions/`), so resuming only works on the same server instance. - -## Tools +- On a resumed thread, the adapter sends only the latest user message. The agent already has the rest. +- Codex saves the thread inside the sandbox. Resume works only when the next call runs in the same sandbox. See [Lifecycle & Snapshots](../sandbox/lifecycle). -Two kinds of tools flow through this adapter: +## Give the agent your own tools -1. **Built-in harness tools** are executed by Codex itself and stream back as tool-call events with results already attached: `command_execution` (shell), `file_change` (patches), `web_search`, and `todo_list` (the agent's running plan). Your code never executes them. +The agent has its own tools for files and the shell. It cannot reach your database or your secrets. Pass your server tools to `chat()`, and the adapter bridges them into the sandbox: -2. **Your TanStack tools** are bridged *into* the harness: the adapter starts a short-lived Streamable-HTTP MCP server on `127.0.0.1` for the duration of the turn and points Codex at it. Define tools as usual with `toolDefinition().server()`; tool-call events come back under the names you registered. - -```typescript group=tools +```ts group=tools import { z } from "zod"; import { chat, toolDefinition } from "@tanstack/ai"; import { codexText } from "@tanstack/ai-codex"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; const lookupTicket = toolDefinition({ name: "lookup_ticket", @@ -200,24 +248,32 @@ const lookupTicket = toolDefinition({ }); const stream = chat({ - adapter: codexText("gpt-5.1-codex"), + adapter: codexText("gpt-5.3-codex"), messages: [{ role: "user", content: "What's the status of ticket T-123?" }], + threadId: "thread-1", tools: [lookupTicket], + middleware: [withSandbox(sandbox)], }); ``` -**Client-side and approval-gated tools are not supported.** The harness executes tools inside a live subprocess, which cannot pause across HTTP requests to wait for a browser round-trip or a human approval. Passing a tool without a server `execute()` implementation — or one marked `needsApproval` — fails fast with a descriptive error. Run those tools outside the harness with a regular provider adapter. +- Each tool runs on your server, with its closures and secrets. The result goes back into the sandbox. +- The tool-call events use the names you gave. +- Only server tools work here. A client tool has no server `execute()`, so the agent cannot run it. + +For how the bridge reaches your server from each provider, see [Sandbox Tools](../sandbox/tools). ## Tool choice -You want Codex to skip your tools, or to see just one of them. Pass `toolChoice` to `chat()`: +You want Codex to skip your tools, or to see only one of them. Pass `toolChoice` to `chat()`: -```typescript group=tools +```ts group=tools const noBridgedTools = chat({ adapter: codexText("gpt-5.3-codex"), messages: [{ role: "user", content: "Explain the bug in src/app.ts." }], + threadId: "thread-1", tools: [lookupTicket], toolChoice: "none", + middleware: [withSandbox(sandbox)], }); ``` @@ -234,71 +290,150 @@ Each value except `'auto'` logs a warning the first time an adapter instance get For the values on other adapters, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool). -## Structured Output +## Structured output -Pass `outputSchema` on `chat()`. Codex runs one harness turn and constrains the last message with `--output-schema`. Tool activity and assistant text stream as Codex writes them. The last message is also parsed as the schema object and arrives as `structured-output.complete`. +You want a typed report after the agent reads the repo. Pass `outputSchema` on the same `chat()` call: ```ts -import { chat } from "@tanstack/ai" -import { codexText } from "@tanstack/ai-codex" -import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox" -import { dockerSandbox } from "@tanstack/ai-sandbox-docker" -import { z } from "zod" +import { chat } from "@tanstack/ai"; +import { codexText } from "@tanstack/ai-codex"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { z } from "zod"; +import { sandbox } from "./sandbox"; const Report = z.object({ summary: z.string(), filesChanged: z.array(z.string()), -}) - -const sandbox = defineSandbox({ - id: "repo-report", - provider: dockerSandbox({ image: "node:22" }), -}) +}); const report = await chat({ adapter: codexText("gpt-5.3-codex"), messages: [{ role: "user", content: "Review this repo." }], + threadId: "thread-1", outputSchema: Report, middleware: [withSandbox(sandbox)], -}) +}); -report.summary +console.log(report.summary); ``` On the client, pass the same schema to `useChat` and read `final`. `partial` stays empty until the end. ```tsx -import { fetchServerSentEvents, useChat } from "@tanstack/ai-react" -import { z } from "zod" +import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"; +import { z } from "zod"; const Report = z.object({ summary: z.string(), filesChanged: z.array(z.string()), -}) +}); -function ReportView() { +export function ReportView() { const { final, isLoading } = useChat({ connection: fetchServerSentEvents("/api/repo-report"), + threadId: "support-chat", outputSchema: Report, - }) + }); - if (isLoading) return

The agent is inspecting the repo.

- if (!final) return null - return

{final.summary}

+ if (isLoading) return

The agent is inspecting the repo.

; + if (!final) return null; + return

{final.summary}

; } ``` -If you only need to extract JSON from a prompt and do not need a sandbox, use `@tanstack/ai-openai`. That path is faster. +- Codex runs one turn and limits its last message with `--output-schema`. +- Tool activity and text stream as Codex writes them. +- The adapter parses the last message as the schema object. It arrives as `structured-output.complete`. + +Do you only need JSON from a prompt, with no repo? Use `@tanstack/ai-openai`. That is faster. For the full walkthrough, see [Harness Agents](../structured-outputs/harnesses). + +## Models and options + +### Models + +| Model | Reasoning levels | +| --- | --- | +| `gpt-5.3-codex` | `off`, `low`, `medium`, `high`, `xhigh` | +| `gpt-5.2-codex` | `low`, `medium`, `high`, `xhigh` | +| `gpt-5.1-codex`, `gpt-5.1-codex-mini` | `low`, `medium`, `high` | +| `gpt-5.1` | `off`, `low`, `medium`, `high` | + +The package exports this list as `CODEX_MODELS`. The adapter also takes any other model id that Codex accepts, as a string. + +### Reasoning + +Set `reasoning` on `chat()`. Codex gets it as `model_reasoning_effort`: + +```ts +import { chat } from "@tanstack/ai"; +import { codexText } from "@tanstack/ai-codex"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; + +const stream = chat({ + adapter: codexText("gpt-5.3-codex"), + messages: [{ role: "user", content: "Fix the failing test in utils.test.ts" }], + threadId: "thread-1", + reasoning: "xhigh", + middleware: [withSandbox(sandbox)], +}); +``` + +A call with no `reasoning` uses `modelReasoningEffort` from the adapter options. See [Reasoning](../chat/reasoning). + +### Adapter options + +Pass these as the second argument of `codexText`: + +| Option | What it does | +| --- | --- | +| `cwd` | The working directory inside the sandbox. Default `/workspace`. | +| `sandboxMode` | The inner Codex sandbox: `'read-only'`, `'workspace-write'`, or `'danger-full-access'`. See [Inner sandbox mode](#inner-sandbox-mode). | +| `approvalPolicy` | `'never'` (default), `'on-failure'`, `'on-request'`, or `'untrusted'`. A headless run has no approval UI, so another value can stop a turn. | +| `modelReasoningEffort` | The effort when a call sets no `reasoning`: `'minimal'`, `'low'`, `'medium'`, or `'high'`. | +| `skipGitRepoCheck` | Skip the git repo check of Codex. Default `true`. | +| `networkAccessEnabled` | Allow network access inside the `workspace-write` sandbox. | +| `webSearchMode` | `'disabled'` or `'live'`. | +| `additionalDirectories` | More writable directories next to `cwd`. | +| `authMode` | `'api-key'` (default) expects `CODEX_API_KEY`. `'host'` uses `codex login`. See [Harness Auth](../sandbox/auth). | +| `codexExecutable` | The path or name of the `codex` executable in the sandbox. Default `codex`. | +| `env` | More environment variables for the `codex` process. | +| `config` | More `--config key=value` overrides for the Codex CLI, for example more `mcp_servers` entries. Values go through as TOML. | + +### Per-call options + +Pass these in `modelOptions` on `chat()`. They override the adapter options for one call: + +- `sessionId`: resume a thread. See [Resume a session](#resume-a-session). +- `sandboxMode`, `approvalPolicy`, `skipGitRepoCheck`. +- `workingDirectory`: the working directory for this call. +- `authMode`: `'host'` or `'api-key'`. + +## Advanced + +### Inner sandbox mode + +The default `sandboxMode` depends on the provider: + +- Local-process and Docker: `'workspace-write'`. +- Daytona and Cloudflare: `'danger-full-access'`. These providers cannot create a nested bubblewrap namespace. The outer VM and your sandbox [Policy](../sandbox/policy) are then the isolation. + +The inner Codex sandbox does not work on Windows and on some Linux images. There it fails with `os error 2`. If you see this error, set `sandboxMode: 'danger-full-access'`. The outer sandbox is still the boundary. + +### Text streaming + +Codex reports text as items that grow. The adapter streams each new piece of an item, so text can arrive in larger pieces than with an HTTP adapter. Tool activity, such as a command that starts or ends, streams live. -Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses). +### Limitations -## Limitations +- **A sandbox is required.** Every call needs `withSandbox(...)`. +- **The agent owns the loop.** TanStack agent-loop strategies and per-iteration middleware do not apply inside a turn. +- **No sampling options.** Options such as `temperature` do not exist here. +- **Sessions stay in the sandbox.** Resume needs the same sandbox. +- **Cold starts.** Each call starts a CLI turn, so the first token arrives later than with an HTTP adapter. +- **No `wrapFetch`.** This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the `codex` process sends the model requests. -- **No token-level text streaming.** The Codex SDK reports assistant text and reasoning only as completed items, so text arrives message-at-a-time. Tool activity (commands starting/finishing) still streams live, which keeps the UI feeling alive during long turns. -- **Server-only (Node).** The harness spawns a subprocess. -- **The harness owns the agent loop.** TanStack's agent-loop strategies and per-iteration middleware don't apply inside a harness turn. -- **No sampling controls.** `temperature`-style options don't exist here. -- **Sessions are machine-local.** Resume requires hitting the same server instance. -- **Cold starts.** Each call spawns a harness turn; expect higher first-token latency than HTTP adapters. +### Example apps -This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the harness process sends the model requests. +- [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare): a TanStack Start app on Workers. You pick Claude Code, Codex, or Grok Build in the UI. It shows session resume, the tool timeline, and tool bridging. +- [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web): Claude Code on Docker, with durable runs that continue after a page refresh. To use Codex there, change the adapter in `src/sandbox-agent.ts`. diff --git a/docs/adapters/cohere.md b/docs/adapters/cohere.md index ce13f129c3..17301998cb 100644 --- a/docs/adapters/cohere.md +++ b/docs/adapters/cohere.md @@ -2,7 +2,7 @@ title: Cohere id: cohere-adapter order: 11 -description: "Use Cohere with TanStack AI via @tanstack/ai-cohere: embed-v4.0 multimodal embeddings for semantic search and RAG, plus rerank models for relevance." +description: "Use Cohere in TanStack AI with @tanstack/ai-cohere: embed-v4.0 multimodal embeddings for semantic search and RAG, and rerank models to sort results by relevance." keywords: - tanstack ai - cohere @@ -16,14 +16,11 @@ keywords: - adapter --- -The Cohere adapter covers the two retrieval steps of a RAG pipeline: +The Cohere adapter embeds text and images, and it reranks documents for a query. This package has no `chat()`, `summarize()`, or media generation. -- **Embeddings** (`cohereEmbedding`): turn text, images, and fused text+image inputs into vectors with `embed()`. -- **Reranking** (`cohereRerank`): reorder candidate documents by relevance to a query with `rerank()`. +For those, use [OpenAI](./openai), [Anthropic](./anthropic), or [Gemini](./gemini). The adapter calls the HTTP API of Cohere with `fetch`, with no SDK dependency. -It does not support `chat()`, `summarize()`, or media generation. Use OpenAI, Anthropic, or Gemini for those. The adapter talks to Cohere's HTTP API directly over `fetch`, with no SDK dependency. - -## Installation +## 1. Install @@ -38,7 +35,17 @@ octane: @tanstack/ai @tanstack/ai-cohere -## Embeddings +## 2. Set the API key + +Get a key from the [Cohere dashboard](https://dashboard.cohere.com/api-keys). Put it in the environment of your server: + +```bash +COHERE_API_KEY=your-cohere-api-key +``` + +`cohereEmbedding` and `cohereRerank` read `COHERE_API_KEY`. To pass the key in code, see [Explicit API keys](#explicit-api-keys). + +## 3. Embed documents ```typescript import { embed } from "@tanstack/ai"; @@ -54,11 +61,37 @@ console.log(result.embeddings[0]?.vector); console.log(result.usage?.promptTokens); ``` -`inputType` is required by Cohere's API. Use `search_document` at index time and `search_query` at query time (or `classification` / `clustering` for those workloads). TanStack AI enforces this at the type level, so `modelOptions` is required for Cohere embedding calls. +The Cohere API requires `inputType`, and the types require `modelOptions` on every Cohere embedding call: -### Multimodal Embeddings +- `search_document`: when you index documents. +- `search_query`: when you embed the search query. +- `classification` or `clustering`: for those workloads. -embed-v4.0 embeds images alongside text. An image part produces an image vector. A nested array of parts (`[textPart, imagePart]`) fuses text and image into one vector, which suits product catalogs and screenshot search. The outer array is the item list, so nest to fuse: +## 4. Rerank results + +```typescript +import { rerank } from "@tanstack/ai"; +import { cohereRerank } from "@tanstack/ai-cohere"; + +const { rerankedDocuments } = await rerank({ + adapter: cohereRerank("rerank-v3.5"), + query: "talk about rain", + documents: ["sunny day at the beach", "rainy afternoon in the city"], +}); + +console.log(rerankedDocuments[0]); // 'rainy afternoon in the city' +``` + +You now have both retrieval steps. For object documents, RAG pipelines, the options, and the result shape, see [Reranking](../rerank/rerank). + +## Multimodal embeddings + +embed-v4.0 embeds images next to text: + +- An image part gives an image vector. +- A nested array of parts (`[textPart, imagePart]`) joins text and image into one vector. Use it for product catalogs and screenshot search. + +The outer array is the list of items, so nest the parts to join them: ```typescript import { embed } from "@tanstack/ai"; @@ -96,27 +129,11 @@ const result = await embed({ console.log(result.embeddings.length); // 2 ``` -Cohere's API does not fetch remote image URLs. Pass base64 data (or a `data:` URI), or opt into adapter-side downloading: - -```typescript -import { embed } from "@tanstack/ai"; -import { cohereEmbedding } from "@tanstack/ai-cohere"; - -const adapter = cohereEmbedding("embed-v4.0", { allowUrlFetch: true }); - -const result = await embed({ - adapter, - input: { - type: "image", - source: { type: "url", value: "https://example.com/guitar.png" }, - }, - modelOptions: { inputType: "search_document" }, -}); -``` +The Cohere API does not fetch remote image URLs. Pass base64 data (or a `data:` URI), or turn on [downloads in the adapter](#image-urls). -### Requesting Dimensions +## Shorter vectors -embed-v4.0 supports Matryoshka output dimensions via the top-level `dimensions` option: +embed-v4.0 takes the top-level `dimensions` option (`256`, `512`, `1024`, or `1536`): ```typescript import { embed } from "@tanstack/ai"; @@ -130,24 +147,9 @@ const result = await embed({ }); ``` -## Reranking - -```typescript -import { rerank } from "@tanstack/ai"; -import { cohereRerank } from "@tanstack/ai-cohere"; - -const { rerankedDocuments } = await rerank({ - adapter: cohereRerank("rerank-v3.5"), - query: "talk about rain", - documents: ["sunny day at the beach", "rainy afternoon in the city"], -}); - -console.log(rerankedDocuments[0]); // 'rainy afternoon in the city' -``` - -For the full reranking guide, with object documents, RAG pipelines, options, and the result shape, see [Reranking](../rerank/rerank). +## Rerank options -Per-request rerank options go on `modelOptions`: +Rerank options for one request go in `modelOptions`: ```typescript import { rerank } from "@tanstack/ai"; @@ -167,30 +169,18 @@ console.log(ranking); ## Models -| Model | Capability | Description | -| -------------------------- | ---------- | ----------------------------------------------------------------- | -| `embed-v4.0` | Embeddings | Multimodal (text + images), Matryoshka `dimensions` support | -| `rerank-v3.5` | Reranking | Latest multilingual reranker (recommended) | -| `rerank-english-v3.0` | Reranking | English-optimized reranker | -| `rerank-multilingual-v3.0` | Reranking | Multilingual reranker | +| Model | Activity | Notes | +| --- | --- | --- | +| `embed-v4.0` | Embeddings | Text and images, `dimensions` support | +| `rerank-v3.5` | Reranking | Newest multilingual reranker. Recommended | +| `rerank-english-v3.0` | Reranking | Tuned for English | +| `rerank-multilingual-v3.0` | Reranking | Multilingual reranker | -## Environment Variables +## Advanced -Both adapters read your API key from the environment: +### Explicit API keys -```bash -COHERE_API_KEY=your-cohere-api-key -``` - -| Variable | Required | Description | -| ---------------- | -------- | ------------------- | -| `COHERE_API_KEY` | Yes | Your Cohere API key | - -Get a key from the [Cohere dashboard](https://dashboard.cohere.com/api-keys). - -## Explicit API Keys - -To pass a key directly instead of reading the environment, use the `create*` factories: +To pass a key in code, not from the environment, use the `create*` factories: ```typescript import { @@ -205,9 +195,29 @@ const embedAdapter = createCohereEmbedding( const rerankAdapter = createCohereRerank("rerank-v3.5", "your-cohere-api-key"); ``` -## Behind a proxy +### Image URLs -Route every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them. +To let the adapter download `http(s)` image URLs and send them as base64, set `allowUrlFetch`: + +```typescript +import { embed } from "@tanstack/ai"; +import { cohereEmbedding } from "@tanstack/ai-cohere"; + +const adapter = cohereEmbedding("embed-v4.0", { allowUrlFetch: true }); + +const result = await embed({ + adapter, + input: { + type: "image", + source: { type: "url", value: "https://example.com/guitar.png" }, + }, + modelOptions: { inputType: "search_document" }, +}); +``` + +### Behind a proxy + +Send every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them. ```typescript import { createCohereEmbedding } from "@tanstack/ai-cohere"; @@ -220,35 +230,19 @@ const adapter = createCohereEmbedding("embed-v4.0", process.env.COHERE_API_KEY!, `baseUrl` and `headers` are aliases of the same two options. If you set both forms, `baseURL` and `defaultHeaders` win. -## API Reference +### API reference -### `cohereEmbedding(model, config?)` +| Factory | Creates | +| --- | --- | +| `cohereEmbedding(model, config?)` | Embedding adapter. Reads `COHERE_API_KEY` | +| `createCohereEmbedding(model, apiKey, config?)` | Embedding adapter with an explicit API key | +| `cohereRerank(model, config?)` | Rerank adapter. Reads `COHERE_API_KEY` | +| `createCohereRerank(model, apiKey, config?)` | Rerank adapter with an explicit API key | -Creates an embedding adapter using `COHERE_API_KEY` from the environment. +Config for both adapters: -- `model`: `"embed-v4.0"` -- `config.baseURL`: override the API base URL (default `https://api.cohere.com`). `baseUrl` is an alias. +- `config.baseURL`: the API base URL (default `https://api.cohere.com`). `baseUrl` is an alias. - `config.defaultHeaders`: extra request headers. `headers` is an alias. -- `config.allowUrlFetch`: download `http(s)` image URLs and inline them as base64 (default `false`) - -### `createCohereEmbedding(model, apiKey, config?)` - -Same as `cohereEmbedding` with an explicit API key. - -### `cohereRerank(model, config?)` - -Creates a rerank adapter using `COHERE_API_KEY` from the environment. - -- `model`: one of the rerank models above -- `config.baseURL`: override the API base URL (default `https://api.cohere.com`). `baseUrl` is an alias. -- `config.defaultHeaders`: extra request headers. `headers` is an alias. - -### `createCohereRerank(model, apiKey, config?)` - -Same as `cohereRerank` with an explicit API key. - -## Next Steps +- `config.allowUrlFetch` (embeddings only): download `http(s)` image URLs and send them as base64. Default `false`. -- [Embeddings guide](../embeddings.md): the full `embed()` API -- [Reranking guide](../rerank/rerank): full walkthrough including RAG pipelines -- [Generation Hooks](../media/generation-hooks.md): usage and lifecycle middleware +For the full APIs, see the [Embeddings guide](../embeddings.md), the [Reranking guide](../rerank/rerank), and [Generation Hooks](../media/generation-hooks.md) for usage and lifecycle middleware. diff --git a/docs/adapters/elevenlabs.md b/docs/adapters/elevenlabs.md index 17d3ecbf0d..a2454e109e 100644 --- a/docs/adapters/elevenlabs.md +++ b/docs/adapters/elevenlabs.md @@ -2,7 +2,7 @@ title: ElevenLabs id: elevenlabs-adapter order: 9 -description: "Build realtime voice-to-voice conversational AI with ElevenLabs agents in TanStack AI via the @tanstack/ai-elevenlabs adapter." +description: "Use ElevenLabs in TanStack AI with @tanstack/ai-elevenlabs: realtime voice agents, text-to-speech, music and sound effects, transcription, and voice design." keywords: - tanstack ai - elevenlabs @@ -13,175 +13,114 @@ keywords: - adapter --- -The ElevenLabs adapter is **voice-focused**. It exposes five capabilities: +`@tanstack/ai-elevenlabs` calls ElevenLabs for voice agents, speech, music, sound effects, transcription, and voice design. It has no text chat. -- **Realtime voice agents** (`elevenlabsRealtime` / `elevenlabsRealtimeToken`) — full-duplex voice-to-voice conversations powered by ElevenLabs Conversational AI agents. -- **Text-to-speech** (`elevenlabsSpeech`) — one-shot speech generation via `generateSpeech()`. -- **Music & sound effects** (`elevenlabsAudio`) — one-shot audio generation via `generateAudio()`. -- **Transcription** (`elevenlabsTranscription`) — speech-to-text via `generateTranscription()`. -- **Voice design** (`elevenlabsVoiceDesign`): create a new voice via `generateVoice()`. +The quick setup below builds a live voice conversation with an ElevenLabs agent. -It does not support text `chat()` or `summarize()` — use OpenAI, Anthropic, or Gemini for those. - -The realtime adapter uses an **agent-based architecture** where you configure your conversational AI agent in the [ElevenLabs dashboard](https://elevenlabs.io/) (voice, personality, knowledge base, tools) and then connect to it at runtime. The adapter wraps the `@elevenlabs/client` SDK for seamless integration with `useRealtimeChat` and `RealtimeClient`. - -## Installation +## 1. Install -react: @tanstack/ai-elevenlabs -vue: @tanstack/ai-elevenlabs -solid: @tanstack/ai-elevenlabs -svelte: @tanstack/ai-elevenlabs -preact: @tanstack/ai-elevenlabs -angular: @tanstack/ai-elevenlabs -vanilla: @tanstack/ai-elevenlabs -octane: @tanstack/ai-elevenlabs +react: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs +vue: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs +solid: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs +svelte: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs +preact: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs +angular: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs +vanilla: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs +octane: @tanstack/ai @tanstack/ai-client @tanstack/ai-elevenlabs -Peer dependencies: +`@tanstack/ai` and `@tanstack/ai-client` are peer dependencies of the adapter. Step 4 also uses the React package, `@tanstack/ai-react`. - +## 2. Set the API key and the agent -react: @tanstack/ai @tanstack/ai-client -vue: @tanstack/ai @tanstack/ai-client -solid: @tanstack/ai @tanstack/ai-client -svelte: @tanstack/ai @tanstack/ai-client -preact: @tanstack/ai @tanstack/ai-client -angular: @tanstack/ai @tanstack/ai-client -vanilla: @tanstack/ai @tanstack/ai-client -octane: @tanstack/ai @tanstack/ai-client +1. Get an API key from the [ElevenLabs dashboard](https://elevenlabs.io/). +2. In the **Conversational AI** section of the dashboard, create an agent. Set its voice, personality, knowledge, and tools there. +3. Put both values in the environment of your server: - +```bash +ELEVENLABS_API_KEY=your-elevenlabs-api-key +ELEVENLABS_AGENT_ID=your-agent-id +``` + +All adapters read `ELEVENLABS_API_KEY`. If you pass `agentId` in code, `ELEVENLABS_AGENT_ID` is optional. -## Server Setup +## 3. Create a voice token on the server -The server generates a **signed WebSocket URL** so your API key never reaches the client. The signed URL is valid for 30 minutes. +The browser must not see your API key. So the server makes a signed WebSocket URL, and the browser connects with it. The URL is valid for 30 minutes. -```typescript group=elevenlabs-1 -import { realtimeToken } from '@tanstack/ai' -import { elevenlabsRealtimeToken } from '@tanstack/ai-elevenlabs' +```typescript +import { realtimeToken } from "@tanstack/ai"; +import { elevenlabsRealtimeToken } from "@tanstack/ai-elevenlabs"; -// In your API route (Express, Hono, TanStack Start, etc.) export async function POST() { const token = await realtimeToken({ adapter: elevenlabsRealtimeToken({ agentId: process.env.ELEVENLABS_AGENT_ID!, }), - }) + }); - return Response.json(token) + return Response.json(token); } ``` -### With Overrides +Serve this handler at `/api/realtime-token`. -You can override agent settings at token generation time without changing your dashboard configuration: +## 4. Talk to the agent -```typescript group=elevenlabs-1 -const token = await realtimeToken({ - adapter: elevenlabsRealtimeToken({ - agentId: process.env.ELEVENLABS_AGENT_ID!, - overrides: { - voiceId: 'custom-voice-id', - systemPrompt: 'You are a helpful voice assistant.', - firstMessage: 'Hello! How can I help you today?', - language: 'en', - }, - }), -}) -``` - -## Client Setup - -### React (useRealtimeChat) +`useRealtimeChat` gets the token, opens the microphone, and plays the voice of the agent: ```tsx -import { useRealtimeChat } from '@tanstack/ai-react' -import { elevenlabsRealtime } from '@tanstack/ai-elevenlabs' +import { useRealtimeChat } from "@tanstack/ai-react"; +import { elevenlabsRealtime } from "@tanstack/ai-elevenlabs"; -function VoiceChat() { - const { - status, - mode, - messages, - connect, - disconnect, - pendingUserTranscript, - pendingAssistantTranscript, - inputLevel, - outputLevel, - } = useRealtimeChat({ - getToken: () => - fetch('/api/realtime-token', { method: 'POST' }).then((r) => r.json()), - adapter: elevenlabsRealtime(), - }) +export function VoiceChat() { + const { status, messages, connect, disconnect, pendingAssistantTranscript } = + useRealtimeChat({ + getToken: () => + fetch("/api/realtime-token", { method: "POST" }).then((r) => r.json()), + adapter: elevenlabsRealtime(), + }); return (
-

Status: {status}

-

Mode: {mode}

- - {pendingUserTranscript &&

You: {pendingUserTranscript}...

} - {pendingAssistantTranscript && ( -

AI: {pendingAssistantTranscript}...

- )} - {messages.map((msg) => ( -
- {msg.role}: - {msg.parts.map((part, i) => ( - - {part.type === 'text' ? part.content : null} - {part.type === 'audio' ? part.transcript : null} + {messages.map((message) => ( +

+ {message.role}: + {message.parts.map((part, index) => ( + + {part.type === "text" ? part.content : null} + {part.type === "audio" ? part.transcript : null} ))} -

+

))} + {pendingAssistantTranscript &&

AI: {pendingAssistantTranscript}...

}
- ) + ); } ``` -### Non-React (RealtimeClient) +Click **Start conversation** and speak. The agent answers out loud, and the transcript shows up below the button. For the other frameworks and for the full realtime API, see [Realtime Voice Chat](../media/realtime-chat). -```typescript -import { RealtimeClient } from '@tanstack/ai-client' -import { elevenlabsRealtime } from '@tanstack/ai-elevenlabs' +## Client tools -const client = new RealtimeClient({ - getToken: () => - fetch('/api/realtime-token', { method: 'POST' }).then((r) => r.json()), - adapter: elevenlabsRealtime(), - onMessage: (message) => { - console.log(`${message.role}:`, message.parts) - }, - onStatusChange: (status) => { - console.log('Status:', status) - }, - onModeChange: (mode) => { - console.log('Mode:', mode) - }, -}) - -await client.connect() -``` - -## Client Tools - -ElevenLabs supports client-side tools that execute in the browser. Define tools using the standard `toolDefinition()` API: +The agent can call tools that run in the browser. Define them with `toolDefinition()` and pass them to the hook: ```typescript -import { toolDefinition } from '@tanstack/ai' -import { useRealtimeChat } from '@tanstack/ai-react' -import { elevenlabsRealtime } from '@tanstack/ai-elevenlabs' -import { z } from 'zod' +import { toolDefinition } from "@tanstack/ai"; +import { useRealtimeChat } from "@tanstack/ai-react"; +import { elevenlabsRealtime } from "@tanstack/ai-elevenlabs"; +import { z } from "zod"; const getWeatherDef = toolDefinition({ - name: 'getWeather', - description: 'Get weather for a location', + name: "getWeather", + description: "Get weather for a location", inputSchema: z.object({ location: z.string(), }), @@ -189,105 +128,28 @@ const getWeatherDef = toolDefinition({ temperature: z.number(), conditions: z.string(), }), -}) +}); const getWeather = getWeatherDef.client(async ({ location }) => { - const res = await fetch(`/api/weather?location=${location}`) - return res.json() -}) - -// Pass tools to the hook -const chat = useRealtimeChat({ - getToken: () => - fetch('/api/realtime-token', { method: 'POST' }).then((r) => r.json()), - adapter: elevenlabsRealtime(), - tools: [getWeather], -}) -``` - -Tool results are automatically serialized to strings and returned to the ElevenLabs agent. The adapter converts TanStack tool definitions into the `@elevenlabs/client` clientTools format internally. - -## Configuration - -### `elevenlabsRealtimeToken` Options - -Used on the **server** to generate a signed WebSocket URL. - -| Option | Type | Required | Description | -|--------|------|----------|-------------| -| `agentId` | `string` | No\* | Agent ID configured in the ElevenLabs dashboard. \*Falls back to `ELEVENLABS_AGENT_ID`; required only if that env var is unset | -| `overrides.voiceId` | `string` | No | Custom voice ID to override the agent's default voice | -| `overrides.systemPrompt` | `string` | No | Custom system prompt to override the agent's default | -| `overrides.firstMessage` | `string` | No | First message the agent speaks when the session starts | -| `overrides.language` | `string` | No | Language code (e.g., `'en'`, `'es'`, `'fr'`) | - -### `elevenlabsRealtime` Options - -Used on the **client** to establish the connection. - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `connectionMode` | `'websocket' \| 'webrtc'` | auto-detect | Transport protocol for the connection | -| `debug` | `boolean \| DebugConfig` | `false` | Enable debug logging — pass `true` for all categories, or a `DebugConfig` to select categories/sink | - -## Differences from OpenAI Realtime - -ElevenLabs and OpenAI take different approaches to realtime voice: - -| | ElevenLabs | OpenAI | -|---|---|---| -| **Configuration** | Agent-based. Configure voice, personality, and knowledge in the ElevenLabs dashboard or via `overrides` at token time. | Session-based. Configure `instructions`, `voice`, `temperature`, etc. per session via `useRealtimeChat` options. | -| **Token type** | Signed WebSocket URL (valid 30 minutes) | Ephemeral API token (valid ~10 minutes) | -| **Transport** | WebSocket (default) or WebRTC | WebRTC | -| **Audio handling** | `@elevenlabs/client` SDK manages audio capture and playback automatically | TanStack AI manages WebRTC peer connection and audio tracks | -| **VAD** | Handled by ElevenLabs server-side | Supports `server`, `semantic`, and `manual` modes | -| **Runtime updates** | Session config is set at creation time and cannot be changed mid-session | Supports `updateSession()` for mid-session config changes | -| **Image input** | Not supported | Supported via `sendImage()` | -| **Time domain data** | Not available from the SDK | Available for waveform visualizations | - -## Audio Visualization - -The ElevenLabs adapter provides audio visualization data through the same interface as other realtime adapters: - -```typescript -import { useRealtimeChat } from '@tanstack/ai-react' -import { elevenlabsRealtime } from '@tanstack/ai-elevenlabs' - -const { - inputLevel, // 0-1 normalized microphone volume - outputLevel, // 0-1 normalized speaker volume - getInputFrequencyData, // Uint8Array frequency spectrum - getOutputFrequencyData, -} = useRealtimeChat({ - getToken: () => - fetch('/api/realtime-token', { method: 'POST' }).then((r) => r.json()), - adapter: elevenlabsRealtime(), -}) -``` - -**Note:** ElevenLabs provides volume levels and frequency data but does not expose time-domain data. The `getInputTimeDomainData()` and `getOutputTimeDomainData()` methods return static placeholder arrays. The default audio sample rate is 16kHz. - -## Environment Variables - -Set these in your server environment: + const res = await fetch(`/api/weather?location=${location}`); + return res.json(); +}); -```bash -ELEVENLABS_API_KEY=your-elevenlabs-api-key -ELEVENLABS_AGENT_ID=your-agent-id +export function useWeatherVoiceChat() { + return useRealtimeChat({ + getToken: () => + fetch("/api/realtime-token", { method: "POST" }).then((r) => r.json()), + adapter: elevenlabsRealtime(), + tools: [getWeather], + }); +} ``` -| Variable | Required | Description | -|----------|----------|-------------| -| `ELEVENLABS_API_KEY` | Yes | Your ElevenLabs API key, used server-side for generating signed URLs | -| `ELEVENLABS_AGENT_ID` | No | Default agent ID. Can also be passed directly to `elevenlabsRealtimeToken()` | +The adapter turns each result into a string and sends it to the agent. -Get your API key from the [ElevenLabs dashboard](https://elevenlabs.io/). Create and configure agents in the **Conversational AI** section of the dashboard. +## Text-to-speech -## Text-to-Speech - -For one-shot speech generation (not realtime), use `elevenlabsSpeech` with `generateSpeech()`: - -The `format` option supports `mp3` (default), `pcm`, `opus`, and `wav`. WAV output contains 44.1 kHz, 16-bit mono PCM with a RIFF header. Requests for `aac` or `flac` throw before the API call. An explicit `modelOptions.outputFormat` overrides `format` and returns the selected provider format without WAV wrapping. +`elevenlabsSpeech` makes one audio clip from text. `voice` is an ElevenLabs voice id: ```typescript import { generateSpeech } from "@tanstack/ai"; @@ -296,84 +158,64 @@ import { elevenlabsSpeech } from "@tanstack/ai-elevenlabs"; const result = await generateSpeech({ adapter: elevenlabsSpeech("eleven_v4"), text: "Hello from ElevenLabs!", - voice: "Rachel", + threadId: "thread-1", + voice: "21m00Tcm4TlvDq8ikWAM", format: "mp3", }); console.log(result.audio); // Base64-encoded audio ``` -### Dialogue and timings - -ElevenLabs has a separate dialogue endpoint that takes up to **10 distinct -voices**, and a timestamped twin of each endpoint. `turns` and `timestamps` -pick between them, so you never choose an endpoint by hand: - -```typescript -import { generateSpeech } from "@tanstack/ai"; -import { elevenlabsSpeech } from "@tanstack/ai-elevenlabs"; - -const result = await generateSpeech({ - adapter: elevenlabsSpeech("eleven_v4"), - turns: [ - { text: "Knock knock.", voice: "bYTqZQo3Jz7LQtmGTgwi" }, - { text: "Who is there?", voice: "6lCwbsX1yVjD49QmpkTR" }, - ], - timestamps: true, -}); - -// Character timings for the whole clip. -console.log(result.alignment?.unit); // 'character' - -// One entry per turn, with the voice that spoke it. -for (const segment of result.segments ?? []) { - console.log(segment.turnIndex, segment.voice, segment.text); -} -``` +- `format` takes `mp3` (the default), `pcm`, `opus`, or `wav`. The adapter throws for `aac` and `flac` before it calls the API. +- For a dialogue with several voices, or for timings, see [Dialogue and timings](#dialogue-and-timings). -`segments` only comes back from the dialogue endpoint. A single-voice request -with `timestamps: true` returns `alignment` alone. +See [Text-to-Speech](../media/text-to-speech). -## Music & Sound Effects +## Music and sound effects -`elevenlabsAudio` covers both music generation and sound effects depending on the model: +`elevenlabsAudio` makes music or a sound effect. The model picks which one: ```typescript import { generateAudio } from "@tanstack/ai"; import { elevenlabsAudio } from "@tanstack/ai-elevenlabs"; -// Music generation const music = await generateAudio({ adapter: elevenlabsAudio("music_v2_5"), prompt: "An upbeat synthwave track for a product launch", + threadId: "thread-1", }); -// Sound effects const sfx = await generateAudio({ adapter: elevenlabsAudio("eleven_text_to_sound_v2"), prompt: "A glass shattering on concrete", + threadId: "thread-1", }); + +console.log(music.audio, sfx.audio); ``` -## Listing Voices +See [Audio Generation](../media/audio-generation). -`elevenlabsSpeech` can read the account's voice catalog, which includes anything `generateVoice()` has saved: +## Transcription ```typescript -import { listVoices } from "@tanstack/ai"; -import { elevenlabsSpeech } from "@tanstack/ai-elevenlabs"; +import { generateTranscription } from "@tanstack/ai"; +import { elevenlabsTranscription } from "@tanstack/ai-elevenlabs"; -const { voices } = await listVoices({ - adapter: elevenlabsSpeech("eleven_v4"), - origins: ["generated", "cloned"], +const result = await generateTranscription({ + adapter: elevenlabsTranscription("scribe_v2"), + audio: "https://example.com/recording.mp3", + threadId: "thread-1", }); + +console.log(result.text); ``` -ElevenLabs has no origin filter on `GET /v1/voices`, so the adapter fetches the catalog and narrows in memory. Its `famous` and `high_quality` categories both read as `professional`. +See [Transcription](../media/transcription). -## Voice Design +## Voice design -`elevenlabsVoiceDesign` creates a new voice from a description. The voice IDs it returns go straight into `generateSpeech()`: +`elevenlabsVoiceDesign` makes a new voice from a description. Use the voice id that it returns in `generateSpeech()`: ```typescript import { generateVoice } from "@tanstack/ai"; @@ -382,6 +224,7 @@ import { elevenlabsVoiceDesign } from "@tanstack/ai-elevenlabs"; const result = await generateVoice({ adapter: elevenlabsVoiceDesign("eleven_ttv_v3"), prompt: "A warm, gravelly narrator in his sixties with a slight Irish lilt", + threadId: "thread-1", name: "Irish Narrator", }); @@ -392,112 +235,215 @@ console.log(voice.voiceId); console.log(voice.saved); // true, because a name was given ``` -Without a `name`, you get preview voices to audition. With a `name`, the best candidate is kept in your ElevenLabs voice library. +- Without a `name`, you get preview voices to listen to. +- With a `name`, ElevenLabs keeps the best voice in your voice library. -`eleven_ttv_v3` also accepts `referenceAudio`, a clip of a real speaker used as a design reference — it still needs a `prompt`, and `modelOptions.promptStrength` balances the two. `eleven_multilingual_ttv_v2` takes no reference audio. See [Voice Creation](../media/voice-creation) for the full guide. +For reference audio and the other options, see [Voice Creation](../media/voice-creation). -## Models +## Models and options -| Family | Models | -| --- | --- | -| Text-to-speech | `eleven_v4`, `eleven_v4_turbo`, `eleven_v3`, `eleven_v3_conversational`, `eleven_multilingual_v2`, `eleven_flash_v2_5`, `eleven_flash_v2` | -| Music | `music_v2_5`, `music_v2` | -| Sound effects | `eleven_text_to_sound_v2` | -| Transcription | `scribe_v2`, `scribe_v2_medical` | -| Voice design | `eleven_ttv_v3`, `eleven_multilingual_ttv_v2` | +| Activity | Factory | Models | +| --- | --- | --- | +| Text-to-speech | `elevenlabsSpeech` | `eleven_v4`, `eleven_v4_turbo`, `eleven_v3`, `eleven_v3_conversational`, `eleven_multilingual_v2`, `eleven_flash_v2_5`, `eleven_flash_v2` | +| Music | `elevenlabsAudio` | `music_v2_5`, `music_v2` | +| Sound effects | `elevenlabsAudio` | `eleven_text_to_sound_v2` | +| Transcription | `elevenlabsTranscription` | `scribe_v2`, `scribe_v2_medical` | +| Voice design | `elevenlabsVoiceDesign` | `eleven_ttv_v3`, `eleven_multilingual_ttv_v2` | -ElevenLabs deprecated `eleven_turbo_v2`, `eleven_turbo_v2_5`, `eleven_monolingual_v1`, `scribe_v1`, and `music_v1`. The adapter still accepts them, and each is commented as deprecated in `model-meta.ts`. Move to the current model in the same row. +The adapter also accepts the deprecated ids `eleven_turbo_v2`, `eleven_turbo_v2_5`, `eleven_monolingual_v1`, `scribe_v1`, and `music_v1`. Use the newest model in the same row. -## Transcription +The package exports the lists as `ELEVENLABS_TTS_MODELS`, `ELEVENLABS_AUDIO_MODELS`, `ELEVENLABS_TRANSCRIPTION_MODELS`, and `ELEVENLABS_VOICE_MODELS`. -Transcribe audio with `elevenlabsTranscription`: +## Advanced + +### Agent overrides + +To change the agent for one session, set `overrides` when you make the token. Your dashboard settings do not change: ```typescript -import { generateTranscription } from "@tanstack/ai"; -import { elevenlabsTranscription } from "@tanstack/ai-elevenlabs"; -import { audioFile } from "./audio"; +import { realtimeToken } from "@tanstack/ai"; +import { elevenlabsRealtimeToken } from "@tanstack/ai-elevenlabs"; -const result = await generateTranscription({ - adapter: elevenlabsTranscription("scribe_v2"), - audio: audioFile, +const token = await realtimeToken({ + adapter: elevenlabsRealtimeToken({ + agentId: process.env.ELEVENLABS_AGENT_ID!, + overrides: { + voiceId: "custom-voice-id", + systemPrompt: "You are a helpful voice assistant.", + firstMessage: "Hello! How can I help you today?", + language: "en", + }, + }), }); -console.log(result.text); +console.log(token.expiresAt); ``` -## Behind a proxy +| Option | Type | Description | +| --- | --- | --- | +| `agentId` | `string` | The agent id from the dashboard. If you do not set it, the adapter reads `ELEVENLABS_AGENT_ID`. | +| `overrides.voiceId` | `string` | A voice id that replaces the default voice of the agent. | +| `overrides.systemPrompt` | `string` | A system prompt that replaces the default prompt of the agent. | +| `overrides.firstMessage` | `string` | The first thing the agent says when the session starts. | +| `overrides.language` | `string` | A language code, for example `"en"`, `"es"`, or `"fr"`. | + +### Client adapter options + +`elevenlabsRealtime(options?)` runs in the browser: -Route every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them. +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `connectionMode` | `"websocket"` or `"webrtc"` | auto-detect | The transport of the connection. | +| `debug` | `boolean` or `DebugConfig` | `false` | Debug logs. `true` turns on all categories. A `DebugConfig` selects categories or a sink. | + +### Without React + +Use `RealtimeClient` from `@tanstack/ai-client`: ```typescript -import { createElevenLabsSpeech } from "@tanstack/ai-elevenlabs"; +import { RealtimeClient } from "@tanstack/ai-client"; +import { elevenlabsRealtime } from "@tanstack/ai-elevenlabs"; -const adapter = createElevenLabsSpeech("eleven_v4", process.env.ELEVENLABS_API_KEY!, { - baseURL: "https://gateway.example.com/elevenlabs", - defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` }, +const client = new RealtimeClient({ + getToken: () => + fetch("/api/realtime-token", { method: "POST" }).then((r) => r.json()), + adapter: elevenlabsRealtime(), + onMessage: (message) => { + console.log(`${message.role}:`, message.parts); + }, + onStatusChange: (status) => { + console.log("Status:", status); + }, + onModeChange: (mode) => { + console.log("Mode:", mode); + }, }); + +await client.connect(); ``` -This applies to the speech, audio, and transcription adapters. `baseUrl` and `headers` are aliases of the same two options. If you set both forms, `baseURL` and `defaultHeaders` win. +### Audio levels for a visualizer -## API Reference +The hook gives you the audio data of the microphone and the speaker: -### `elevenlabsRealtimeToken(options)` +```typescript +import { useRealtimeChat } from "@tanstack/ai-react"; +import { elevenlabsRealtime } from "@tanstack/ai-elevenlabs"; -Creates an ElevenLabs realtime token adapter for server-side use with `realtimeToken()`. +export function useVoiceLevels() { + const { + inputLevel, // 0 to 1, microphone volume + outputLevel, // 0 to 1, speaker volume + getInputFrequencyData, // Uint8Array frequency spectrum + getOutputFrequencyData, + } = useRealtimeChat({ + getToken: () => + fetch("/api/realtime-token", { method: "POST" }).then((r) => r.json()), + adapter: elevenlabsRealtime(), + }); + + return { inputLevel, outputLevel, getInputFrequencyData, getOutputFrequencyData }; +} +``` + +ElevenLabs gives volume levels and frequency data, but no time-domain data. `getInputTimeDomainData()` and `getOutputTimeDomainData()` return fixed placeholder arrays. The default sample rate is 16 kHz. + +### ElevenLabs and OpenAI realtime + +| | ElevenLabs | OpenAI | +|---|---|---| +| Configuration | Agent-based. Set voice, personality, and knowledge in the dashboard, or with `overrides` on the token. | Session-based. Set `instructions`, `voice`, and more for each session in the `useRealtimeChat` options. | +| Token | Signed WebSocket URL, valid for 30 minutes | Ephemeral API token, valid for about 10 minutes | +| Transport | WebSocket (default) or WebRTC | WebRTC | +| Audio | The `@elevenlabs/client` SDK records and plays the audio | TanStack AI runs the WebRTC connection and the audio tracks | +| Voice activity detection | ElevenLabs does it on its server | `server`, `semantic`, and `manual` modes | +| Session changes | Fixed when the session starts | `updateSession()` changes the session while it runs | +| Image input | Not supported | `sendImage()` | +| Time-domain data | Not available | Available for waveforms | -**Parameters:** +### Dialogue and timings + +ElevenLabs has a dialogue endpoint that takes up to 10 different voices. Each endpoint also has a version with timestamps. `turns` and `timestamps` pick the endpoint for you: + +```typescript +import { generateSpeech } from "@tanstack/ai"; +import { elevenlabsSpeech } from "@tanstack/ai-elevenlabs"; -- `options.agentId` - Agent ID from the ElevenLabs dashboard -- `options.overrides?.voiceId` - Custom voice ID -- `options.overrides?.systemPrompt` - Custom system prompt -- `options.overrides?.firstMessage` - First message the agent speaks -- `options.overrides?.language` - Language code +const result = await generateSpeech({ + adapter: elevenlabsSpeech("eleven_v4"), + turns: [ + { text: "Knock knock.", voice: "bYTqZQo3Jz7LQtmGTgwi" }, + { text: "Who is there?", voice: "6lCwbsX1yVjD49QmpkTR" }, + ], + timestamps: true, + threadId: "thread-1", +}); -**Returns:** A `RealtimeTokenAdapter` for use with `realtimeToken()`. +// Character timings for the whole clip. +console.log(result.alignment?.unit); // "character" -### `elevenlabsRealtime(options?)` +// One entry per turn, with the voice that spoke it. +for (const segment of result.segments ?? []) { + console.log(segment.turnIndex, segment.voice, segment.text); +} +``` -Creates an ElevenLabs realtime client adapter for use with `useRealtimeChat` or `RealtimeClient`. +Only the dialogue endpoint returns `segments`. A request with one voice and `timestamps: true` returns `alignment` only. -**Parameters:** +### Speech output format -- `options.connectionMode?` - `'websocket'` or `'webrtc'` (default: auto-detect) -- `options.debug?` - Enable debug logging +- `wav` output is 44.1 kHz, 16-bit mono PCM with a RIFF header. +- `modelOptions.outputFormat` overrides `format`. The adapter then returns the format of ElevenLabs as it is, with no WAV header. -**Returns:** A `RealtimeAdapter` for use with `useRealtimeChat()` or `RealtimeClient`. +### List voices -### `elevenlabsSpeech(model, config?)` / `createElevenLabsSpeech(model, apiKey, config?)` +`elevenlabsSpeech` reads the voices of your account. This includes the voices that `generateVoice()` saved: -Creates an ElevenLabs text-to-speech adapter for use with `generateSpeech()`. +```typescript +import { listVoices } from "@tanstack/ai"; +import { elevenlabsSpeech } from "@tanstack/ai-elevenlabs"; -### `elevenlabsAudio(model, config?)` / `createElevenLabsAudio(model, apiKey, config?)` +const { voices } = await listVoices({ + adapter: elevenlabsSpeech("eleven_v4"), + origins: ["generated", "cloned"], +}); -Creates an ElevenLabs audio adapter that covers both music generation and sound effects (selected via the model id) for use with `generateAudio()`. +console.log(voices.length); +``` -### `elevenlabsTranscription(model, config?)` / `createElevenLabsTranscription(model, apiKey, config?)` +`GET /v1/voices` has no origin filter, so the adapter gets all voices and filters them in memory. The `famous` and `high_quality` categories both come back as `professional`. -Creates an ElevenLabs transcription adapter for use with `generateTranscription()`. +### Behind a proxy -### `elevenlabsVoiceDesign(model, config?)` / `createElevenLabsVoiceDesign(model, apiKey, config?)` +To send each request through a gateway, such as Cloudflare AI Gateway or a company proxy, set `baseURL` and `defaultHeaders`. All TanStack AI adapters use these two option names, so one gateway configuration works for all of them. -Creates an ElevenLabs voice-design adapter for use with `generateVoice()`. +```typescript +import { createElevenLabsSpeech } from "@tanstack/ai-elevenlabs"; -### `elevenlabsSpeech(...).listVoices(options?)` +const adapter = createElevenLabsSpeech("eleven_v4", process.env.ELEVENLABS_API_KEY!, { + baseURL: "https://gateway.example.com/elevenlabs", + defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` }, +}); +``` -Reads the account's voice catalog via `GET /v1/voices`, for use with `listVoices()`. +- This works for the speech, audio, transcription, and voice design adapters. +- `baseUrl` and `headers` are aliases. If you set both forms, `baseURL` and `defaultHeaders` win. -## Limitations +### Limits -- **No text chat support** -- Use OpenAI, Anthropic, Gemini, or another text adapter for `chat()`. -- **No summarization** -- Use a text adapter for `summarize()`. -- **No image input** (realtime) -- ElevenLabs realtime does not support sending images during a conversation. -- **No runtime session updates** (realtime) -- Session configuration is fixed at connection time. -- **No time-domain audio data** (realtime) -- Frequency data and volume levels are available, but waveform data is not. -- **Agent required** (realtime) -- You must create and configure an agent in the ElevenLabs dashboard before using the realtime adapter. +- Realtime does not take images during a conversation. +- Realtime fixes the session configuration when the connection starts. +- Realtime needs an agent that you made in the ElevenLabs dashboard. -## Next Steps +### API reference -- [Realtime Voice Chat Guide](../media/realtime-chat) - Complete guide to building realtime voice applications -- [OpenAI Adapter](./openai) - Alternative realtime voice provider with WebRTC -- [Tools Guide](../tools/tools) - Learn about the isomorphic tool system +| Factory | Use it with | +| --- | --- | +| `elevenlabsRealtimeToken(options)` | `realtimeToken()` on the server. Returns a `RealtimeTokenAdapter`. | +| `elevenlabsRealtime(options?)` | `useRealtimeChat()` or `RealtimeClient` in the browser. Returns a `RealtimeAdapter`. | +| `elevenlabsSpeech(model, config?)` / `createElevenLabsSpeech(model, apiKey, config?)` | `generateSpeech()` and `listVoices()` | +| `elevenlabsAudio(model, config?)` / `createElevenLabsAudio(model, apiKey, config?)` | `generateAudio()`, for music and sound effects | +| `elevenlabsTranscription(model, config?)` / `createElevenLabsTranscription(model, apiKey, config?)` | `generateTranscription()` | +| `elevenlabsVoiceDesign(model, config?)` / `createElevenLabsVoiceDesign(model, apiKey, config?)` | `generateVoice()` | + +The short factories read `ELEVENLABS_API_KEY`. The `create*` factories take an explicit API key. diff --git a/docs/adapters/fal.md b/docs/adapters/fal.md index c8fc2503ec..e921c9a51c 100644 --- a/docs/adapters/fal.md +++ b/docs/adapters/fal.md @@ -1,7 +1,7 @@ --- title: fal.ai id: fal-adapter -description: "Generate images and videos with 600+ models on fal.ai using TanStack AI — Nano Banana Pro, FLUX, and more via the @tanstack/ai-fal adapter." +description: "Use fal.ai in TanStack AI with @tanstack/ai-fal: 600+ hosted models for images, video, live video, speech, transcription, music, and sound effects." keywords: - tanstack ai - fal.ai @@ -13,11 +13,11 @@ keywords: - adapter --- -The fal.ai adapter provides access to 600+ models on the fal.ai platform for image, video, live, audio, speech, and transcription. Unlike text-focused adapters, the fal adapter is **media-focused**. It supports `generateImage()`, `generateVideo()`, `generateLiveVideo()`, `generateAudio()`, `generateSpeech()`, and `generateTranscription()`. It does not support `chat()` or tools. +The fal.ai adapter calls hosted models for images, video, live video, speech, transcription, music, and sound effects. It has no text chat and no tools. -For a full working example, see the [fal.ai example app](https://github.com/TanStack/ai/tree/main/examples/ts-react-media). +The quick setup below generates an image on your server and shows it in React. For a full app, see the [media example](https://github.com/TanStack/ai/tree/main/examples/ts-react-media). -## Installation +## 1. Install @@ -32,110 +32,99 @@ octane: @tanstack/ai-fal -## Type Safety with String Literals +## 2. Set the API key -The fal adapter provides full type safety when you pass the model ID as a **string literal**. This gives you autocomplete for `size` and `modelOptions` specific to that model. Always use string literals — not variables — when creating adapters: +Create a key at [fal.ai](https://fal.ai). Put it in the environment of your server: -```typescript -import { falImage } from "@tanstack/ai-fal"; - -// Good — full type safety and autocomplete -const adapter = falImage("fal-ai/z-image/turbo"); -``` - -```typescript -import { falImage } from "@tanstack/ai-fal"; - -// Bad — no type inference for model-specific options -const modelId = "fal-ai/z-image/turbo"; -const adapter = falImage(modelId); +```bash +FAL_KEY=your-fal-api-key ``` -You can also pass any string for new models that fal.ai hasn't provided types for yet — you just won't get type safety on those endpoints. - -## Basic Usage +All fal adapters read `FAL_KEY`. To pass the key in code, set `apiKey` in the config: ```typescript -import { generateImage } from "@tanstack/ai"; import { falImage } from "@tanstack/ai-fal"; -const result = await generateImage({ - adapter: falImage("fal-ai/flux/dev"), - prompt: "A futuristic cityscape at sunset", - numberOfImages: 1, +const adapter = falImage("fal-ai/nano-banana-pro", { + apiKey: process.env.FAL_KEY!, }); - -console.log(result.images); ``` -## Basic Usage - Custom API Key +## 3. Generate an image on the server + +Add a `POST` route at `/api/generate/image`: ```typescript -import { generateImage } from "@tanstack/ai"; +import { + generateImage, + generationParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; import { falImage } from "@tanstack/ai-fal"; -const adapter = falImage("fal-ai/flux/dev", { - apiKey: process.env.FAL_KEY!, -}); - -const result = await generateImage({ - adapter, - prompt: "A futuristic cityscape at sunset", - numberOfImages: 1, -}); -``` - -## Configuration - -```typescript -import { falImage, type FalClientConfig } from "@tanstack/ai-fal"; +export async function POST(request: Request) { + const { input, threadId } = await generationParamsFromRequest("image", request); + if (typeof input.prompt !== "string") { + return new Response("Send a text prompt.", { status: 400 }); + } -// Direct API key -const adapter = falImage("fal-ai/flux/dev", { - apiKey: "your-api-key", -}); + const stream = generateImage({ + adapter: falImage("fal-ai/nano-banana-pro"), + prompt: input.prompt, + threadId, + stream: true, + }); -// Using a proxy URL (for client-side usage) -const proxiedAdapter = falImage("fal-ai/flux/dev", { - apiKey: "your-api-key", - proxyUrl: "https://your-server.com/api/fal/proxy", -}); + return toServerSentEventsResponse(stream); +} ``` -## Example: Image Generation - -From the [fal.ai example app](https://github.com/TanStack/ai/tree/main/examples/ts-react-media): - -```typescript -import { generateImage } from "@tanstack/ai"; -import { falImage } from "@tanstack/ai-fal"; - -// Use string literals for the model to get full type safety -const result = await generateImage({ - adapter: falImage("fal-ai/nano-banana-pro"), - prompt: "A futuristic cityscape at sunset", - numberOfImages: 1, - size: "16:9_4K", - modelOptions: { - output_format: "jpeg", - }, -}); +## 4. Show the image + +```tsx +import { fetchServerSentEvents, useGenerateImage } from "@tanstack/ai-react"; + +export function ImageGenerator() { + const { generate, result, isLoading, error } = useGenerateImage({ + connection: fetchServerSentEvents("/api/generate/image"), + threadId: "image-studio", + }); + + return ( +
+ + {error &&

{error.message}

} + {result?.images.map((image, index) => ( + Generated image + ))} +
+ ); +} ``` -## Example: Image with Model Options +Click the button. The image shows up below it. For the other frameworks, see [Generation Hooks](../media/generation-hooks). -Each fal.ai model has its own type-safe options. The adapter uses fal.ai's types to provide autocomplete and type checking for model-specific parameters. There are 1000s of combinations. If you provide the proper model endpoint id as as string literal, you will then only be able to provide `size` and `modelOptions` that the model supports. +## Model options -_IMPORTANT_: It is possible to pass strings and new endpoint ids that Fal has not provided types for. You just won't get type safety on those endpoints. You'll find this happens for very new models. +Each fal model has its own options. Write the model id as a string literal, and TypeScript completes `size` and `modelOptions` for that model: ```typescript import { generateImage } from "@tanstack/ai"; import { falImage } from "@tanstack/ai-fal"; -// Model-specific options are type-safe const result = await generateImage({ adapter: falImage("fal-ai/z-image/turbo"), prompt: "A serene mountain landscape", + threadId: "thread-1", numberOfImages: 1, size: "landscape_16_9", modelOptions: { @@ -143,69 +132,41 @@ const result = await generateImage({ enable_prompt_expansion: true, }, }); -``` - -## Image Size Options - -The fal adapter supports a flexible `size` parameter that maps either to `image_size` or to `aspect_ratio` and `resolution` parameters: - -| | `size` | Maps To | -|--------|---------|---------| -| named | `"landscape_16_9"` | `image_size: "landscape_16_9"` | -| width x height (OpenAI) | `"1536x1024"` | | `image_size: "1536x1024"` | -| aspect ratio & resolution | `"16:9_4K"` | `aspect_ratio: "16:9"`, `resolution: "4K"` | -| aspect ratio only | `"16:9"` | `aspect_ratio: "16:9"` | - -```typescript ignore -// Aspect ratio only -size: "16:9" - -// Aspect ratio with resolution -size: "16:9_4K" -// Named size (model-specific) -size: "landscape_16_9" +console.log(result.images); ``` -## Video Generation (Experimental) - -> **Note:** Video generation is an experimental feature and may change in future releases. +- Pass the id directly, not through a `string` variable. A variable loses the types of the model. +- Any string works as a model id. A model that is newer than the fal SDK types runs, but has no autocomplete. -Video generation uses a queue-based workflow: submit a job, poll for status, then retrieve the video URL when complete. +For the `size` formats, see [Image sizes](#image-sizes). -```typescript -import { generateVideo, getVideoJobStatus } from "@tanstack/ai"; -import { falVideo } from "@tanstack/ai-fal"; -``` +## Video -## Example: Text-to-Video +> Video generation is an [experimental feature](../media/video-generation). -From the [fal.ai example app](https://github.com/TanStack/ai/tree/main/examples/ts-react-media): +Video runs as a job. `generateVideo()` starts the job, and `getVideoJobStatus()` reads its status: ```typescript import { generateVideo, getVideoJobStatus } from "@tanstack/ai"; import { falVideo } from "@tanstack/ai-fal"; -// 1. Submit the video generation job const adapter = falVideo("fal-ai/kling-video/v2.6/pro/text-to-video"); const job = await generateVideo({ adapter, prompt: "A timelapse of a flower blooming", + threadId: "thread-1", size: "16:9", duration: "5", }); -// 2. Poll for status -const status = await getVideoJobStatus({ - adapter, - jobId: job.jobId, -}); +const status = await getVideoJobStatus({ adapter, jobId: job.jobId }); console.log(status.status); // "pending" | "processing" | "completed" ``` -## Example: Image-to-Video +To animate an image, use an image-to-video model and set the start image in `modelOptions`: ```typescript import { generateVideo } from "@tanstack/ai"; @@ -214,68 +175,26 @@ import { falVideo } from "@tanstack/ai-fal"; const job = await generateVideo({ adapter: falVideo("fal-ai/kling-video/v2.6/pro/image-to-video"), prompt: "Animate this scene with gentle wind", + threadId: "thread-1", duration: "5", modelOptions: { start_image_url: "https://example.com/image.jpg", generate_audio: true, }, }); -``` - -`duration` is typed per model from `@fal-ai/client`'s `EndpointTypeMap`. Popular models also implement `availableDurations()` / `snapDuration()` for UI sliders: - -| Model | `duration` type | `availableDurations()` | -| --- | --- | --- | -| `fal-ai/kling-video/v1.6/{standard,pro}/text-to-video`, `fal-ai/kling-video/v2.6/pro/{text,image}-to-video` | `'5' \| '10'` | discrete | -| `fal-ai/kling-video/v3/pro/{text,image}-to-video` | `'3'` … `'15'` | discrete | -| `fal-ai/pika/v2.2/text-to-video` | `'5' \| '10'` | discrete | -| `fal-ai/ltx-2.3/{text,image}-to-video` (+ `/fast`) | `'6' \| '8' \| '10'` | discrete | -| `fal-ai/luma-dream-machine/ray-2` | `'5s' \| '9s'` | discrete | -| `fal-ai/veo3.1`, `fal-ai/veo3.1/fast` (+ `/image-to-video`), `fal-ai/veo3` (+ `/image-to-video`) | `'4s' \| '6s' \| '8s'` | discrete | -| `fal-ai/wan-25-preview/text-to-video` | `'2'` … `'15'` | discrete | -| `fal-ai/minimax/video-01` | not accepted | `{ kind: 'none' }` | -| `fal-ai/hunyuan-video-v1.5/text-to-video` | not accepted (`num_frames`) | `{ kind: 'none' }` | - -```typescript -import { generateVideo } from "@tanstack/ai"; -import { falVideo } from "@tanstack/ai-fal"; -const adapter = falVideo("fal-ai/veo3.1"); -adapter.availableDurations(); // { kind: 'discrete', values: ['4s', '6s', '8s'] } -adapter.snapDuration(7); // '6s' - -await generateVideo({ - adapter, - prompt: "A timelapse of a city skyline at dusk", - duration: adapter.snapDuration(7), -}); +console.log(job.jobId); ``` -Uncurated models still type `duration` from the SDK when the endpoint declares the field, but `availableDurations()` returns `{ kind: 'none' }` until they are added to the runtime map. - -## Text-to-Speech - -Text-to-speech uses `falSpeech()` with the `generateSpeech()` activity. The adapter fetches the generated audio from fal's CDN and returns it as base64-encoded data to match the `TTSResult` contract. +The `duration` values of each model are in [Video durations](#video-durations). To poll from a React component, see [Video Generation](../media/video-generation). -```typescript -import { generateSpeech } from "@tanstack/ai"; -import { falSpeech } from "@tanstack/ai-fal"; - -const result = await generateSpeech({ - adapter: falSpeech("fal-ai/kokoro/american-english"), - text: "Hello from fal!", - voice: "af_heart", - speed: 1.0, -}); +## Live video -// result.audio is a base64-encoded string -console.log(result.format); // e.g. "wav" -console.log(result.contentType); // e.g. "audio/wav" -``` +`falLiveVideo("minimax/h3-max/director")` starts a live video stream that you can steer. The browser connects through a proxy on your server. See [Live Generation](../media/live-generation#fal-h3-max-director). -### Google Gemini 3.1 Flash TTS +## Speech -Google's newest TTS model (`fal-ai/gemini-3.1-flash-tts`) supports 80+ languages and introduces **granular audio tags** for expressive control — you can embed speaker tags and style cues directly in the text. +`falSpeech` gets the audio from the fal CDN and returns it as base64: ```typescript import { generateSpeech } from "@tanstack/ai"; @@ -284,13 +203,16 @@ import { falSpeech } from "@tanstack/ai-fal"; const result = await generateSpeech({ adapter: falSpeech("fal-ai/gemini-3.1-flash-tts"), text: "[warm, enthusiastic] Welcome to TanStack AI! [pause] Let's build something great.", + threadId: "thread-1", voice: "Kore", }); + +console.log(result.format, result.contentType); ``` -> **Note:** This model is newer than `@fal-ai/client@1.9.1`'s type map, so `modelOptions` won't autocomplete. The call still works — the fal adapter accepts any model ID as a string. Type-safe autocomplete will land when fal's SDK types catch up. +`fal-ai/gemini-3.1-flash-tts` is the TTS model of Google. It supports 80+ languages. You can put speaker tags and style tags, such as `[pause]`, in the text. Its `modelOptions` have no autocomplete yet. -### ElevenLabs v3 +Other models put the voice in `modelOptions`: ```typescript import { generateSpeech } from "@tanstack/ai"; @@ -299,16 +221,19 @@ import { falSpeech } from "@tanstack/ai-fal"; const result = await generateSpeech({ adapter: falSpeech("fal-ai/elevenlabs/tts/eleven-v3"), text: "Welcome to TanStack AI.", + threadId: "thread-1", modelOptions: { voice: "Rachel", stability: 0.5, }, }); + +console.log(result.audio); // Base64-encoded audio ``` -## Transcription +See [Text-to-Speech](../media/text-to-speech). -Speech-to-text uses `falTranscription()` with the `generateTranscription()` activity. The `audio` input accepts a URL string, `Blob`, `File`, or `ArrayBuffer` — `ArrayBuffer` is automatically wrapped in a `Blob` for upload. +## Transcription ```typescript import { generateTranscription } from "@tanstack/ai"; @@ -318,234 +243,232 @@ const result = await generateTranscription({ adapter: falTranscription("fal-ai/whisper"), audio: "https://example.com/recording.mp3", language: "en", + threadId: "thread-1", }); console.log(result.text); console.log(result.language); -// When the model returns word/segment timestamps, they're mapped to result.segments +// When the model returns timestamps, they are in result.segments for (const segment of result.segments ?? []) { - console.log(`[${segment.start}s → ${segment.end}s] ${segment.text}`); + console.log(`[${segment.start}s to ${segment.end}s] ${segment.text}`); } ``` -## Audio Generation (Music & Sound Effects) +`audio` takes a URL string, a `Blob`, a `File`, or an `ArrayBuffer`. The adapter wraps an `ArrayBuffer` in a `Blob` for the upload. + +## Music and sound effects -Music and sound-effect generation uses `falAudio()` with the `generateAudio()` activity. Unlike TTS, the result is returned as a URL in `result.audio.url` (you can fetch it yourself if you need raw bytes). +`falAudio` returns a URL in `result.audio.url`. If you need the bytes, fetch the URL: ```typescript import { generateAudio } from "@tanstack/ai"; import { falAudio } from "@tanstack/ai-fal"; -// Music generation with MiniMax Music 2.6 (latest) const music = await generateAudio({ adapter: falAudio("fal-ai/minimax-music/v2.6"), prompt: "City Pop, 80s retro, groovy synth bass, warm female vocal, 104 BPM, nostalgic urban night", + threadId: "thread-1", +}); + +const sfx = await generateAudio({ + adapter: falAudio("fal-ai/elevenlabs/sound-effects/v2"), + prompt: "Thunderclap with rain", + threadId: "thread-1", + duration: 5, }); -console.log(music.audio.url); +console.log(music.audio.url, sfx.audio.url); ``` +Some music models take lyrics in `modelOptions`: + ```typescript import { generateAudio } from "@tanstack/ai"; import { falAudio } from "@tanstack/ai-fal"; -// DiffRhythm with explicit lyrics -const lyrical = await generateAudio({ +const song = await generateAudio({ adapter: falAudio("fal-ai/diffrhythm"), prompt: "An upbeat electronic track with synths", + threadId: "thread-1", modelOptions: { lyrics: "[verse]\nHello world\n[chorus]\nLa la la", }, }); + +console.log(song.audio.url); ``` -```typescript -import { generateAudio } from "@tanstack/ai"; -import { falAudio } from "@tanstack/ai-fal"; +See [Audio Generation](../media/audio-generation). -// Sound effects -const sfx = await generateAudio({ - adapter: falAudio("fal-ai/elevenlabs/sound-effects/v2"), - prompt: "Thunderclap with rain", - duration: 5, -}); -``` +## Files + +`falFiles` uploads media to fal storage, so a request can point to the file by URL. fal storage is upload-only. See [Files API](../advanced/files-api). -## Popular Models +## Models and options -### Image Models +fal has hundreds of models. These are popular picks. Any fal model id works. + +### Image models | Model | Description | |-------|-------------| -| `fal-ai/nano-banana-pro` | Fast, high-quality image generation (4K) | -| `fal-ai/flux-2/klein/9b` | Enhanced realism, crisp text generation | -| `fal-ai/z-image/turbo` | Super fast 6B parameter model | -| `xai/grok-imagine-image` | xAI highly aesthetic images with prompt enhancement | +| `fal-ai/nano-banana-pro` | Fast, high-quality images, up to 4K | +| `fal-ai/flux-2/klein/9b` | Realistic images, clear text | +| `fal-ai/z-image/turbo` | A very fast 6B-parameter model | +| `xai/grok-imagine-image` | xAI images with prompt enhancement | -### Video Models +### Video models | Model | Mode | Description | |-------|------|-------------| -| `fal-ai/kling-video/v2.6/pro/text-to-video` | Text-to-Video | High-quality text-to-video | -| `fal-ai/kling-video/v2.6/pro/image-to-video` | Image-to-Video | Animate images with Kling | -| `fal-ai/veo3.1` | Text-to-Video | Google Veo text-to-video | -| `fal-ai/veo3.1/image-to-video` | Image-to-Video | Google Veo image-to-video | -| `xai/grok-imagine-video/text-to-video` | Text-to-Video | xAI video from text | -| `xai/grok-imagine-video/image-to-video` | Image-to-Video | xAI animate images to video | -| `fal-ai/ltx-2/text-to-video/fast` | Text-to-Video | Fast text-to-video | -| `fal-ai/ltx-2/image-to-video/fast` | Image-to-Video | Fast image-to-video animation | +| `fal-ai/kling-video/v2.6/pro/text-to-video` | Text-to-video | High-quality text-to-video | +| `fal-ai/kling-video/v2.6/pro/image-to-video` | Image-to-video | Animate images with Kling | +| `fal-ai/veo3.1` | Text-to-video | Google Veo text-to-video | +| `fal-ai/veo3.1/image-to-video` | Image-to-video | Google Veo image-to-video | +| `xai/grok-imagine-video/text-to-video` | Text-to-video | xAI video from text | +| `xai/grok-imagine-video/image-to-video` | Image-to-video | xAI video from an image | +| `fal-ai/ltx-2/text-to-video/fast` | Text-to-video | Fast text-to-video | +| `fal-ai/ltx-2/image-to-video/fast` | Image-to-video | Fast image-to-video | -### Live Models +### Live models -| Model | Mode | Description | -|-------|------|-------------| -| `minimax/h3-max/director` | Live | Steerable live stream. Use `falLiveVideo()` with `generateLiveVideo()`. | +| Model | Description | +|-------|-------------| +| `minimax/h3-max/director` | A live stream that you can steer. Use `falLiveVideo()` with `generateLiveVideo()`. | -### Text-to-Speech Models +### Text-to-speech models | Model | Description | |-------|-------------| -| `fal-ai/gemini-3.1-flash-tts` | **New** — Google's flagship TTS with 80+ languages and expressive audio tags | -| `fal-ai/elevenlabs/tts/eleven-v3` | ElevenLabs v3 expressive multi-voice TTS | -| `fal-ai/elevenlabs/tts/turbo-v2.5` | Low-latency ElevenLabs TTS | -| `fal-ai/minimax/speech-2.6-hd` | MiniMax HD speech synthesis | -| `fal-ai/minimax/speech-2.6-turbo` | MiniMax low-latency variant | -| `fal-ai/kokoro/american-english` | Kokoro multilingual TTS — also `british-english`, `french`, `spanish`, `italian`, `japanese`, `mandarin-chinese`, `hindi`, `brazilian-portuguese` | +| `fal-ai/gemini-3.1-flash-tts` | Google TTS with 80+ languages and style tags | +| `fal-ai/elevenlabs/tts/eleven-v3` | ElevenLabs v3, expressive, several voices | +| `fal-ai/elevenlabs/tts/turbo-v2.5` | ElevenLabs, low latency | +| `fal-ai/minimax/speech-2.6-hd` | MiniMax HD speech | +| `fal-ai/minimax/speech-2.6-turbo` | MiniMax, low latency | +| `fal-ai/kokoro/american-english` | Kokoro. Also `british-english`, `french`, `spanish`, `italian`, `japanese`, `mandarin-chinese`, `hindi`, `brazilian-portuguese` | | `fal-ai/inworld-tts` | Inworld TTS-1.5 Max | -| `fal-ai/chatterbox/text-to-speech/multilingual` | Chatterbox multilingual TTS | -| `fal-ai/dia-tts` | Dia expressive dialogue TTS | -| `fal-ai/orpheus-tts` | Orpheus open-source TTS | -| `fal-ai/f5-tts` | F5-TTS voice cloning | -| `fal-ai/vibevoice/7b` | VibeVoice 7B conversational TTS | +| `fal-ai/chatterbox/text-to-speech/multilingual` | Chatterbox, many languages | +| `fal-ai/dia-tts` | Dia, expressive dialogue | +| `fal-ai/orpheus-tts` | Orpheus, open source | +| `fal-ai/f5-tts` | F5-TTS, voice cloning | +| `fal-ai/vibevoice/7b` | VibeVoice 7B, conversation | -### Transcription Models +### Transcription models | Model | Description | |-------|-------------| -| `fal-ai/whisper` | OpenAI Whisper on fal infra | -| `fal-ai/wizper` | Faster-whisper variant with word-level timestamps | -| `fal-ai/speech-to-text/turbo` | Turbo STT with diarization | -| `fal-ai/elevenlabs/speech-to-text` | ElevenLabs STT | +| `fal-ai/whisper` | OpenAI Whisper on fal | +| `fal-ai/wizper` | A faster Whisper with word timestamps | +| `fal-ai/speech-to-text/turbo` | Fast speech-to-text with speaker labels | +| `fal-ai/elevenlabs/speech-to-text` | ElevenLabs speech-to-text | -### Audio / Music Models +### Music and audio models | Model | Mode | Description | |-------|------|-------------| -| `fal-ai/minimax-music/v2.6` | Music | **New** — MiniMax Music 2.6, full vocal + instrumental compositions from a prompt | +| `fal-ai/minimax-music/v2.6` | Music | MiniMax Music 2.6, vocals and instruments from a prompt | | `fal-ai/minimax-music/v2.5` | Music | MiniMax Music 2.5 | -| `fal-ai/minimax-music/v2` | Music | MiniMax Music v2 — supports `lyrics_prompt` | -| `fal-ai/diffrhythm` | Music | DiffRhythm — prompt + lyrics | -| `fal-ai/lyria2` | Music | Google Lyria 2 high-fidelity music | -| `fal-ai/stable-audio-25/text-to-audio` | Music / Audio | Stability AI Stable Audio 2.5 | -| `fal-ai/mmaudio-v2/text-to-audio` | Audio | MMAudio v2 text-to-audio | -| `fal-ai/elevenlabs/sound-effects/v2` | SFX | ElevenLabs sound-effect generation | -| `fal-ai/beatoven/sound-effect-generation` | SFX | Beatoven professional sound effects | -| `fal-ai/thinksound` | Audio | Thinksound reasoning-based audio generation | +| `fal-ai/minimax-music/v2` | Music | MiniMax Music v2, takes `lyrics_prompt` | +| `fal-ai/diffrhythm` | Music | DiffRhythm, prompt and lyrics | +| `fal-ai/lyria2` | Music | Google Lyria 2 | +| `fal-ai/stable-audio-25/text-to-audio` | Music or audio | Stability AI Stable Audio 2.5 | +| `fal-ai/mmaudio-v2/text-to-audio` | Audio | MMAudio v2 | +| `fal-ai/elevenlabs/sound-effects/v2` | Sound effects | ElevenLabs sound effects | +| `fal-ai/beatoven/sound-effect-generation` | Sound effects | Beatoven sound effects | +| `fal-ai/thinksound` | Audio | Thinksound audio | -> **Very new models** (e.g. `gemini-3.1-flash-tts`, `minimax-music/v2.6`, `beatoven/sound-effect-generation`) may not yet appear in `@fal-ai/client`'s type map — they still work as plain string model IDs, you just won't get autocomplete for their `modelOptions`. +Some very new models, for example `fal-ai/gemini-3.1-flash-tts`, `fal-ai/minimax-music/v2.6`, and `fal-ai/beatoven/sound-effect-generation`, are not in the type map of `@fal-ai/client` yet. They work as plain string ids, with no autocomplete for `modelOptions`. -## Environment Variables - -Create an API key at [fal.ai](https://fal.ai) and set it in your environment: - -```bash -FAL_KEY=your-fal-api-key -``` +## Advanced -## API Reference +### Image sizes -### `falImage(model, config?)` +`size` maps to `image_size`, or to `aspect_ratio` and `resolution`: -Creates a fal.ai image adapter using the `FAL_KEY` environment variable or an explicit config. - -**Parameters:** - -- `model` - The fal.ai model ID (e.g., `"fal-ai/flux/dev"`) -- `config.apiKey?` - Your fal.ai API key (falls back to `FAL_KEY` env var) -- `config.proxyUrl?` - Proxy URL for client-side usage - -**Returns:** A `FalImageAdapter` instance for use with `generateImage()`. - -### `falVideo(model, config?)` - -Creates a fal.ai video adapter using the `FAL_KEY` environment variable or an explicit config. - -**Parameters:** - -- `model` - The fal.ai model ID (e.g., `"fal-ai/kling-video/v2.6/pro/text-to-video"`) -- `config.apiKey?` - Your fal.ai API key (falls back to `FAL_KEY` env var) -- `config.proxyUrl?` - Proxy URL for client-side usage - -**Returns:** A `FalVideoAdapter` instance for use with `generateVideo()` and `getVideoJobStatus()`. - -### `falLiveVideo(model, config?)` - -Creates a fal.ai live-video adapter for H3 Max Director. `generateLiveVideo()` returns the WMA app id on `result.model` (`fal-ai/minimax-h3-max-director`). Open that id with `wma(live.model)` through a server proxy that attaches `FAL_KEY`. Do not send `live.token` as `Key` credentials. Call `allowedFalLiveVideoProxyTarget()` in the proxy so it forwards only WMA `/ice`, `/session`, `/session/heartbeat`, and Director `/ice`. - -**Parameters:** - -- `model` - `"minimax/h3-max/director"` -- `config.apiKey?` - Your fal.ai API key (falls back to `FAL_KEY` env var) - -**Returns:** A `FalLiveVideoAdapter` instance for use with `generateLiveVideo()`. - -See [Live Generation](../media/live-generation) for the browser connect step. - -### `falSpeech(model, config?)` - -Creates a fal.ai text-to-speech adapter. - -**Parameters:** - -- `model` - The fal.ai TTS model ID (e.g., `"fal-ai/kokoro/american-english"`) -- `config.apiKey?` - Your fal.ai API key (falls back to `FAL_KEY` env var) -- `config.proxyUrl?` - Proxy URL for client-side usage - -**Returns:** A `FalSpeechAdapter` instance for use with `generateSpeech()`. The adapter fetches the generated audio URL from fal and returns it as base64 in `result.audio`. +| Form | `size` | Sent to fal as | +|--------|---------|---------| +| Named | `"landscape_16_9"` | `image_size: "landscape_16_9"` | +| Width x height | `"1536x1024"` | `image_size: "1536x1024"` | +| Ratio and resolution | `"16:9_4K"` | `aspect_ratio: "16:9"`, `resolution: "4K"` | +| Ratio only | `"16:9"` | `aspect_ratio: "16:9"` | -### `falTranscription(model, config?)` +Named sizes change by model. -Creates a fal.ai transcription (speech-to-text) adapter. +### Video durations -**Parameters:** +`duration` gets its type for each model from the `EndpointTypeMap` of `@fal-ai/client`. Popular models also have `availableDurations()` and `snapDuration()`, for a UI slider: -- `model` - The fal.ai STT model ID (e.g., `"fal-ai/whisper"`) -- `config.apiKey?` - Your fal.ai API key (falls back to `FAL_KEY` env var) -- `config.proxyUrl?` - Proxy URL for client-side usage +| Model | `duration` type | `availableDurations()` | +| --- | --- | --- | +| `fal-ai/kling-video/v1.6/{standard,pro}/text-to-video`, `fal-ai/kling-video/v2.6/pro/{text,image}-to-video` | `"5"` or `"10"` | discrete | +| `fal-ai/kling-video/v3/pro/{text,image}-to-video` | `"3"` to `"15"` | discrete | +| `fal-ai/pika/v2.2/text-to-video` | `"5"` or `"10"` | discrete | +| `fal-ai/ltx-2.3/{text,image}-to-video` (and `/fast`) | `"6"`, `"8"`, or `"10"` | discrete | +| `fal-ai/luma-dream-machine/ray-2` | `"5s"` or `"9s"` | discrete | +| `fal-ai/veo3.1`, `fal-ai/veo3.1/fast` (and `/image-to-video`), `fal-ai/veo3` (and `/image-to-video`) | `"4s"`, `"6s"`, or `"8s"` | discrete | +| `fal-ai/wan-25-preview/text-to-video` | `"2"` to `"15"` | discrete | +| `fal-ai/minimax/video-01` | not accepted | `{ kind: "none" }` | +| `fal-ai/hunyuan-video-v1.5/text-to-video` | not accepted (uses `num_frames`) | `{ kind: "none" }` | -**Returns:** A `FalTranscriptionAdapter` instance for use with `generateTranscription()`. +```typescript +import { generateVideo } from "@tanstack/ai"; +import { falVideo } from "@tanstack/ai-fal"; -### `falAudio(model, config?)` +const adapter = falVideo("fal-ai/veo3.1"); +adapter.availableDurations(); // { kind: "discrete", values: ["4s", "6s", "8s"] } +adapter.snapDuration(7); // "6s" -Creates a fal.ai audio generation adapter (music and sound effects). +await generateVideo({ + adapter, + prompt: "A timelapse of a city skyline at dusk", + threadId: "thread-1", + duration: adapter.snapDuration(7), +}); +``` -**Parameters:** +Other models still get the `duration` type from the SDK when the endpoint has the field. For these models, `availableDurations()` returns `{ kind: "none" }`. -- `model` - The fal.ai audio model ID (e.g., `"fal-ai/diffrhythm"`, `"fal-ai/minimax-music/v2"`) -- `config.apiKey?` - Your fal.ai API key (falls back to `FAL_KEY` env var) -- `config.proxyUrl?` - Proxy URL for client-side usage +### Configuration -**Returns:** A `FalAudioAdapter` instance for use with `generateAudio()`. The result contains a URL at `result.audio.url`. +Each factory takes an optional `FalClientConfig`: -### `getFalApiKeyFromEnv()` +- `apiKey`: your fal key. If you do not set a config, the adapter reads `FAL_KEY`. +- `proxyUrl`: a proxy URL on your server, for calls from the browser. +- `fetch`: a custom `fetch` for the fal requests. The adapter wraps it to read the billed units. -Reads the `FAL_KEY` environment variable. Throws if not set. +```typescript +import { falImage } from "@tanstack/ai-fal"; -**Returns:** The API key string. +const adapter = falImage("fal-ai/nano-banana-pro", { + apiKey: process.env.FAL_KEY!, + proxyUrl: "https://your-server.com/api/fal/proxy", +}); +``` -### `configureFalClient(config?)` +### Live video proxy -Configures the underlying `@fal-ai/client`. Called automatically by adapter constructors. Uses `proxyUrl` if provided, otherwise sets credentials from the API key. +`generateLiveVideo()` returns the WMA app id on `result.model` (`fal-ai/minimax-h3-max-director`). In the browser: -## Limitations +- Open that id with `wma(live.model)` through a proxy on your server. The proxy attaches `FAL_KEY`. +- Do not send `live.token` as `Key` credentials. +- In the proxy, call `allowedFalLiveVideoProxyTarget()`. It lets the proxy forward only to WMA `/ice`, `/session`, `/session/heartbeat`, and Director `/ice`. -- **No text/chat support** — Use OpenAI, Anthropic, Gemini, or another text adapter for `chat()` -- **No tools support** — Tool definitions are not applicable to media generation -- **No summarization** — Use a text adapter for `summarize()` -- **Video is experimental** — The video generation API may change in future releases +### API reference -## Next Steps +Each factory takes `model` first and an optional `config` (`apiKey`, `proxyUrl`, `fetch`). Without `apiKey`, it reads `FAL_KEY`. -- [Getting Started](../getting-started/quick-start) - Learn the basics -- [Other Adapters](./openai) - Explore other providers +| Factory | Returns | Use it with | +| --- | --- | --- | +| `falImage(model, config?)` | `FalImageAdapter` | `generateImage()` | +| `falVideo(model, config?)` | `FalVideoAdapter` | `generateVideo()`, `getVideoJobStatus()` | +| `falLiveVideo(model, config?)` / `createFalLiveVideo(model, apiKey, config?)` | `FalLiveVideoAdapter` | `generateLiveVideo()` | +| `falSpeech(model, config?)` | `FalSpeechAdapter` | `generateSpeech()`. The audio comes back as base64 in `result.audio`. | +| `falTranscription(model, config?)` | `FalTranscriptionAdapter` | `generateTranscription()` | +| `falAudio(model, config?)` | `FalAudioAdapter` | `generateAudio()`. The result has a URL at `result.audio.url`. | +| `falFiles(config?)` | `FalFilesAdapter` | The [Files API](../advanced/files-api) | + +Helpers: + +- `getFalApiKeyFromEnv()`: reads `FAL_KEY`. Throws if it is not set. +- `configureFalClient(config?)`: configures `@fal-ai/client`. The adapter constructors call it for you. It sets the credentials from the API key, and the proxy when you set `proxyUrl`. diff --git a/docs/adapters/gemini-advanced.md b/docs/adapters/gemini-advanced.md new file mode 100644 index 0000000000..7edc6d5f3f --- /dev/null +++ b/docs/adapters/gemini-advanced.md @@ -0,0 +1,663 @@ +--- +title: "Google Gemini: Advanced" +id: gemini-advanced +order: 3 +description: "Advanced Gemini setup in TanStack AI: proxies, the experimental Interactions API, image sizes and edits, video understanding options, two-voice speech, every provider tool, and the API reference." +keywords: + - tanstack ai + - gemini + - interactions api + - nano banana + - imagen + - provider tools + - adapter +--- + +This page has the details of the Gemini adapter. To get a first chat working, start at [Google Gemini](./gemini). + +## Configuration + +```typescript +import { createGeminiChat, type GeminiTextConfig } from "@tanstack/ai-gemini"; + +const config: Omit = { + baseURL: "https://generativelanguage.googleapis.com/v1beta", // Optional + defaultHeaders: { "X-Custom-Header": "value" }, // Optional +}; + +const adapter = createGeminiChat("gemini-3.8-flash", process.env.GEMINI_API_KEY!, config); +``` + +## Behind a proxy + +Send every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them. + +```typescript +import { createGeminiChat } from "@tanstack/ai-gemini"; + +const adapter = createGeminiChat("gemini-3.8-flash", process.env.GEMINI_API_KEY!, { + baseURL: "https://gateway.example.com/google-ai-studio", + defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` }, +}); +``` + +- `baseURL` sets `httpOptions.baseUrl`, and `defaultHeaders` sets `httpOptions.headers`. If you set both forms, `baseURL` and `defaultHeaders` win. +- This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the `@google/genai` SDK does not take a custom fetch. + +## Video understanding options + +By default, Gemini samples the video at 1 frame per second. Change the sampling in the part metadata: + +```typescript ignore +// Watch a 30s-90s clip at half a frame per second. +geminiVideoPart(file, { fps: 0.5, startOffset: "30s", endOffset: "90s" }); +``` + +### Agentic understanding + +Sampling one time can miss detail in long or fast videos. Set `processing: "agentic"` to let the model drive. It moves through the timeline and gets frames, transcripts, and audio when it needs them. + +```typescript ignore +geminiVideoPart(file, { processing: "agentic" }); +``` + +Supported models: + +- `gemini-3.8-flash` +- `gemini-3.7-flash` +- `gemini-3.6-flash` +- `gemini-3.5-flash-lite` + +With `agentic`, the adapter sends the request through the Interactions API of Gemini. You give the sampling rate in your prompt ("watch it at 2 fps"), not with `fps`. The result is deeper but slower, because the model looks at the video again on each turn. + +## Stateful conversations: Interactions API (experimental) + +The [Interactions API](https://ai.google.dev/gemini-api/docs/interactions) of Gemini keeps the conversation on the server. It is the Gemini equivalent of the OpenAI Responses API, and it is in Beta. You pass a `previous_interaction_id`, and the server keeps the transcript, so you do not send the full history on every turn. This also gets more cache hits for repeated starts of a prompt. + +The `geminiTextInteractions` adapter calls `client.interactions.create`. Right before `RUN_FINISHED`, it sends the interaction id from the server as an AG-UI `CUSTOM` event (`name: 'gemini.interactionId'`), so you can chain turns. + +> [!WARNING] +> The Interactions API is experimental. Google marks it as Beta and can make breaking changes until it is generally available. Import the adapter from `@tanstack/ai-gemini/experimental`. + +What the adapter supports: + +- Text output and function tools. +- The built-in tools `google_search`, `code_execution`, `url_context`, `file_search`, and `computer_use`. +- `google_search_retrieval`, `google_maps`, and `mcp_server` throw on this adapter. Use `geminiText()` for the first two. + +### Basic usage + +```typescript ignore +import { chat } from "@tanstack/ai"; +import { geminiTextInteractions } from "@tanstack/ai-gemini/experimental"; + +// Turn 1: introduce yourself, capture the interaction id. +let interactionId: string | undefined; + +for await (const chunk of chat({ + adapter: geminiTextInteractions("gemini-3.5-flash"), + messages: [{ role: "user", content: "Hi, my name is Amir." }], + threadId: "thread-1", +})) { + if ( + chunk.type === "CUSTOM" && + chunk.name === "gemini.interactionId" && + chunk.value && + typeof chunk.value === "object" && + "interactionId" in chunk.value + ) { + interactionId = String(chunk.value.interactionId); + } +} + +// Turn 2: only send the new turn. The server has the history. +for await (const chunk of chat({ + adapter: geminiTextInteractions("gemini-3.5-flash"), + messages: [{ role: "user", content: "What is my name?" }], + threadId: "thread-1", + modelOptions: { + previous_interaction_id: interactionId, + }, +})) { + // ...stream "Your name is Amir." back to the client. +} +``` + +### Wiring with `useChat` (React) + +The Interactions API **does not accept a history of many turns without a `previous_interaction_id`**. If a chat client sends `[user, assistant, user]` to a new interaction, the adapter throws `cannot send prior conversation history on a fresh interaction`. To make `useChat` work, keep the id from the server and send it back on the next turn. + +**Server route** (for example, a TanStack Start handler): + +```typescript +import { + chat, + chatParamsFromRequestBody, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { geminiTextInteractions } from "@tanstack/ai-gemini/experimental"; + +export async function POST({ request }: { request: Request }) { + const params = await chatParamsFromRequestBody(await request.json()); + + // The client sends previousInteractionId in `forwardedProps`. + const previousInteractionId = + typeof params.forwardedProps.previousInteractionId === "string" + ? params.forwardedProps.previousInteractionId + : undefined; + + const stream = chat({ + adapter: geminiTextInteractions("gemini-3.5-flash"), + messages: params.messages, + threadId: params.threadId, + modelOptions: { + previous_interaction_id: previousInteractionId, + store: true, // required for chaining on the next turn + }, + }); + + return toServerSentEventsResponse(stream); +} +``` + +**React client**: + +```tsx +import { useEffect, useMemo, useState } from "react"; +import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"; + +function GeminiChat() { + const [interactionId, setInteractionId] = useState(); + + const forwardedProps = useMemo( + () => (interactionId ? { previousInteractionId: interactionId } : {}), + [interactionId], + ); + + const { messages, setMessages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", + forwardedProps, + onCustomEvent: (eventType, data) => { + if ( + eventType === "gemini.interactionId" && + typeof data === "object" && + data !== null && + "interactionId" in data + ) { + setInteractionId(String(data.interactionId)); + } + }, + }); + + // A new provider or model starts a new chain on the server. Clear the id + // AND the local messages together. If you do not, the next turn sends + // many messages with no previous_interaction_id, and the adapter throws. + const [provider, setProvider] = useState("gemini-interactions"); + useEffect(() => { + setInteractionId(undefined); + setMessages([]); + }, [provider]); + + // ...render messages, call sendMessage(input) +} +``` + +The full example is in [`examples/ts-react-chat`](https://github.com/TanStack/ai/tree/main/examples/ts-react-chat). See `src/routes/index.tsx` for the client and `src/routes/api.tanchat.ts` for the route. + +### How it differs from `geminiText` + +| | `geminiText` | `geminiTextInteractions` | +| --- | --- | --- | +| Endpoint | `models:generateContent` | `interactions:create` | +| Conversation state | Stateless: send the full history each turn | Stateful: the server keeps the transcript through `previous_interaction_id` | +| Provider options | camelCase (`stopSequences`, `responseModalities`, `safetySettings`) | snake_case (`generation_config`, `response_modalities`, `previous_interaction_id`) | +| Built-in tools | `google_search`, `code_execution`, `url_context`, `file_search`, `google_maps`, `google_search_retrieval`, `computer_use` | `google_search`, `code_execution`, `url_context`, `file_search`, `computer_use`. Only the first four stream `CUSTOM` activity events | +| Stability | GA | Experimental (Google Beta) | + +### Provider options + +The adapter has options for the Interactions API on `modelOptions`: + +```typescript +import { chat } from "@tanstack/ai"; +import { geminiTextInteractions } from "@tanstack/ai-gemini/experimental"; + +const stream = chat({ + adapter: geminiTextInteractions("gemini-3.5-flash"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + // Goes out as generation_config.thinking_level and thinking_summaries. + reasoning: "low", + modelOptions: { + // Chain to the last turn. Pass it only on turn 2 and later. + previous_interaction_id: "int_abc123", + + // Keep the interaction on the server (default true). Must be true for + // previous_interaction_id to work on the next turn. + store: true, + + // System instruction for this interaction only. Send it again each turn. + system_instruction: "You are a helpful assistant.", + + // snake_case generation config, not the camelCase config of geminiText. + generation_config: { + stop_sequences: [""], + }, + + response_modalities: ["text"], + }, +}); +``` + +### Reading the interaction id + +The interaction id from the server arrives as an AG-UI `CUSTOM` event, right before `RUN_FINISHED`: + +```typescript ignore +for await (const chunk of stream) { + if ( + chunk.type === "CUSTOM" && + chunk.name === "gemini.interactionId" && + typeof chunk.value === "object" && + chunk.value !== null && + "interactionId" in chunk.value + ) { + const id = String(chunk.value.interactionId); + // Keep `id` where you store the conversation pointer of each user. + // Pass it back on the next turn as `previous_interaction_id`. + } +} +``` + +### Limits + +Know these before you ship: + +- **A history of many turns needs `previous_interaction_id`.** The Interactions API has no stateless replay. More than one message in `messages` without a `previous_interaction_id` throws. A chat UI with a local history must keep the id from the server and chain (see [Wiring with `useChat`](#wiring-with-usechat-react)). When the provider or model changes, also clear the local messages. +- **Tools, `system_instruction`, and `generation_config` belong to one interaction.** Google does not carry them over through `previous_interaction_id`. Pass them again on every turn that needs them. +- `store: false` does not work with `previous_interaction_id` (there is no state to recall) or with `background: true`. +- Retention, at the time of writing: **55 days on the Paid Tier, 1 day on the Free Tier.** See the [Interactions API docs](https://ai.google.dev/gemini-api/docs/interactions) of Google for the current policy. + +Tools and output: + +- The built-in tools (`google_search`, `code_execution`, `url_context`, `file_search`, `computer_use`) go out as request tools. The four search and code tools stream their activity back as AG-UI `CUSTOM` events with the raw Interactions delta: `gemini.googleSearchCall` and `gemini.googleSearchResult`, and the matching `codeExecutionCall`/`Result`, `urlContextCall`/`Result`, and `fileSearchCall`/`Result`. +- The Interactions API sends no `CUSTOM` events for `computer_use`. Function-tool `TOOL_CALL_*` events do not change. If only built-in tools ran, `finishReason` stays `stop`. +- `google_search_retrieval`, `google_maps`, and `mcp_server` throw an error on this adapter. +- This adapter gives text only. For images and audio, use `geminiImage` and `geminiSpeech`. + +## Images + +`geminiImage` picks the API from the model: + +- A model in `GEMINI_NATIVE_IMAGE_MODELS` uses the Interactions API. `result.id` is the interaction id. +- An Imagen model, or an unknown id, uses `generateImages`. + +Import the list or `isGeminiNativeImageModel` from `@tanstack/ai-gemini`. + +### Edit a Gemini native image + +You have an image from a Gemini native model. You want to change that image, without a second upload of it. Pass the last `id` back, and write only the change in `prompt`: + +```typescript +import { generateImage } from "@tanstack/ai"; +import { geminiImage } from "@tanstack/ai-gemini"; + +const adapter = geminiImage("gemini-3.1-flash-image"); + +const first = await generateImage({ + adapter, + prompt: "A red bicycle on a quiet street", + threadId: "thread-1", +}); + +const edited = await generateImage({ + adapter, + prompt: "Make the bicycle blue", + threadId: "thread-1", + modelOptions: { previous_interaction_id: first.id }, +}); +``` + +1. Generate the first image. +2. Call `generateImage` again with `previous_interaction_id` set to `first.id`. +3. Read `edited.images` for the new picture. `edited.id` is the id for the next edit. + +Keep the interaction, so that the next edit can run: + +- Leave `store` unset. The API default for `store` is `true`. +- If you set `store` to `false`, a later call cannot use that id. +- A free tier keeps a stored interaction for 1 day. A paid tier keeps it for 55 days. + +Also: + +- If the model is Imagen, do not pass `previous_interaction_id`. The call throws. +- If this edit needs a new reference image, add that image part on this call. Do not send the image from `first`. +- The Interactions API is in Beta. + +The same steps are in [Image Generation](../media/image-generation#edit-a-gemini-native-image). + +### Imagen + +```typescript +import { generateImage } from "@tanstack/ai"; +import { geminiImage } from "@tanstack/ai-gemini"; + +const result = await generateImage({ + adapter: geminiImage("imagen-4.0-generate-001"), + prompt: "A futuristic cityscape at sunset", + threadId: "thread-1", + numberOfImages: 1, +}); + +console.log(result.images); +``` + +### Image sizes + +#### Gemini native models (Nano Banana) + +The size of a native model joins an aspect ratio and a resolution tier: + +```typescript ignore +// Format: "aspectRatio_resolution" +size: "16:9_4K" +size: "1:1_2K" +size: "9:16_1K" +``` + +Each model has its own set. TypeScript narrows `size` to the set of the model: + +| Model | Aspect ratios | Resolutions | +|-------|---------------|-------------| +| `gemini-3.1-flash-image` | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9`, `1:4`, `4:1`, `1:8`, `8:1` | `512`, `1K`, `2K`, `4K` | +| `gemini-3.1-flash-lite-image` | the same 14 as above (see the note) | `1K` only | +| `gemini-nano-banana-2.1` | the same 14 as above | `1K`, `2K`, `4K` | +| `gemini-3-pro-image` | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` | `1K`, `2K`, `4K` | + +- The `K` is case-sensitive. The API rejects `1k`. +- The smallest tier is `512`, not `512px` or `0.5K`. +- `9:21` is for Vertex and Cloud only, so this adapter does not accept it. + +> [!NOTE] +> For `gemini-3.1-flash-lite-image`, the four banner ratios (`1:4`, `4:1`, `1:8`, `8:1`) are partly inferred. The Gemini API guide has no ratio table for this model. The set of 14 comes from the Cloud model page and from the statement in the guide of "a discrete set of 14 aspect ratios". If the API rejects those four, use the 10 standard ratios on this model. + +#### Imagen models + +Imagen models take WIDTHxHEIGHT. The adapter maps the size to an aspect ratio: + +| Size | Aspect ratio | +|------|-------------| +| `1024x1024` | 1:1 | +| `1920x1080` | 16:9 | +| `1080x1920` | 9:16 | + +You can also set the aspect ratio in `modelOptions`: + +```typescript +import { generateImage } from "@tanstack/ai"; +import { geminiImage } from "@tanstack/ai-gemini"; + +const result = await generateImage({ + adapter: geminiImage("imagen-4.0-generate-001"), + prompt: "A landscape photo", + threadId: "thread-1", + modelOptions: { + aspectRatio: "16:9", + }, +}); +``` + +### Image model options + +The two model groups call different APIs, so `modelOptions` has a different type for each. + +Imagen models (`generateImages`) take `GenerateImagesConfig` fields: + +```typescript ignore +import { generateImage } from "@tanstack/ai"; +import { geminiImage } from "@tanstack/ai-gemini"; + +const result = await generateImage({ + adapter: geminiImage("imagen-4.0-generate-001"), + prompt: "...", + threadId: "thread-1", + modelOptions: { + aspectRatio: "16:9", // "1:1" | "3:4" | "4:3" | "9:16" | "16:9" + personGeneration: "DONT_ALLOW", // Control person generation + safetyFilterLevel: "BLOCK_SOME", // Safety filtering + }, +}); +``` + +#### Gemini native models + +Set native image options on `modelOptions`: + +```typescript +import { generateImage } from "@tanstack/ai"; +import { geminiImage, ThinkingLevel } from "@tanstack/ai-gemini"; + +const result = await generateImage({ + adapter: geminiImage("gemini-3.1-flash-image"), + prompt: "A beautiful garden", + threadId: "thread-1", + size: "16:9_4K", + modelOptions: { + thinkingConfig: { thinkingLevel: ThinkingLevel.LOW }, + // imageConfig replaces one field from size. The 16:9 ratio from size stays. + // imageConfig accepts only aspectRatio and imageSize. + imageConfig: { imageSize: "2K" }, + }, +}); +``` + +- `thinkingConfig.thinkingLevel` takes `ThinkingLevel.MINIMAL`, `ThinkingLevel.LOW`, `ThinkingLevel.MEDIUM`, or `ThinkingLevel.HIGH`. +- `thinkingConfig.thinkingBudget` throws. +- If `systemInstruction` contains an image, a file, or a tool call, the call throws. +- To edit, pass the last result `id` as `previous_interaction_id`. See [Edit a Gemini native image](#edit-a-gemini-native-image). + +See [Image Generation](../media/image-generation) for the full list of native options. + +## Speech with two speakers + +Gemini names the speakers in the prompt and maps each name to a voice. Pass `turns`, and the adapter does both for you. It uses the voice name as the speaker label: + +```typescript +import { generateSpeech } from "@tanstack/ai"; +import { geminiSpeech } from "@tanstack/ai-gemini"; + +const result = await generateSpeech({ + adapter: geminiSpeech("gemini-3.1-flash-tts-preview"), + turns: [ + { text: "Hey, how is it going?", voice: "Puck" }, + { text: "Not bad, you?", voice: "Kore" }, + ], + threadId: "thread-1", +}); +``` + +- Gemini accepts **two different voices** in one request. The adapter rejects a third before the call. +- Each voice must be one of the prebuilt Gemini voices (`GeminiTTSVoices`). +- Gemini reports no timings, so this adapter rejects `timestamps: true`. +- Gemini TTS is experimental. Full support can need the Live API. + +## Provider tools + +Gemini has tools of its own, next to the function tools that you define. Import them from `@tanstack/ai-gemini/tools` and pass them into `chat({ tools: [...] })`. + +For the concept, a comparison table, and type gating, see [Provider Tools](../tools/provider-tools.md). + +### `codeExecutionTool` + +Gemini runs Python code in a sandbox and returns the results inline. It takes no arguments. Add it to the `tools` array. + +```typescript +import { chat } from "@tanstack/ai"; +import { geminiText } from "@tanstack/ai-gemini"; +import { codeExecutionTool } from "@tanstack/ai-gemini/tools"; + +const stream = chat({ + adapter: geminiText("gemini-3.8-flash"), + messages: [{ role: "user", content: "Calculate the first 10 Fibonacci numbers" }], + threadId: "thread-1", + tools: [codeExecutionTool()], +}); +``` + +**Supported models:** Gemini 1.5 Pro, Gemini 2.x, Gemini 2.5 and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `fileSearchTool` + +Searches the files that you uploaded to the Gemini File API. Pass a `FileSearch` config object with the stores to search. + +```typescript +import { chat } from "@tanstack/ai"; +import { geminiText } from "@tanstack/ai-gemini"; +import { fileSearchTool } from "@tanstack/ai-gemini/tools"; + +const stream = chat({ + adapter: geminiText("gemini-3.8-flash"), + messages: [{ role: "user", content: "Find the quarterly revenue figures" }], + threadId: "thread-1", + tools: [ + fileSearchTool({ + fileSearchStoreNames: ["fileSearchStores/my-file-search-store-123"], + }), + ], +}); +``` + +**Supported models:** Gemini 2.x and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `googleSearchTool` + +Gemini searches Google and uses the grounded results in its answer. Pass a `GoogleSearch` config, or call it with no arguments for the defaults. + +```typescript +import { chat } from "@tanstack/ai"; +import { geminiText } from "@tanstack/ai-gemini"; +import { googleSearchTool } from "@tanstack/ai-gemini/tools"; + +const stream = chat({ + adapter: geminiText("gemini-3.8-flash"), + messages: [{ role: "user", content: "What's the weather in Tokyo right now?" }], + threadId: "thread-1", + tools: [googleSearchTool()], +}); +``` + +**Supported models:** Gemini 1.5 Pro, Gemini 2.x, Gemini 2.5. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +When Google Search returns grounding data: + +- The assistant tool-call part has `metadata.providerExecuted: true` and the normalized `metadata.sources` array. +- The raw `groundingMetadata` of Gemini stays under `metadata.gemini`. +- The agent loop does not run this call in your app. + +### `googleSearchRetrievalTool` + +A Google Search variant for retrieval. It returns ranked passages from the web, with a dynamic retrieval mode that you can set. Pass a `GoogleSearchRetrieval` config. + +```typescript ignore +import { chat } from "@tanstack/ai"; +import { geminiText } from "@tanstack/ai-gemini"; +import { googleSearchRetrievalTool } from "@tanstack/ai-gemini/tools"; + +const stream = chat({ + adapter: geminiText("gemini-3.1-pro-preview"), + messages: [{ role: "user", content: "Explain the latest JavaScript proposals" }], + threadId: "thread-1", + tools: [ + googleSearchRetrievalTool({ + dynamicRetrievalConfig: { mode: "MODE_DYNAMIC", dynamicThreshold: 0.7 }, + }), + ], +}); +``` + +**Supported models:** Gemini 1.5 Pro and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `googleMapsTool` + +Connects Gemini to Google Maps for questions about places: directions, place search, and geocoding. Pass a `GoogleMaps` config, or call it with no arguments. + +```typescript +import { chat } from "@tanstack/ai"; +import { geminiText } from "@tanstack/ai-gemini"; +import { googleMapsTool } from "@tanstack/ai-gemini/tools"; + +const stream = chat({ + adapter: geminiText("gemini-2.5-pro"), + messages: [{ role: "user", content: "Find coffee shops near Union Square, SF" }], + threadId: "thread-1", + tools: [googleMapsTool()], +}); +``` + +**Supported models:** Gemini 2.5 and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `urlContextTool` + +Gets the content of the URLs in the conversation, so Gemini can use live web pages. It takes no arguments. + +```typescript +import { chat } from "@tanstack/ai"; +import { geminiText } from "@tanstack/ai-gemini"; +import { urlContextTool } from "@tanstack/ai-gemini/tools"; + +const stream = chat({ + adapter: geminiText("gemini-3.8-flash"), + messages: [{ role: "user", content: "Summarise https://example.com/article" }], + threadId: "thread-1", + tools: [urlContextTool()], +}); +``` + +**Supported models:** Gemini 2.x and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `computerUseTool` + +Gemini sees a virtual desktop through screenshots. It acts on the desktop with predefined computer-use functions. Give the `environment`. You can limit the functions with `excludedPredefinedFunctions`. + +```typescript ignore +import { chat } from "@tanstack/ai"; +import { geminiText } from "@tanstack/ai-gemini"; +import { computerUseTool } from "@tanstack/ai-gemini/tools"; + +const stream = chat({ + adapter: geminiText("gemini-3.1-pro-preview"), + messages: [{ role: "user", content: "Navigate to example.com in the browser" }], + threadId: "thread-1", + tools: [ + computerUseTool({ + environment: "browser", + }), + ], +}); +``` + +**Supported models:** Gemini 2.5 and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +## API reference + +Each factory pair has the same shape. The short factory (`geminiText`, `geminiImage`, and the others) reads `GOOGLE_API_KEY` or `GEMINI_API_KEY` from the environment. The `create*` factory takes an explicit API key. Both take `model` as the first argument. + +| Factories | Creates | +| --- | --- | +| `geminiText(model, config?)` / `createGeminiChat(model, apiKey, config?)` | Chat adapter | +| `geminiTextInteractions(model, config?)` / `createGeminiTextInteractions(model, apiKey, config?)` | Chat adapter on the Interactions API. Experimental. Import from `@tanstack/ai-gemini/experimental` | +| `geminiSummarize(model, config?)` / `createGeminiSummarize(model, apiKey, config?)` | Summarization adapter | +| `geminiImage(model, config?)` / `createGeminiImage(model, apiKey, config?)` | Image adapter. See [Images](#images) | +| `geminiSpeech(model, config?)` / `createGeminiSpeech(model, apiKey, config?)` | Text-to-speech adapter. Experimental | +| `geminiAudio(model, config?)` / `createGeminiAudio(model, apiKey, config?)` | Lyria music adapter. Experimental | +| `geminiVideo(model, config?)` / `createGeminiVideo(model, apiKey, config?)` | Video adapter. Experimental | +| `geminiEmbedding(model, config?)` / `createGeminiEmbedding(model, apiKey, config?)` | Embedding adapter | +| `geminiFiles` / `createGeminiFiles` | Files API adapter. See [Files API](../advanced/files-api) | + +Chat adapter config: + +- `config.baseURL`: a custom base URL. Optional. +- `config.defaultHeaders`: headers for every request. Optional. + +`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). diff --git a/docs/adapters/gemini.md b/docs/adapters/gemini.md index 75680c8ac8..5d427750b6 100644 --- a/docs/adapters/gemini.md +++ b/docs/adapters/gemini.md @@ -2,7 +2,7 @@ title: Google Gemini id: gemini-adapter order: 3 -description: "Use Google Gemini with TanStack AI — text, image generation via Imagen and Gemini native (NanoBanana), and experimental TTS via @tanstack/ai-gemini." +description: "Use Google Gemini in TanStack AI with @tanstack/ai-gemini: multimodal chat with thinking, native and Imagen images, speech, embeddings, video understanding, and Google tools." keywords: - tanstack ai - gemini @@ -14,11 +14,11 @@ keywords: - google ai --- -The Google Gemini adapter provides access to Google's Gemini models, including text generation, image generation with both Imagen and Gemini native image models (NanoBanana), and experimental text-to-speech. +The Gemini adapter calls Gemini for multimodal chat, thinking, and Google tools. It also calls image, speech, embedding, music, and video models. -For a full working example with image generation, see the [media generation example app](https://github.com/TanStack/ai/tree/main/examples/ts-react-media). +Need Gemini on Vertex AI, for regional endpoints and Google Cloud credentials? Use the [Vertex adapter](./vertex). -## Installation +## 1. Install @@ -33,127 +33,120 @@ octane: @tanstack/ai-gemini -Need Gemini on Vertex AI (regional endpoints and Google Cloud credentials)? Use the [Vertex adapter](./vertex). +## 2. Set the API key -## Basic Usage +1. Open [Google AI Studio](https://aistudio.google.com/apikey). +2. Create an API key. +3. Put the key in the environment of your server: -Use `gemini-3.8-flash` for chat with multimodal input, thinking, and built-in tools. It also supports structured output and caching. - -To set how hard Gemini 3.8 Flash thinks, pass `reasoning: "low"`, `"medium"`, or `"high"` to `chat()`. It has no `minimal` level. See [Reasoning](#reasoning). - -```typescript -import { chat } from "@tanstack/ai"; -import { geminiText } from "@tanstack/ai-gemini"; - -const stream = chat({ - adapter: geminiText("gemini-3.8-flash"), - messages: [{ role: "user", content: "Hello!" }], -}); -``` - -## Basic Usage - Custom API Key - -```typescript -import { chat } from "@tanstack/ai"; -import { createGeminiChat } from "@tanstack/ai-gemini"; - -const adapter = createGeminiChat("gemini-3.1-pro-preview", process.env.GEMINI_API_KEY!, { - // ... your config options -}); - -const stream = chat({ - adapter, - messages: [{ role: "user", content: "Hello!" }], -}); -``` - -## Configuration - -```typescript -import { createGeminiChat, type GeminiTextConfig } from "@tanstack/ai-gemini"; - -const config: Omit = { - baseURL: "https://generativelanguage.googleapis.com/v1beta", // Optional - defaultHeaders: { "X-Custom-Header": "value" }, // Optional -}; - -const adapter = createGeminiChat("gemini-3.1-pro-preview", process.env.GEMINI_API_KEY!, config); +```bash +GEMINI_API_KEY=your-api-key-here ``` - -## Behind a proxy - -Route every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them. +The short factories (`geminiText`, `geminiImage`, and the others) read `GOOGLE_API_KEY` first, then `GEMINI_API_KEY`. To pass the key in code, use the `create*` factory of the same adapter: ```typescript import { createGeminiChat } from "@tanstack/ai-gemini"; -const adapter = createGeminiChat("gemini-3.8-flash", process.env.GEMINI_API_KEY!, { - baseURL: "https://gateway.example.com/google-ai-studio", - defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` }, -}); +const adapter = createGeminiChat("gemini-3.8-flash", process.env.GEMINI_API_KEY!); ``` -`baseURL` sets `httpOptions.baseUrl` and `defaultHeaders` sets `httpOptions.headers`. If you set both forms, `baseURL` and `defaultHeaders` win. - -This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the `@google/genai` SDK does not take a custom fetch. - -## Example: Chat Completion +## 3. Stream a chat from the server ```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; import { geminiText } from "@tanstack/ai-gemini"; export async function POST(request: Request) { - const { messages } = await request.json(); + const { messages, threadId, runId } = await chatParamsFromRequest(request); const stream = chat({ - adapter: geminiText("gemini-3.1-pro-preview"), + adapter: geminiText("gemini-3.8-flash"), messages, + threadId, + runId, }); return toServerSentEventsResponse(stream); } ``` -## Example: With Tools +`gemini-3.8-flash` takes text, images, audio, video, and documents. It supports thinking, built-in tools, structured output, and caching. -```typescript -import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai"; -import { geminiText } from "@tanstack/ai-gemini"; -import { z } from "zod"; - -const getCalendarEventsDef = toolDefinition({ - name: "get_calendar_events", - description: "Get calendar events for a date", - inputSchema: z.object({ - date: z.string(), - }), -}); +## 4. Render the chat -const getCalendarEvents = getCalendarEventsDef.server(async ({ date }) => { - // Fetch calendar events - return { events: [] }; -}); +The client is the same for every provider. Point `useChat` at your route: -export async function POST(request: Request) { - const { messages } = await request.json(); +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; - const stream = chat({ - adapter: geminiText("gemini-3.1-pro-preview"), - messages, - tools: [getCalendarEvents], +export function Chat() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", }); - return toServerSentEventsResponse(stream); + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => ( +

+ {message.parts + .map((part) => (part.type === "text" ? part.content : "")) + .join("")} +

+ ))} + setInput(event.target.value)} /> +
+ ); } ``` -## Video Understanding +For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat). + +You now have a streaming Gemini chat. [Tools](../tools/tools) and [Structured Outputs](../structured-outputs/overview) work the same as with every adapter. + +## Images + +`geminiImage` takes two model groups: + +- **Gemini native image models** (Nano Banana), such as `gemini-nano-banana-2.1`. You set an aspect ratio and a resolution tier in `size`, for example `"16:9_4K"`. You can edit the last image with a short prompt. +- **Imagen models**, such as `imagen-4.0-generate-001`. You set `size` as WIDTHxHEIGHT. + +```typescript +import { generateImage } from "@tanstack/ai"; +import { geminiImage } from "@tanstack/ai-gemini"; + +const result = await generateImage({ + adapter: geminiImage("gemini-nano-banana-2.1"), + prompt: "A futuristic cityscape at sunset", + threadId: "thread-1", + numberOfImages: 1, + size: "16:9_4K", +}); + +console.log(result.images); +``` + +The sizes of each model, image edits, and the image options are on [Gemini: Advanced](./gemini-advanced#images). For a full app, see the [media generation example](https://github.com/TanStack/ai/tree/main/examples/ts-react-media). -Ask questions about a video: "what happens at 0:30?", "summarize the demo", "list every product shown". Upload the file once, then chat about it. +## Video understanding -Video is too large to inline as base64, so upload it to the Gemini Files API first. `uploadGeminiFile()` uploads the file and waits until it is ready to use. `geminiVideoPart()` turns that upload into a message content part. +Ask questions about a video: "what happens at 0:30?", "summarize the demo", "list every product shown". A video is too large to send inline, so upload it to the Gemini Files API first: + +- `uploadGeminiFile()` uploads the file and waits until it is ready. +- `geminiVideoPart()` turns that upload into a content part for a message. ```typescript import { chat, toServerSentEventsResponse } from "@tanstack/ai"; @@ -173,245 +166,126 @@ export async function POST(request: Request) { ], }, ], + threadId: "thread-1", }); return toServerSentEventsResponse(stream); } ``` -By default Gemini samples the video at 1 frame per second. Tune that single-pass sampling through the part metadata: +To change the frame rate, cut a clip, or let the model explore the video, see [Video understanding options](./gemini-advanced#video-understanding-options). -```typescript ignore -// Watch a 30s-90s clip at half a frame per second. -geminiVideoPart(file, { fps: 0.5, startOffset: "30s", endOffset: "90s" }); -``` +## Speech -### Agentic understanding +Gemini speech is experimental. -Single-pass sampling can miss detail in long or fast-moving videos. Set `processing: "agentic"` to let the model drive: it navigates the timeline and pulls frames, transcripts, and audio on demand. Supported models: +```typescript +import { generateSpeech } from "@tanstack/ai"; +import { geminiSpeech } from "@tanstack/ai-gemini"; -- `gemini-3.8-flash` -- `gemini-3.7-flash` -- `gemini-3.6-flash` -- `gemini-3.5-flash-lite` +const result = await generateSpeech({ + adapter: geminiSpeech("gemini-3.1-flash-tts-preview"), + text: "Hello from Gemini TTS!", + threadId: "thread-1", +}); -```typescript ignore -geminiVideoPart(file, { processing: "agentic" }); +console.log(result.audio); // Base64 encoded audio ``` -With `agentic`, the adapter routes the request through Gemini's Interactions API, and you express the sampling rate in your prompt ("watch it at 2 fps") instead of through `fps`. It is deeper but slower, since it re-analyzes the video on each turn. - -## Stateful Conversations — Interactions API (Experimental) - -Gemini's [Interactions API](https://ai.google.dev/gemini-api/docs/interactions) (currently in Beta) offers server-side conversation state — the Gemini equivalent of OpenAI's Responses API. Instead of replaying the full message history on every turn, you pass a `previous_interaction_id` and the server retains the transcript. This also improves cache hit rates for repeated prefixes. - -The `geminiTextInteractions` adapter routes through `client.interactions.create` and surfaces the server-assigned interaction id via an AG-UI `CUSTOM` event (`name: 'gemini.interactionId'`) emitted just before `RUN_FINISHED`, so you can chain turns. - -> **⚠️ Experimental.** Google marks the Interactions API as Beta and explicitly flags possible breaking changes until it reaches general availability. The adapter is exported from the `@tanstack/ai-gemini/experimental` subpath so the experimental status is load-bearing in your editor and bundle. Text output, function tools, and the built-in tools `google_search`, `code_execution`, `url_context`, `file_search`, and `computer_use` are supported. `google_search_retrieval`, `google_maps`, and `mcp_server` still throw on this adapter — use `geminiText()` for those or wait for follow-up work. +For a dialogue with two voices, see [Speech with two speakers](./gemini-advanced#speech-with-two-speakers). -### Basic Usage +## Embeddings -```typescript ignore -import { chat } from "@tanstack/ai"; -import { geminiTextInteractions } from "@tanstack/ai-gemini/experimental"; - -// Turn 1: introduce yourself, capture the interaction id. -let interactionId: string | undefined; - -for await (const chunk of chat({ - adapter: geminiTextInteractions("gemini-3.5-flash"), - messages: [{ role: "user", content: "Hi, my name is Amir." }], -})) { - if ( - chunk.type === "CUSTOM" && - chunk.name === "gemini.interactionId" && - chunk.value && - typeof chunk.value === "object" && - "interactionId" in chunk.value - ) { - interactionId = String(chunk.value.interactionId); - } -} +```typescript +import { embed } from "@tanstack/ai"; +import { geminiEmbedding } from "@tanstack/ai-gemini"; -// Turn 2: only send the new turn's content — the server has the history. -for await (const chunk of chat({ - adapter: geminiTextInteractions("gemini-3.5-flash"), - messages: [{ role: "user", content: "What is my name?" }], +const result = await embed({ + adapter: geminiEmbedding("gemini-embedding-001"), + input: "a red guitar", + dimensions: 1536, modelOptions: { - previous_interaction_id: interactionId, + taskType: "RETRIEVAL_DOCUMENT", }, -})) { - // ...stream "Your name is Amir." back to the client. -} -``` - -### Wiring with `useChat` (React) - -The Interactions API is stateful and **does not accept multi-turn history without a `previous_interaction_id`** — if a chat client sends `[user, assistant, user]` to a fresh interaction the adapter throws `cannot send prior conversation history on a fresh interaction`. To make `useChat` work, persist the server-assigned id and send it back on the next turn: - -**Server route** (e.g. TanStack Start handler): - -```typescript -import { - chat, - chatParamsFromRequestBody, - toServerSentEventsResponse, -} from "@tanstack/ai"; -import { geminiTextInteractions } from "@tanstack/ai-gemini/experimental"; - -export async function POST({ request }: { request: Request }) { - const params = await chatParamsFromRequestBody(await request.json()); - - // The client sends body.previousInteractionId; AG-UI maps `body` into - // `forwardedProps` on the wire. - const previousInteractionId = - typeof params.forwardedProps.previousInteractionId === "string" - ? params.forwardedProps.previousInteractionId - : undefined; - - const stream = chat({ - adapter: geminiTextInteractions("gemini-3.5-flash"), - messages: params.messages, - modelOptions: { - previous_interaction_id: previousInteractionId, - store: true, // required for chaining on the next turn - }, - }); +}); - return toServerSentEventsResponse(stream); -} +console.log(result.embeddings[0]?.vector); ``` -**React client**: +- The embedding endpoint of the Gemini API does not report token usage, so `result.usage` is absent. +- This adapter does not support `multimodalembedding@001`, which is a Vertex-only model. -```tsx -import { useEffect, useMemo, useState } from "react"; -import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"; +See the [Embeddings guide](../embeddings.md). -function GeminiChat() { - const [interactionId, setInteractionId] = useState(); +## Summarization - const body = useMemo( - () => (interactionId ? { previousInteractionId: interactionId } : {}), - [interactionId], - ); +```typescript +import { summarize } from "@tanstack/ai"; +import { geminiSummarize } from "@tanstack/ai-gemini"; - const { messages, setMessages, sendMessage } = useChat({ - connection: fetchServerSentEvents("/api/chat"), - body, - onCustomEvent: (eventType, data) => { - if ( - eventType === "gemini.interactionId" && - typeof data === "object" && - data !== null && - "interactionId" in data - ) { - setInteractionId(String(data.interactionId)); - } - }, - }); +const result = await summarize({ + adapter: geminiSummarize("gemini-3.1-pro-preview"), + text: "Your long text to summarize...", + threadId: "thread-1", + maxLength: 100, + style: "concise", // "concise" | "bullet-points" | "paragraph" +}); - // Switching provider/model resets the server-side chain — drop the id - // AND the local message history together, otherwise the next turn - // ships multi-turn messages with no previous_interaction_id and the - // adapter errors out. - const [provider, setProvider] = useState("gemini-interactions"); - useEffect(() => { - setInteractionId(undefined); - setMessages([]); - }, [provider]); - - // ...render messages, call sendMessage(input) -} +console.log(result.summary); ``` -The full working example is in [`examples/ts-react-chat`](https://github.com/TanStack/ai/tree/main/examples/ts-react-chat) — see `src/routes/index.tsx` for the client and `src/routes/api.tanchat.ts` for the route. - -### How it differs from `geminiText` +## Music, video, voice, and files -| Concern | `geminiText` | `geminiTextInteractions` | -| --- | --- | --- | -| Underlying endpoint | `models:generateContent` | `interactions:create` | -| Conversation state | Stateless — send full history each turn | Stateful — server retains transcript via `previous_interaction_id` | -| Provider options shape | camelCase (`stopSequences`, `responseModalities`, `safetySettings`) | snake_case (`generation_config`, `response_modalities`, `previous_interaction_id`) | -| Built-in tools | `google_search`, `code_execution`, `url_context`, `file_search`, `google_maps`, `google_search_retrieval`, `computer_use` | `google_search`, `code_execution`, `url_context`, `file_search`, `computer_use` (only the first four stream `CUSTOM` event activity; `computer_use` is accepted in the request but does not currently emit per-delta events) | -| Stability | GA | Experimental (Google Beta) | +- **Music**: `geminiAudio` makes music with the Lyria models. Experimental. See [Audio Generation](../media/audio-generation). +- **Video**: `geminiVideo` makes videos with Veo and Gemini Omni. Experimental. See [Video Generation](../media/video-generation). +- **Realtime voice**: `geminiRealtime` and `geminiRealtimeToken` run a live voice chat. See [Realtime Voice Chat](../media/realtime-chat). +- **Files**: `geminiFiles` uploads media to the Gemini Files API. See [Files API](../advanced/files-api). -### Provider Options +## Provider tools -The adapter exposes Interactions-specific options on `modelOptions`: +Gemini runs some tools itself, such as Google Search and a code sandbox. Import them from `@tanstack/ai-gemini/tools` and add them to `tools`: ```typescript import { chat } from "@tanstack/ai"; -import { geminiTextInteractions } from "@tanstack/ai-gemini/experimental"; +import { geminiText } from "@tanstack/ai-gemini"; +import { googleSearchTool } from "@tanstack/ai-gemini/tools"; const stream = chat({ - adapter: geminiTextInteractions("gemini-3.5-flash"), - messages: [{ role: "user", content: "Hello!" }], - // Goes out as generation_config.thinking_level and thinking_summaries. - reasoning: "low", - modelOptions: { - // Stateful chaining — passed only on turn 2+. - previous_interaction_id: "int_abc123", - - // Persist the interaction server-side (default true). Must be true for - // previous_interaction_id to work on the *next* turn. - store: true, + adapter: geminiText("gemini-3.8-flash"), + messages: [{ role: "user", content: "What's the weather in Tokyo right now?" }], + threadId: "thread-1", + tools: [googleSearchTool()], +}); +``` - // Per-request system instruction (interaction-scoped — re-specify each turn). - system_instruction: "You are a helpful assistant.", +Tools that search or read: - // snake_case generation config distinct from geminiText's camelCase one. - generation_config: { - stop_sequences: [""], - }, +- `googleSearchTool` and `googleSearchRetrievalTool`: search Google. +- `googleMapsTool`: find places and directions. +- `urlContextTool`: read the URLs in the conversation. +- `fileSearchTool`: search files in Gemini file search stores. - response_modalities: ["text"], - }, -}); -``` +Tools that act: -### Reading the interaction id - -The server's interaction id arrives as an AG-UI `CUSTOM` event emitted just before `RUN_FINISHED`: - -```typescript ignore -for await (const chunk of stream) { - if ( - chunk.type === "CUSTOM" && - chunk.name === "gemini.interactionId" && - typeof chunk.value === "object" && - chunk.value !== null && - "interactionId" in chunk.value - ) { - const id = String(chunk.value.interactionId); - // Persist `id` wherever you store per-user conversation pointers — - // pass it back on the next turn as `previous_interaction_id`. - } -} -``` +- `codeExecutionTool`: run Python in a sandbox. +- `computerUseTool`: control a virtual desktop. -### Caveats +Each tool, with a full sample, is on [Gemini: Advanced](./gemini-advanced#provider-tools). For which models take which tool, see [Provider Tools](../tools/provider-tools.md). -- **Multi-turn history requires `previous_interaction_id`.** The Interactions API has no stateless replay path — sending more than one message in `messages` without a `previous_interaction_id` throws. Chat UIs that maintain local history must capture the server-assigned id and chain (see [Wiring with `useChat`](#wiring-with-usechat-react)). On provider/model switch, also clear the local message buffer. -- **Tools, `system_instruction`, and `generation_config` are interaction-scoped.** Per Google's docs these are NOT inherited from a prior interaction via `previous_interaction_id` — pass them again on every turn you need them. -- `store: false` is incompatible with `previous_interaction_id` (no state to recall) and with `background: true`. -- Retention (as of the time of writing): **55 days on the Paid Tier, 1 day on the Free Tier.** See [Google's Interactions API docs](https://ai.google.dev/gemini-api/docs/interactions) for current retention policy. -- Built-in tools in scope (`google_search`, `code_execution`, `url_context`, `file_search`, `computer_use`) are wired through as request tools. Per-delta activity for the four search/exec tools streams back as AG-UI `CUSTOM` events — `gemini.googleSearchCall` / `gemini.googleSearchResult` (and the matching `codeExecutionCall`/`Result`, `urlContextCall`/`Result`, `fileSearchCall`/`Result`) — carrying the raw Interactions delta. `computer_use` is accepted in the request but the Interactions API does not currently emit per-delta `CUSTOM` events for it. Function-tool `TOOL_CALL_*` events are unchanged, and `finishReason` stays `stop` when only built-in tools ran. -- `google_search_retrieval`, `google_maps`, and `mcp_server` still throw a targeted error on this adapter. Use `geminiText()` for the first two, or wait for a dedicated follow-up for `mcp_server`. -- Image and audio output via Interactions aren't routed through this adapter yet — it's text-only. Use `geminiImage` / `geminiSpeech` for non-text generation for now. +## Models and options -## Model Options +### Model options -Gemini supports various model-specific options. Sampling parameters live here too — `temperature`, `topP`, and `maxOutputTokens` — rather than as root-level props on `chat()`: +Provider options go in `modelOptions`, with the camelCase names of the Gemini SDK. This includes the sampling options `temperature`, `topP`, and `maxOutputTokens`: ```typescript import { chat } from "@tanstack/ai"; import { geminiText } from "@tanstack/ai-gemini"; const stream = chat({ - adapter: geminiText("gemini-3.1-pro-preview"), + adapter: geminiText("gemini-3.8-flash"), messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", modelOptions: { maxOutputTokens: 2048, temperature: 0.7, @@ -422,7 +296,8 @@ const stream = chat({ }); ``` -> If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, see [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). +- For JSON output, set `responseMimeType: "application/json"`. For typed output, use [Structured Outputs](../structured-outputs/overview). +- Sampling options do not go at the root of `chat()`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). ### Reasoning @@ -435,11 +310,12 @@ import { geminiText } from "@tanstack/ai-gemini"; const stream = chat({ adapter: geminiText("gemini-3.8-flash"), messages: [{ role: "user", content: "Plan a database migration." }], + threadId: "thread-1", reasoning: "high", }); ``` -What goes on the wire: +What goes to Gemini: - Gemini 3 models: `thinkingConfig.thinkingLevel`. - Gemini 2.5 models: `thinkingConfig.thinkingBudget`. Set it with `budgetTokens`, or the level picks it. `off` sends a budget of `0`. @@ -464,553 +340,35 @@ import { geminiText } from "@tanstack/ai-gemini"; const stream = chat({ adapter: geminiText("gemini-2.5-pro"), messages: [{ role: "user", content: "Check this proof." }], + threadId: "thread-1", reasoning: { level: "high", budgetTokens: 8000 }, }); ``` -Image models keep their own `thinkingConfig` in `modelOptions`. See [Image Model Options](#image-model-options). - -### Structured Output - -Configure structured output format: - -```typescript ignore -modelOptions: { - responseMimeType: "application/json", -} -``` - -## Summarization - -Summarize long text content: - -```typescript -import { summarize } from "@tanstack/ai"; -import { geminiSummarize } from "@tanstack/ai-gemini"; - -const result = await summarize({ - adapter: geminiSummarize("gemini-3.1-pro-preview"), - text: "Your long text to summarize...", - maxLength: 100, - style: "concise", // "concise" | "bullet-points" | "paragraph" -}); - -console.log(result.summary); -``` - -## Embeddings - -Generate embedding vectors with gemini-embedding-001: - -```typescript -import { embed } from "@tanstack/ai"; -import { geminiEmbedding } from "@tanstack/ai-gemini"; - -const result = await embed({ - adapter: geminiEmbedding("gemini-embedding-001"), - input: "a red guitar", - dimensions: 1536, - modelOptions: { - taskType: "RETRIEVAL_DOCUMENT", - }, -}); - -console.log(result.embeddings[0]?.vector); -``` - -> The Gemini API's embedding endpoint does not report token usage, so -> `result.usage` is absent. The Vertex-only multimodal embedding model -> (`multimodalembedding@001`) is not supported by this adapter. - -See the [Embeddings guide](../embeddings.md) for the full API. - -## Image Generation - -The Gemini adapter has two image model groups: +Image models keep their own `thinkingConfig` in `modelOptions`. See [Image model options](./gemini-advanced#image-model-options). -- **Gemini native image models** (NanoBanana). A model such as `gemini-3.1-flash-image` uses the Interactions API. You set an aspect ratio. Some models also take a resolution tier (`512`, `1K`, `2K`, or `4K`). TypeScript checks the set for the model. -- **Imagen models**. A model such as `imagen-4.0-generate-001` uses `generateImages`. You set the size as WIDTHxHEIGHT. +### Models -When the model is in `GEMINI_NATIVE_IMAGE_MODELS`, the adapter uses the Interactions API. An Imagen model uses `generateImages`. An unknown id uses `generateImages`. Import the list or `isGeminiNativeImageModel` from `@tanstack/ai-gemini`. +Newest first. The package exports the full lists, for example `GeminiTextModels`. -### Example: Gemini Native Image Generation (NanoBanana) - -From the [media generation example app](https://github.com/TanStack/ai/tree/main/examples/ts-react-media): - -```typescript -import { generateImage } from "@tanstack/ai"; -import { geminiImage } from "@tanstack/ai-gemini"; - -const result = await generateImage({ - adapter: geminiImage("gemini-3.1-flash-image"), - prompt: "A futuristic cityscape at sunset", - numberOfImages: 1, - size: "16:9_4K", -}); - -console.log(result.images); -``` - -### Edit a Gemini native image - -You have an image from a Gemini native model. You want to change that image without a second upload of that image. - -Pass the last `id` back. Write only the change in `prompt`: - -```typescript -import { generateImage } from "@tanstack/ai"; -import { geminiImage } from "@tanstack/ai-gemini"; - -const adapter = geminiImage("gemini-3.1-flash-image"); - -const first = await generateImage({ - adapter, - prompt: "A red bicycle on a quiet street", -}); - -const edited = await generateImage({ - adapter, - prompt: "Make the bicycle blue", - modelOptions: { previous_interaction_id: first.id }, -}); -``` - -1. Generate the first image. -2. Call `generateImage` again with `previous_interaction_id` set to `first.id`. -3. Read `edited.images` for the new picture. - -`edited.id` is the id for the next edit. - -Keep the interaction so the next edit can run: - -1. Leave `store` unset. -2. If you set `store` to `false`, a later call cannot use that id. - -The API default for `store` is `true`. - -A free tier keeps a stored interaction for 1 day. A paid tier keeps it for 55 days. - -If the model is Imagen, do not pass `previous_interaction_id`. The call throws. - -If this edit needs a new reference image, add that image part on this call. Do not send the image from `first`. - -The Interactions API is in Beta. - -The same steps are in [Image Generation](../media/image-generation#edit-a-gemini-native-image). - -### Example: Imagen - -```typescript -import { generateImage } from "@tanstack/ai"; -import { geminiImage } from "@tanstack/ai-gemini"; - -const result = await generateImage({ - adapter: geminiImage("imagen-4.0-generate-001"), - prompt: "A futuristic cityscape at sunset", - numberOfImages: 1, -}); - -console.log(result.images); -``` - -### Image Size Options - -#### Gemini Native Models (NanoBanana) - -Gemini native image models use a template literal size format combining aspect ratio and resolution tier: - -```typescript ignore -// Format: "aspectRatio_resolution" -size: "16:9_4K" -size: "1:1_2K" -size: "9:16_1K" -``` - -The accepted set differs per model, and each model's `size` is narrowed to its own set at compile time: - -| Model | Aspect ratios | Resolutions | -|-------|---------------|-------------| -| `gemini-3.1-flash-image` | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9`, `1:4`, `4:1`, `1:8`, `8:1` | `512`, `1K`, `2K`, `4K` | -| `gemini-3.1-flash-lite-image` | same 14 as above (see note) | `1K` only | -| `gemini-nano-banana-2.1` | same 14 as above | `1K`, `2K`, `4K` | -| `gemini-3-pro-image` | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` | `1K`, `2K`, `4K` | -| `gemini-2.5-flash-image` | same 10 as above | none — pass the bare ratio, e.g. `size: "16:9"` | - -The `K` is case-sensitive (`1k` is rejected by the API), the smallest tier's token is `512` (not `512px` or `0.5K`), and `9:21` is Vertex/Cloud-only so it is not accepted here. Google documents no `image_size` for `gemini-2.5-flash-image`, so that model takes a bare aspect ratio and the adapter sends no `imageSize`. - -> **Note on `gemini-3.1-flash-lite-image`.** The four extreme banner ratios (`1:4`, `4:1`, `1:8`, `8:1`) are partially inferred for this model. Unlike the other three, Flash Lite has no per-model ratio table in Google's Gemini API guide; the 14-value set comes from the Cloud model page's explicit enumeration plus the guide's bare "a discrete set of 14 aspect ratios" assertion. The only Gemini-API enumeration for it is a 10-item bullet headed "New aspect ratios", which we read as a what's-new list rather than an exhaustive one. If the API rejects those four in practice, prefer the 10 standard ratios on this model. - -#### Imagen Models - -Imagen models use WIDTHxHEIGHT format, which maps to aspect ratios internally: - -| Size | Aspect Ratio | -|------|-------------| -| `1024x1024` | 1:1 | -| `1920x1080` | 16:9 | -| `1080x1920` | 9:16 | - -Alternatively, you can specify the aspect ratio directly in Model Options: - -```typescript -import { generateImage } from "@tanstack/ai"; -import { geminiImage } from "@tanstack/ai-gemini"; - -const result = await generateImage({ - adapter: geminiImage("imagen-4.0-generate-001"), - prompt: "A landscape photo", - modelOptions: { - aspectRatio: "16:9", - }, -}); -``` - -### Image Model Options - -`modelOptions` is typed per model family, because the two families hit different APIs. - -Imagen models (`generateImages`) take `GenerateImagesConfig` fields: - -```typescript ignore -import { generateImage } from "@tanstack/ai"; -import { geminiImage } from "@tanstack/ai-gemini"; - -const result = await generateImage({ - adapter: geminiImage("imagen-4.0-generate-001"), - prompt: "...", - modelOptions: { - aspectRatio: "16:9", // "1:1" | "3:4" | "4:3" | "9:16" | "16:9" - personGeneration: "DONT_ALLOW", // Control person generation - safetyFilterLevel: "BLOCK_SOME", // Safety filtering - }, -}); -``` - -#### Gemini native models - -Set native image options on `modelOptions`: - -```typescript -import { generateImage } from "@tanstack/ai"; -import { geminiImage, ThinkingLevel } from "@tanstack/ai-gemini"; - -const result = await generateImage({ - adapter: geminiImage("gemini-3.1-flash-image"), - prompt: "A beautiful garden", - size: "16:9_4K", - modelOptions: { - thinkingConfig: { thinkingLevel: ThinkingLevel.LOW }, - // imageConfig replaces one field from size. The 16:9 ratio from size stays. - // imageConfig accepts only aspectRatio and imageSize. - imageConfig: { imageSize: "2K" }, - }, -}); -``` - -Set `thinkingConfig.thinkingLevel` to one of these values: - -- `ThinkingLevel.MINIMAL` -- `ThinkingLevel.LOW` -- `ThinkingLevel.MEDIUM` -- `ThinkingLevel.HIGH` - -`thinkingConfig.thinkingBudget` throws. - -If `systemInstruction` contains an image, a file, or a tool call, the call throws. - -Pass the last result `id` as `previous_interaction_id`. Read [Edit a Gemini native image](#edit-a-gemini-native-image) for the edit steps. - -See [Image Generation](../media/image-generation) for the full native option list. - -## Text-to-Speech (Experimental) - -> **Note:** Gemini TTS is experimental and may require the Live API for full functionality. - -Generate speech from text: - -```typescript -import { generateSpeech } from "@tanstack/ai"; -import { geminiSpeech } from "@tanstack/ai-gemini"; - -const result = await generateSpeech({ - adapter: geminiSpeech("gemini-3.1-flash-tts-preview"), - text: "Hello from Gemini TTS!", -}); - -console.log(result.audio); // Base64 encoded audio -``` - -Gemini names speakers in the prompt and maps each name to a voice. Pass `turns` -and the adapter does both for you, using the voice name as the speaker label: - -```typescript -import { generateSpeech } from "@tanstack/ai"; -import { geminiSpeech } from "@tanstack/ai-gemini"; - -const result = await generateSpeech({ - adapter: geminiSpeech("gemini-3.1-flash-tts-preview"), - turns: [ - { text: "Hey, how is it going?", voice: "Puck" }, - { text: "Not bad, you?", voice: "Kore" }, - ], -}); -``` - -Gemini accepts **two distinct voices** per request, and a third is rejected -before the call is made. Each voice must be one of the prebuilt Gemini voices. -Gemini reports no timings, so `timestamps: true` is rejected on this adapter. - -## Environment Variables - -Set your API key in environment variables: - -```bash -GEMINI_API_KEY=your-api-key-here -# or -GOOGLE_API_KEY=your-api-key-here -``` - -## Getting an API Key - -1. Go to [Google AI Studio](https://aistudio.google.com/apikey) -2. Create a new API key -3. Add it to your environment variables - -## Popular Image Models - -### Gemini Native Image Models (NanoBanana) - -These models use the Interactions API. Each model has its own resolution tiers. - -| Model | Description | -|-------|-------------| -| `gemini-3.1-flash-image` | Nano Banana 2 — latest and fastest Gemini native image generation (512/1K/2K/4K) | -| `gemini-3.1-flash-lite-image` | Nano Banana 2 Lite — ultra-low-latency, low-cost image generation (1K only) | -| `gemini-nano-banana-2.1` | Nano Banana 2.1 (1K/2K/4K, no 512 tier) | -| `gemini-3-pro-image` | Nano Banana Pro — higher quality Gemini native image generation (1K/2K/4K) | -| `gemini-2.5-flash-image` | Nano Banana — legacy; shuts down 2026-10-02. Takes a bare aspect ratio | - -The `gemini-3.1-flash-image-preview` and `gemini-3-pro-image-preview` ids were shut down on 2026-06-25. They remain in the type union as deprecated aliases so existing code compiles, but calls to them fail — use the GA ids above. - -### Imagen Models - -These models use the dedicated `generateImages` API. - -| Model | Description | -|-------|-------------| -| `imagen-4.0-ultra-generate-001` | Best quality Imagen image generation | -| `imagen-4.0-generate-001` | High quality Imagen image generation | -| `imagen-4.0-fast-generate-001` | Fast Imagen image generation | - -## API Reference - -Every factory pair follows the same shape: the short factory (`geminiText`, `geminiImage`, …) reads `GEMINI_API_KEY` (or `GOOGLE_API_KEY`) from the environment, while the `create*` variant takes an explicit API key. Both take `model` as the first argument. - -### `geminiText(model, config?)` / `createGeminiChat(model, apiKey, config?)` - -Creates a Gemini text/chat adapter. - -`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). - -**Parameters:** - -- `model` - Gemini chat model id (e.g. `"gemini-3.1-pro-preview"`) -- `config?.baseURL` - Custom base URL (optional) - -### `geminiTextInteractions(model, config?)` / `createGeminiTextInteractions(model, apiKey, config?)` (experimental) - -Creates a Gemini Interactions API text adapter. Backs the stateful conversation pattern via `previous_interaction_id`. Exported from `@tanstack/ai-gemini/experimental`. - -**Parameters:** - -- `model` - Gemini chat model id (e.g. `"gemini-3.5-flash"`) -- `config?.baseURL` - Custom base URL (optional) - -### `geminiSummarize(model, config?)` / `createGeminiSummarize(model, apiKey, config?)` - -Creates a Gemini summarization adapter. - -### `geminiImage(model, config?)` / `createGeminiImage(model, apiKey, config?)` - -Creates a Gemini image adapter. When the model is in `GEMINI_NATIVE_IMAGE_MODELS`, the call uses the Interactions API. `result.id` is the interaction id. An Imagen model uses `generateImages`. An unknown id uses `generateImages`. - -### `geminiSpeech(model, config?)` / `createGeminiSpeech(model, apiKey, config?)` - -Creates a Gemini text-to-speech adapter. _Experimental._ - -### `geminiAudio(model, config?)` / `createGeminiAudio(model, apiKey, config?)` - -Creates a Gemini Lyria music generation adapter. _Experimental._ - -## Next Steps - -- [Image Generation Guide](../media/image-generation) - Learn more about image generation -- [Media Generation Example](https://github.com/TanStack/ai/tree/main/examples/ts-react-media) - Full working example with Gemini and fal.ai -- [Getting Started](../getting-started/quick-start) - Learn the basics -- [Tools Guide](../tools/tools) - Learn about tools -- [Other Adapters](./openai) - Explore other providers - -## Provider Tools - -Google Gemini exposes several native tools beyond user-defined function calls. -Import them from `@tanstack/ai-gemini/tools` and pass them into -`chat({ tools: [...] })`. - -> For the full concept, a comparison matrix, and type-gating details, see -> [Provider Tools](../tools/provider-tools.md). - -### `codeExecutionTool` - -Enables Gemini to execute Python code in a sandboxed environment and return -results inline. Takes no arguments — include it in the `tools` array to -activate code execution. - -```typescript -import { chat } from "@tanstack/ai"; -import { geminiText } from "@tanstack/ai-gemini"; -import { codeExecutionTool } from "@tanstack/ai-gemini/tools"; - -const stream = chat({ - adapter: geminiText("gemini-3.1-pro-preview"), - messages: [{ role: "user", content: "Calculate the first 10 Fibonacci numbers" }], - tools: [codeExecutionTool()], -}); -``` - -**Supported models:** Gemini 1.5 Pro, Gemini 2.x, Gemini 2.5 and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `fileSearchTool` - -Searches files that have been uploaded to the Gemini File API. Pass a -`FileSearch` config object with the corpus and file IDs to scope the search. - -```typescript -import { chat } from "@tanstack/ai"; -import { geminiText } from "@tanstack/ai-gemini"; -import { fileSearchTool } from "@tanstack/ai-gemini/tools"; - -const stream = chat({ - adapter: geminiText("gemini-3.1-pro-preview"), - messages: [{ role: "user", content: "Find the quarterly revenue figures" }], - tools: [ - fileSearchTool({ - fileSearchStoreNames: ["fileSearchStores/my-file-search-store-123"], - }), - ], -}); -``` - -**Supported models:** Gemini 2.x and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `googleSearchTool` - -Enables Gemini to query Google Search and incorporate grounded search results -into its response. Pass an optional `GoogleSearch` config or call with no -arguments to use defaults. - -```typescript -import { chat } from "@tanstack/ai"; -import { geminiText } from "@tanstack/ai-gemini"; -import { googleSearchTool } from "@tanstack/ai-gemini/tools"; - -const stream = chat({ - adapter: geminiText("gemini-3.1-pro-preview"), - messages: [{ role: "user", content: "What's the weather in Tokyo right now?" }], - tools: [googleSearchTool()], -}); -``` - -**Supported models:** Gemini 1.5 Pro, Gemini 2.x, Gemini 2.5. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -When Google Search returns grounding data, the assistant tool-call part -includes `metadata.providerExecuted: true` and the normalized -`metadata.sources` array. Gemini's raw `groundingMetadata` stays under -`metadata.gemini`. The agent loop does not execute this call in your -application. - -### `googleSearchRetrievalTool` - -A retrieval-augmented variant of Google Search that returns ranked passages -from the web with configurable dynamic retrieval mode. Pass an optional -`GoogleSearchRetrieval` config. - -```typescript ignore -import { chat } from "@tanstack/ai"; -import { geminiText } from "@tanstack/ai-gemini"; -import { googleSearchRetrievalTool } from "@tanstack/ai-gemini/tools"; - -const stream = chat({ - adapter: geminiText("gemini-3.1-pro-preview"), - messages: [{ role: "user", content: "Explain the latest JavaScript proposals" }], - tools: [ - googleSearchRetrievalTool({ - dynamicRetrievalConfig: { mode: "MODE_DYNAMIC", dynamicThreshold: 0.7 }, - }), - ], -}); -``` - -**Supported models:** Gemini 1.5 Pro and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `googleMapsTool` - -Connects Gemini to the Google Maps API for location-aware queries such as -directions, place search, and geocoding. Pass an optional `GoogleMaps` config -or call with no arguments. - -```typescript -import { chat } from "@tanstack/ai"; -import { geminiText } from "@tanstack/ai-gemini"; -import { googleMapsTool } from "@tanstack/ai-gemini/tools"; - -const stream = chat({ - adapter: geminiText("gemini-2.5-pro"), - messages: [{ role: "user", content: "Find coffee shops near Union Square, SF" }], - tools: [googleMapsTool()], -}); -``` - -**Supported models:** Gemini 2.5 and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `urlContextTool` - -Fetches and includes the content of URLs mentioned in the conversation so -Gemini can reason over live web pages. Takes no arguments. - -```typescript -import { chat } from "@tanstack/ai"; -import { geminiText } from "@tanstack/ai-gemini"; -import { urlContextTool } from "@tanstack/ai-gemini/tools"; - -const stream = chat({ - adapter: geminiText("gemini-3.1-pro-preview"), - messages: [{ role: "user", content: "Summarise https://example.com/article" }], - tools: [urlContextTool()], -}); -``` - -**Supported models:** Gemini 2.x and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `computerUseTool` +| Activity | Factory | Models | +| --- | --- | --- | +| Chat | `geminiText` | `gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-pro-preview`, `gemini-3-flash-preview`, `gemini-3.1-flash-lite`, `gemini-3.1-flash-lite-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | +| Images (native) | `geminiImage` | `gemini-nano-banana-2.1`, `gemini-3.1-flash-image`, `gemini-3.1-flash-lite-image`, `gemini-3-pro-image` | +| Images (Imagen) | `geminiImage` | `imagen-4.0-ultra-generate-001`, `imagen-4.0-generate-001`, `imagen-4.0-fast-generate-001` | +| Speech | `geminiSpeech` | `gemini-3.1-flash-tts-preview`, `gemini-2.5-flash-preview-tts`, `gemini-2.5-pro-preview-tts` | +| Music | `geminiAudio` | `lyria-3-pro-preview`, `lyria-3-clip-preview` | +| Video | `geminiVideo` | `veo-3.1-generate-preview`, `veo-3.1-fast-generate-preview`, `veo-3.1-lite-generate-preview`, `gemini-omni-1.1-flash` | +| Embeddings | `geminiEmbedding` | `gemini-embedding-001` | -Allows Gemini to observe a virtual desktop via screenshots and interact with -it using predefined computer-use functions. Provide the `environment` and -optionally restrict callable functions via `excludedPredefinedFunctions`. +`adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). -```typescript ignore -import { chat } from "@tanstack/ai"; -import { geminiText } from "@tanstack/ai-gemini"; -import { computerUseTool } from "@tanstack/ai-gemini/tools"; +## Advanced -const stream = chat({ - adapter: geminiText("gemini-3.1-pro-preview"), - messages: [{ role: "user", content: "Navigate to example.com in the browser" }], - tools: [ - computerUseTool({ - environment: "browser", - }), - ], -}); -``` +These topics are on [Gemini: Advanced](./gemini-advanced): -**Supported models:** Gemini 2.5 and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). +- Configuration and a proxy or gateway. +- Stateful conversations with the Interactions API (experimental). +- Image sizes, image edits, and image options. +- Video understanding options and speech with two speakers. +- Every provider tool, with a full sample, and the API reference. diff --git a/docs/adapters/grok-build.md b/docs/adapters/grok-build.md index f1dcf0e00a..1ae80980b7 100644 --- a/docs/adapters/grok-build.md +++ b/docs/adapters/grok-build.md @@ -2,7 +2,7 @@ title: Grok Build id: grok-build-adapter order: 15 -description: "Use xAI's Grok Build coding agent as a chat backend in TanStack AI — a sandbox harness that runs the grok CLI against a real workspace, with tool bridging via @tanstack/ai-grok-build." +description: "Run xAI's Grok Build coding agent inside a sandbox from TanStack AI. It edits files and runs commands in a real repo and streams each step to your UI." keywords: - tanstack ai - grok @@ -14,188 +14,274 @@ keywords: - adapter --- -The Grok Build adapter runs xAI's **Grok Build** coding agent as a chat backend. -Unlike HTTP provider adapters, this is a **harness adapter**: Grok Build runs -its own agent loop and executes its own tools — shell commands, file edits, -search — by spawning the `grok` CLI **inside a sandbox**. Each `chat()` call runs -one full harness turn; the harness's tool activity streams back as -already-resolved tool-call events your UI can render. +The Grok Build adapter runs the `grok` CLI in a sandbox and streams each step to `chat()`. The agent edits files, runs commands, and searches a repo. -> **Requires a sandbox.** `grok-build` declares `requires: [SandboxCapability]`, -> so `chat()` errors at the call site unless you provide a sandbox with -> `withSandbox(...)` middleware. The sandbox — your laptop, a Docker container, -> or a cloud VM — is the filesystem and safety boundary the agent runs in. See -> the [Sandboxes overview](../sandbox/overview) for the full picture. +You need: -## Installation +- A sandbox provider. The samples use Docker, so Docker must run on your machine. +- An `XAI_API_KEY`, or a `grok login` on the machine. +- The `grok` CLI inside the sandbox. A setup step in the sandbox installs it. - +Do you only want a Grok chat, with no repo and no shell? Use the [Grok adapter](./grok). -react: @tanstack/ai-grok-build @tanstack/ai-sandbox -vue: @tanstack/ai-grok-build @tanstack/ai-sandbox -solid: @tanstack/ai-grok-build @tanstack/ai-sandbox -svelte: @tanstack/ai-grok-build @tanstack/ai-sandbox -preact: @tanstack/ai-grok-build @tanstack/ai-sandbox -angular: @tanstack/ai-grok-build @tanstack/ai-sandbox -vanilla: @tanstack/ai-grok-build @tanstack/ai-sandbox -octane: @tanstack/ai-grok-build @tanstack/ai-sandbox +## 1. Install - + -You also need a sandbox provider (e.g. `@tanstack/ai-sandbox-docker`) and the -`grok` CLI available inside the sandbox image. +react: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +vue: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +solid: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +svelte: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +preact: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +angular: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +vanilla: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +octane: @tanstack/ai-grok-build @tanstack/ai-sandbox @tanstack/ai-sandbox-docker -## Authentication + -Your laptop can already have `grok login`. A CI runner only has `XAI_API_KEY`. -Both can use the same sandbox provider. The default `authMode` is `'api-key'`. -Set `'host'` when you want `grok login`. See [Harness Auth](../sandbox/auth). +## 2. Set the API key -```ts -import { grokBuildText } from "@tanstack/ai-grok-build" +Put the key in the environment of your server: -grokBuildText("composer-2.5") -grokBuildText("composer-2.5", { authMode: "host" }) +```bash +XAI_API_KEY=xai-... ``` -- `'api-key'` (default): inject `XAI_API_KEY` and authenticate with it. -- `'host'`: skip ACP `authenticate`. Use `grok login`. Do not inject - `XAI_API_KEY` into that process. +The sandbox in step 3 gives this key to the `grok` process as a workspace secret. + +Did the machine already run `grok login`? Then you can use that login and no key. See [Harness Auth](../sandbox/auth). -The two modes list the model under slightly different ids. The adapter maps -the short alias for you (see [Models](#models)). +## 3. Run the agent from the server -## Basic Usage +Define the sandbox once. It clones your repo into a Docker container and installs the `grok` CLI with `GROK_CLI_INSTALL_COMMAND`: ```ts -import { chat } from '@tanstack/ai' -import { grokBuildText } from '@tanstack/ai-grok-build' +// sandbox.ts +import { GROK_CLI_INSTALL_COMMAND } from "@tanstack/ai-grok-build"; import { createSecrets, defineSandbox, defineWorkspace, githubRepo, - withSandbox, -} from '@tanstack/ai-sandbox' -import { dockerSandbox } from '@tanstack/ai-sandbox-docker' -import { messages, threadId } from './chat-context' - -const sandbox = defineSandbox({ - id: 'grok-build-agent', - provider: dockerSandbox({ image: 'node:22' }), +} from "@tanstack/ai-sandbox"; +import { dockerSandbox } from "@tanstack/ai-sandbox-docker"; + +export const sandbox = defineSandbox({ + id: "grok-build-agent", + provider: dockerSandbox({ image: "node:22" }), workspace: defineWorkspace({ - source: githubRepo({ repo: 'owner/app' }), - setup: ['corepack enable', 'pnpm install'], - secrets: createSecrets({ XAI_API_KEY: process.env.XAI_API_KEY ?? '' }), + source: githubRepo({ repo: "owner/app" }), + setup: [GROK_CLI_INSTALL_COMMAND], + secrets: createSecrets({ + XAI_API_KEY: process.env.XAI_API_KEY ?? "", + }), }), -}) +}); +``` -const stream = chat({ - threadId, - adapter: grokBuildText('composer-2.5'), - messages, - middleware: [withSandbox(sandbox)], -}) +Then pass the adapter and the sandbox to `chat()` in your route: + +```ts +// api/agent.ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { grokBuildText } from "@tanstack/ai-grok-build"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; + +export async function POST(request: Request) { + const { messages, threadId, runId } = await chatParamsFromRequest(request); + + const stream = chat({ + adapter: grokBuildText("grok-build"), + messages, + threadId, + runId, + middleware: [withSandbox(sandbox)], + }); + + return toServerSentEventsResponse(stream); +} ``` -## Models +Replace `owner/app` with your repo. -Grok Build accepts any xAI model id its backend supports; the known ids get -autocomplete (any string is allowed): +## 4. Render the agent's work -| Model id | Notes | -| --- | --- | -| `grok-build` | The short alias. With a grok.com browser login the CLI lists it under this name. | -| `grok-build-0.1` | The fully-qualified id the CLI lists when authenticated with `XAI_API_KEY`. | -| `composer-2.5` | Also runnable through the Grok Build harness. | - -Pass any of these to `grokBuildText(...)` — the adapter resolves `grok-build` to -the CLI's `grok-build-0.1` automatically, so the same code works under both auth -modes. - -## Configuration - -Adapter config (second argument to `grokBuildText`): - -| Option | Description | -| ---------------- | --------------------------------------------------------------------------- | -| `cwd` | Working directory inside the sandbox. Defaults to `/workspace`. | -| `grokExecutable` | Path/name of the `grok` executable inside the sandbox. Defaults to `grok`. | -| `env` | Extra environment variables for the `grok` process inside the sandbox. | -| `emitDiff` | Emit a `file.changed` CUSTOM event with the working-tree `git diff` after the run. Defaults to `true`. | -| `protocol` | Harness wire protocol: `'acp'` or `'streaming-json'`. Defaults to `'acp'`. A durable sandbox run with no protocol set uses `'streaming-json'` so the run can journal. | -| `authMode` | `'api-key'` (default) uses `XAI_API_KEY`. `'host'` uses `grok login`. See [Harness Auth](../sandbox/auth). | -| `authMethodId` | Explicit ACP auth method. Wins over `authMode`. | -| `extraArgs` | Extra raw CLI flags appended verbatim (advanced). | - -Per-call overrides go through `modelOptions`: - -| `modelOptions` | Description | -| --------------- | ------------------------------------------------------------ | -| `sessionId` | Resume an existing Grok Build session (see below). | -| `cwd` | Per-call override of the harness working directory. | -| `maxTurns` | Per-call cap on the number of harness turns. | -| `protocol` | Per-call override of the harness wire protocol. | -| `authMode` | Per-call `'host'` or `'api-key'`. Default `'api-key'`. | -| `authMethodId` | Per-call ACP auth method. Wins over `authMode`. | - -## Stateful Sessions - -Grok Build sessions are stateful — the harness keeps the working context (files -read, commands run, conclusions reached) between turns. The adapter surfaces the -session id of every fresh run as a custom stream event named -`grok-build.session-id`; thread it back via `modelOptions.sessionId` to resume. -When resuming, only the latest user message is sent — the harness already holds -the prior context. +The client is the same as for any chat. Render the text, and show each tool that the agent ran: + +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; + +export function CodingAgent() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/agent"), + threadId: "support-chat", + }); + + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => + message.parts.map((part, index) => { + if (part.type === "text") { + return

{part.content}

; + } + if (part.type === "tool-call") { + return {part.name}; + } + return null; + }), + )} + setInput(event.target.value)} /> +
+ ); +} +``` + +Ask it "Fix the failing test in utils.test.ts". You see the agent's text, plus a line for each tool it used. The agent edited the files inside the container. + +For a version of this setup that runs as a script and prints the diff, see the [Sandbox Quick Start](../sandbox/quick-start). + +## How it works + +- `grokBuildText` declares that it needs a sandbox. `withSandbox(sandbox)` gives it one. Without a sandbox, `chat()` fails at the call site. +- Each `chat()` call runs one full agent turn. Grok Build runs its own loop and its own tools. +- The tools of the agent stream back as `tool-call` parts with the results attached. Your code does not run them. +- By default, the adapter talks to `grok` over the Agent Client Protocol (ACP). +- When the run ends, the adapter sends a `file.changed` custom event with the `git diff` of the workspace. + +To learn more about sandboxes, see [Sandboxes](../sandbox/overview). + +## Resume a session + +Grok Build keeps its working context between turns: the files it read, the commands it ran, and what it found. The adapter sends the session id of each new run as a custom event named `grok-build.session-id`. Send it back in `modelOptions.sessionId` to continue the session. + +On the server, read the id from the request: ```ts -import { chat, chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai' -import { grokBuildText } from '@tanstack/ai-grok-build' -import { withSandbox } from '@tanstack/ai-sandbox' -import { sandbox } from './sandbox' +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { grokBuildText } from "@tanstack/ai-grok-build"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; export async function POST(request: Request) { - const params = await chatParamsFromRequest(request) + const params = await chatParamsFromRequest(request); + + // Extra fields the client puts in the connection `body` arrive here. const sessionId = - typeof params.forwardedProps.sessionId === 'string' + typeof params.forwardedProps.sessionId === "string" ? params.forwardedProps.sessionId - : undefined + : undefined; const stream = chat({ - adapter: grokBuildText('grok-build'), + adapter: grokBuildText("grok-build"), messages: params.messages, + threadId: params.threadId, + runId: params.runId, middleware: [withSandbox(sandbox)], modelOptions: { sessionId }, - }) + }); - return toServerSentEventsResponse(stream) + return toServerSentEventsResponse(stream); } ``` -## Tools +On the client, keep the id from the custom event and send it with each request: -Two kinds of tools flow through this adapter: +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; + +export function useCodingAgent() { + const [sessionId, setSessionId] = useState(undefined); + + return useChat({ + connection: fetchServerSentEvents("/api/agent", () => ({ + body: { sessionId }, + })), + threadId: "support-chat", + onCustomEvent: (name, value) => { + if ( + name === "grok-build.session-id" && + typeof value === "object" && + value !== null && + "sessionId" in value && + typeof value.sessionId === "string" + ) { + setSessionId(value.sessionId); + } + }, + }); +} +``` + +On a resumed session, the adapter sends only the latest user message. The agent already has the rest. + +## Give the agent your own tools -1. **Built-in harness tools** are executed by Grok Build itself (shell, file - edits, search) and stream back as tool-call events with results already - attached. Your code never executes them. -2. **Your TanStack tools** are bridged *into* the harness over an authenticated - MCP tool-proxy: define them with `toolDefinition().server()` and pass them to - `chat({ tools })`. Tool-call events come back under the names you registered. - Because the harness runs in a sandbox, see [Sandbox tools](../sandbox/tools) - for how the bridge reaches your host across providers (local/Docker vs cloud). +The agent has its own tools for files and the shell. It cannot reach your database or your secrets. Pass your server tools to `chat()`, and the adapter bridges them into the sandbox over an MCP tool proxy that checks a token: -**Client-side and approval-gated tools are not supported** — the harness runs -tools inside a live process and can't pause across an HTTP round-trip. A tool -without a server `execute()` (or marked `needsApproval`) fails fast; run those -with a regular provider adapter. +```ts group=tools +import { z } from "zod"; +import { chat, toolDefinition } from "@tanstack/ai"; +import { grokBuildText } from "@tanstack/ai-grok-build"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; + +const lookupTicket = toolDefinition({ + name: "lookup_ticket", + description: "Look up an issue ticket by id", + inputSchema: z.object({ ticketId: z.string() }), +}).server(async ({ ticketId }) => { + return { ticketId, status: "open", title: "Crash on startup" }; +}); + +const stream = chat({ + adapter: grokBuildText("grok-build"), + messages: [{ role: "user", content: "What's the status of ticket T-123?" }], + threadId: "thread-1", + tools: [lookupTicket], + middleware: [withSandbox(sandbox)], +}); +``` + +- Each tool runs on your server, with its closures and secrets. The result goes back into the sandbox. +- The tool-call events use the names you gave. +- Only server tools work here. A client tool has no server `execute()`, so the agent cannot run it. + +For how the bridge reaches your server from each provider (local, Docker, or cloud), see [Sandbox Tools](../sandbox/tools). ## Tool choice -You want Grok Build to see just one of your tools, or none of them. Pass -`toolChoice` to `chat()`. It limits only the tools that the adapter bridges -into Grok Build. The built-in Grok Build tools always stay on, and Grok Build -decides when it calls a tool. The rules are the same on both protocols. +You want Grok Build to see only one of your tools, or none of them. Pass `toolChoice` to `chat()`: + +```ts group=tools +const onlyLookup = chat({ + adapter: grokBuildText("grok-build"), + messages: [{ role: "user", content: "What's the status of ticket T-123?" }], + threadId: "thread-1", + tools: [lookupTicket], + toolChoice: { type: "tool", name: "lookup_ticket" }, + middleware: [withSandbox(sandbox)], +}); +``` + +`toolChoice` limits only the tools that the adapter bridges into Grok Build. The built-in Grok Build tools always stay on, and Grok Build decides when it calls a tool. The rules are the same on both protocols. | Value | What the adapter does | | --- | --- | @@ -204,96 +290,134 @@ decides when it calls a tool. The rules are the same on both protocols. | `{ type: 'tool', name }` | Bridges only the named tool. Grok Build does not have to call it. | | `'required'` | Bridges all of your tools. Grok Build does not have to call one. | -Each value except `'auto'` logs a warning the first time an adapter instance -gets it. +Each value except `'auto'` logs a warning the first time an adapter instance gets it. -For the values on other adapters, see -[Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool). +For the values on other adapters, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool). -## Durable runs +## Structured output -A durable sandbox run (you pass `runs` and `durability` to `withSandbox`) -journals the agent output so a later request can take the run over. ACP does -not journal. When durability is wired and you do not set `protocol`, the -adapter uses `'streaming-json'`. - -Set `protocol: 'acp'` only when you want ACP on that call. A fresh ACP run -with durability wired logs a warning. An attach against ACP throws -`DurableAttachNotSupportedError`. - -## Headless NDJSON flags - -On the `'streaming-json'` path with auto-approve, the adapter adds -`--always-approve --no-plan --no-auto-update`. Those flags keep Plan Mode and -the CLI update check from blocking a headless run. - -## Structured Output - -Pass `outputSchema` on `chat()`. Grok Build has no native schema flag. The adapter adds the JSON Schema to the prompt (ACP and `streaming-json`) and parses the last assistant text. Tool activity still streams. The object arrives as `structured-output.complete`. +You want a typed report after the agent reads the repo. Pass `outputSchema` on the same `chat()` call: ```ts -import { chat } from "@tanstack/ai" -import { grokBuildText } from "@tanstack/ai-grok-build" -import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox" -import { dockerSandbox } from "@tanstack/ai-sandbox-docker" -import { z } from "zod" +import { chat } from "@tanstack/ai"; +import { grokBuildText } from "@tanstack/ai-grok-build"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { z } from "zod"; +import { sandbox } from "./sandbox"; const Report = z.object({ summary: z.string(), filesChanged: z.array(z.string()), -}) - -const sandbox = defineSandbox({ - id: "repo-report", - provider: dockerSandbox({ image: "node:22" }), -}) +}); const report = await chat({ adapter: grokBuildText("grok-build"), messages: [{ role: "user", content: "Review this repo." }], + threadId: "thread-1", outputSchema: Report, middleware: [withSandbox(sandbox)], -}) +}); -report.summary +console.log(report.summary); ``` -This path parses JSON from the last assistant message. If extract-only is the job, use `@tanstack/ai-grok`. - On the client, pass the same schema to `useChat` and read `final`. `partial` stays empty until the end. ```tsx -import { fetchServerSentEvents, useChat } from "@tanstack/ai-react" -import { z } from "zod" +import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"; +import { z } from "zod"; const Report = z.object({ summary: z.string(), filesChanged: z.array(z.string()), -}) +}); -function ReportView() { +export function ReportView() { const { final, isLoading } = useChat({ connection: fetchServerSentEvents("/api/repo-report"), + threadId: "support-chat", outputSchema: Report, - }) + }); - if (isLoading) return

The agent is inspecting the repo.

- if (!final) return null - return

{final.summary}

+ if (isLoading) return

The agent is inspecting the repo.

; + if (!final) return null; + return

{final.summary}

; } ``` -Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses). +- Grok Build has no schema flag. The adapter adds the JSON Schema to the prompt, on both protocols. +- The adapter parses the last assistant text. Tool activity still streams. +- The object arrives as `structured-output.complete`. + +Do you only need JSON from a prompt, with no repo? Use `@tanstack/ai-grok`. For the full walkthrough, see [Harness Agents](../structured-outputs/harnesses). + +## Models and options + +### Models + +| Model id | Notes | +| --- | --- | +| `grok-build` | The short alias. With a grok.com browser login, the CLI lists the model under this name. | +| `grok-build-0.1` | The full id. The CLI lists the model under this name when it signs in with `XAI_API_KEY`. | +| `composer-2.5` | Also runs through the Grok Build harness. | + +The adapter changes `grok-build` to `grok-build-0.1` for the CLI, so the same code works with both ways to sign in. The package exports the list as `GROK_BUILD_MODELS`. The adapter also takes any other xAI model id as a string. + +### Adapter options + +Pass these as the second argument of `grokBuildText`: + +| Option | What it does | +| --- | --- | +| `cwd` | The working directory inside the sandbox. Default `/workspace`. | +| `authMode` | `'api-key'` (default) signs in with `XAI_API_KEY`. `'host'` uses `grok login`. See [Harness Auth](../sandbox/auth). | +| `authMethodId` | An explicit ACP auth method. It wins over `authMode`. | +| `protocol` | `'acp'` (default) or `'streaming-json'`. See [Protocols and durable runs](#protocols-and-durable-runs). | +| `transport` | The ACP transport: `'auto'` (default), `'stdio'`, or `'websocket'`. | +| `acpPort` | The port of `grok agent serve` in the sandbox, for the WebSocket transport. | +| `permissionMode` | The ACP permission policy: `'default'`, `'acceptEdits'`, or `'bypassPermissions'` (default). | +| `emitDiff` | Send the `file.changed` event with the `git diff` after the run. Default `true`. | +| `grokExecutable` | The path or name of the `grok` executable in the sandbox. Default `grok`. | +| `env` | More environment variables for the `grok` process. | +| `extraArgs` | More raw CLI flags. The adapter adds them as they are. | + +### Per-call options + +Pass these in `modelOptions` on `chat()`. They override the adapter options for one call: + +- `sessionId`: resume a session. See [Resume a session](#resume-a-session). +- `maxTurns`: the maximum number of agent turns for this call. +- `cwd`, `protocol`, `transport`, `acpPort`, `permissionMode`. +- `authMode` and `authMethodId`. + +## Advanced + +### Protocols and durable runs + +The adapter talks to `grok` over one of two protocols: + +- `'acp'` (default): the Agent Client Protocol. +- `'streaming-json'`: the headless NDJSON output of the CLI. + +A durable sandbox run (you pass `runs` and `durability` to `withSandbox`) writes the agent output to a journal, so a later request can take the run over. ACP does not write a journal. So when durability is wired and you do not set `protocol`, the adapter uses `'streaming-json'`. + +Set `protocol: 'acp'` only when you want ACP on that call: + +- A new ACP run with durability wired logs a warning. +- An attach to an ACP run throws `DurableAttachNotSupportedError`. + +See [Durable Runs](../sandbox/durable-runs) and [The Run Journal](../sandbox/journal). + +### Headless NDJSON flags + +On the `'streaming-json'` path with auto-approve, the adapter adds `--always-approve --no-plan --no-auto-update`. These flags stop Plan Mode and the CLI update check from blocking a headless run. -## Limitations +### Limitations -- **Requires a sandbox.** Always run it under `withSandbox(...)`; see the - [Sandboxes overview](../sandbox/overview). -- **Server-only (Node).** The harness spawns the `grok` CLI in a sandbox. -- **The harness owns the agent loop.** TanStack's agent-loop strategies and - per-iteration middleware don't apply inside a harness turn. -- **No sampling controls.** `temperature`-style options don't exist here. -- **Cold starts.** Each call runs a full harness turn; expect higher first-token - latency than HTTP adapters. +- **A sandbox is required.** Every call needs `withSandbox(...)`. +- **The agent owns the loop.** TanStack agent-loop strategies and per-iteration middleware do not apply inside a turn. +- **No sampling options.** Options such as `temperature` do not exist here. +- **Cold starts.** Each call runs a full CLI turn, so the first token arrives later than with an HTTP adapter. +- **No `wrapFetch`.** This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the `grok` process sends the model requests. -This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the harness process sends the model requests. +For any other ACP agent, use the [ACP-Compatible Harness](./acp-compatible). diff --git a/docs/adapters/grok.md b/docs/adapters/grok.md index 07c00a9723..c66009af7f 100644 --- a/docs/adapters/grok.md +++ b/docs/adapters/grok.md @@ -2,7 +2,7 @@ title: Grok (xAI) id: grok-adapter order: 5 -description: "Use xAI Grok models with TanStack AI — Grok 4.3, Grok Build 0.1, Grok Imagine image generation, and Grok Imagine video generation via @tanstack/ai-grok." +description: "Use xAI Grok models in TanStack AI with @tanstack/ai-grok: Grok chat on the Responses API, Grok Imagine images and video, speech, transcription, and realtime voice." keywords: - tanstack ai - grok @@ -15,9 +15,9 @@ keywords: - adapter --- -The Grok text and summarization adapters provide access to xAI's Responses API for `grok-4.3` and `grok-build-0.1`, plus Grok Imagine image generation and Grok Imagine video generation. +The Grok adapter calls xAI chat models on the Responses API. It also calls Grok Imagine for images and video, plus speech, transcription, and realtime voice. -## Installation +## 1. Install @@ -32,182 +32,253 @@ octane: @tanstack/ai-grok -## Basic Usage +## 2. Set the API key -```typescript -import { chat } from "@tanstack/ai"; -import { grokText } from "@tanstack/ai-grok"; +Put the key in the environment of your server: -const stream = chat({ - adapter: grokText("grok-build-0.1"), - messages: [{ role: "user", content: "Hello!" }], -}); +```bash +XAI_API_KEY=xai-... ``` -## Basic Usage - Custom API Key +`grokText` and the other short factories read `XAI_API_KEY`. To pass the key in code, use the `create*` factory of the same adapter: ```typescript -import { chat } from "@tanstack/ai"; import { createGrokText } from "@tanstack/ai-grok"; -const adapter = createGrokText("grok-build-0.1", process.env.XAI_API_KEY!); - -const stream = chat({ - adapter, - messages: [{ role: "user", content: "Hello!" }], -}); +const adapter = createGrokText("grok-4.7", process.env.XAI_API_KEY!); ``` -## Configuration +## 3. Stream a chat from the server ```typescript -import { createGrokText, type GrokTextConfig } from "@tanstack/ai-grok"; +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { grokText } from "@tanstack/ai-grok"; -const config: Omit = { - baseURL: "https://api.x.ai/v1", // Optional, this is the default -}; +export async function POST(request: Request) { + const { messages, threadId, runId } = await chatParamsFromRequest(request); -const adapter = createGrokText("grok-build-0.1", process.env.XAI_API_KEY!, config); + const stream = chat({ + adapter: grokText("grok-4.7"), + messages, + threadId, + runId, + }); + + return toServerSentEventsResponse(stream); +} ``` -Need a header on each request, or a log of each model call? The chat adapters on this page support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). +## 4. Render the chat -## Grok on Vertex +The client is the same for every provider. Point `useChat` at your route: -Use `@tanstack/ai-grok/vertex` when Grok must run on Vertex AI. That path -uses Google Cloud credentials and Vertex regional or global endpoints. +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; - +export function Chat() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", + }); -react: @tanstack/ai-grok google-auth-library -vue: @tanstack/ai-grok google-auth-library -solid: @tanstack/ai-grok google-auth-library -svelte: @tanstack/ai-grok google-auth-library -preact: @tanstack/ai-grok google-auth-library -angular: @tanstack/ai-grok google-auth-library -vanilla: @tanstack/ai-grok google-auth-library -octane: @tanstack/ai-grok google-auth-library + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => ( +

+ {message.parts + .map((part) => (part.type === "text" ? part.content : "")) + .join("")} +

+ ))} + setInput(event.target.value)} /> +
+ ); +} +``` - +For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat). + +You now have a streaming Grok chat. [Tools](../tools/tools) and [Structured Outputs](../structured-outputs/overview) work the same as with every adapter. + +## Images ```typescript -import { chat } from "@tanstack/ai"; -import { grokVertexText } from "@tanstack/ai-grok/vertex"; +import { generateImage } from "@tanstack/ai"; +import { grokImage } from "@tanstack/ai-grok"; -const stream = chat({ - adapter: grokVertexText("grok-4.3", { - project: "my-project", - location: "global", - }), - messages: [{ role: "user", content: "Hello!" }], +const result = await generateImage({ + adapter: grokImage("grok-imagine-image-2.0"), + prompt: "A futuristic cityscape at sunset", + threadId: "thread-1", + size: "16:9_2k", + modelOptions: { quality: "medium" }, }); ``` -`project` and `location` use the same names as `@tanstack/ai-vertex`. If you -omit `location`, the factory uses `global`. +- The image models (`grok-imagine-image`, `grok-imagine-image-2.0`, `grok-imagine-image-quality`) take an aspect ratio as the size. `size` takes an `aspectRatio_resolution` template like `"16:9_2k"`. The `_2k` part is optional. +- `grok-imagine-image-2.0` is the model that xAI recommends. It adds a `quality` option (`'low'` or `'medium'`, default `'medium'`). +- To change an existing image, see [Image editing](#image-editing). -`grokVertexText` accepts only the Grok chat models that Vertex lists: +## Video -- `grok-4.3` -- `grok-4.20-reasoning` -- `grok-4.20-non-reasoning` -- `grok-4.1-fast-reasoning` -- `grok-4.1-fast-non-reasoning` +Grok Imagine makes short clips (1 to 15 seconds, with audio). Video generation is experimental. You start a job, then poll it: -xAI API models such as `grok-4.6` and `grok-build-0.1` are not on Vertex. +```typescript +import { generateVideo, getVideoJobStatus } from "@tanstack/ai"; +import { grokVideo } from "@tanstack/ai-grok"; -The adapter sends the Vertex model id `xai/grok-4.3`. Install -`google-auth-library` for Application Default Credentials, or pass -`authClient` or `getAccessToken`. +const adapter = grokVideo("grok-imagine-video-1.5"); -Use `grokVertexSummarize` from the same entry when you need summarize on -Vertex. +// 1. Create the job +const { jobId } = await generateVideo({ + adapter, + prompt: "A red panda balancing on a bamboo stalk in the rain", + threadId: "thread-1", + size: "16:9_720p", // "aspectRatio" or "aspectRatio_resolution" + duration: 5, // integer seconds, 1 to 15 +}); -Gemini on Vertex lives in [`@tanstack/ai-vertex`](./vertex). +// 2. Poll until complete, then read the video URL +let status = await getVideoJobStatus({ adapter, jobId }); +while (status.status !== "completed" && status.status !== "failed") { + await new Promise((r) => setTimeout(r, 5000)); + status = await getVideoJobStatus({ adapter, jobId }); +} -## Example: Chat Completion +console.log(status.url); // hosted .mp4 URL +``` -```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; -import { grokText } from "@tanstack/ai-grok"; +- `grok-imagine-video-1.5` is the default that xAI recommends. It does text-to-video (with native 1080p), image-to-video, and reference-to-video. +- `grok-imagine-video` (v1.0) also edits and extends a source video. -export async function POST(request: Request) { - const { messages } = await request.json(); +Image-to-video, references, edits, and sizes are in [Video options](#video-options). For the full flow and the `useGenerateVideo` hook, see [Video Generation](../media/video-generation). - const stream = chat({ - adapter: grokText("grok-build-0.1"), - messages, - }); +## Speech - return toServerSentEventsResponse(stream); -} +```typescript +import { generateSpeech } from "@tanstack/ai"; +import { grokSpeech } from "@tanstack/ai-grok"; + +const result = await generateSpeech({ + adapter: grokSpeech("grok-tts"), + text: "Hello from Grok!", + threadId: "thread-1", + voice: "default", + format: "mp3", +}); + +console.log(result.audio); // Base64-encoded audio ``` -## Example: With Tools +## Transcription ```typescript -import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai"; -import { grokText } from "@tanstack/ai-grok"; -import { z } from "zod"; +import { generateTranscription } from "@tanstack/ai"; +import { grokTranscription } from "@tanstack/ai-grok"; +import { audioFile } from "./audio"; -const getWeatherDef = toolDefinition({ - name: "get_weather", - description: "Get the current weather", - inputSchema: z.object({ - location: z.string(), - }), +const result = await generateTranscription({ + adapter: grokTranscription("grok-stt"), + audio: audioFile, + threadId: "thread-1", }); -const getWeather = getWeatherDef.server(async ({ location }) => { - // Fetch weather data - return { temperature: 72, conditions: "sunny" }; -}); +console.log(result.text); +``` -export async function POST(request: Request) { - const { messages } = await request.json(); +## Summarization - const stream = chat({ - adapter: grokText("grok-build-0.1"), - messages, - tools: [getWeather], - }); + - return toServerSentEventsResponse(stream); -} +```typescript ignore +import { summarize } from "@tanstack/ai"; +import { grokSummarize } from "@tanstack/ai-grok"; + +const result = await summarize({ + adapter: grokSummarize("grok-4.3"), + text: "Your long text to summarize...", + threadId: "thread-1", + maxLength: 100, + style: "concise", // "concise" | "bullet-points" | "paragraph" +}); + +console.log(result.summary); ``` -## Model Options +## Realtime voice and files + +- **Realtime voice**: `grokRealtime` and the token issuer `grokRealtimeToken` run a live voice chat. The default model is `grok-voice-think-fast-2.0`. `grok-voice-latest` always points to the newest model. See [Realtime Voice Chat](../media/realtime-chat). +- **Files**: `grokFiles` uploads media to the xAI Files API. See [Files API](../advanced/files-api). + +## Provider tools -Grok supports xAI Responses API options. Sampling parameters live here too — `temperature`, `top_p`, and `max_output_tokens` — rather than as root-level props on `chat()`: +xAI runs some tools itself. Import them from `@tanstack/ai-grok/tools` and add them to `tools`: ```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { chat } from "@tanstack/ai"; import { grokText } from "@tanstack/ai-grok"; +import { grokWebSearchTool } from "@tanstack/ai-grok/tools"; -export async function POST(request: Request) { - const { messages } = await request.json(); +const stream = chat({ + adapter: grokText("grok-4.3"), + messages: [{ role: "user", content: "What's new in AI this week?" }], + threadId: "thread-1", + tools: [grokWebSearchTool()], +}); +``` - const stream = chat({ - adapter: grokText("grok-build-0.1"), - messages, - modelOptions: { - temperature: 0.7, - top_p: 0.9, - max_output_tokens: 1024, - store: false, - include: ["reasoning.encrypted_content"], - }, - }); +- `grokWebSearchTool`: search the web. `filters` takes up to 5 `allowed_domains` or up to 5 `excluded_domains`, not both. +- `grokXSearchTool`: search posts on X. Takes up to 20 `allowed_x_handles` or up to 20 `excluded_x_handles`, not both. +- `grokFileSearchTool`: search your xAI file collections with `vector_store_ids`. `max_num_results` is 1 to 50. +- `grokMCPTool`: connect a remote MCP server with `server_url` and `server_label`. - return toServerSentEventsResponse(stream); -} +**Supported models:** `grok-4.3` and `grok-build-0.1`. For your own tools, use `toolDefinition()`. See [Tools](../tools/tools.md). + +## Models and options + +### Model options + +Provider options go in `modelOptions`, with the names of the xAI Responses API. This includes the sampling options `temperature`, `top_p`, and `max_output_tokens`: + +```typescript +import { chat } from "@tanstack/ai"; +import { grokText } from "@tanstack/ai-grok"; + +const stream = chat({ + adapter: grokText("grok-4.7"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + modelOptions: { + temperature: 0.7, + top_p: 0.9, + max_output_tokens: 1024, + store: false, + include: ["reasoning.encrypted_content"], + }, +}); ``` -> If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, see [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). +- Requests use `store: false` by default, and include the encrypted reasoning with `include: ["reasoning.encrypted_content"]`. You can change both in `modelOptions`. +- Sampling options do not go at the root of `chat()`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). -## Reasoning +### Reasoning -Want Grok to think harder? Set `reasoning` on `chat()`. It goes out as `reasoning.effort`, with a summary so the thinking text streams: +Want Grok to think harder? Set `reasoning` on `chat()`. It goes out as `reasoning.effort`, with a summary so that the thinking text streams: ```typescript import { chat } from "@tanstack/ai"; @@ -216,6 +287,7 @@ import { grokText } from "@tanstack/ai-grok"; const stream = chat({ adapter: grokText("grok-4.7"), messages: [{ role: "user", content: "Plan a database migration." }], + threadId: "thread-1", reasoning: "xhigh", }); ``` @@ -226,60 +298,85 @@ const stream = chat({ | `grok-4.5` | `low`, `medium`, `high` | | `grok-4.3` | `off`, `low`, `medium`, `high` | -`grok-build-0.1` and the `non-reasoning` models take no `reasoning`. `grok-4.20-reasoning` and `grok-4.1-fast-reasoning` take no `reasoning` either: they always think, and have no levels. +- `grok-build-0.1` and the `non-reasoning` models take no `reasoning`. +- `grok-4.20-reasoning` and `grok-4.1-fast-reasoning` take no `reasoning` either. They always think, and have no levels. -## Summarization +### Models -Summarize long text content: +| Activity | Factory | Models | +| --- | --- | --- | +| Chat | `grokText` | `grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-build-0.1` | +| Chat on Vertex | `grokVertexText` | `grok-4.3`, `grok-4.20-reasoning`, `grok-4.20-non-reasoning`, `grok-4.1-fast-reasoning`, `grok-4.1-fast-non-reasoning` | +| Images | `grokImage` | `grok-imagine-image-2.0`, `grok-imagine-image`, `grok-imagine-image-quality` | +| Video | `grokVideo` | `grok-imagine-video-1.5`, `grok-imagine-video` | +| Speech | `grokSpeech` | `grok-tts` | +| Transcription | `grokTranscription` | `grok-stt` | +| Realtime voice | `grokRealtime` | `grok-voice-think-fast-2.0`, `grok-voice-latest`, `grok-voice-fast-1.0` | - +`adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). -```typescript ignore -import { summarize } from "@tanstack/ai"; -import { grokSummarize } from "@tanstack/ai-grok"; +## Advanced -const result = await summarize({ - adapter: grokSummarize("grok-4.3"), - text: "Your long text to summarize...", - maxLength: 100, - style: "concise", // "concise" | "bullet-points" | "paragraph" -}); +### Configuration -console.log(result.summary); +```typescript +import { createGrokText, type GrokTextConfig } from "@tanstack/ai-grok"; + +const config: Omit = { + baseURL: "https://api.x.ai/v1", // Optional, this is the default +}; + +const adapter = createGrokText("grok-4.7", process.env.XAI_API_KEY!, config); ``` -## Image Generation +- Need a header on each request, or a log of each model call? The chat adapters of this package support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). +- The text and summarize adapters use the xAI **Responses API** (`/v1/responses`). They support streaming text, reasoning events, structured output through `text.format`, and function tools. + +### Grok on Vertex -Generate images with a Grok Imagine model. +Use `@tanstack/ai-grok/vertex` when Grok must run on Vertex AI. That path uses Google Cloud credentials and the regional or global endpoints of Vertex. -The image models (`grok-imagine-image`, `grok-imagine-image-2.0`, -`grok-imagine-image-quality`) are aspect-ratio sized — `size` takes an -`aspectRatio_resolution` template like `"16:9_2k"` (the `_2k` suffix is -optional). `grok-imagine-image-2.0` is xAI's recommended model and adds a -2.0-only `quality` provider option (`'low' | 'medium'`, default `'medium'`): + + +react: @tanstack/ai-grok google-auth-library +vue: @tanstack/ai-grok google-auth-library +solid: @tanstack/ai-grok google-auth-library +svelte: @tanstack/ai-grok google-auth-library +preact: @tanstack/ai-grok google-auth-library +angular: @tanstack/ai-grok google-auth-library +vanilla: @tanstack/ai-grok google-auth-library +octane: @tanstack/ai-grok google-auth-library + + ```typescript -import { generateImage } from "@tanstack/ai"; -import { grokImage } from "@tanstack/ai-grok"; +import { chat } from "@tanstack/ai"; +import { grokVertexText } from "@tanstack/ai-grok/vertex"; -const result = await generateImage({ - adapter: grokImage("grok-imagine-image-2.0"), - prompt: "A futuristic cityscape at sunset", - size: "16:9_2k", - modelOptions: { quality: "medium" }, +const stream = chat({ + adapter: grokVertexText("grok-4.3", { + project: "my-project", + location: "global", + }), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", }); ``` -### Image Editing (image-to-image) +- `project` and `location` use the same names as `@tanstack/ai-vertex`. If you omit `location`, the factory uses `global`. +- `grokVertexText` accepts only the Grok chat models that Vertex lists. See the [model table](#models). xAI API models such as `grok-4.6` and `grok-build-0.1` are not on Vertex. +- The adapter sends the Vertex model id `xai/grok-4.3`. +- Install `google-auth-library` for Application Default Credentials, or pass `authClient` or `getAccessToken`. +- For summarize on Vertex, use `grokVertexSummarize` from the same entry. + +Gemini on Vertex is in [`@tanstack/ai-vertex`](./vertex). + +### Image editing -The grok-imagine models accept image prompt parts for image-conditioned -generation via xAI's `/v1/images/edits` endpoint — up to 3 source images, -addressed by xAI in the order they appear in the prompt. Per xAI's docs -there is no in-prompt referencing syntax; write the prompt naturally and -your text is sent verbatim: +The grok-imagine models take image prompt parts. The adapter then calls the `/v1/images/edits` endpoint of xAI. + +- You can send up to 3 source images. xAI reads them in the order of the prompt. +- xAI has no syntax to point to an image in the prompt. Write the prompt in plain words. Your text goes out without changes. ```typescript import { generateImage } from "@tanstack/ai"; @@ -301,48 +398,25 @@ const result = await generateImage({ source: { type: "url", value: "https://example.com/style.png" }, }, ], + threadId: "thread-1", }); ``` -URL sources are fetched by xAI's servers, so they must be publicly -reachable; use a `data` source for private images. - -## Video Generation (Experimental) - -Generate short video clips (1–15 seconds, with audio) with the Grok Imagine video models via xAI's asynchronous jobs/polling API. - -Available models: +The servers of xAI fetch URL sources, so the URLs must be public. For a private image, use a `data` source. -- `grok-imagine-video` (v1.0) — text-to-video, image-to-video, and source-video edit / extend, $0.05 per second of video. -- `grok-imagine-video-1.5` — xAI's recommended default, $0.08 per second of video. Supports text-to-video (with native 1080p), image-to-video, and reference-to-video. It does not accept a source video. +### Video options -Text-to-video: +#### Sizes -```typescript -import { generateVideo, getVideoJobStatus } from "@tanstack/ai"; -import { grokVideo } from "@tanstack/ai-grok"; +The video `size` takes an `aspectRatio_resolution` template. The resolution part is optional. -const adapter = grokVideo("grok-imagine-video-1.5"); +- Aspect ratios: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, and `2:3`. +- Resolutions: `480p` and `720p`. `1080p` (for example `"9:16_1080p"`) works only on `grok-imagine-video-1.5` for text-to-video and image-to-video. +- Price: `grok-imagine-video` costs $0.05 per second of video. `grok-imagine-video-1.5` costs $0.08 per second. -// 1. Create the job -const { jobId } = await generateVideo({ - adapter, - prompt: "A red panda balancing on a bamboo stalk in the rain", - size: "16:9_720p", // "aspectRatio" or "aspectRatio_resolution" - duration: 5, // integer seconds, 1–15 -}); +#### Image-to-video -// 2. Poll until complete, then read the video URL -let status = await getVideoJobStatus({ adapter, jobId }); -while (status.status !== "completed" && status.status !== "failed") { - await new Promise((r) => setTimeout(r, 5000)); - status = await getVideoJobStatus({ adapter, jobId }); -} - -console.log(status.url); // hosted .mp4 URL -``` - -For image-to-video, include an `image` prompt part as the starting frame and describe the desired motion in the text part. URL sources are fetched by xAI's servers (so they must be publicly reachable); use a `data` source for a base64 starting frame: +Add an `image` prompt part as the first frame. Describe the motion in the text part. The servers of xAI fetch URL sources, so they must be public. For a base64 first frame, use a `data` source. ```typescript import { generateVideo } from "@tanstack/ai"; @@ -360,16 +434,20 @@ const { jobId } = await generateVideo({ source: { type: "url", value: "https://example.com/waterfall-still.png" }, }, ], + threadId: "thread-1", size: "16:9_720p", duration: 10, }); ``` -Like the Grok Imagine image models, sizing is aspect-ratio based: the `size` option takes an `aspectRatio_resolution` template. Supported aspect ratios are `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, and `2:3`; supported resolutions are `480p`, `720p`, and (on `grok-imagine-video-1.5` text-to-video / image-to-video only) `1080p` (e.g. `"9:16_1080p"`). The resolution suffix is optional. +#### Reference-to-video -### Reference-to-Video +On `grok-imagine-video-1.5`, image parts with `metadata.role: 'reference'` (or `'character'`) become `reference_images`. They guide subjects and style, but do not lock the first frame. The prompt text names them ``, ``, and so on, in request order. -On `grok-imagine-video-1.5`, image prompt parts with `metadata.role: 'reference'` (or `'character'`) become `reference_images` — they guide subjects and style without locking the first frame, and are addressed from the prompt text as ``, ``, … in request order. Preset TTS voices (up to 3) can be referenced for generated speech via `modelOptions.reference_audios`, addressed as ``, ``, ``. Reference-to-video output is capped at 720p. A starting-frame image can be combined with reference inputs on 1.5: the start frame pins the opening frame while the references steer subjects, style, and voices. Reference inputs are a 1.5-only feature, so the adapter rejects them on `grok-imagine-video`: +- `modelOptions.reference_audios` takes up to 3 preset TTS voices for generated speech. The prompt names them ``, ``, ``. +- You can add a first-frame image to the references. The first frame fixes the opening frame, and the references guide subjects, style, and voices. +- The output of reference-to-video is at most 720p. +- References work only on 1.5. The adapter rejects them on `grok-imagine-video`. ```typescript import { generateVideo } from "@tanstack/ai"; @@ -393,6 +471,7 @@ const { jobId } = await generateVideo({ metadata: { role: "reference" }, }, ], + threadId: "thread-1", size: "16:9_720p", modelOptions: { reference_audios: [{ voice_id: "eve" }], @@ -400,12 +479,12 @@ const { jobId } = await generateVideo({ }); ``` -### Video Editing and Extension +#### Edit and extend a video -`grok-imagine-video` (v1.0) can rewrite or continue an existing clip. `grok-imagine-video-1.5` has no video input — the adapter rejects a source-video part or `mode` on that model. Pass the source clip as a `video` prompt part and pick the mode with `modelOptions.mode`: +`grok-imagine-video` (v1.0) can change or continue a clip. `grok-imagine-video-1.5` takes no video input, so the adapter rejects a source-video part or `mode` on that model. Pass the source clip as a `video` prompt part and pick the mode with `modelOptions.mode`: -- `mode: 'edit'` posts to `/v1/videos/edits` — modifies only what the prompt asks for, keeping the rest of the clip intact. Duration, aspect ratio, and resolution are inherited from the source (capped at 720p), so the adapter rejects `size`, `aspect_ratio`, `resolution`, and `duration` in this mode rather than sending fields the API ignores. -- `mode: 'extend'` posts to `/v1/videos/extensions` — continues the clip. `duration` is the length of the **added tail**, not the total: extending a 10-second clip with `duration: 5` yields 15 seconds. Output geometry is still inherited from the source, so `size` / `aspect_ratio` / `resolution` are rejected here too. +- `mode: 'edit'` posts to `/v1/videos/edits`. It changes only what the prompt asks for. The clip keeps the duration, aspect ratio, and resolution of the source (at most 720p). So the adapter rejects `size`, `aspect_ratio`, `resolution`, and `duration` in this mode. +- `mode: 'extend'` posts to `/v1/videos/extensions`. It continues the clip. `duration` is the length of the **added part**, not the total: extending a 10-second clip with `duration: 5` gives 15 seconds. The size still comes from the source, so the adapter rejects `size`, `aspect_ratio`, and `resolution`. ```typescript import { generateVideo } from "@tanstack/ai"; @@ -423,6 +502,7 @@ const edit = await generateVideo({ source: { type: "url", value: "https://example.com/clip.mp4" }, }, ], + threadId: "thread-1", modelOptions: { mode: "edit" }, }); @@ -436,142 +516,33 @@ const extension = await generateVideo({ source: { type: "url", value: "https://example.com/clip.mp4" }, }, ], + threadId: "thread-1", duration: 5, // added seconds, not the total modelOptions: { mode: "extend" }, }); ``` -Both return the usual `{ jobId }` and are polled like any other Grok video job. - -When the job completes, the adapter reports usage on the result: `usage.billed` carries the billed seconds of video (`{ quantity, unit: 'seconds' }`) and `usage.cost` the exact cost in USD, both as returned by the xAI API. - -See [Video Generation](../media/video-generation) for the full jobs/polling flow, streaming mode, and the `useGenerateVideo` hook. - -## Text-to-Speech - -Generate speech with Grok TTS: - -```typescript -import { generateSpeech } from "@tanstack/ai"; -import { grokSpeech } from "@tanstack/ai-grok"; - -const result = await generateSpeech({ - adapter: grokSpeech("grok-tts"), - text: "Hello from Grok!", - voice: "default", - format: "mp3", -}); - -console.log(result.audio); // Base64-encoded audio -``` - -## Transcription - -Transcribe audio with Grok STT: - -```typescript -import { generateTranscription } from "@tanstack/ai"; -import { grokTranscription } from "@tanstack/ai-grok"; -import { audioFile } from "./audio"; - -const result = await generateTranscription({ - adapter: grokTranscription("grok-stt"), - audio: audioFile, -}); - -console.log(result.text); -``` - -## Realtime Voice - -Grok also exposes a Realtime voice adapter (`grokRealtime`) and a token issuer (`grokRealtimeToken`) for low-latency voice conversations. The default model is `grok-voice-think-fast-2.0` (xAI's current recommended speech-to-speech model); `grok-voice-latest` always points at the newest model. The 1.0 ids remain accepted for compatibility, but xAI has deprecated `grok-voice-think-fast-1.0`. See [Realtime Voice Chat](../media/realtime-chat) for the end-to-end flow. - -## Environment Variables - -Set your API key in environment variables: - -```bash -XAI_API_KEY=xai-... -``` - -## Implementation Notes - -### Responses API - -The Grok text and summarize adapters use xAI's **Responses API** (`/v1/responses`). Requests default to `store: false` and include encrypted reasoning content with `include: ["reasoning.encrypted_content"]`; both can be overridden through `modelOptions`. - -The shared Responses implementation supports streaming text, reasoning events, structured output via `text.format`, and user-defined function tools. - -## API Reference - -### `grokText(model, config?)` - -Creates a Grok text adapter using environment variables. - -`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). - -**Parameters:** - -- `model` - The model name (`'grok-4.3'` or `'grok-build-0.1'`) -- `config.baseURL?` - Custom base URL (optional) - -**Returns:** A Grok text adapter instance. - -### `createGrokText(model, apiKey, config?)` - -Creates a Grok text adapter with an explicit API key. - -**Parameters:** +Both return the usual `{ jobId }`. Poll them like any other Grok video job. -- `model` - The model name -- `apiKey` - Your xAI API key -- `config.baseURL?` - Custom base URL (optional) +When the job is complete, the result has the usage from the xAI API: -**Returns:** A Grok text adapter instance. +- `usage.billed`: the billed seconds of video (`{ quantity, unit: 'seconds' }`). +- `usage.cost`: the exact cost in USD. -### `grokSummarize(model, config?)` +### API reference -Creates a Grok summarization adapter using environment variables. +Each factory pair has the same shape. The short factory reads `XAI_API_KEY` from the environment. The `create*` factory takes an explicit API key. Both take `model` as the first argument. -**Returns:** A Grok summarize adapter instance. - -### `createGrokSummarize(model, apiKey, config?)` - -Creates a Grok summarization adapter with an explicit API key. - -**Returns:** A Grok summarize adapter instance. - -### `grokImage(model, config?)` / `createGrokImage(model, apiKey, config?)` - -Creates a Grok image generation adapter. - -### `grokVideo(model, config?)` / `createGrokVideo(model, apiKey, config?)` - -Creates a Grok video generation adapter (experimental) for the Grok Imagine video models (`'grok-imagine-video'`, `'grok-imagine-video-1.5'`). - -### `grokSpeech(model, config?)` / `createGrokSpeech(model, apiKey, config?)` - -Creates a Grok text-to-speech adapter. - -### `grokTranscription(model, config?)` / `createGrokTranscription(model, apiKey, config?)` - -Creates a Grok speech-to-text adapter. - -### `grokRealtime(...)` / `grokRealtimeToken(...)` - -Realtime voice adapter and token issuer. See [Realtime Voice Chat](../media/realtime-chat) for usage. - -## Next Steps - -- [Getting Started](../getting-started/quick-start) - Learn the basics -- [Tools Guide](../tools/tools) - Learn about tools -- [Other Adapters](./openai) - Explore other providers - -## Provider Tools - -Grok does not currently expose provider-specific tool factories. -Define your own tools with `toolDefinition()` from `@tanstack/ai`. - -See [Tools](../tools/tools.md) for the general tool-definition flow, or -[Provider Tools](../tools/provider-tools.md) for other providers' -native-tool offerings. +| Factories | Creates | +| --- | --- | +| `grokText(model, config?)` / `createGrokText(model, apiKey, config?)` | Text adapter | +| `grokSummarize(model, config?)` / `createGrokSummarize(model, apiKey, config?)` | Summarization adapter | +| `grokImage(model, config?)` / `createGrokImage(model, apiKey, config?)` | Image adapter | +| `grokVideo(model, config?)` / `createGrokVideo(model, apiKey, config?)` | Video adapter for the Grok Imagine video models. Experimental | +| `grokSpeech(model, config?)` / `createGrokSpeech(model, apiKey, config?)` | Text-to-speech adapter | +| `grokTranscription(model, config?)` / `createGrokTranscription(model, apiKey, config?)` | Speech-to-text adapter | +| `grokFiles` / `createGrokFiles` | Files API adapter. See [Files API](../advanced/files-api) | +| `grokRealtime(...)` / `grokRealtimeToken(...)` | Realtime voice adapter and token issuer. See [Realtime Voice Chat](../media/realtime-chat) | +| `grokVertexText` / `grokVertexSummarize` | Text and summarize adapters on Vertex. Import from `@tanstack/ai-grok/vertex` | + +`config.baseURL` sets a custom base URL. `adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. diff --git a/docs/adapters/groq.md b/docs/adapters/groq.md index e450ddd895..ecb1586dfb 100644 --- a/docs/adapters/groq.md +++ b/docs/adapters/groq.md @@ -2,7 +2,7 @@ title: Groq id: groq-adapter order: 6 -description: "Use Groq's fast inference API with TanStack AI for low-latency LLM responses and Whisper transcription — Llama and other open-weight models via @tanstack/ai-groq." +description: "Use the fast Groq inference API in TanStack AI with @tanstack/ai-groq: low-latency chat with Llama, gpt-oss, Qwen, and Kimi, plus Whisper transcription and Orpheus speech." keywords: - tanstack ai - groq @@ -15,9 +15,9 @@ keywords: - transcription --- -The Groq adapter provides access to Groq's fast inference API, featuring the world's fastest LLM inference and Whisper-based audio transcription. +The Groq adapter calls Llama, gpt-oss, Qwen, and Kimi with low latency. It also calls Whisper for transcription and Orpheus for speech. -## Installation +## 1. Install @@ -32,100 +32,89 @@ octane: @tanstack/ai-groq -## Basic Usage +## 2. Set the API key -```typescript -import { chat } from "@tanstack/ai"; -import { groqText } from "@tanstack/ai-groq"; +Put the key in the environment of your server: -const stream = chat({ - adapter: groqText("llama-3.3-70b-versatile"), - messages: [{ role: "user", content: "Hello!" }], -}); +```bash +GROQ_API_KEY=gsk_... ``` -## Basic Usage - Custom API Key +`groqText` and the other short factories read `GROQ_API_KEY`. To pass the key in code, use the `create*` factory of the same adapter: ```typescript -import { chat } from "@tanstack/ai"; import { createGroqText } from "@tanstack/ai-groq"; -const adapter = createGroqText("llama-3.3-70b-versatile", process.env.GROQ_API_KEY!, { - // ... your config options -}); - -const stream = chat({ - adapter, - messages: [{ role: "user", content: "Hello!" }], -}); -``` - -## Configuration - -```typescript -import { createGroqText, type GroqTextConfig } from "@tanstack/ai-groq"; - -const config: Omit = { - baseURL: "https://api.groq.com/openai/v1", // Optional, for custom endpoints -}; - -const adapter = createGroqText("llama-3.3-70b-versatile", process.env.GROQ_API_KEY!, config); +const adapter = createGroqText("openai/gpt-oss-120b", process.env.GROQ_API_KEY!); ``` -Need a header on each request, or a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). - -## Example: Chat Completion +## 3. Stream a chat from the server ```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; import { groqText } from "@tanstack/ai-groq"; export async function POST(request: Request) { - const { messages } = await request.json(); + const { messages, threadId, runId } = await chatParamsFromRequest(request); const stream = chat({ - adapter: groqText("llama-3.3-70b-versatile"), + adapter: groqText("openai/gpt-oss-120b"), messages, + threadId, + runId, }); return toServerSentEventsResponse(stream); } ``` -## Example: With Tools +## 4. Render the chat -```typescript -import { chat, toolDefinition, type ModelMessage } from "@tanstack/ai"; -import { groqText } from "@tanstack/ai-groq"; -import { z } from "zod"; - -const searchDatabaseDef = toolDefinition({ - name: "search_database", - description: "Search the database", - inputSchema: z.object({ - query: z.string(), - }), -}); +The client is the same for every provider. Point `useChat` at your route: -const searchDatabase = searchDatabaseDef.server(async ({ query }) => { - // Search database - return { results: [] }; -}); +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; -const messages: Array = [{ role: "user", content: "Search for something" }]; +export function Chat() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", + }); -const stream = chat({ - adapter: groqText("llama-3.3-70b-versatile"), - messages, - tools: [searchDatabase], -}); + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => ( +

+ {message.parts + .map((part) => (part.type === "text" ? part.content : "")) + .join("")} +

+ ))} + setInput(event.target.value)} /> +
+ ); +} ``` -If Groq rejects a generated tool call with `tool_use_failed` and includes a reconstructable tool call in `failed_generation`, the adapter returns the provider error as that tool's result without executing the call. The agent loop can then repair the call on its next iteration. Other provider errors remain terminal run errors. +For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat). + +You now have a streaming chat on Groq. [Tools](../tools/tools) and [Structured Outputs](../structured-outputs/overview) work the same as with every adapter. For the Groq details of both, see [Tool calls that Groq rejects](#tool-calls-that-groq-rejects) and [Structured output](#structured-output). ## Transcription -Groq exposes Whisper-based speech-to-text via `groqTranscription()` and the `generateTranscription()` activity. The `audio` input accepts a `File`, `Blob`, `ArrayBuffer`, base64 string, data URL, or an `https://` URL (forwarded directly to Groq without re-uploading). +`groqTranscription` turns speech into text with Whisper: ```typescript import { generateTranscription } from "@tanstack/ai"; @@ -135,67 +124,51 @@ const result = await generateTranscription({ adapter: groqTranscription("whisper-large-v3-turbo"), audio: "https://example.com/recording.mp3", language: "en", + threadId: "thread-1", }); console.log(result.text); -// verbose_json (the default) populates language, duration, and timestamped segments +// verbose_json (the default) fills language, duration, and timestamped segments for (const segment of result.segments ?? []) { - console.log(`[${segment.start}s → ${segment.end}s] ${segment.text}`); + console.log(`[${segment.start}s to ${segment.end}s] ${segment.text}`); } ``` -Supported models: `whisper-large-v3-turbo`, `whisper-large-v3`. Supported `responseFormat` values: `json`, `text`, `verbose_json` (default). `srt` and `vtt` are not supported by Groq. +- `audio` takes a `File`, a `Blob`, an `ArrayBuffer`, a base64 string, a data URL, or an `https://` URL. The adapter sends a URL to Groq as it is, with no new upload. +- Models: `whisper-large-v3-turbo`, `whisper-large-v3`. +- `responseFormat` takes `json`, `text`, or `verbose_json` (the default). Groq does not support `srt` or `vtt`. -See [Transcription](../media/transcription) for the full API. +See [Transcription](../media/transcription). -## Model Options +## Speech -Groq supports various provider-specific options. Sampling parameters live here too — `temperature`, `top_p`, and `max_completion_tokens` (Groq's token-limit key) — rather than as root-level props on `chat()`: +`groqSpeech` turns text into speech with the Orpheus models: ```typescript -import { chat } from "@tanstack/ai"; -import { groqText } from "@tanstack/ai-groq"; - -const stream = chat({ - adapter: groqText("llama-3.3-70b-versatile"), - messages: [{ role: "user", content: "Hello!" }], - modelOptions: { - temperature: 0.7, - max_completion_tokens: 1024, - top_p: 0.9, - }, +import { generateSpeech } from "@tanstack/ai"; +import { groqSpeech } from "@tanstack/ai-groq"; + +const result = await generateSpeech({ + adapter: groqSpeech("canopylabs/orpheus-v1-english"), + text: "Hello from Groq!", + threadId: "thread-1", + voice: "autumn", + format: "wav", }); -``` - -> If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, see [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). - -### Reasoning - -Set how hard a reasoning model thinks with `reasoning` on `chat()`: - -```typescript -import { chat } from "@tanstack/ai"; -import { groqText } from "@tanstack/ai-groq"; -const stream = chat({ - adapter: groqText("openai/gpt-oss-120b"), - messages: [{ role: "user", content: "Hello!" }], - reasoning: "medium", -}); +console.log(result.audio); // Base64-encoded audio ``` -It goes out as `reasoning_effort`: - -- `openai/gpt-oss-120b`, `openai/gpt-oss-20b`, `openai/gpt-oss-safeguard-20b`: `low`, `medium`, `high`. -- `qwen/qwen3-32b`: `off` (sent as `none`) and `high` (sent as `default`). +- Models: `canopylabs/orpheus-v1-english` and `canopylabs/orpheus-arabic-saudi`. +- English voices: `autumn` (the default), `diana`, `hannah`, `austin`, `daniel`, `troy`. +- Arabic voices: `fahad`, `sultan`, `lulwa`, `noura`. +- Formats: `flac`, `mp3`, `mulaw`, `ogg`, and `wav` (the default). `modelOptions.sample_rate` sets the sample rate. -To pick how the thinking text comes back, set `reasoning_format` in `modelOptions`. +See [Text-to-Speech](../media/text-to-speech). ## Summarization -Summarize long text content: - ```typescript import { summarize } from "@tanstack/ai"; import { groqSummarize } from "@tanstack/ai-groq"; @@ -203,6 +176,7 @@ import { groqSummarize } from "@tanstack/ai-groq"; const result = await summarize({ adapter: groqSummarize("llama-3.3-70b-versatile"), text: "Your long text to summarize...", + threadId: "thread-1", maxLength: 100, style: "concise", // "concise" | "bullet-points" | "paragraph" }); @@ -210,107 +184,103 @@ const result = await summarize({ console.log(result.summary); ``` -## Supported Models - -Groq offers a diverse selection of models from multiple providers: - -### Meta Llama +## No image generation -- `llama-3.3-70b-versatile` - Fast, capable model with 128K context -- `llama-3.1-8b-instant` - Fast, cost-effective model -- `meta-llama/llama-4-maverick-17b-128e-instruct` - Latest Llama 4 with vision support -- `meta-llama/llama-4-scout-17b-16e-instruct` - Efficient Llama 4 model +Groq does not make images. For images, use [OpenAI](./openai) or [Gemini](./gemini). Groq has no provider tools of its own. Define your tools with `toolDefinition()`. -### Security Models +## Models and options -- `meta-llama/llama-guard-4-12b` - Content moderation -- `meta-llama/llama-prompt-guard-2-86m` - Prompt injection detection -- `meta-llama/llama-prompt-guard-2-22m` - Lightweight prompt guard +### Model options -### OpenAI GPT-OSS Models +Provider options go in `modelOptions`. This includes the sampling options `temperature`, `top_p`, and `max_completion_tokens` (the token limit of Groq): -- `openai/gpt-oss-120b` - Large OSS model with reasoning support -- `openai/gpt-oss-20b` - Efficient OSS model -- `openai/gpt-oss-safeguard-20b` - Safety-tuned OSS model - -### Other Providers - -- `moonshotai/kimi-k2-instruct-0905` - Kimi K2 with 256K context -- `qwen/qwen3-32b` - Qwen 3 with reasoning support - -## Environment Variables - -Set your API key in environment variables: +```typescript +import { chat } from "@tanstack/ai"; +import { groqText } from "@tanstack/ai-groq"; -```bash -GROQ_API_KEY=gsk_... +const stream = chat({ + adapter: groqText("llama-3.3-70b-versatile"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + modelOptions: { + temperature: 0.7, + max_completion_tokens: 1024, + top_p: 0.9, + }, +}); ``` -## API Reference +Sampling options do not go at the root of `chat()`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). -### `groqText(model, config?)` +### Reasoning -Creates a Groq chat adapter using environment variables. +Set how hard a reasoning model thinks with `reasoning` on `chat()`: -`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). +```typescript +import { chat } from "@tanstack/ai"; +import { groqText } from "@tanstack/ai-groq"; -**Parameters:** +const stream = chat({ + adapter: groqText("openai/gpt-oss-120b"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + reasoning: "medium", +}); +``` -- `model` - The model name (e.g., `llama-3.3-70b-versatile`) -- `config` (optional) - Optional configuration object. Supports the same options as `createGroqText` except `apiKey`, which is auto-detected from `GROQ_API_KEY` environment variable. Common options: - - `baseURL` - Custom base URL for API requests (optional) +It goes out as `reasoning_effort`: -**Returns:** A Groq chat adapter instance. +- `openai/gpt-oss-120b`, `openai/gpt-oss-20b`, `openai/gpt-oss-safeguard-20b`: `low`, `medium`, `high`. +- `qwen/qwen3-32b`: `off` (sent as `none`) and `high` (sent as `default`). -### `createGroqText(model, apiKey, config?)` +To pick how the thinking text comes back, set `reasoning_format` in `modelOptions`. -Creates a Groq chat adapter with an explicit API key. +### Models -**Parameters:** +The package exports the full lists as `GROQ_CHAT_MODELS`, `GROQ_TRANSCRIPTION_MODELS`, and `GROQ_TTS_MODELS`. -- `model` - The model name (e.g., `llama-3.3-70b-versatile`) -- `apiKey` - Your Groq API key -- `config` (optional) - Optional configuration object: - - `baseURL` - Custom base URL for API requests (optional) +| Group | Models | +| --- | --- | +| OpenAI gpt-oss | `openai/gpt-oss-120b` (reasoning), `openai/gpt-oss-20b`, `openai/gpt-oss-safeguard-20b` (safety-tuned) | +| Meta Llama | `meta-llama/llama-4-maverick-17b-128e-instruct` (vision), `meta-llama/llama-4-scout-17b-16e-instruct`, `llama-3.3-70b-versatile` (128K context), `llama-3.1-8b-instant` | +| Other | `qwen/qwen3-32b` (reasoning), `moonshotai/kimi-k2-instruct-0905` (256K context) | +| Safety | `meta-llama/llama-guard-4-12b` (content moderation), `meta-llama/llama-prompt-guard-2-86m` and `meta-llama/llama-prompt-guard-2-22m` (prompt injection detection) | -**Returns:** A Groq chat adapter instance. +`adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). -### `groqSummarize(model, config?)` +## Advanced -Creates a Groq summarization adapter using environment variables. +### Configuration -**Returns:** A Groq summarize adapter instance. +```typescript +import { createGroqText, type GroqTextConfig } from "@tanstack/ai-groq"; -### `createGroqSummarize(model, apiKey, config?)` +const config: Omit = { + baseURL: "https://api.groq.com/openai/v1", // Optional, for custom endpoints +}; -Creates a Groq summarization adapter with an explicit API key. +const adapter = createGroqText("openai/gpt-oss-120b", process.env.GROQ_API_KEY!, config); +``` -**Returns:** A Groq summarize adapter instance. +Need a header on each request, or a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). -### `groqTranscription(model, config?)` / `createGroqTranscription(model, apiKey, config?)` +### Tool calls that Groq rejects -Creates a Groq transcription (speech-to-text) adapter. The short form reads `GROQ_API_KEY` from the environment; the `create*` form takes an explicit API key. Supported models: `whisper-large-v3-turbo`, `whisper-large-v3`. +Groq can reject a tool call from the model with `tool_use_failed`. If the error has a tool call in `failed_generation` that the adapter can rebuild, the adapter does not run the call. It returns the provider error as the result of that tool. The agent loop can then repair the call on its next step. Other provider errors still end the run. -## Structured output +### Structured output Some Groq models, for example `llama-3.3-70b-versatile`, reject strict JSON schema. For these models, `chat({ outputSchema })` sends `json_object` and adds your schema to the system prompt. Read [When strict JSON schema is rejected](../structured-outputs/overview.md#when-strict-json-schema-is-rejected). -## Limitations - -- **Text-to-Speech**: Groq does not currently expose a TTS adapter. Use OpenAI, Gemini, ElevenLabs, or fal for speech generation. -- **Image Generation**: Groq does not support image generation. Use OpenAI, Gemini, or fal for image generation. - -## Next Steps - -- [Getting Started](../getting-started/quick-start) - Learn the basics -- [Tools Guide](../tools/tools) - Learn about tools -- [Other Adapters](./openai) - Explore other providers +### API reference -## Provider Tools +Each factory pair has the same shape. The short factory reads `GROQ_API_KEY` from the environment. The `create*` factory takes an explicit API key. Both take `model` as the first argument. -Groq does not currently expose provider-specific tool factories. -Define your own tools with `toolDefinition()` from `@tanstack/ai`. +| Factories | Creates | +| --- | --- | +| `groqText(model, config?)` / `createGroqText(model, apiKey, config?)` | Chat adapter | +| `groqSummarize(model, config?)` / `createGroqSummarize(model, apiKey, config?)` | Summarization adapter | +| `groqTranscription(model, config?)` / `createGroqTranscription(model, apiKey, config?)` | Speech-to-text adapter | +| `groqSpeech(model, config?)` / `createGroqSpeech(model, apiKey, config?)` | Text-to-speech adapter | -See [Tools](../tools/tools.md) for the general tool-definition flow, or -[Provider Tools](../tools/provider-tools.md) for other providers' -native-tool offerings. +`config.baseURL` sets a custom base URL for the API requests. `adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. diff --git a/docs/adapters/llmgateway.md b/docs/adapters/llmgateway.md index 34b3ccab1e..cf0d7eec36 100644 --- a/docs/adapters/llmgateway.md +++ b/docs/adapters/llmgateway.md @@ -1,7 +1,7 @@ --- title: LLM Gateway id: llmgateway-adapter -description: "Route chat, tool calling, and structured outputs to hundreds of LLMs from OpenAI, Anthropic, Google, Moonshot, DeepSeek, and more through LLM Gateway's single OpenAI-compatible endpoint in TanStack AI." +description: "Reach hundreds of models from OpenAI, Anthropic, Google, Moonshot, DeepSeek, and more through one OpenAI-compatible LLM Gateway endpoint with @tanstack/ai-llmgateway. Hosted or self-hosted." keywords: - tanstack ai - llm gateway @@ -13,9 +13,9 @@ keywords: - adapter --- -[LLM Gateway](https://llmgateway.io) is an open-source AI gateway that routes one OpenAI-compatible endpoint to hundreds of models across many providers — with automatic provider selection, fallback, usage analytics, and cost tracking. Use the hosted gateway at `api.llmgateway.io` or point the adapter at your own self-hosted deployment. +`llmgatewayText` calls one OpenAI-compatible endpoint that routes across providers, falls back on failure, and records usage and cost. -## Installation +## 1. Install @@ -30,75 +30,111 @@ octane: @tanstack/ai-llmgateway -## Basic Usage +## 2. Set the API key -```typescript -import { chat } from "@tanstack/ai"; -import { llmGatewayText } from "@tanstack/ai-llmgateway"; +Get a key from the [LLM Gateway dashboard](https://llmgateway.io). Put it in the environment of your server: -const stream = chat({ - adapter: llmGatewayText("gpt-5.6-terra"), - messages: [{ role: "user", content: "Hello!" }], -}); +```bash +LLM_GATEWAY_API_KEY=llmgtwy_your-api-key ``` -`llmGatewayText` reads your API key from the `LLM_GATEWAY_API_KEY` environment variable. Use `createLLMGatewayText` to pass it explicitly. - -## Configuration +`llmGatewayText` and `llmGatewaySummarize` read `LLM_GATEWAY_API_KEY`. To pass the key in code, use the `create*` factory of the same adapter: ```typescript import { createLLMGatewayText } from "@tanstack/ai-llmgateway"; -const adapter = createLLMGatewayText( - "gpt-5.6-terra", - process.env.LLM_GATEWAY_API_KEY!, - { - baseURL: "https://api.llmgateway.io/v1", // Optional — set for self-hosted deployments - }, -); -``` - -LLM Gateway is open source and self-hostable; point `baseURL` at your own deployment to keep the same adapter surface. - -Need a header on each request, or a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). - -## Available Models - -Any model listed at [llmgateway.io/models](https://llmgateway.io/models) works — pass its id as the model name. A bare model id lets the gateway route to the best available provider; prefix it with `provider/` to pin routing to a specific provider: - -```text -model: "gpt-5.6-terra" // gateway picks the provider -model: "claude-sonnet-5" // gateway picks the provider -model: "moonshot/kimi-k3" // always routed to Moonshot -model: "fireworks/kimi-k3" // always routed to Fireworks +const adapter = createLLMGatewayText("gpt-5.6-terra", process.env.LLM_GATEWAY_API_KEY!); ``` -A curated set of flagship models (see `LLMGATEWAY_CHAT_MODELS`) additionally carries per-model type metadata — input modalities and provider options — with editor autocomplete. Uncurated ids still work and fall back to text-only input with the generic options. - -`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). - -## Example: Chat Completion +## 3. Stream a chat from the server ```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; import { llmGatewayText } from "@tanstack/ai-llmgateway"; export async function POST(request: Request) { - const { messages } = await request.json(); + const { messages, threadId, runId } = await chatParamsFromRequest(request); const stream = chat({ adapter: llmGatewayText("gpt-5.6-terra"), messages, + threadId, + runId, }); return toServerSentEventsResponse(stream); } ``` -## Example: With Tools +## 4. Render the chat + +The client is the same for every provider. Point `useChat` at your route: + +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; + +export function Chat() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", + }); + + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => ( +

+ {message.parts + .map((part) => (part.type === "text" ? part.content : "")) + .join("")} +

+ ))} + setInput(event.target.value)} /> +
+ ); +} +``` + +For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat). + +You now have a streaming chat through LLM Gateway. To try another model, change the id, for example to `claude-opus-5` or `kimi-k3`. + +## Pick a model and a provider + +Any model on [llmgateway.io/models](https://llmgateway.io/models) works. Pass its id as the model name: + +- A bare id lets the gateway pick the best provider that is available. +- A `provider/` prefix always sends the request to that provider. + +```text +model: "gpt-5.6-terra" // the gateway picks the provider +model: "claude-sonnet-5" // the gateway picks the provider +model: "moonshot/kimi-k3" // always sent to Moonshot +model: "fireworks/kimi-k3" // always sent to Fireworks +``` + +## Tools + +Tools work the same as with every adapter: ```typescript -import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai"; +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, + toolDefinition, +} from "@tanstack/ai"; import { llmGatewayText } from "@tanstack/ai-llmgateway"; import { z } from "zod"; @@ -111,15 +147,17 @@ const getWeatherDef = toolDefinition({ }); const getWeather = getWeatherDef.server(async ({ location }) => { - return { temperature: 72, conditions: "sunny" }; + return { location, temperature: 72, conditions: "sunny" }; }); export async function POST(request: Request) { - const { messages } = await request.json(); + const { messages, threadId, runId } = await chatParamsFromRequest(request); const stream = chat({ adapter: llmGatewayText("gpt-5.6-terra"), messages, + threadId, + runId, tools: [getWeather], }); @@ -127,9 +165,29 @@ export async function POST(request: Request) { } ``` -## Model Options +See [Tools](../tools/tools). + +## Summarization + +```typescript +import { summarize } from "@tanstack/ai"; +import { llmGatewaySummarize } from "@tanstack/ai-llmgateway"; + +const result = await summarize({ + adapter: llmGatewaySummarize("gpt-5.4-mini"), + text: "Long article text...", + threadId: "thread-1", + stream: false, +}); + +console.log(result.summary); +``` + +## Models and options + +### Model options -The gateway accepts the standard Chat Completions parameters and forwards them to the routed provider (parameters a provider doesn't support are stripped server-side). Sampling parameters live in `modelOptions`: +The gateway takes the standard Chat Completions parameters and sends them to the provider that it picks. The gateway removes the parameters that a provider does not support. Sampling options go in `modelOptions`: ```typescript import { chat } from "@tanstack/ai"; @@ -138,46 +196,72 @@ import { llmGatewayText } from "@tanstack/ai-llmgateway"; const stream = chat({ adapter: llmGatewayText("kimi-k3"), messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", modelOptions: { temperature: 0.7, max_completion_tokens: 4096, }, - reasoning: "high", }); ``` ### Reasoning -`reasoning` goes out as `reasoning_effort`. The types take the levels of each model, and no `budgetTokens`: +Set `reasoning` on `chat()`: -- `kimi-k3`, `glm-5.2`, `deepseek-v4-pro`, `qwen3.7-max`: `off` to `max`. -- `gpt-5.6-terra`: `off`, `low` to `max`. `gpt-5.5`, `gpt-5.4-mini`: `off`, `low` to `xhigh`. -- `claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5`, `minimax-m2.5`: `low` to `max`. -- `gemini-pro-latest`, `grok-4-5`: `low` to `high`. `gemini-3.6-flash`: `minimal` to `high`. +```typescript +import { chat } from "@tanstack/ai"; +import { llmGatewayText } from "@tanstack/ai-llmgateway"; -Reasoning models stream their thinking as `reasoning_content` deltas, which the adapter surfaces as AG-UI `REASONING_*` events. +const stream = chat({ + adapter: llmGatewayText("kimi-k3"), + messages: [{ role: "user", content: "Plan a database migration." }], + threadId: "thread-1", + reasoning: "high", +}); +``` -## Summarization +It goes out as `reasoning_effort`. The types take the levels of each model, and no `budgetTokens`: + +| Model | Levels | +| --- | --- | +| `kimi-k3`, `glm-5.2`, `deepseek-v4-pro`, `qwen3.7-max` | `off` to `max` | +| `gpt-5.6-terra` | `off`, `low` to `max` | +| `gpt-5.5`, `gpt-5.4-mini` | `off`, `low` to `xhigh` | +| `claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5`, `minimax-m2.5` | `low` to `max` | +| `gemini-pro-latest`, `grok-4-5` | `low` to `high` | +| `gemini-3.6-flash` | `minimal` to `high` | + +Reasoning models stream their thinking as `reasoning_content` deltas. The adapter turns them into AG-UI `REASONING_*` events. See [Reasoning](../chat/reasoning). + +### Models + +`LLMGATEWAY_CHAT_MODELS` is a curated set of flagship models. These models have type data for their input kinds and provider options, with autocomplete in the editor. Other ids still work. They get text-only input and the generic options. + +`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). + +## Advanced + +### Self-hosted gateway + +To use your own deployment, set `baseURL`: ```typescript -import { summarize } from "@tanstack/ai"; -import { llmGatewaySummarize } from "@tanstack/ai-llmgateway"; +import { createLLMGatewayText } from "@tanstack/ai-llmgateway"; -const result = await summarize({ - adapter: llmGatewaySummarize("gpt-5.4-mini"), - text: "Long article text...", - stream: false, +const adapter = createLLMGatewayText("gpt-5.6-terra", process.env.LLM_GATEWAY_API_KEY!, { + baseURL: "https://api.llmgateway.io/v1", // the hosted default. Set your own URL here. }); - -console.log(result.summary); ``` -## Environment Variables +The adapter works the same against a self-hosted gateway. -Set your API key in environment variables: +Need a header on each request, or a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). -```bash -LLM_GATEWAY_API_KEY=llmgtwy_your-api-key -``` +### API reference + +| Factories | Creates | +| --- | --- | +| `llmGatewayText(model, config?)` / `createLLMGatewayText(model, apiKey, config?)` | Chat adapter | +| `llmGatewaySummarize(model, config?)` / `createLLMGatewaySummarize(model, apiKey, config?)` | Summarization adapter | -Get an API key from the [LLM Gateway dashboard](https://llmgateway.io). +The short factory reads `LLM_GATEWAY_API_KEY`. The `create*` factory takes an explicit API key. diff --git a/docs/adapters/lovable.md b/docs/adapters/lovable.md index f0c09ba671..1adca2eb0e 100644 --- a/docs/adapters/lovable.md +++ b/docs/adapters/lovable.md @@ -1,7 +1,7 @@ --- title: Lovable AI Gateway id: lovable-adapter -description: "Use one Lovable API key to call Google and OpenAI models through Lovable AI Gateway. Chat, embeddings, image, video, speech, and transcription all work with @tanstack/ai-lovable." +description: "Call Google and OpenAI models with one Lovable project key through Lovable AI Gateway and @tanstack/ai-lovable: chat, images, video, embeddings, speech, and transcription." keywords: - tanstack ai - lovable @@ -16,11 +16,9 @@ keywords: - transcription --- -Install `@tanstack/ai-lovable`. Then call `lovableText` with a model id such as `google/gemini-3.7-flash`. +The Lovable adapter calls Google and OpenAI models with one project key. It covers chat, images, video, embeddings, speech, and transcription. -Lovable AI Gateway sits in front of Google and OpenAI models. You use one project key. You do not set up a provider account. - -## Installation +## 1. Install @@ -35,262 +33,307 @@ octane: @tanstack/ai-lovable -## Auth +## 2. Set the API key -Set `LOVABLE_API_KEY`. Lovable creates this key for each Cloud project. +Lovable creates a `LOVABLE_API_KEY` for each Cloud project. Put it in the environment of your server: ```bash -export LOVABLE_API_KEY="..." +LOVABLE_API_KEY=your-lovable-key ``` -You can also pass the key to a `create*` factory: +`lovableText` and the other short factories read `LOVABLE_API_KEY`. To pass the key in code, use the `create*` factory of the same adapter: ```typescript -import { createLovableText } from "@tanstack/ai-lovable" +import { createLovableText } from "@tanstack/ai-lovable"; -const adapter = createLovableText( - "google/gemini-3.7-flash", - process.env.LOVABLE_API_KEY!, -) +const adapter = createLovableText("google/gemini-3.7-flash", process.env.LOVABLE_API_KEY!); ``` -The adapter sends `Authorization: Bearer`, `Lovable-API-Key`, and `X-Lovable-AIG-SDK: tanstack-ai`. Calls go to `https://ai.gateway.lovable.dev/v1`. - -## Chat - -The default adapter uses the OpenAI Responses API. Model ids use the `google/` or `openai/` form. +## 3. Stream a chat from the server -**Server.** An endpoint that streams the reply over SSE: +Model ids use the `google/` or `openai/` form: ```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai" -import { lovableText } from "@tanstack/ai-lovable" +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { lovableText } from "@tanstack/ai-lovable"; export async function POST(request: Request) { - const { messages } = await request.json() + const { messages, threadId, runId } = await chatParamsFromRequest(request); const stream = chat({ adapter: lovableText("google/gemini-3.7-flash"), messages, - }) + threadId, + runId, + }); - return toServerSentEventsResponse(stream) + return toServerSentEventsResponse(stream); } ``` -**Client.** The same `useChat` hook as every other provider: +## 4. Render the chat + +The client is the same for every provider. Point `useChat` at your route: ```tsx -import { useState } from "react" -import { fetchServerSentEvents, useChat } from "@tanstack/ai-react" +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; export function Chat() { - const [input, setInput] = useState("") - - const { messages, sendMessage, isLoading } = useChat({ + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ connection: fetchServerSentEvents("/api/chat"), - }) + threadId: "support-chat", + }); return ( -
+
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > {messages.map((message) => ( -
- {message.role} - {message.parts.map((part, index) => - part.type === "text" ?

{part.content}

: null, - )} -
+

+ {message.parts + .map((part) => (part.type === "text" ? part.content : "")) + .join("")} +

))} - - { - event.preventDefault() - if (!input.trim() || isLoading) return - sendMessage(input) - setInput("") - }} - > - setInput(event.target.value)} /> - -
-
- ) + setInput(event.target.value)} /> + + ); } ``` -## Chat Completions - -Pass `{ api: "chat" }` when the model must talk to Chat Completions. The default is Responses. - -```typescript -import { chat } from "@tanstack/ai" -import { lovableText } from "@tanstack/ai-lovable" - -const stream = chat({ - adapter: lovableText("openai/gpt-5.5", { api: "chat" }), - messages: [{ role: "user", content: "Hello" }], -}) -``` - -`api: "responses"` is the same as the default. `api: "chat-completions"` is the same as `api: "chat"`. - -Need a header on each request, or a log of each model call? The chat adapters on this page support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). - -## Reasoning +For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat). -Set `reasoning` on `chat()`. The types take the levels of each model: - -```typescript -import { chat } from "@tanstack/ai" -import { lovableText } from "@tanstack/ai-lovable" - -const stream = chat({ - adapter: lovableText("openai/gpt-5.6-sol"), - messages: [{ role: "user", content: "Plan a database migration." }], - reasoning: "xhigh", -}) -``` - -- Responses (the default) sends `reasoning.effort`. Chat Completions sends `reasoning_effort`. -- Neither takes `budgetTokens`. -- The OpenAI and Gemini models take the same levels as on [OpenAI](./openai#reasoning) and [Gemini](./gemini#reasoning). - -## Summarize - -```typescript -import { summarize } from "@tanstack/ai" -import { lovableSummarize } from "@tanstack/ai-lovable" - -const result = await summarize({ - adapter: lovableSummarize("google/gemini-3.7-flash"), - text: "The Fender Stratocaster is a versatile electric guitar.", -}) -``` +You now have a streaming chat on Lovable. To use an OpenAI model, change the id, for example to `openai/gpt-5.6-sol`. ## Images -Generate a new image, or pass image parts to edit one. OpenAI image models also accept a mask part (`metadata.role === "mask"`). +Make a new image from a prompt: ```typescript -import { generateImage } from "@tanstack/ai" -import { lovableImage } from "@tanstack/ai-lovable" +import { generateImage } from "@tanstack/ai"; +import { lovableImage } from "@tanstack/ai-lovable"; const result = await generateImage({ adapter: lovableImage("openai/gpt-image-2"), prompt: "a red guitar on a wooden bench", -}) + threadId: "thread-1", +}); ``` +To edit an image, pass image parts. OpenAI image models also take a mask part (`metadata.role === "mask"`). See [Image Generation](../media/image-generation). + ## Video -Video jobs are async. Create a job, poll status, then fetch the MP4 URL. Clips last 4, 6, or 8 seconds. You can write `4`, `"4"`, or `"4s"` (same forms for 6 and 8). +> [!NOTE] +> Video generation is an [experimental feature](../media/video-generation). -1080p and 4K clips are always 8 seconds. `"8s"` counts as 8 seconds. 4K works only on `google/veo-3.1-fast` and `google/veo-3.1`. +Video runs as a job. `generateVideo()` starts the job and returns a `jobId`. Poll the job until it has an MP4 URL: ```typescript -import { generateVideo } from "@tanstack/ai" -import { lovableVideo } from "@tanstack/ai-lovable" +import { generateVideo, getVideoJobStatus } from "@tanstack/ai"; +import { lovableVideo } from "@tanstack/ai-lovable"; + +const adapter = lovableVideo("google/veo-3.1-lite"); const { jobId } = await generateVideo({ - adapter: lovableVideo("google/veo-3.1-lite"), + adapter, prompt: "a red guitar on a wooden bench, slow camera push-in", + threadId: "thread-1", duration: "4s", size: "1280x720", -}) -``` +}); -Then poll `getVideoJobStatus` with that `jobId`. When the job is complete, the result includes the MP4 URL. +let status = await getVideoJobStatus({ adapter, jobId }); +while (status.status === "pending" || status.status === "processing") { + await new Promise((resolve) => setTimeout(resolve, 5000)); + status = await getVideoJobStatus({ adapter, jobId }); +} + +console.log(status.status === "completed" ? status.url : status.error); +``` -Pass one image part in `prompt` to animate a still frame. +- A clip is 4, 6, or 8 seconds. You can write `4`, `"4"`, or `"4s"`. The same forms work for 6 and 8. +- 1080p and 4K clips are always 8 seconds. +- 4K works only on `google/veo-3.1-fast` and `google/veo-3.1`. +- To animate a still frame, pass one image part in `prompt`. ## Embeddings ```typescript -import { embed } from "@tanstack/ai" -import { lovableEmbedding } from "@tanstack/ai-lovable" +import { embed } from "@tanstack/ai"; +import { lovableEmbedding } from "@tanstack/ai-lovable"; const result = await embed({ adapter: lovableEmbedding("google/gemini-embedding-2"), input: "a red guitar", -}) +}); -console.log(result.embeddings[0]?.vector) +console.log(result.embeddings[0]?.vector); ``` ## Speech ```typescript -import { generateSpeech } from "@tanstack/ai" -import { lovableSpeech } from "@tanstack/ai-lovable" +import { generateSpeech } from "@tanstack/ai"; +import { lovableSpeech } from "@tanstack/ai-lovable"; const result = await generateSpeech({ adapter: lovableSpeech("openai/gpt-4o-mini-tts"), text: "Welcome to the guitar shop.", + threadId: "thread-1", voice: "nova", -}) +}); ``` +See [Text-to-Speech](../media/text-to-speech). + ## Transcription +`audio` takes a `File`, a `Blob`, an `ArrayBuffer`, a base64 string, or a data URL: + ```typescript -import { generateTranscription } from "@tanstack/ai" -import { lovableTranscription } from "@tanstack/ai-lovable" +import { generateTranscription } from "@tanstack/ai"; +import { lovableTranscription } from "@tanstack/ai-lovable"; -const audio = await fetch("/voice-note.mp3").then((response) => response.blob()) +const audio = await fetch("https://example.com/voice-note.mp3").then((response) => + response.blob(), +); const result = await generateTranscription({ adapter: lovableTranscription("openai/gpt-4o-mini-transcribe"), audio, language: "en", -}) + threadId: "thread-1", +}); -console.log(result.text) +console.log(result.text); ``` -## Bring Your Own Key +See [Transcription](../media/transcription). -Users can paste a Lovable project key in the browser. Import `lovableByok` from `@tanstack/ai-lovable/byok`, not from the package main entry. +## Summarization ```typescript -import { createLovableText } from "@tanstack/ai-lovable" -import { lovableByok } from "@tanstack/ai-lovable/byok" -import { byokMissing, getByokKey } from "@tanstack/ai/byok/server" +import { summarize } from "@tanstack/ai"; +import { lovableSummarize } from "@tanstack/ai-lovable"; -export async function POST(request: Request) { - const apiKey = getByokKey(request, lovableByok) - if (!apiKey) return byokMissing(lovableByok) +const result = await summarize({ + adapter: lovableSummarize("google/gemini-3.7-flash"), + text: "The Fender Stratocaster is a versatile electric guitar.", + threadId: "thread-1", +}); +``` - const adapter = createLovableText("google/gemini-3.7-flash", apiKey) - // ... -} +## Models and options + +### Reasoning + +Set `reasoning` on `chat()`. The types take the levels of each model: + +```typescript +import { chat } from "@tanstack/ai"; +import { lovableText } from "@tanstack/ai-lovable"; + +const stream = chat({ + adapter: lovableText("openai/gpt-5.6-sol"), + messages: [{ role: "user", content: "Plan a database migration." }], + threadId: "thread-1", + reasoning: "xhigh", +}); ``` -See [Bring Your Own Key](../advanced/byok) for the client store and a save UI. +- Responses (the default) sends `reasoning.effort`. Chat Completions sends `reasoning_effort`. +- Neither takes `budgetTokens`. +- The OpenAI and Gemini models take the same levels as on [OpenAI](./openai#reasoning) and [Gemini](./gemini#reasoning). -## Models +### Models Pass a model id from the curated lists. TypeScript rejects an id that is not on a list. -Chat: +| Activity | Default model | List | +| --- | --- | --- | +| Chat | `google/gemini-3.7-flash` | `LOVABLE_CHAT_MODELS` | +| Images | `openai/gpt-image-2` | `LOVABLE_IMAGE_MODELS` | +| Video | `google/veo-3.1-lite` | `LOVABLE_VIDEO_MODELS` | +| Embeddings | `google/gemini-embedding-2` | `LOVABLE_EMBEDDING_MODELS` | +| Speech | `openai/gpt-4o-mini-tts` | `LOVABLE_TTS_MODELS` | +| Transcription | `openai/gpt-4o-mini-transcribe` | `LOVABLE_TRANSCRIPTION_MODELS` | + +For chat, `openai/gpt-5.5` is a fast OpenAI choice. -- Default: `google/gemini-3.7-flash` -- Fast OpenAI: `openai/gpt-5.5` +## Advanced -Image, video, embeddings, and speech: +### Chat Completions + +The default text adapter uses the OpenAI Responses API. If a model must use Chat Completions, pass `{ api: "chat" }`: + +```typescript +import { chat } from "@tanstack/ai"; +import { lovableText } from "@tanstack/ai-lovable"; + +const stream = chat({ + adapter: lovableText("openai/gpt-5.5", { api: "chat" }), + messages: [{ role: "user", content: "Hello" }], + threadId: "thread-1", +}); +``` + +- `api: "responses"` is the same as the default. +- `api: "chat-completions"` is the same as `api: "chat"`. + +Need a header on each request, or a log of each model call? The chat adapters on this page support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). + +### Bring Your Own Key + +Users can paste a Lovable project key in the browser. Import `lovableByok` from `@tanstack/ai-lovable/byok`. The package main entry does not export it. + +```typescript +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { byokMissing, getByokKey } from "@tanstack/ai/byok/server"; +import { createLovableText } from "@tanstack/ai-lovable"; +import { lovableByok } from "@tanstack/ai-lovable/byok"; + +export async function POST(request: Request) { + const apiKey = getByokKey(request, lovableByok); + if (!apiKey) return byokMissing(lovableByok); + + const { messages, threadId, runId } = await chatParamsFromRequest(request); + + const stream = chat({ + adapter: createLovableText("google/gemini-3.7-flash", apiKey), + messages, + threadId, + runId, + }); + + return toServerSentEventsResponse(stream); +} +``` + +See [Bring Your Own Key](../advanced/byok) for the client store and a save UI. -- Image default: `openai/gpt-image-2` -- Video default: `google/veo-3.1-lite` -- Embedding default: `google/gemini-embedding-2` -- Speech default: `openai/gpt-4o-mini-tts` -- Transcription default: `openai/gpt-4o-mini-transcribe` +### Requests -The curated lists are `LOVABLE_CHAT_MODELS`, `LOVABLE_IMAGE_MODELS`, `LOVABLE_VIDEO_MODELS`, `LOVABLE_EMBEDDING_MODELS`, `LOVABLE_TTS_MODELS`, and `LOVABLE_TRANSCRIPTION_MODELS`. +- Calls go to `https://ai.gateway.lovable.dev/v1`. +- The adapter sends `Authorization: Bearer`, `Lovable-API-Key`, and `X-Lovable-AIG-SDK: tanstack-ai`. -## Errors +### Errors -- `429 Too Many Requests`: the workspace hit its request-per-minute limit. -- `402 Payment Required`: the workspace is out of credits. +- `429 Too Many Requests`: the workspace hit its limit of requests per minute. +- `402 Payment Required`: the workspace has no credits left. See [Lovable AI features](https://docs.lovable.dev/features/ai) for models, credits, and rate limits. diff --git a/docs/adapters/mistral.md b/docs/adapters/mistral.md index 87f87057a9..088ee8c7f0 100644 --- a/docs/adapters/mistral.md +++ b/docs/adapters/mistral.md @@ -2,7 +2,7 @@ title: Mistral id: mistral-adapter order: 7 -description: "Use Mistral models with TanStack AI — Mistral Large, Mistral Medium, Pixtral vision models, Magistral reasoning models, and Codestral via @tanstack/ai-mistral." +description: "Use Mistral models in TanStack AI with @tanstack/ai-mistral: Mistral Large and Medium, Pixtral vision, Magistral reasoning, Codestral, and Mistral embeddings." keywords: - tanstack ai - mistral @@ -14,9 +14,9 @@ keywords: - llm --- -The Mistral adapter provides access to Mistral's chat models, including Mistral Large, the multimodal Pixtral family, the Magistral reasoning models, and the Codestral code-specialized model. +The Mistral adapter calls Mistral Large, Pixtral, Magistral, and Codestral. It also creates embeddings. -## Installation +## 1. Install @@ -31,172 +31,89 @@ octane: @tanstack/ai-mistral -## Basic Usage +## 2. Set the API key -```typescript -import { chat } from "@tanstack/ai"; -import { mistralText } from "@tanstack/ai-mistral"; +Get a key from the [Mistral Console](https://console.mistral.ai/). Put it in the environment of your server: -const stream = chat({ - adapter: mistralText("mistral-large-latest"), - messages: [{ role: "user", content: "Hello!" }], -}); +```bash +MISTRAL_API_KEY=... ``` -## Basic Usage - Custom API Key +`mistralText` and `mistralEmbedding` read `MISTRAL_API_KEY`. To pass the key in code, use `createMistralText`: ```typescript -import { chat } from "@tanstack/ai"; import { createMistralText } from "@tanstack/ai-mistral"; -const adapter = createMistralText( - "mistral-large-latest", - process.env.MISTRAL_API_KEY!, -); - -const stream = chat({ - adapter, - messages: [{ role: "user", content: "Hello!" }], -}); +const adapter = createMistralText("mistral-large-latest", process.env.MISTRAL_API_KEY!); ``` -## Configuration +## 3. Stream a chat from the server ```typescript import { - createMistralText, - type MistralTextConfig, -} from "@tanstack/ai-mistral"; - -const config: Omit = { - baseURL: "https://api.mistral.ai", // Optional, this is the default - defaultHeaders: { - "X-Custom-Header": "value", - }, -}; - -const adapter = createMistralText( - "mistral-large-latest", - process.env.MISTRAL_API_KEY!, - config, -); -``` - -## Behind a proxy - -Route every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them. - -```typescript -import { createMistralText } from "@tanstack/ai-mistral"; - -const adapter = createMistralText("mistral-large-latest", process.env.MISTRAL_API_KEY!, { - baseURL: "https://gateway.example.com/mistral", - defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` }, -}); -``` - -`serverURL` is an alias of `baseURL`. If you set both, `baseURL` wins. - -Need more than a fixed header, such as a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). - -## Mistral on Vertex - -Use `@tanstack/ai-mistral/vertex` when Mistral must run on Vertex AI. That -path uses Google Cloud credentials and the publisher `rawPredict` endpoint. - -Mistral on Vertex is regional only. Use `us-central1` or `europe-west4`. - - - -react: @tanstack/ai-mistral google-auth-library -vue: @tanstack/ai-mistral google-auth-library -solid: @tanstack/ai-mistral google-auth-library -svelte: @tanstack/ai-mistral google-auth-library -preact: @tanstack/ai-mistral google-auth-library -angular: @tanstack/ai-mistral google-auth-library -vanilla: @tanstack/ai-mistral google-auth-library -octane: @tanstack/ai-mistral google-auth-library - - - -```typescript -import { chat } from "@tanstack/ai"; -import { mistralVertexText } from "@tanstack/ai-mistral/vertex"; - -const stream = chat({ - adapter: mistralVertexText("mistral-medium-3", { - project: "my-project", - location: "europe-west4", - }), - messages: [{ role: "user", content: "Hello!" }], -}); -``` - -`project` and `location` use the same names as `@tanstack/ai-vertex`. -`location` is required. - -`mistralVertexText` accepts only the Mistral chat models that Vertex lists: - -- `mistral-medium-3` -- `mistral-small-2503` -- `codestral-2` - -Mistral API aliases such as `mistral-large-latest` and `mistral-medium-latest` -are not Vertex model ids. Vertex also lists `mistral-ocr-2505`, but that -model is OCR, not chat. - -Install `google-auth-library` for Application Default Credentials, or pass -`authClient` or `getAccessToken`. - -Gemini on Vertex lives in [`@tanstack/ai-vertex`](./vertex). - -## Example: Chat Completion - -```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; import { mistralText } from "@tanstack/ai-mistral"; export async function POST(request: Request) { - const { messages } = await request.json(); + const { messages, threadId, runId } = await chatParamsFromRequest(request); const stream = chat({ adapter: mistralText("mistral-large-latest"), messages, + threadId, + runId, }); return toServerSentEventsResponse(stream); } ``` -## Example: With Tools +## 4. Render the chat -```typescript -import { chat, toolDefinition } from "@tanstack/ai"; -import { mistralText } from "@tanstack/ai-mistral"; -import { z } from "zod"; +The client is the same for every provider. Point `useChat` at your route: -const getWeatherDef = toolDefinition({ - name: "get_weather", - description: "Get the current weather for a location", - inputSchema: z.object({ - location: z.string(), - }), -}); +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; -const getWeather = getWeatherDef.server(async ({ location }) => { - return { temperature: 72, conditions: "sunny" }; -}); +export function Chat() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", + }); -const stream = chat({ - adapter: mistralText("mistral-large-latest"), - messages: [{ role: "user", content: "What's the weather in Paris?" }], - tools: [getWeather], -}); + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => ( +

+ {message.parts + .map((part) => (part.type === "text" ? part.content : "")) + .join("")} +

+ ))} + setInput(event.target.value)} /> +
+ ); +} ``` -## Example: Multimodal (Vision) +For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat). + +You now have a streaming Mistral chat. [Tools](../tools/tools) work the same as with every adapter. Mistral has no provider tools of its own, so define your tools with `toolDefinition()`. -Use a vision-capable model — `pixtral-large-latest`, `pixtral-12b-2409`, `mistral-medium-latest`, or `mistral-small-latest` — to send images alongside text: +## Images in a message + +Send an image with the text to a vision model: `pixtral-large-latest`, `pixtral-12b-2409`, `mistral-medium-latest`, or `mistral-small-latest`. ```typescript import { chat } from "@tanstack/ai"; @@ -219,10 +136,11 @@ const stream = chat({ ], }, ], + threadId: "thread-1", }); ``` -For data-URL or base64 images, set `source.type` to `"data"` and provide `mimeType`: +For base64 data, set `source.type` to `"data"` and add `mimeType`: ```typescript import { chat } from "@tanstack/ai"; @@ -248,65 +166,15 @@ const stream = chat({ ], }, ], + threadId: "thread-1", }); ``` -See [Multimodal Content](../advanced/multimodal-content) for the full content-part shape. - -## Example: Reasoning (Magistral) - -Magistral models (`magistral-medium-latest`, `magistral-small-latest`) stream their reasoning as separate events before the final answer. The adapter emits AG-UI `REASONING_*` chunks for the thinking content and `TEXT_MESSAGE_*` chunks for the answer: - -```typescript ignore -// ignore: narrowing the raw AG-UI stream by `chunk.type` relies on @ag-ui/core's -// discriminated-union `type` field, which kiira can't resolve in a source-only -// check. The runtime behaviour is exactly as shown. -import { chat } from "@tanstack/ai"; -import { mistralText } from "@tanstack/ai-mistral"; - -const stream = chat({ - adapter: mistralText("magistral-medium-latest"), - messages: [{ role: "user", content: "Why is the sky blue?" }], -}); - -for await (const chunk of stream) { - if (chunk.type === "REASONING_MESSAGE_CONTENT") { - process.stdout.write(`[thinking] ${chunk.delta}`); - } else if (chunk.type === "TEXT_MESSAGE_CONTENT") { - process.stdout.write(chunk.delta); - } -} -``` - -Reasoning events are always closed before any text or tool output begins, so consumers see a complete `REASONING_START → REASONING_MESSAGE_START → REASONING_MESSAGE_CONTENT* → REASONING_MESSAGE_END → REASONING_END` sequence first. +See [Multimodal Content](../advanced/multimodal-content) for the full shape of a content part. -See [Thinking & Reasoning](../chat/thinking-content) for the cross-provider event spec. +## Structured output -## Reasoning - -Set `reasoning` on `chat()` to turn thinking up, down, or off: - -```typescript -import { chat } from "@tanstack/ai"; -import { mistralText } from "@tanstack/ai-mistral"; - -const stream = chat({ - adapter: mistralText("mistral-medium-latest"), - messages: [{ role: "user", content: "Why is the sky blue?" }], - reasoning: "high", -}); -``` - -What goes on the wire: - -- `mistral-medium-latest`, `mistral-small-latest`: `reasoning_effort`. They take `off` (sent as `none`) and `high`. -- `magistral-medium-latest`, `magistral-small-latest`: they take `off` and `high`. `high` sends `prompt_mode: "reasoning"`. `off` sends nothing. - -The other models take no `reasoning`. - -## Example: Structured Output - -Generate JSON that conforms to a Zod schema using Mistral's `json_schema` response format: +Get JSON that matches a Zod schema. The adapter uses the `json_schema` response format of Mistral: ```typescript import { chat } from "@tanstack/ai"; @@ -324,17 +192,54 @@ const recipe = await chat({ messages: [ { role: "user", content: "Give me a chocolate chip cookie recipe." }, ], + threadId: "thread-1", outputSchema: recipeSchema, }); console.log(recipe.name); // typed as z.infer ``` -See [Structured Outputs](../chat/structured-outputs) for the full guide. +See [Structured Outputs](../chat/structured-outputs). + +## Embeddings + +```typescript +import { embed } from "@tanstack/ai"; +import { mistralEmbedding } from "@tanstack/ai-mistral"; + +const result = await embed({ + adapter: mistralEmbedding("mistral-embed"), + input: ["a red guitar", "a blue drum kit"], +}); + +console.log(result.embeddings[0]?.vector); // 1024 dimensions +``` + +`mistral-embed` always gives 1024 dimensions. `codestral-embed` is tuned for code, and it takes the top-level `dimensions` option: + +```typescript +import { embed } from "@tanstack/ai"; +import { mistralEmbedding } from "@tanstack/ai-mistral"; + +const result = await embed({ + adapter: mistralEmbedding("codestral-embed"), + input: "function add(a, b) { return a + b }", + dimensions: 512, +}); +``` + +See the [Embeddings guide](../embeddings.md). -## Model Options +## What Mistral does not do -Mistral exposes provider-specific options via `modelOptions`: +- Image, audio, and video generation. Use [OpenAI](./openai), [Gemini](./gemini), or [fal.ai](./fal). +- Text-to-speech and transcription. Use [OpenAI](./openai) or [ElevenLabs](./elevenlabs). + +## Models and options + +### Model options + +Provider options go in `modelOptions`, with the snake_case names of Mistral. This includes the sampling options `temperature`, `top_p`, and `max_tokens`: ```typescript import { chat } from "@tanstack/ai"; @@ -343,6 +248,7 @@ import { mistralText } from "@tanstack/ai-mistral"; const stream = chat({ adapter: mistralText("mistral-large-latest"), messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", modelOptions: { temperature: 0.7, top_p: 0.9, @@ -358,133 +264,175 @@ const stream = chat({ }); ``` -> All sampling parameters — including `temperature`, `top_p`, and `max_tokens` — -> go inside `modelOptions` using Mistral's native (snake_case) names. +Sampling options do not go at the root of `chat()`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). -## Prompt caching +### Reasoning -Mistral caches the start of your requests by itself. `chat()` sends `prompt_cache_key` by default. The key is the `threadId` that you pass, so one conversation shares a cache. The key goes only to `api.mistral.ai`, on `chat()` and on structured output. With `promptCache: 'none'`, no key goes out. See [Prompt Caching](../advanced/prompt-caching). +Set `reasoning` on `chat()` to turn thinking up, down, or off: -Cache reads show up in `usage.promptTokensDetails.cachedTokens`. +```typescript +import { chat } from "@tanstack/ai"; +import { mistralText } from "@tanstack/ai-mistral"; -## Embeddings +const stream = chat({ + adapter: mistralText("mistral-medium-latest"), + messages: [{ role: "user", content: "Why is the sky blue?" }], + threadId: "thread-1", + reasoning: "high", +}); +``` -Generate embedding vectors with mistral-embed or codestral-embed: +What goes to Mistral: -```typescript -import { embed } from "@tanstack/ai"; -import { mistralEmbedding } from "@tanstack/ai-mistral"; +- `mistral-medium-latest`, `mistral-small-latest`: `reasoning_effort`. They take `off` (sent as `none`) and `high`. +- `magistral-medium-latest`, `magistral-small-latest`: they take `off` and `high`. `high` sends `prompt_mode: "reasoning"`. `off` sends nothing. -const result = await embed({ - adapter: mistralEmbedding("mistral-embed"), - input: ["a red guitar", "a blue drum kit"], -}); +The other models take no `reasoning`. The Magistral models stream their thinking before the answer. See [Magistral thinking events](#magistral-thinking-events). -console.log(result.embeddings[0]?.vector); // 1024 dimensions -``` +### Models -`mistral-embed` has fixed 1024-dimension output; `codestral-embed` (tuned for code) supports the top-level `dimensions` option: +| Group | Models | +| --- | --- | +| Chat | `mistral-large-latest` (flagship, 128k context), `mistral-medium-latest` (with vision), `mistral-small-latest` (fast, with vision), `ministral-8b-latest`, `ministral-3b-latest`, `open-mistral-nemo` | +| Code | `codestral-latest` (256k context) | +| Vision | `pixtral-large-latest`, `pixtral-12b-2409` | +| Reasoning | `magistral-medium-latest`, `magistral-small-latest` | +| Embeddings | `mistral-embed`, `codestral-embed` | +| Chat on Vertex | `mistral-medium-3`, `mistral-small-2503`, `codestral-2` | -```typescript -import { embed } from "@tanstack/ai"; -import { mistralEmbedding } from "@tanstack/ai-mistral"; +See the [model comparison](https://docs.mistral.ai/getting-started/models/compare) of Mistral for the full details. `adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). -const result = await embed({ - adapter: mistralEmbedding("codestral-embed"), - input: "function add(a, b) { return a + b }", - dimensions: 512, -}); -``` +## Advanced -See the [Embeddings guide](../embeddings.md) for the full API. +### Configuration -## Environment Variables +```typescript +import { + createMistralText, + type MistralTextConfig, +} from "@tanstack/ai-mistral"; -Set your API key in environment variables: +const config: Omit = { + baseURL: "https://api.mistral.ai", // Optional, this is the default + defaultHeaders: { + "X-Custom-Header": "value", + }, +}; -```bash -MISTRAL_API_KEY=... +const adapter = createMistralText( + "mistral-large-latest", + process.env.MISTRAL_API_KEY!, + config, +); ``` -Get a key from the [Mistral Console](https://console.mistral.ai/). - -## Supported Models +### Behind a proxy -### Chat +Send every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them. -- `mistral-large-latest` — Flagship general-purpose model (128k context) -- `mistral-medium-latest` — Multimodal mid-tier model with vision -- `mistral-small-latest` — Fast, affordable multimodal model with vision -- `ministral-8b-latest` — 8B edge model -- `ministral-3b-latest` — 3B edge model -- `open-mistral-nemo` — Open 12B model +```typescript +import { createMistralText } from "@tanstack/ai-mistral"; -### Code +const adapter = createMistralText("mistral-large-latest", process.env.MISTRAL_API_KEY!, { + baseURL: "https://gateway.example.com/mistral", + defaultHeaders: { "cf-aig-authorization": `Bearer ${process.env.GATEWAY_TOKEN}` }, +}); +``` -- `codestral-latest` — Code-specialized model (256k context) +- `serverURL` is an alias of `baseURL`. If you set both, `baseURL` wins. +- Need more than a fixed header, such as a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). -### Vision +### Prompt caching -- `pixtral-large-latest` — Large vision model -- `pixtral-12b-2409` — 12B vision model +Mistral caches the start of your requests by itself. -### Reasoning +- `chat()` sends `prompt_cache_key` by default. The key is the `threadId` that you pass, so one conversation shares a cache. +- The key goes only to `api.mistral.ai`, on `chat()` and on structured output. +- With `promptCache: 'none'`, no key goes out. +- Cache reads show up in `usage.promptTokensDetails.cachedTokens`. -Reasoning content is streamed as `REASONING_*` events before the final answer. +See [Prompt Caching](../advanced/prompt-caching). -- `magistral-medium-latest` — Mid-tier reasoning model -- `magistral-small-latest` — Small reasoning model +### Magistral thinking events -See [Mistral's model comparison](https://docs.mistral.ai/getting-started/models/compare) for full details. +The Magistral models (`magistral-medium-latest`, `magistral-small-latest`) stream their thinking as separate events before the answer. The adapter sends AG-UI `REASONING_*` chunks for the thinking and `TEXT_MESSAGE_*` chunks for the answer: -## API Reference +```typescript ignore +// ignore: narrowing the raw AG-UI stream by `chunk.type` relies on @ag-ui/core's +// discriminated-union `type` field, which kiira can't resolve in a source-only +// check. The runtime behaviour is exactly as shown. +import { chat } from "@tanstack/ai"; +import { mistralText } from "@tanstack/ai-mistral"; -### `mistralText(model, config?)` +const stream = chat({ + adapter: mistralText("magistral-medium-latest"), + messages: [{ role: "user", content: "Why is the sky blue?" }], + threadId: "thread-1", +}); -Creates a Mistral text adapter using the `MISTRAL_API_KEY` environment variable. +for await (const chunk of stream) { + if (chunk.type === "REASONING_MESSAGE_CONTENT") { + process.stdout.write(`[thinking] ${chunk.delta}`); + } else if (chunk.type === "TEXT_MESSAGE_CONTENT") { + process.stdout.write(chunk.delta); + } +} +``` -`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). +The reasoning events always close before any text or tool output starts. So you first see the full sequence: `REASONING_START`, `REASONING_MESSAGE_START`, `REASONING_MESSAGE_CONTENT` (one or more), `REASONING_MESSAGE_END`, `REASONING_END`. -**Parameters:** +See [Thinking & Reasoning](../chat/thinking-content) for the event spec of all providers. -- `model` — The model name (e.g., `'mistral-large-latest'`) -- `config.baseURL?`: custom base URL (optional). `serverURL` is an alias. -- `config.defaultHeaders?` — Headers to attach to every request (optional) +### Mistral on Vertex -**Returns:** A Mistral text adapter instance. +Use `@tanstack/ai-mistral/vertex` when Mistral must run on Vertex AI. That path uses Google Cloud credentials and the publisher `rawPredict` endpoint. -### `createMistralText(model, apiKey, config?)` +Mistral on Vertex is regional only. Use `us-central1` or `europe-west4`. -Creates a Mistral text adapter with an explicit API key. + -**Parameters:** +react: @tanstack/ai-mistral google-auth-library +vue: @tanstack/ai-mistral google-auth-library +solid: @tanstack/ai-mistral google-auth-library +svelte: @tanstack/ai-mistral google-auth-library +preact: @tanstack/ai-mistral google-auth-library +angular: @tanstack/ai-mistral google-auth-library +vanilla: @tanstack/ai-mistral google-auth-library +octane: @tanstack/ai-mistral google-auth-library -- `model` — The model name -- `apiKey` — Your Mistral API key -- `config.baseURL?`: custom base URL (optional). `serverURL` is an alias. -- `config.defaultHeaders?` — Headers to attach to every request (optional) + -**Returns:** A Mistral text adapter instance. +```typescript +import { chat } from "@tanstack/ai"; +import { mistralVertexText } from "@tanstack/ai-mistral/vertex"; -## Limitations +const stream = chat({ + adapter: mistralVertexText("mistral-medium-3", { + project: "my-project", + location: "europe-west4", + }), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", +}); +``` -- **Embeddings**: Use the [Mistral SDK](https://github.com/mistralai/client-ts) directly for `mistral-embed`. -- **Image / Audio / Video Generation**: Mistral does not provide these endpoints. Use OpenAI, Gemini, or fal.ai. -- **Text-to-Speech / Transcription**: Not supported. Use OpenAI or ElevenLabs. +- `project` and `location` use the same names as `@tanstack/ai-vertex`. `location` is required. +- `mistralVertexText` accepts only the Mistral chat models that Vertex lists: `mistral-medium-3`, `mistral-small-2503`, and `codestral-2`. +- Mistral API aliases such as `mistral-large-latest` and `mistral-medium-latest` are not Vertex model ids. Vertex also lists `mistral-ocr-2505`, but that model is OCR, not chat. +- Install `google-auth-library` for Application Default Credentials, or pass `authClient` or `getAccessToken`. -## Next Steps +Gemini on Vertex is in [`@tanstack/ai-vertex`](./vertex). -- [Getting Started](../getting-started/quick-start) — Learn the basics -- [Tools Guide](../tools/tools) — Define and call tools -- [Structured Outputs](../chat/structured-outputs) — Generate typed JSON -- [Multimodal Content](../advanced/multimodal-content) — Send images and other modalities -- [Other Adapters](./openai) — Explore other providers +### API reference -## Provider Tools +| Factory | Creates | +| --- | --- | +| `mistralText(model, config?)` | Text adapter. Reads `MISTRAL_API_KEY` | +| `createMistralText(model, apiKey, config?)` | Text adapter with an explicit API key | +| `mistralEmbedding(model, config?)` / `createMistralEmbedding(model, apiKey, config?)` | Embedding adapter | +| `mistralVertexText(model, config)` | Text adapter on Vertex. Import from `@tanstack/ai-mistral/vertex` | -Mistral does not currently expose provider-specific tool factories. -Define your own tools with `toolDefinition()` from `@tanstack/ai`. +- `config.baseURL`: a custom base URL. Optional. `serverURL` is an alias. +- `config.defaultHeaders`: headers for every request. Optional. -See [Tools](../tools/tools.md) for the general tool-definition flow, or -[Provider Tools](../tools/provider-tools.md) for other providers' -native-tool offerings. +`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. diff --git a/docs/adapters/ollama.md b/docs/adapters/ollama.md index 406887868a..2f8e695b6b 100644 --- a/docs/adapters/ollama.md +++ b/docs/adapters/ollama.md @@ -2,7 +2,7 @@ title: Ollama id: ollama-adapter order: 4 -description: "Run local LLMs with Ollama in TanStack AI for private, no-cost AI on your own hardware via the @tanstack/ai-ollama adapter." +description: "Run local open models with Ollama in TanStack AI with @tanstack/ai-ollama. No API key, no API cost, and your data stays on your hardware." keywords: - tanstack ai - ollama @@ -14,9 +14,9 @@ keywords: - adapter --- -The Ollama adapter provides access to local models running via Ollama, allowing you to run AI models on your own infrastructure with full privacy and no API costs. +The Ollama adapter calls Qwen, Llama, gpt-oss, and other open models on your machine. It needs no API key and it works offline. -## Installation +## 1. Install @@ -31,153 +31,171 @@ octane: @tanstack/ai-ollama -## Basic Usage +## 2. Start Ollama and pull a model -```typescript -import { chat } from "@tanstack/ai"; -import { ollamaText } from "@tanstack/ai-ollama"; - -const stream = chat({ - adapter: ollamaText("llama3"), - messages: [{ role: "user", content: "Hello!" }], -}); -``` +Ollama needs no API key. It needs a running server with a model. -## Basic Usage - Custom Host +1. Install Ollama: -```typescript -import { chat } from "@tanstack/ai"; -import { createOllamaChat } from "@tanstack/ai-ollama"; +```bash +# macOS +brew install ollama -const adapter = createOllamaChat("llama3", "http://your-server:11434"); +# Linux +curl -fsSL https://ollama.com/install.sh | sh -const stream = chat({ - adapter, - messages: [{ role: "user", content: "Hello!" }], -}); +# Windows: download the installer from https://ollama.com ``` -## Configuration - -```typescript -import { createOllamaChat } from "@tanstack/ai-ollama"; - -// Custom host (URL string) -const adapter = createOllamaChat("llama3", "http://your-server:11434"); +2. Pull a model: -// Custom client config (e.g., custom headers, fetch) -const adapter2 = createOllamaChat("llama3", { - host: "http://your-server:11434", - headers: { Authorization: "Bearer ..." }, -}); +```bash +ollama pull qwen3:8b ``` -## Behind a proxy +3. Start the server: -Route every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them. - -```typescript -import { createOllamaChat } from "@tanstack/ai-ollama"; - -const adapter = createOllamaChat("llama3", { - baseURL: "https://gateway.example.com/ollama", - defaultHeaders: { Authorization: `Bearer ${process.env.GATEWAY_TOKEN}` }, -}); +```bash +ollama serve ``` -`host` and `headers` are aliases of the same two options. If you set both forms, `baseURL` and `defaultHeaders` win. - -Need more than a fixed header, such as a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). An adapter with your own `Ollama` client ignores it and the abort signal. - -## Available Models - -To see available models on your Ollama instance: +The server runs on `http://localhost:11434` by default. If your server is somewhere else, set `OLLAMA_HOST`: ```bash -ollama list +OLLAMA_HOST=http://localhost:11434 ``` -### Popular Models - -- `llama3` / `llama3.1` / `llama3.2` - Meta's Llama models -- `mistral` / `mistral:7b` - Mistral AI models -- `mixtral` - Mixtral MoE model -- `codellama` - Code-focused Llama -- `phi3` - Microsoft's Phi models -- `gemma` / `gemma2` - Google's Gemma models -- `qwen2` / `qwen2.5` - Alibaba's Qwen models -- `deepseek-coder` - DeepSeek coding model - -## Example: Chat Completion +## 3. Stream a chat from the server ```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; import { ollamaText } from "@tanstack/ai-ollama"; export async function POST(request: Request) { - const { messages } = await request.json(); + const { messages, threadId, runId } = await chatParamsFromRequest(request); const stream = chat({ - adapter: ollamaText("llama3"), + adapter: ollamaText("qwen3:8b"), messages, + threadId, + runId, }); return toServerSentEventsResponse(stream); } ``` -## Example: With Tools +`ollamaText` reads the host from `OLLAMA_HOST`. To pass the host in code, use `createOllamaChat("qwen3:8b", "http://your-server:11434")`. -```typescript -import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai"; -import { ollamaText } from "@tanstack/ai-ollama"; -import { z } from "zod"; - -const getLocalDataDef = toolDefinition({ - name: "get_local_data", - description: "Get data from local storage", - inputSchema: z.object({ - key: z.string(), - }), -}); +## 4. Render the chat -const getLocalData = getLocalDataDef.server(async ({ key }) => { - // Access local data - return { data: "..." }; -}); +The client is the same for every provider. Point `useChat` at your route: -export async function POST(request: Request) { - const { messages } = await request.json(); +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; - const stream = chat({ - adapter: ollamaText("llama3"), - messages, - tools: [getLocalData], +export function Chat() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", }); - return toServerSentEventsResponse(stream); + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => ( +

+ {message.parts + .map((part) => (part.type === "text" ? part.content : "")) + .join("")} +

+ ))} + setInput(event.target.value)} /> +
+ ); } ``` -**Note:** Tool support varies by model. Models like `llama3`, `mistral`, and `qwen2` generally have good tool calling support. +For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat). + +You now have a streaming chat with a local model. The speed depends on your hardware. A GPU helps for larger models. + +## Tools + +[Tools](../tools/tools) work the same as with every adapter. The tool support depends on the model. Models like `llama3`, `mistral`, and `qwen2` call tools well. -## Tool choice +### Tool choice Ollama ignores `toolChoice`. Ollama has no tool choice, so the model decides to call a tool or not, also with `'none'` or `'required'`. -Want no tool calls for a request? Do not pass `tools`. For the other providers, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool). +To get no tool calls for a request, do not pass `tools`. For the other providers, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool). -## Model Options +## Embeddings + +```typescript ignore +import { embed } from "@tanstack/ai"; +import { ollamaEmbedding } from "@tanstack/ai-ollama"; + +const result = await embed({ + adapter: ollamaEmbedding("nomic-embed-text"), + input: ["a red guitar", "a blue drum kit"], +}); + +console.log(result.embeddings[0]?.vector); +``` + +- Pull the model first: `ollama pull nomic-embed-text`. +- The known models get autocomplete: `nomic-embed-text`, `mxbai-embed-large`, `all-minilm`, `snowflake-arctic-embed`, `bge-m3`, and `embeddinggemma`. Any other model name works too. +- Each model has a fixed output size, so the top-level `dimensions` option does not work. + +See the [Embeddings guide](../embeddings.md). + +## Summarization + +```typescript ignore +import { summarize } from "@tanstack/ai"; +import { ollamaSummarize } from "@tanstack/ai-ollama"; + +const result = await summarize({ + adapter: ollamaSummarize("qwen3:8b"), + text: "Your long text to summarize...", + threadId: "thread-1", + maxLength: 100, + style: "concise", // "concise" | "bullet-points" | "paragraph" +}); -Ollama supports various provider-specific options. Unlike the other providers, Ollama nests its sampling and runner parameters inside an `options` object **within** `modelOptions` — `temperature`, `top_p`, and `num_predict` (the token-limit key) all live under `modelOptions.options`: +console.log(result.summary); +``` + +## No image generation + +Ollama does not make images. For images, use [OpenAI](./openai) or [Gemini](./gemini). + +## Models and options + +### Model options + +Ollama nests its sampling and runner options in an `options` object **inside** `modelOptions`. This includes `temperature`, `top_p`, and `num_predict` (the token limit): ```typescript import { chat } from "@tanstack/ai"; import { ollamaText } from "@tanstack/ai-ollama"; const stream = chat({ - adapter: ollamaText("llama3:latest"), + adapter: ollamaText("qwen3:8b"), messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", modelOptions: { options: { temperature: 0.7, @@ -192,47 +210,9 @@ const stream = chat({ }); ``` -> If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, note that for Ollama they map to `modelOptions.options.temperature`, `modelOptions.options.top_p`, and `modelOptions.options.num_predict`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). - -### Advanced Options +Sampling options do not go at the root of `chat()`. For Ollama, they go to `modelOptions.options.temperature`, `modelOptions.options.top_p`, and `modelOptions.options.num_predict`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). The full option list is in [All runner options](#all-runner-options). -All sampling and runner parameters are nested under `modelOptions.options`: - -```typescript ignore -modelOptions: { - options: { - // Sampling - temperature: 0.7, - top_p: 0.9, - top_k: 40, - min_p: 0.05, - typical_p: 1.0, - - // Generation - num_predict: 1000, - repeat_penalty: 1.1, - repeat_last_n: 64, - penalize_newline: false, - - // Performance - num_ctx: 4096, - num_batch: 512, - num_gpu: -1, - num_thread: 0, // 0 = auto - - // Memory - use_mmap: true, - use_mlock: false, - - // Mirostat sampling - mirostat: 0, // 0 = disabled, 1 = Mirostat, 2 = Mirostat 2.0 - mirostat_tau: 5.0, - mirostat_eta: 0.1, - }, -} -``` - -## Reasoning +### Reasoning Running a thinking model like Qwen 3 or gpt-oss? Set `reasoning` on `chat()`: @@ -243,6 +223,7 @@ import { ollamaText } from "@tanstack/ai-ollama"; const stream = chat({ adapter: ollamaText("qwen3:8b"), messages: [{ role: "user", content: "Plan a database migration." }], + threadId: "thread-1", reasoning: "off", }); ``` @@ -253,137 +234,123 @@ The adapter sends it as `think`: | --- | --- | --- | | `qwen3`, `deepseek-r1`, `deepseek-v3.1` | `off`, `high` | `think: false` or `true` | | `gpt-oss` | `low`, `medium`, `high` | `think: "low"` and so on | -| a model name this package does not list | `off`, `high` | `think: false` or `true` | +| a model name that this package does not list | `off`, `high` | `think: false` or `true` | The other listed models take no `reasoning`. -## Summarization +### Models -Summarize long text content locally: - -```typescript ignore -import { summarize } from "@tanstack/ai"; -import { ollamaSummarize } from "@tanstack/ai-ollama"; - -const result = await summarize({ - adapter: ollamaSummarize("llama3"), - text: "Your long text to summarize...", - maxLength: 100, - style: "concise", // "concise" | "bullet-points" | "paragraph" -}); +To see the models on your Ollama server, run: -console.log(result.summary); +```bash +ollama list ``` -## Embeddings +Any model name works. The package knows many families and gives them autocomplete. Some of them: -Generate embedding vectors locally with any Ollama embedding model: +- `qwen3`, `qwen2.5`, `qwq`: Qwen models from Alibaba. +- `llama4`, `llama3.3`, `llama3.2`, `llama3.1`, `llama3`: Llama models from Meta. +- `gpt-oss`: open-weight models from OpenAI. +- `gemma3`, `deepseek-r1`, `deepseek-v3.1`, `phi4`: models from Google, DeepSeek, and Microsoft. +- `mistral`, `mistral-small`, `mixtral`, `codellama`: Mistral and code models. -```typescript ignore -import { embed } from "@tanstack/ai"; -import { ollamaEmbedding } from "@tanstack/ai-ollama"; +The package exports the full list as `OllamaTextModels`. -const result = await embed({ - adapter: ollamaEmbedding("nomic-embed-text"), - input: ["a red guitar", "a blue drum kit"], -}); +## Advanced -console.log(result.embeddings[0]?.vector); -``` +### Custom host and client config -Known models (`nomic-embed-text`, `mxbai-embed-large`, `all-minilm`, `snowflake-arctic-embed`, `bge-m3`, `embeddinggemma`) get autocomplete, and any other model name is accepted. Pull the model first with `ollama pull nomic-embed-text`. Output dimensions are fixed per model — the top-level `dimensions` option is not supported. - -See the [Embeddings guide](../embeddings.md) for the full API. - -## Setting Up Ollama - -### 1. Install Ollama - -```bash -# macOS -brew install ollama +```typescript +import { createOllamaChat } from "@tanstack/ai-ollama"; -# Linux -curl -fsSL https://ollama.com/install.sh | sh +// Custom host (URL string) +const adapter = createOllamaChat("qwen3:8b", "http://your-server:11434"); -# Windows -# Download from https://ollama.com +// Custom client config (for example, custom headers) +const adapter2 = createOllamaChat("qwen3:8b", { + host: "http://your-server:11434", + headers: { Authorization: "Bearer ..." }, +}); ``` -### 2. Pull a Model +### Running on a remote server -```bash -ollama pull llama3 -``` - -### 3. Start Ollama Server +To make Ollama listen on a network interface, start it with `OLLAMA_HOST`: ```bash -ollama serve +OLLAMA_HOST=0.0.0.0:11434 ollama serve ``` -The server runs on `http://localhost:11434` by default. - -## Running on a Remote Server +Then point the adapter at that server: ```typescript import { createOllamaChat } from "@tanstack/ai-ollama"; -const adapter = createOllamaChat("llama3", "http://your-server:11434"); +const adapter = createOllamaChat("qwen3:8b", "http://your-server:11434"); ``` -To expose Ollama on a network interface: +### Behind a proxy -```bash -OLLAMA_HOST=0.0.0.0:11434 ollama serve -``` +Send every request through a gateway, such as Cloudflare AI Gateway or a corporate proxy, with `baseURL` and `defaultHeaders`. These two option names are the same on every TanStack AI adapter, so one gateway config works for all of them. -## Environment Variables - -Optionally set the host in environment variables: +```typescript +import { createOllamaChat } from "@tanstack/ai-ollama"; -```bash -OLLAMA_HOST=http://localhost:11434 +const adapter = createOllamaChat("qwen3:8b", { + baseURL: "https://gateway.example.com/ollama", + defaultHeaders: { Authorization: `Bearer ${process.env.GATEWAY_TOKEN}` }, +}); ``` -## API Reference - -### `ollamaText(model)` - -Creates an Ollama text/chat adapter using `OLLAMA_HOST` from the environment (defaults to `http://localhost:11434`). +- `host` and `headers` are aliases of the same two options. If you set both forms, `baseURL` and `defaultHeaders` win. +- Need more than a fixed header, such as a log of each model call? This adapter supports [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). An adapter with your own `Ollama` client ignores `wrapFetch` and the abort signal. -**Parameters:** +### All runner options -- `model` - Model name (e.g. `"llama3"`, `"mistral:7b"`) +All sampling and runner options go in `modelOptions.options`: -### `createOllamaChat(model, hostOrConfig?)` - -Creates an Ollama text/chat adapter with an explicit host or client config. - -**Parameters:** - -- `model` - Model name -- `hostOrConfig?` - Either an `OLLAMA_HOST`-style URL string, or an `OllamaClientConfig` object (e.g. `{ baseURL, defaultHeaders }`). `host` and `headers` are aliases. - -### `ollamaSummarize(model)` / `createOllamaSummarize(model, hostOrConfig?)` +```typescript ignore +modelOptions: { + options: { + // Sampling + temperature: 0.7, + top_p: 0.9, + top_k: 40, + min_p: 0.05, + typical_p: 1.0, -Creates an Ollama summarization adapter — same signature shape as the chat adapter. + // Generation + num_predict: 1000, + repeat_penalty: 1.1, + repeat_last_n: 64, + penalize_newline: false, -## Benefits of Ollama + // Performance + num_ctx: 4096, + num_batch: 512, + num_gpu: -1, + num_thread: 0, // 0 = auto -- ✅ **Privacy** - Data stays on your infrastructure -- ✅ **Cost** - No API costs after hardware -- ✅ **Customization** - Use any compatible model -- ✅ **Offline** - Works without internet -- ✅ **Speed** - No network latency for local deployment + // Memory + use_mmap: true, + use_mlock: false, -## Limitations + // Mirostat sampling + mirostat: 0, // 0 = disabled, 1 = Mirostat, 2 = Mirostat 2.0 + mirostat_tau: 5.0, + mirostat_eta: 0.1, + }, +} +``` -- **Image Generation**: Ollama does not support image generation. Use OpenAI or Gemini for image generation. -- **Performance**: Depends on your hardware (GPU recommended for larger models) +### API reference -## Next Steps +| Factory | Creates | +| --- | --- | +| `ollamaText(model)` | Chat adapter. Reads the host from `OLLAMA_HOST` (default `http://localhost:11434`) | +| `createOllamaChat(model, hostOrConfig?)` | Chat adapter with an explicit host or client config | +| `ollamaSummarize(model)` / `createOllamaSummarize(model, host?)` | Summarization adapter. `host` is a URL string | +| `ollamaEmbedding(model)` / `createOllamaEmbedding(model, hostOrConfig?)` | Embedding adapter | -- [Getting Started](../getting-started/quick-start) - Learn the basics -- [Tools Guide](../tools/tools) - Learn about tools -- [Other Adapters](./openai) - Explore other providers +- `model`: a model name, for example `"qwen3:8b"` or `"mistral:7b"`. +- `hostOrConfig`: a URL string in the `OLLAMA_HOST` style, or an `OllamaClientConfig` object (for example `{ baseURL, defaultHeaders }`). `host` and `headers` are aliases. diff --git a/docs/adapters/ollaya.md b/docs/adapters/ollaya.md index f315e126b9..c005e352e3 100644 --- a/docs/adapters/ollaya.md +++ b/docs/adapters/ollaya.md @@ -1,7 +1,7 @@ --- title: Ollaya id: ollaya-adapter -description: "Call decide() on a local Ollaya server with @tanstack/ai-ollaya." +description: "Run decide() on a local Ollaya server with @tanstack/ai-ollaya. Typed answers about your data, with no API key, and the data stays on your machine." keywords: - tanstack ai - ollaya @@ -12,114 +12,133 @@ keywords: - adapter --- -You want a queue, an urgency, and a yes or no from a ticket that stays on your machine. -Install Ollaya. -Then pass `ollayaDecider` to `decide()`. +The Ollaya adapter calls the `laya` models on your machine through `decide()`. It returns a typed answer. It has no chat. -The adapter covers evaluate only. -It sends `POST /v1/systemone` to a local Ollaya server with `fetch`. -There is no Ollaya SDK. +## 1. Install -## Install the server + + +react: @tanstack/ai @tanstack/ai-ollaya +vue: @tanstack/ai @tanstack/ai-ollaya +solid: @tanstack/ai @tanstack/ai-ollaya +svelte: @tanstack/ai @tanstack/ai-ollaya +preact: @tanstack/ai @tanstack/ai-ollaya +angular: @tanstack/ai @tanstack/ai-ollaya +vanilla: @tanstack/ai @tanstack/ai-ollaya +octane: @tanstack/ai @tanstack/ai-ollaya -On macOS or Linux, run: + + +## 2. Start Ollaya and pull a model + +Ollaya needs no API key. It needs a running server with a model. + +1. Install Ollaya: ```bash +# macOS or Linux curl -fsSL https://ollaya.dev/install.sh | sh ``` -On Windows, run this in PowerShell: - ```powershell +# Windows (PowerShell) irm https://ollaya.dev/install.ps1 | iex ``` -The server listens on `http://127.0.0.1:11435`. -This adapter uses that URL by default. +2. Start the server: -1. Run `ollaya serve`. -2. Run `ollaya pull laya:latest`. - -The download page and the desktop app are on [ollaya.dev/download](https://ollaya.dev/download). +```bash +ollaya serve +``` -## Install the adapter +3. Pull a model: - +```bash +ollaya pull laya:latest +``` -react: @tanstack/ai @tanstack/ai-ollaya -vue: @tanstack/ai @tanstack/ai-ollaya -solid: @tanstack/ai @tanstack/ai-ollaya -svelte: @tanstack/ai @tanstack/ai-ollaya -preact: @tanstack/ai @tanstack/ai-ollaya -angular: @tanstack/ai @tanstack/ai-ollaya -vanilla: @tanstack/ai @tanstack/ai-ollaya -octane: @tanstack/ai @tanstack/ai-ollaya +The server listens on `http://127.0.0.1:11435`. The adapter uses this URL by default. The download page and the desktop app are on [ollaya.dev/download](https://ollaya.dev/download). - +## 3. Ask your first questions from the server -## Evaluate +This route reads a support ticket and asks three questions about it: ```typescript import { decide, choice, score, boolean } from "@tanstack/ai"; import { ollayaDecider } from "@tanstack/ai-ollaya"; -const ticket = { - subject: "Charged twice for the same invoice", - body: "Please refund the extra payment.", -}; - -const result = await decide({ - adapter: ollayaDecider("laya:latest"), - state: ticket, - questions: { - queue: choice({ - instructions: "Which team should handle this ticket?", - options: { - billing: "Payments, invoices, refunds", - tech: "Bugs, outages, integrations", - sales: "Pricing, upgrades, new accounts", - }, - }), - urgency: score({ - instructions: "How urgent is this ticket?", - levels: ["low", "medium", "high"], - }), - refund: boolean({ - instructions: "Is the customer asking for a refund?", - }), - }, -}); +export async function POST(request: Request) { + const ticket = await request.text(); + + const result = await decide({ + adapter: ollayaDecider("laya:latest"), + state: ticket, + questions: { + queue: choice({ + instructions: "Which team should handle this ticket?", + options: { + billing: "Payments, invoices, refunds", + tech: "Bugs, outages, integrations", + sales: "Pricing, upgrades, new accounts", + }, + }), + urgency: score({ + instructions: "How urgent is this ticket?", + levels: ["low", "medium", "high"], + }), + refund: boolean({ + instructions: "Is the customer asking for a refund?", + }), + }, + }); + + return Response.json({ + queue: result.queue.value, + urgency: result.urgency.value, + refund: result.refund.value, + model: result.meta.model, + }); +} +``` + +When you ask for `laya:latest`, the server picks `laya:en` or `laya:multilingual` from the text. `result.meta.model` tells you which one answered. + +## 4. Call the route from the client + +The client sends the ticket text to the route: -console.log(result.queue.value); -console.log(result.urgency.value); -console.log(result.refund.value); -console.log(result.meta.model); -console.log(result.meta.usage); +```typescript +export async function evaluateTicket(ticket: string) { + const response = await fetch("/api/evaluate", { + method: "POST", + body: ticket, + }); + return response.json(); +} ``` -You now have `result.queue.value`, `result.urgency.value`, and `result.refund.value`. +Call `evaluateTicket("Charged twice for the same invoice. Please refund the extra payment.")` and you get back the queue, the urgency, and the refund answer. -See [Evaluate](../evaluate/evaluate) for the rest of `decide()`. +For the rest of `decide()`, see [Evaluate](../evaluate/evaluate). For a full TanStack Start app, see [Evaluate a ticket](../tutorials/evaluate). -When you request `laya:latest`, `result.meta.model` can be `laya:en` or `laya:multilingual`. -The server picks the model from the text. +## Models and options -## Models +| Model | Description | +| --- | --- | +| `laya:latest` | Sends each request to `laya:en` or `laya:multilingual`. | +| `laya:en` | The English decision model (ModernBERT-large). | +| `laya:multilingual` | The decision model for 100+ languages (mmBERT-base). | -| Model | Description | -| ------------------- | -------------------------------------------------------- | -| `laya:latest` | Routes each request to `laya:en` or `laya:multilingual`. | -| `laya:en` | English decision model (ModernBERT-large). | -| `laya:multilingual` | Decision model for 100+ languages (mmBERT-base). | +You can pass any other model tag as a string. The package exports the known tags as `OLLAYA_EVALUATE_MODELS`. -You can pass any other model tag as a string. +## Advanced -## Host and API key +### Another host, or an API key -By default the adapter calls `http://127.0.0.1:11435`. The adapter does not read environment variables. -If the server is on another host, pass `baseURL`. -If the server requires `OLLAYA_API_KEY`, pass `apiKey`. + +- If the server is on another host, pass `baseURL`. +- If the server requires `OLLAYA_API_KEY`, pass the key as `apiKey`. ```typescript import { ollayaDecider } from "@tanstack/ai-ollaya"; @@ -130,25 +149,15 @@ const adapter = ollayaDecider("laya:latest", { }); ``` -`baseUrl` and `headers` are aliases of `baseURL` and `defaultHeaders`. -If you set both forms, `baseURL` and `defaultHeaders` win. -A blank `baseURL` keeps the default host. - -## API reference - -### `ollayaDecider(model, config?)` - -Creates an evaluate adapter. +`baseUrl` and `headers` are aliases of `baseURL` and `defaultHeaders`. If you set both forms, `baseURL` and `defaultHeaders` win. A blank `baseURL` keeps the default host. -- `model`: `"laya:latest"`, `"laya:en"`, `"laya:multilingual"`, or another model tag -- `config.baseURL`: server URL (default `http://127.0.0.1:11435`). `baseUrl` is an alias. A blank value keeps the default. -- `config.apiKey`: bearer token when the server requires `OLLAYA_API_KEY` -- `config.defaultHeaders`: extra request headers. `headers` is an alias. -- `config.fetch`: override `fetch` +### API reference -The adapter sends `POST /v1/systemone` to that base URL. +The adapter sends `POST /v1/systemone` to the base URL with `fetch`. There is no Ollaya SDK. -## Next steps +`ollayaDecider(model, config?)` creates an evaluate adapter. The `config` fields: -- [Evaluate guide](../evaluate/evaluate): full `decide()` walkthrough -- [Evaluate a ticket](../tutorials/evaluate): build a Start route +- `baseURL`: the server URL. The default is `http://127.0.0.1:11435`. `baseUrl` is an alias. +- `apiKey`: a bearer token, for a server that requires `OLLAYA_API_KEY`. +- `defaultHeaders`: extra request headers. `headers` is an alias. +- `fetch`: your own `fetch` function. diff --git a/docs/adapters/openai-advanced.md b/docs/adapters/openai-advanced.md new file mode 100644 index 0000000000..600f8397df --- /dev/null +++ b/docs/adapters/openai-advanced.md @@ -0,0 +1,865 @@ +--- +title: "OpenAI: Advanced" +id: openai-advanced +order: 1 +description: "Advanced OpenAI setup in TanStack AI: Chat Completions, Azure OpenAI, Sign in with ChatGPT, prompt caching, compaction, every provider tool, and the API reference." +keywords: + - tanstack ai + - openai + - azure openai + - chat completions + - responses api + - sign in with chatgpt + - prompt caching + - provider tools + - adapter +--- + +This page has the details of the OpenAI adapter. To get a first chat working, start at [OpenAI](./openai). + +## Chat Completions API + +`@tanstack/ai-openai` has two text adapters. They call different OpenAI endpoints: + +| | `openaiText` (Responses) | `openaiChatCompletions` (Chat Completions) | +|---|---|---| +| Endpoint | `/v1/responses` | `/v1/chat/completions` | +| Reasoning summaries | Yes. With `reasoning` set, the summary text streams as `REASONING_*` events | No. `reasoning` goes out as `reasoning_effort`, and no text streams back | +| Wire format | OpenAI only | The older shape that many providers use (Grok, Groq, OpenRouter, many local model servers) | +| Structured output streaming | `text.format: { type: 'json_schema', strict: true }` + `stream: true` | `response_format: { type: 'json_schema', strict: true }` + `stream: true` | + +- Use `openaiText` for reasoning summaries and the other Responses features. +- If you move from a provider with the Chat Completions shape, or share request code with other Chat Completions adapters, use `openaiChatCompletions`. + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiChatCompletions } from "@tanstack/ai-openai"; + +const stream = chat({ + adapter: openaiChatCompletions("gpt-6.1-sol"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", +}); +``` + +With an explicit API key: + +```typescript +import { chat } from "@tanstack/ai"; +import { createOpenaiChatCompletions } from "@tanstack/ai-openai"; + +const adapter = createOpenaiChatCompletions("gpt-6.1-sol", process.env.OPENAI_API_KEY!, { + organization: "org-...", // optional +}); + +const stream = chat({ + adapter, + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", +}); +``` + +Both adapters work with [Structured Outputs](../structured-outputs/overview), also with `stream: true`. Both take the same `modelOptions` (temperature, top_p, max_tokens, stop, and more) and [`reasoning`](./openai#reasoning). `openaiChatCompletions` cannot stream the thinking text. + +## Azure OpenAI + +Does your OpenAI model run as a deployment in Azure? Use `createAzureOpenaiText`. It calls the Azure Responses API and sends your key in the `api-key` header. + +```typescript +import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { createAzureOpenaiText } from "@tanstack/ai-openai"; + +export async function POST(request: Request) { + const { messages, threadId } = await request.json(); + + const stream = chat({ + adapter: createAzureOpenaiText("gpt-5.6", process.env.AZURE_OPENAI_API_KEY!, { + resourceName: "my-resource", + deploymentName: "production-chat", + }), + messages, + threadId, + }); + + return toServerSentEventsResponse(stream); +} +``` + +- Azure gets `production-chat` as the model. Your code still uses `gpt-5.6`. +- The client does not change. It reads this route like any other chat route. +- `reasoning` takes the levels of the OpenAI model name, here `gpt-5.6`. A model name that is not an OpenAI model, such as a bare deployment name, takes no `reasoning`. See [Reasoning](./openai#reasoning). +- `createAzureOpenaiText` reads nothing from the environment. You pass the key, and you pass the endpoint as `resourceName` or `baseURL`. + +To keep all of it in the environment, use `azureOpenaiText`. Like `openaiText`, it reads the key from the environment: + +```sh +AZURE_OPENAI_API_KEY=your-key +AZURE_OPENAI_RESOURCE_NAME=my-resource +AZURE_OPENAI_DEPLOYMENT_NAME_MAP=gpt-5.6=production-chat +``` + +```typescript +import { azureOpenaiText } from "@tanstack/ai-openai"; + +const adapter = azureOpenaiText("gpt-5.6"); +``` + +`azureOpenaiText` always reads the key from `AZURE_OPENAI_API_KEY`. If that variable is not set, it throws. For the other values, the config wins over the environment: + +- **Endpoint:** `baseURL`, then `resourceName`, then `AZURE_OPENAI_BASE_URL`, then `AZURE_OPENAI_RESOURCE_NAME`. You must set one of them. +- **API version:** `apiVersion`, then `AZURE_OPENAI_API_VERSION`, then `v1`. +- **Deployment:** `deploymentName`, then `deploymentNameMap`, then `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`, then the model name. A `deploymentNameMap` in the config replaces the environment map. The two maps do not merge. + +An Azure resource URL always goes to the `/openai/v1` path. A proxy URL keeps its own path. + +## Sign in with ChatGPT (BYOK) + +Many of your users have a ChatGPT plan but no API key. Let them sign in with ChatGPT. The token bills their ChatGPT plan, not your account. + +The helpers in `@tanstack/ai-openai/siwc` put the access token into the `openai` [BYOK](../advanced/byok) slot. Your relay reads it like a pasted key. + +Before you start, know these limits: + +- OpenAI allows this sign-in only for open-source and locally hosted apps. For a hosted app, fill in the [interest form](https://openai.com/form/sign-in-with-chatgpt-interest/). +- OpenAI redirects only to `127.0.0.1`. Something must answer on that address. Vite listens on `::1` by default. The `ts-react-chat` example runs a small script on `127.0.0.1:3000` that redirects to `localhost:3000`. +- Keep the app on `localhost`, because passkey storage does not work on an IP address. The callback lands on `127.0.0.1`, then `completeChatGptSignIn` sends the browser back to `localhost`. +- The callback page must be at `/auth/callback`. + +### 1. Add the sign-in button + +Call `startChatGptSignIn` from a click. `agentName` is your app name. OpenAI shows it on the consent screen. + +```tsx +import { startChatGptSignIn } from "@tanstack/ai-openai/siwc"; + +export function ChatGptButton() { + return ( + + ); +} +``` + +### 2. Finish on `/auth/callback` + +Render this component on the `/auth/callback` route. It exchanges the code when the page loads. Then the user clicks to save. + +```tsx +import { useEffect, useRef, useState } from "react"; +import { + completeChatGptSignIn, + saveChatGptSignIn, +} from "@tanstack/ai-openai/siwc"; +import type { ChatGptSignIn } from "@tanstack/ai-openai/siwc"; +import { byok } from "./byok"; + +export function ChatGptCallback() { + const started = useRef(false); + const [signIn, setSignIn] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + // The code works one time. Strict Mode runs effects two times. + if (started.current) return; + started.current = true; + completeChatGptSignIn().then(setSignIn, (caught: unknown) => { + setError(caught instanceof Error ? caught.message : "Sign-in failed"); + }); + }, []); + + if (error) return

{error}

; + if (!signIn) return

Finishing sign-in...

; + return ( + + ); +} +``` + +The save needs a click, because passkey storage shows a browser prompt. The refresh token goes into a second keyring slot. No send attaches that slot, so the refresh token stays in the browser. + +### 3. Refresh the token + +The access token lasts one hour. Call `refreshChatGptSignIn` on a timer: + +```tsx +import { useEffect } from "react"; +import { refreshChatGptSignIn } from "@tanstack/ai-openai/siwc"; +import { byok } from "./byok"; + +export function useChatGptRefresh() { + useEffect(() => { + const refresh = () => { + refreshChatGptSignIn(byok).catch(console.error); + }; + refresh(); + const timer = setInterval(refresh, 60_000); + return () => clearInterval(timer); + }, []); +} +``` + +The call does nothing until the token has less than five minutes left. If OpenAI rejects the refresh token, the call clears the sign-in. Then the user must sign in again. + +### 4. Read the token on the relay + +The relay code is the same as for a pasted key. Add `store: false`, because the ChatGPT route requires it. + +```typescript +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { createOpenaiChat } from "@tanstack/ai-openai"; +import { openaiByok } from "@tanstack/ai-openai/byok"; +import { byokMissing, getByokKey } from "@tanstack/ai/byok/server"; + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request); + const apiKey = getByokKey(request, openaiByok); + if (!apiKey) return byokMissing(openaiByok); + + const stream = chat({ + adapter: createOpenaiChat("gpt-6-astra", apiKey), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + modelOptions: { store: false }, + }); + return toServerSentEventsResponse(stream); +} +``` + +`store: false` also works with an API key. A ChatGPT token does not start with `sk-`. If you must send different options, use that test. + +The ChatGPT route has these limits: + +- Use a model that the ChatGPT plan of the user includes. +- Do not send `temperature`, `top_p`, `max_output_tokens`, `metadata`, or `previous_response_id`. +- Hosted tools do not work. This includes image generation, file search, code interpreter, computer use, and hosted MCP. +- Audio input and transcription do not work. + +Click **Continue with ChatGPT**, approve, save, then send a message. The relay calls OpenAI on the ChatGPT plan of the user. + +The `ts-react-chat` example has this flow in its key dialog. + +## Configuration + +```typescript +import { createOpenaiChat, type OpenAITextConfig } from "@tanstack/ai-openai"; + +const config: Omit = { + organization: "org-...", // Optional + baseURL: "https://api.openai.com/v1", // Optional, for custom endpoints +}; + +const adapter = createOpenaiChat("gpt-6.1-sol", process.env.OPENAI_API_KEY!, config); +``` + +Need a header on each request, or a log of each model call? The chat adapters of this package support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). + +### Tools that cannot use strict mode + +OpenAI holds tool arguments to the schema only when the tool goes out with `strict: true`. Some schemas cannot be strict, for example a schema that uses `$ref`, `z.any()`, or `z.record()`. The adapter sends these tools with `strict: false`, so the tool still works. The model can then return arguments that do not match the schema. + +In development, the adapter logs a warning one time for each of these tools: + +```txt +tool "lookup_user" sent with strict: false: schema uses $ref, which strict mode does not support +``` + +To get strict mode back, change the schema so that the reason goes away. If you accept a non-strict tool, turn the warning off in the adapter config: + +```typescript +import { createOpenaiChat } from "@tanstack/ai-openai"; + +const adapter = createOpenaiChat("gpt-6-astra", process.env.OPENAI_API_KEY!, { + strictFallbackWarning: false, +}); +``` + +The warning never runs when `NODE_ENV` is `production`. The same option works on every adapter built on the OpenAI API, for example Groq, Bedrock, and Vercel AI Gateway. + +## Prompt caching + +`chat()` sends `prompt_cache_key` by default, set to the `threadId` that you pass. OpenAI uses the key to send requests with the same start to the same cache. With `promptCache: 'long'`, it also asks for the long retention. See [Prompt Caching](../advanced/prompt-caching). + +To use your own key, set it in `modelOptions`. Your value wins: + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; + +const stream = chat({ + adapter: openaiText("gpt-6.1-sol"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + modelOptions: { + prompt_cache_key: "acme-support", + }, +}); +``` + +- On gpt-5.6 and later, `modelOptions.prompt_cache_options` wins over the automatic options. +- On earlier models, `modelOptions.prompt_cache_retention` wins over the automatic retention. +- `openaiChatCompletions` sends the key only to `api.openai.com` and its regional hosts (such as `eu.api.openai.com`), unless you pass `promptCache: 'long'`. + +### Tools and prompts added during a conversation + +On some models, a tool or a system prompt that you add between model calls goes into the conversation. The cached start stays the same. These models are `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `gpt-6-luna`, and `gpt-6-sol`. + +What `openaiText` sends on these models: + +- `tools` and `instructions` keep the tools and the system prompts of the first call. +- An added tool goes into `input` as `{ type: "additional_tools", role: "developer", tools: [...] }`. +- An added system prompt goes into `input` as a `developer` message, at its place in the conversation. +- With a provider tool such as `webSearchTool()`, `tools` is the full list for that request. + +The channels are on by default with the API of OpenAI. A custom `baseURL`, a custom `fetch`, or `OPENAI_BASE_URL` turns them off. Set `midConversationChannels` to choose: + +```typescript +import { openaiText } from "@tanstack/ai-openai"; + +export const fullLists = openaiText("gpt-6-astra", { midConversationChannels: false }); + +export const throughProxy = openaiText("gpt-6-astra", { + baseURL: "https://llm-proxy.example.com/v1", + midConversationChannels: true, +}); +``` + +If that endpoint sends the request on to OpenAI without changes, set `true`. See [Mid-Conversation Changes](../advanced/mid-conversation-changes). + +## Native compaction + +OpenAI can compact a long conversation on its side, with `POST /responses/compact`. The Responses adapters (`openaiText` and `createOpenaiChat`) have a `compact` method for it. Turn it on with the `native` option of `withCompaction`: + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { withCompaction } from "@tanstack/ai-compaction"; + +const adapter = openaiText("gpt-6.1-sol"); + +const stream = chat({ + adapter, + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + middleware: [withCompaction({ maxTokens: 100_000, native: adapter })], +}); +``` + +- The result holds your user messages and one encrypted `compaction` item. +- The adapter sends that item back without changes on each later request to the same model. +- `openaiChatCompletions` has no `compact` method, so `withCompaction` uses its `strategy`. + +See [Let the provider compact](../advanced/compaction#let-the-provider-compact). + +## Answers on the next request + +OpenAI gives each answer item an `id` and a `phase`: `commentary` for text before a tool call, and `final_answer` for the answer. The Responses adapters (`openaiText` and `createOpenaiChat`) keep both in the assistant message, in `metadata.tanstack.responseItems`. + +When the same model gets that message again, for example on the next turn, the adapter sends each item back with its `id` and `phase`. Another model gets the plain text. + +## Transcription options + +```typescript +import { generateTranscription } from "@tanstack/ai"; +import { openaiTranscription } from "@tanstack/ai-openai"; +import { audioFile } from "./audio"; + +const result = await generateTranscription({ + adapter: openaiTranscription("whisper-1"), + audio: audioFile, + responseFormat: "verbose_json", + prompt: "Technical terms: API, SDK", + threadId: "thread-1", + modelOptions: { + temperature: 0, + timestamp_granularities: ["word", "segment"], + }, +}); + +console.log(result.text); +``` + +### Speaker diarization + +Use `gpt-4o-transcribe-diarize` for a transcript with speaker labels: + +```typescript +import { generateTranscription } from "@tanstack/ai"; +import { openaiTranscription } from "@tanstack/ai-openai"; +import { meetingAudioFile } from "./audio"; + +const result = await generateTranscription({ + adapter: openaiTranscription("gpt-4o-transcribe-diarize"), + audio: meetingAudioFile, + modelOptions: { + known_speaker_names: ["agent", "customer"], + known_speaker_references: [ + "data:audio/wav;base64,...", + "data:audio/wav;base64,...", + ], + }, + threadId: "thread-1", +}); + +for (const segment of result.segments ?? []) { + console.log(segment.speaker, segment.start, segment.end, segment.text); +} +``` + +- With no response format, the request uses `response_format: "diarized_json"` and `chunking_strategy: "auto"`. +- A top-level `responseFormat` of `"json"` or `"text"` gives no speaker segments. +- Pass `known_speaker_names` and `known_speaker_references` together: up to 4 each, with the same length. +- OpenAI does not support `prompt`, `include`, or `timestamp_granularities` with diarized transcription. + +## Provider tools + +OpenAI has tools of its own, next to the function tools that you define. Import them from `@tanstack/ai-openai/tools` and pass them into `chat({ tools: [...] })`. + +For the concept, a comparison table, and type gating, see [Provider Tools](../tools/provider-tools.md). + +### `webSearchTool` + +The model runs a web search and returns grounded results with citations. Pass a `WebSearchToolConfig` object (typed from the OpenAI SDK). + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { webSearchTool } from "@tanstack/ai-openai/tools"; + +const stream = chat({ + adapter: openaiText("gpt-6.1-sol"), + messages: [{ role: "user", content: "What's new in AI this week?" }], + threadId: "thread-1", + tools: [webSearchTool({ type: "web_search" })], +}); +``` + +**Supported models:** GPT-4o, GPT-5, and the models that use the Responses API. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `webSearchPreviewTool` + +The preview version of web search. It has more options to set the search context size and the user location. + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { webSearchPreviewTool } from "@tanstack/ai-openai/tools"; + +const stream = chat({ + adapter: openaiText("gpt-6.1-sol"), + messages: [{ role: "user", content: "Latest news about TypeScript" }], + threadId: "thread-1", + tools: [ + webSearchPreviewTool({ + type: "web_search_preview_2025_03_11", + search_context_size: "high", + }), + ], +}); +``` + +**Supported models:** GPT-4o and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +When the model uses hosted web search: + +- The assistant tool-call part has `metadata.providerExecuted: true` and the normalized `metadata.sources` array. +- The raw search call and URL citations of OpenAI stay under `metadata.openai`. +- The agent loop does not run this call in your app. The adapter sends the raw response items back on the next turn. + +### `fileSearchTool` + +Searches OpenAI vector stores that you filled before. The model gets the matching document chunks. Give the `vector_store_ids` to search. You can set `max_num_results` (1 to 50) to limit the results. + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { fileSearchTool } from "@tanstack/ai-openai/tools"; + +const stream = chat({ + adapter: openaiText("gpt-6.1-sol"), + messages: [{ role: "user", content: "What does the handbook say about PTO?" }], + threadId: "thread-1", + tools: [ + fileSearchTool({ + type: "file_search", + vector_store_ids: ["vs_abc123"], + max_num_results: 5, + }), + ], +}); +``` + +**Supported models:** GPT-4o and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `imageGenerationTool` + +The model makes images during a conversation with DALL-E or GPT-Image. Set quality, size, and style in the config object. + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { imageGenerationTool } from "@tanstack/ai-openai/tools"; + +const stream = chat({ + adapter: openaiText("gpt-6.1-sol"), + messages: [{ role: "user", content: "Draw a logo for my app" }], + threadId: "thread-1", + tools: [ + imageGenerationTool({ + quality: "high", + size: "1024x1024", + }), + ], +}); +``` + +**Supported models:** GPT-5 and the models that can use GPT-Image. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `codeInterpreterTool` + +Gives the model a sandbox that runs Python. The `container` field sets the environment. Pass the full `CodeInterpreterToolConfig` object. + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { codeInterpreterTool } from "@tanstack/ai-openai/tools"; + +const stream = chat({ + adapter: openaiText("gpt-6.1-sol"), + messages: [{ role: "user", content: "Analyse this CSV and plot a chart" }], + threadId: "thread-1", + tools: [ + codeInterpreterTool({ type: "code_interpreter", container: { type: "auto" } }), + ], +}); +``` + +**Supported models:** GPT-4o and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `mcpTool` + +Connects the model to a remote MCP (Model Context Protocol) server. The model can call all tools of that server. Give `server_url` or `connector_id`, not both. + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { mcpTool } from "@tanstack/ai-openai/tools"; + +const stream = chat({ + adapter: openaiText("gpt-6.1-sol"), + messages: [{ role: "user", content: "List my GitHub issues" }], + threadId: "thread-1", + tools: [ + mcpTool({ + server_url: "https://mcp.example.com", + server_label: "github", + }), + ], +}); +``` + +**Supported models:** GPT-4o and later. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `computerUseTool` + +The model can look at a screen and use the mouse and the keyboard. Your app runs each action and sends back a screenshot. `createComputerUse` from `@tanstack/ai-computer-use` does this on a Playwright browser or an E2B desktop. Each thread gets its own machine: + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { createComputerUse } from "@tanstack/ai-computer-use"; +import { playwrightComputer } from "@tanstack/ai-computer-playwright"; + +declare const threadId: string; + +const computerUse = createComputerUse({ + driver: playwrightComputer({ startUrl: "https://tanstack.com" }), +}); + +const stream = chat({ + adapter: openaiText("gpt-6.1-sol"), + messages: [{ role: "user", content: "Find the TanStack AI docs" }], + threadId, + tools: computerUse.tools, + middleware: [computerUse.middleware], + interrupts: [computerUse.interrupt], +}); +``` + +The adapter sends the `computer` tool to OpenAI as its native computer tool. + +- For the setup, the threads, and how to close the machines, see [Computer use](../computer-use/computer-use.md). +- For the approval of OpenAI safety checks, see [Safety checks](../computer-use/safety-checks.md). +- For your own machine, see [Custom driver](../computer-use/custom-driver.md). + +#### The raw tool + +`computerUseTool` from `@tanstack/ai-openai/tools` is the native tool with no `execute`. Use it when your app runs the actions itself: + +```typescript +import { computerUseTool } from "@tanstack/ai-openai/tools"; + +const computer = computerUseTool({ type: "computer" }); +``` + +Add an `execute` that runs the actions and returns a screenshot as an image part. The input of each call has two fields: + +- `actions`: the actions to run, in order, as OpenAI sends them. For example `[{ type: "click", button: "left", x: 120, y: 340 }, { type: "type", text: "ada@example.com" }]`. +- `pending_safety_checks`: the safety checks that OpenAI attached to the actions. + +The adapter sends the first image of the result back as the screenshot. When the call does not fail, the adapter also tells OpenAI that the safety checks are accepted. When the call fails, they stay unaccepted. + +The `computer-use-preview` model takes the older tool type. Give it the display size and the environment. Each call then has one `action` field in place of `actions`: + +```typescript +import { computerUseTool } from "@tanstack/ai-openai/tools"; + +const previewTool = computerUseTool({ + type: "computer_use_preview", + display_width: 1024, + display_height: 768, + environment: "browser", +}); +``` + +**Supported models:** `type: "computer"` works with the models that list computer use, for example `gpt-6.1-sol`. `type: "computer_use_preview"` works with `computer-use-preview`. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `localShellTool` + +The model returns a `local_shell` tool call. Your app runs the command. Then your app sends the output back on the next request. `localShellTool()` takes no arguments. Add it to `tools`. + +Match `toolCallName` on `TOOL_CALL_START`. Read `input` on the `TOOL_CALL_END` with the same `toolCallId`. `input.command` is the command. `input.env` is the environment variables. + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { localShellTool } from "@tanstack/ai-openai/tools"; + +const stream = chat({ + adapter: openaiText("gpt-6.1-sol"), + messages: [{ role: "user", content: "Run the test suite and summarise failures" }], + threadId: "thread-1", + tools: [localShellTool()], +}); + +let localShellCallId: string | undefined; +for await (const chunk of stream) { + if (chunk.type === "TOOL_CALL_START" && chunk.toolCallName === "local_shell") { + localShellCallId = chunk.toolCallId; + } + if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === localShellCallId) { + console.log(chunk.input); + } +} +``` + +Send the command output as the tool result. Use `{ output: string }`, or a string. The next request sends `local_shell_call_output`. + +**Supported models:** GPT-5.x and other models that can act as agents. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `shellTool` + +`shellTool()` gives the model a shell. A container environment runs on OpenAI. A local environment runs in your app. + +If your app must run the commands, pass `environment: { type: "local" }`. Then: + +- Match `toolCallName` on `TOOL_CALL_START`. Read `input` on the `TOOL_CALL_END` with the same `toolCallId`. +- `input.commands` is the command list. +- `input.timeout_ms` is the time limit. +- `input.max_output_length` is the output limit. + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { shellTool } from "@tanstack/ai-openai/tools"; + +const stream = chat({ + adapter: openaiText("gpt-6.1-sol"), + messages: [{ role: "user", content: "Count lines in all JS files" }], + threadId: "thread-1", + tools: [shellTool({ environment: { type: "local" } })], +}); + +let shellCallId: string | undefined; +for await (const chunk of stream) { + if (chunk.type === "TOOL_CALL_START" && chunk.toolCallName === "shell") { + shellCallId = chunk.toolCallId; + } + if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === shellCallId) { + console.log(chunk.input); + } +} +``` + +Return one result for each command: + +```typescript +const shellResult = { + output: [ + { + stdout: "12\n", + stderr: "", + outcome: { type: "exit" as const, exit_code: 0 }, + }, + ], +}; +``` + +- `outcome.type` is `"exit"` or `"timeout"`. The next request sends `shell_call_output`. +- If the same response has no `shell_call_output`, a shell call with no environment comes back to your app. +- A container environment does not come back as a tool call. Pass `environment` to attach that container and hosted skills. + +**Supported models:** GPT-5.x and other models that can act as agents. Responses API only: Chat Completions does not support the shell tool. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +#### Attaching hosted skills + +Pass `environment.skills` to load skill bundles that the provider manages into the container of the shell (Responses API only). + +```typescript +import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { shellTool } from "@tanstack/ai-openai/tools"; + +export async function POST(request: Request) { + const { messages, threadId } = await request.json(); + + const stream = chat({ + adapter: openaiText("gpt-6.1-sol"), + messages, + threadId, + tools: [ + shellTool({ + environment: { + type: "container_auto", + skills: [ + { type: "skill_reference", skill_id: "skill_abc", version: "2" }, + ], + }, + }), + ], + }); + + return toServerSentEventsResponse(stream); +} +``` + +For the skill shape, the `version` string format, and the Anthropic equivalent, see [Provider Skills](../tools/provider-skills.md). + +### `applyPatchTool` + +The model returns an `apply_patch` tool call. Your app applies the diff. Then your app sends the result back. `applyPatchTool()` takes no arguments. Add it to `tools`. + +Match `toolCallName` on `TOOL_CALL_START`. Read `input` on the `TOOL_CALL_END` with the same `toolCallId`: + +- `input.operation` is one file change. +- `operation.type` is `create_file`, `update_file`, or `delete_file`. +- `operation.path` is the file path. +- `create_file` and `update_file` also include `operation.diff`. + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { applyPatchTool } from "@tanstack/ai-openai/tools"; + +const stream = chat({ + adapter: openaiText("gpt-6.1-sol"), + messages: [{ role: "user", content: "Fix the import paths in src/index.ts" }], + threadId: "thread-1", + tools: [applyPatchTool()], +}); + +let patchCallId: string | undefined; +for await (const chunk of stream) { + if (chunk.type === "TOOL_CALL_START" && chunk.toolCallName === "apply_patch") { + patchCallId = chunk.toolCallId; + } + if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === patchCallId) { + console.log(chunk.input); + } +} +``` + +Send this object as the tool result. The next request sends `apply_patch_call_output`. + +```typescript +const patchResult = { + status: "completed" as const, + output: "Updated src/index.ts", +}; +``` + +- If the patch fails, set `status` to `"failed"`. Put the error text in `output`. +- Keep `applyPatchTool()` in `tools` on the next request. +- A turn with only a patch has no text. With `chat({ stream: false })`, `text` is empty. Read the `apply_patch` tool call from `chunks` with the same checks as the loop above. + +**Supported models:** GPT-5.x and other models that can act as agents. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `customTool` + +Defines a custom Responses API tool with an explicit name, description, and format. If none of the other tool types fits, use it. `customTool` returns a plain `Tool`, so every chat model accepts it. + +```typescript +import { chat } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; +import { customTool } from "@tanstack/ai-openai/tools"; + +const stream = chat({ + adapter: openaiText("gpt-6.1-sol"), + messages: [{ role: "user", content: "Look up order #1234" }], + threadId: "thread-1", + tools: [ + customTool({ + type: "custom", + name: "lookup_order", + description: "Look up the status of a customer order by order ID", + }), + ], +}); +``` + +**Supported models:** all Responses API models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +## API reference + +Each factory pair has the same shape. The short factory (`openaiText`, `openaiImage`, and the others) reads `OPENAI_API_KEY` from the environment. The `create*` factory takes an explicit API key. Both take `model` as the first argument. + +| Factories | Creates | +| --- | --- | +| `openaiText(model, config?)` / `createOpenaiChat(model, apiKey, config?)` | Text adapter on the Responses API (`/v1/responses`) | +| `openaiChatCompletions(model, config?)` / `createOpenaiChatCompletions(model, apiKey, config?)` | Text adapter on `/v1/chat/completions`. See [Chat Completions API](#chat-completions-api) | +| `azureOpenaiText(model, config?)` / `createAzureOpenaiText(model, apiKey, config)` | Text adapter on Azure. See [Azure OpenAI](#azure-openai) | +| `openaiSummarize(model, config?)` / `createOpenaiSummarize(model, apiKey, config?)` | Summarization adapter | +| `openaiImage(model, config?)` / `createOpenaiImage(model, apiKey, config?)` | Image adapter (DALL-E, gpt-image) | +| `openaiSpeech(model, config?)` / `createOpenaiSpeech(model, apiKey, config?)` | Text-to-speech adapter | +| `openaiTranscription(model, config?)` / `createOpenaiTranscription(model, apiKey, config?)` | Transcription adapter (Whisper, GPT-4o transcription, and GPT-4o diarized transcription) | +| `openaiEmbedding(model, config?)` / `createOpenaiEmbedding(model, apiKey, config?)` | Embedding adapter | +| `openaiVideo(model, config?)` / `createOpenaiVideo(model, apiKey, config?)` | Video adapter (Sora). Experimental | +| `openaiDecider(model, config?)` / `createOpenaiDecider(model, apiKey, config?)` | Evaluate adapter for `decide()`. See [Evaluate](./openai#evaluate) | +| `openaiFiles` / `createOpenaiFiles` | Files API adapter. See [Files API](../advanced/files-api) | +| `openaiRealtime(...)` / `openaiRealtimeToken(...)` | Realtime voice adapters. See [Realtime Voice Chat](../media/realtime-chat) | + +Text adapter config: + +- `config.organization`: the organization ID. Optional. +- `config.baseURL`: a custom base URL. Optional. +- `config.strictFallbackWarning`: set `false` to turn off the [strict mode warning](#tools-that-cannot-use-strict-mode). +- `config.midConversationChannels`: see [Tools and prompts added during a conversation](#tools-and-prompts-added-during-a-conversation). + +`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). diff --git a/docs/adapters/openai-compatible-advanced.md b/docs/adapters/openai-compatible-advanced.md new file mode 100644 index 0000000000..a79f1988e6 --- /dev/null +++ b/docs/adapters/openai-compatible-advanced.md @@ -0,0 +1,480 @@ +--- +title: "OpenAI-Compatible: Advanced" +id: openai-compatible-advanced +description: "Advanced setup for the OpenAI-compatible adapter in TanStack AI: headers and query parameters, the Responses API, Azure OpenAI, and model calls paid by a GitHub Copilot or ChatGPT plan." +keywords: + - tanstack ai + - openai compatible + - responses api + - azure openai + - github copilot + - chatgpt plan + - adapter +--- + +This page has the details of the OpenAI-compatible adapter. To get a first chat working, start at [OpenAI-Compatible Adapter](./openai-compatible). + +## Configuration + +`openaiCompatible` accepts every `ClientOptions` field of the OpenAI SDK. `apiKey` and `baseURL` are required, and they are at the top level. The most useful other fields are `defaultHeaders` and `defaultQuery`, for providers that need extra auth or routing parameters: + +```typescript +import { openaiCompatible } from "@tanstack/ai-openai/compatible"; + +const provider = openaiCompatible({ + baseURL: "https://api.example.com/v1", + apiKey: process.env.EXAMPLE_API_KEY!, + models: ["some-model"], + defaultHeaders: { "X-Custom-Header": "value" }, + defaultQuery: { "api-version": "2026-01-01" }, +}); +``` + +Need a header on each request, or a log of each model call? The adapters that `openaiCompatible` makes support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). + +## Chat Completions vs Responses + +By default, the adapter calls the **Chat Completions** API (`/chat/completions`). For a provider with the **Responses** API, set `api: "responses"`: + +```typescript +import { openaiCompatible } from "@tanstack/ai-openai/compatible"; + +const provider = openaiCompatible({ + baseURL: "https://my-resource.openai.azure.com/openai/v1", + apiKey: process.env.AZURE_OPENAI_API_KEY!, + models: ["gpt-4o"], + api: "responses", // default is "chat-completions" +}); +``` + +The `api` choice also sets how `ChatStreamSummarizeAdapter` sends `maxLength`, with any wrapper name: + +- Chat Completions uses `max_tokens`. +- Responses uses `max_output_tokens`. + +An explicit token limit in `modelOptions` wins over `maxLength`. + +Reasoning deltas stream as thinking content. Endpoints on the OpenAI spec from before July 2025 can still send the old event name `response.reasoning.delta`, which the spec replaced with `response.reasoning_text.delta`. The adapter reads both names the same way. + +## Azure OpenAI + +Use `createAzureOpenaiText` from `@tanstack/ai-openai`. It sets the `api-key` header, the endpoint, the API version, and the deployment name for you: + +```typescript +import { createAzureOpenaiText } from "@tanstack/ai-openai"; + +const adapter = createAzureOpenaiText("gpt-5.6", process.env.AZURE_OPENAI_API_KEY!, { + resourceName: "my-resource", + deploymentName: "production-chat", +}); +``` + +See [Azure OpenAI](./openai-advanced#azure-openai) for `azureOpenaiText`, which reads the key and the other values from the environment. + +## GitHub Copilot + +Your users can pay for model calls with their GitHub Copilot plan. Each user signs in with GitHub. Your server sends the GitHub token of the user to Copilot as `Authorization: Bearer`. + +### 1. Create a GitHub OAuth app + +1. On GitHub, open **Settings > Developer settings > OAuth Apps**. Click **New OAuth App**. +2. Set **Authorization callback URL** to your callback route, for example `https://my-app.com/auth/github/callback`. +3. Clear **Expire user access tokens**. The examples below keep one token and do not refresh it. +4. If your app runs in a terminal, select **Enable Device Flow**. +5. Set `GITHUB_CLIENT_ID` to the client ID. For a web app, also generate a client secret and set `GITHUB_CLIENT_SECRET` to it. + +If you keep **Expire user access tokens** on, a token lasts 8 hours. Refresh it with its refresh token, as [Authorizing OAuth apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps) tells you. + +### 2. Sign the user in + +Pick the flow for your app: + +- Web app: the [web flow](#web-app) sends the user to GitHub and back. +- Terminal or desktop app: the [device flow](#terminal-app) shows a code. The user types the code on GitHub. + +Both flows ask for the `read:user` scope only. + +#### Web app + +`copilot-auth.ts` has the two sign-in routes and a reader for your chat routes. The token goes into an `HttpOnly` cookie, so browser code cannot read it: + +```typescript +import { copilotBaseURL } from "./copilot-host"; + +function env(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Set ${name}.`); + return value; +} + +function readCookie(request: Request, name: string): string | undefined { + for (const pair of (request.headers.get("cookie") ?? "").split(";")) { + const [key, ...rest] = pair.trim().split("="); + if (key !== name) continue; + try { + return decodeURIComponent(rest.join("=")); + } catch { + return undefined; // A malformed value counts as no cookie. + } + } + return undefined; +} + +function cookie(name: string, value: string, maxAge: number): string { + return `${name}=${encodeURIComponent(value)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAge}`; +} + +/** GET /auth/github: send the user to GitHub. */ +export function startGithubSignIn(): Response { + const state = crypto.randomUUID(); + const url = new URL("https://github.com/login/oauth/authorize"); + url.searchParams.set("client_id", env("GITHUB_CLIENT_ID")); + url.searchParams.set("scope", "read:user"); + url.searchParams.set("state", state); + return new Response(null, { + status: 302, + headers: { + Location: url.toString(), + "Set-Cookie": cookie("github_state", state, 600), + }, + }); +} + +/** GET /auth/github/callback: swap the code for a token. */ +export async function finishGithubSignIn(request: Request): Promise { + const url = new URL(request.url); + const state = url.searchParams.get("state"); + if (!state || state !== readCookie(request, "github_state")) { + return new Response("The sign-in failed. Try again.", { status: 400 }); + } + const reply = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: env("GITHUB_CLIENT_ID"), + client_secret: env("GITHUB_CLIENT_SECRET"), + code: url.searchParams.get("code"), + }), + }); + const { access_token }: { access_token?: string } = await reply.json(); + if (!access_token) { + return new Response("The sign-in failed. Try again.", { status: 400 }); + } + const baseURL = await copilotBaseURL(access_token); + if (!baseURL) { + return new Response("This GitHub account has no Copilot access.", { + status: 403, + }); + } + const month = 60 * 60 * 24 * 30; + const headers = new Headers({ Location: "/" }); + headers.append("Set-Cookie", cookie("copilot_token", access_token, month)); + headers.append("Set-Cookie", cookie("copilot_base_url", baseURL, month)); + headers.append("Set-Cookie", cookie("github_state", "", 0)); + return new Response(null, { status: 302, headers }); +} + +/** The Copilot sign-in of the request, or `undefined`. */ +export function getCopilotSignIn(request: Request) { + const token = readCookie(request, "copilot_token"); + const baseURL = readCookie(request, "copilot_base_url"); + if (!token || !baseURL || !URL.canParse(baseURL)) return undefined; + // The user can change cookies. Send the token only to a Copilot host. + const { protocol, hostname } = new URL(baseURL); + if (protocol !== "https:" || !hostname.endsWith(".githubcopilot.com")) { + return undefined; + } + return { token, baseURL }; +} +``` + +1. Call `startGithubSignIn` from your `GET /auth/github` route. +2. Call `finishGithubSignIn` from your `GET /auth/github/callback` route. +3. Link a **Sign in with GitHub** button to `/auth/github`. + +- For GitHub Enterprise, also accept your `copilot-api.` host in `getCopilotSignIn`. +- To keep the token fully out of the browser, keep it in your session store. Then put only the session id in the cookie. + +#### Terminal app + +`copilot-device.ts` shows a code. Then it waits until the user types the code on GitHub: + +```typescript +interface DeviceCode { + device_code?: string; + user_code?: string; + verification_uri?: string; + interval?: number; + error_description?: string; +} + +interface TokenReply { + access_token?: string; + error?: string; + error_description?: string; + interval?: number; +} + +async function postGithub(path: string, body: Record) { + const response = await fetch(`https://github.com${path}`, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return response.json(); +} + +export async function signInWithDevice(): Promise { + const clientId = process.env.GITHUB_CLIENT_ID; + if (!clientId) throw new Error("Set GITHUB_CLIENT_ID."); + const code: DeviceCode = await postGithub("/login/device/code", { + client_id: clientId, + scope: "read:user", + }); + if (!code.device_code) { + throw new Error(code.error_description ?? "The GitHub sign-in failed."); + } + console.log(`Open ${code.verification_uri} and type ${code.user_code}`); + + let interval = code.interval ?? 5; + for (;;) { + await new Promise((resolve) => setTimeout(resolve, interval * 1000)); + const reply: TokenReply = await postGithub("/login/oauth/access_token", { + client_id: clientId, + device_code: code.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }); + if (reply.access_token) return reply.access_token; + if (reply.error === "slow_down") { + interval = reply.interval ?? interval + 5; + } else if (reply.error !== "authorization_pending") { + throw new Error(reply.error_description ?? "The GitHub sign-in failed."); + } + } +} +``` + +- `authorization_pending`: the user did not type the code yet. The loop asks again. +- `slow_down`: GitHub wants a longer wait. The loop waits longer. +- Any other error stops the sign-in. For example, the code expires after 15 minutes. + +Keep the token in the OS keychain, or in a file that only the user can read. With **Expire user access tokens** cleared, the token does not expire, so the user signs in one time. + +### 3. Find the Copilot host + +Any GitHub account can sign in, but only some accounts have Copilot. Business and enterprise accounts can also have a different host. `copilot-host.ts` reads both from GitHub: + +```typescript +interface CopilotUser { + chat_enabled?: boolean; + endpoints?: { api?: string }; +} + +/** + * The Copilot API host of the user, or `undefined` when the account has no + * Copilot. `githubHost` is your GitHub Enterprise domain, or `github.com`. + */ +export async function copilotBaseURL( + token: string, + githubHost = "github.com", +): Promise { + const api = + githubHost === "github.com" + ? "https://api.github.com" + : `https://api.${githubHost}`; + const response = await fetch(`${api}/copilot_internal/user`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2025-04-01", + }, + }); + // A failed lookup uses the default host. Only GitHub can say "no Copilot". + const user: CopilotUser = response.ok ? await response.json() : {}; + if (user.chat_enabled === false) return undefined; + if (user.endpoints?.api) return user.endpoints.api.replace(/\/+$/, ""); + return githubHost === "github.com" + ? "https://api.githubcopilot.com" + : `https://copilot-api.${githubHost}`; +} +``` + +- `chat_enabled: false`: the account has no Copilot plan and no seat from an organization. +- `endpoints.api`: the host of this account. +- GitHub Enterprise: pass your domain as `githubHost`. Also put your domain in place of `github.com` in the sign-in URLs of step 2. + +`copilot_internal/user` is not a public GitHub API. GitHub can change it. + +### 4. Add the Copilot headers + +Copilot reads a set of headers on every request. One `wrapFetch` in `copilot-headers.ts` sets all of them for each route: + +```typescript +import type { FetchWrapper } from "@tanstack/ai"; + +/** The Copilot interaction types: the main loop, a subagent, a title, or a compaction. */ +type Interaction = + | "conversation-agent" + | "conversation-subagent" + | "conversation-background" + | "conversation-compaction"; + +export function copilotHeaders( + sessionId: string, + interaction: Interaction = "conversation-agent", +): FetchWrapper { + return (next) => (input, init) => { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : {}; + const items = body.input ?? body.messages ?? []; + const last = items.at(-1); + // A user turn has text from the user. A tool result alone is an agent turn. + const userTurn = + last?.role === "user" && + (!Array.isArray(last.content) || + last.content.some( + (part: { type?: string }) => part.type !== "tool_result", + )); + const url = input instanceof Request ? input.url : String(input); + const headers = new Headers(init?.headers); + headers.set("User-Agent", "my-app/1.0.0"); + headers.set("Openai-Intent", "conversation-edits"); + headers.set("X-GitHub-Api-Version", "2026-08-01"); + headers.set("X-Interaction-Type", interaction); + headers.set("X-Interaction-Id", sessionId); + headers.set( + "x-initiator", + interaction === "conversation-agent" && userTurn ? "user" : "agent", + ); + if (/"type":"(image|image_url|input_image)"/.test(JSON.stringify(items))) { + headers.set("Copilot-Vision-Request", "true"); + } + if (url.includes("/v1/messages")) { + // Keep the betas that the adapter already set. + const betas = headers.get("anthropic-beta"); + const thinking = "interleaved-thinking-2025-05-14"; + headers.set("anthropic-beta", betas ? `${betas},${thinking}` : thinking); + } + return next(input, { ...init, headers }); + }; +} +``` + +- `x-initiator`: Copilot bills the requests that a user starts (`user`). Tool loop requests and the other interaction types are `agent`. +- `X-Interaction-Id`: use one stable id for each chat session. +- `X-Interaction-Type`: use `conversation-subagent` in a subagent, `conversation-background` for a title, and `conversation-compaction` for a summary of old messages. +- `Copilot-Vision-Request`: if a request has images, set it. + +### 5. Call the model + +Copilot serves each model on one of three APIs. `GET {baseURL}/models` lists the `supported_endpoints` of each model. Choose the adapter in this order: + +1. `/v1/messages`: use `createAnthropicChat` from `@tanstack/ai-anthropic`, with the token and `auth: "bearer"`. +2. `/responses`: use `openaiCompatible` with `api: "responses"`. +3. `/chat/completions`: use `openaiCompatible` without `api` and without `modelOptions`. + +The examples below read the sign-in from the cookies of the web app. In a terminal app, use the saved token and the host from step 3 in place of `signIn`. + +#### Claude models + +Set the Copilot host as `baseURL`. The Anthropic SDK adds `/v1/messages`. Set `auth: "bearer"`. Do not use `"oauth"`, because it adds the Claude Code headers: + +```typescript +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { createAnthropicChat } from "@tanstack/ai-anthropic"; +import { copilotHeaders } from "./copilot-headers"; +import { getCopilotSignIn } from "./copilot-auth"; + +export async function POST(request: Request) { + const signIn = getCopilotSignIn(request); + if (!signIn) return new Response("Sign in with GitHub.", { status: 401 }); + const params = await chatParamsFromRequest(request); + const stream = chat({ + adapter: createAnthropicChat("claude-sonnet-5-5", signIn.token, { + baseURL: signIn.baseURL, + auth: "bearer", + }), + messages: params.messages, + threadId: params.threadId, + wrapFetch: copilotHeaders(params.threadId), + }); + return toServerSentEventsResponse(stream); +} +``` + +`auth: "bearer"` sends `Authorization: Bearer` and no `x-api-key`. With a custom `baseURL`, the adapter sends no mid-conversation betas. It adds cache markers only when you set `promptCache`. + +#### OpenAI models + +Pass the token as `apiKey`: + +```typescript +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { openaiCompatible } from "@tanstack/ai-openai/compatible"; +import { copilotHeaders } from "./copilot-headers"; +import { getCopilotSignIn } from "./copilot-auth"; + +export async function POST(request: Request) { + const signIn = getCopilotSignIn(request); + if (!signIn) return new Response("Sign in with GitHub.", { status: 401 }); + const params = await chatParamsFromRequest(request); + const copilot = openaiCompatible({ + name: "github-copilot", + baseURL: signIn.baseURL, + apiKey: signIn.token, + api: "responses", + models: ["gpt-6.1-sol"], + }); + + const stream = chat({ + adapter: copilot("gpt-6.1-sol"), + messages: params.messages, + threadId: params.threadId, + modelOptions: { store: false, include: ["reasoning.encrypted_content"] }, + wrapFetch: copilotHeaders(params.threadId), + }); + return toServerSentEventsResponse(stream); +} +``` + +- `store: false`: Copilot keeps no responses. With `include`, the stream gives the encrypted reasoning. The adapter sends it back on the next turn when your history keeps it. +- The Chat Completions adapter reads the thinking that Copilot streams on `delta.reasoning_text`. + +Click **Sign in with GitHub**, approve, then send a message. The model call goes to the Copilot plan of the user. + +## ChatGPT plan + +A user with a ChatGPT plan can sign in with ChatGPT, with no API key. [Sign in with ChatGPT](./openai-advanced#sign-in-with-chatgpt-byok) adds the sign-in button and keeps the access token in the `openai` BYOK slot. Your server reads the token like a pasted key: + +```typescript +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { createOpenaiChat } from "@tanstack/ai-openai"; +import { openaiByok } from "@tanstack/ai-openai/byok"; +import { byokMissing, getByokKey } from "@tanstack/ai/byok/server"; + +export async function POST(request: Request) { + const apiKey = getByokKey(request, openaiByok); + if (!apiKey) return byokMissing(openaiByok); + const params = await chatParamsFromRequest(request); + const stream = chat({ + adapter: createOpenaiChat("gpt-6.1-sol", apiKey), + messages: params.messages, + threadId: params.threadId, + modelOptions: { store: false }, + }); + return toServerSentEventsResponse(stream); +} +``` + +The ChatGPT route requires `store: false`. Do not set `max_output_tokens`. [Sign in with ChatGPT](./openai-advanced#sign-in-with-chatgpt-byok) lists the other limits. diff --git a/docs/adapters/openai-compatible.md b/docs/adapters/openai-compatible.md index a5d2897064..0f73318eed 100644 --- a/docs/adapters/openai-compatible.md +++ b/docs/adapters/openai-compatible.md @@ -1,7 +1,7 @@ --- title: OpenAI-Compatible Adapter id: openai-compatible-adapter -description: "Use any OpenAI-compatible provider (DeepSeek, Moonshot/Kimi, Together, Fireworks, Cerebras, Qwen, Perplexity, local servers, and more) in TanStack AI with one generic adapter." +description: "Use any OpenAI-compatible provider in TanStack AI with one generic adapter: DeepSeek, Moonshot/Kimi, Together, Fireworks, Cerebras, Qwen, Perplexity, local servers, and more." keywords: - tanstack ai - openai compatible @@ -19,15 +19,21 @@ keywords: - adapter --- -Many providers expose the OpenAI **Chat Completions** API (`/chat/completions`) — DeepSeek, Moonshot/Kimi, Together, Fireworks, Cerebras, Alibaba Qwen, Perplexity, NVIDIA NIM, and local servers like LM Studio, Ollama, and vLLM. Instead of a dedicated package per provider, TanStack AI ships one generic adapter: point it at any compatible `baseURL`, give it your models, and you get the same type-safe `chat()` experience as the first-class adapters. +`openaiCompatible` calls any server that speaks `/chat/completions` and has no `@tanstack/ai-*` package. Set `baseURL` and the model list, then call `chat()`. -Use this when your provider speaks the OpenAI Chat Completions wire format but doesn't have its own `@tanstack/ai-*` package. If a dedicated adapter exists (OpenAI, Grok, Groq, OpenRouter), prefer it. Those carry curated per-model metadata. For Vercel AI Gateway, install `@tanstack/ai-vercel-gateway` and use `vercelGatewayText`. See [Vercel AI Gateway](./vercel-gateway.md). For Lovable AI Gateway, install `@tanstack/ai-lovable` and use `lovableText` (plus image, video, embeddings, and speech factories). See [Lovable AI Gateway](./lovable.md). +`openaiCompatible` is one generic adapter for all of them. Point it at the `baseURL`, list your models, and you get the same type-safe `chat()` as with the other adapters. -Perplexity Sonar chat stays on this adapter. [`@tanstack/ai-perplexity`](./perplexity.md) is Search/grounding only — it does not replace `openaiCompatible` for `chat()`. Optional: pass `defaultHeaders: getPerplexityIntegrationHeaders()` from that package to send Perplexity's `X-Pplx-Integration` attribution header. +If a dedicated adapter exists, use it, because it has curated metadata for each model: -## Installation +- [OpenAI](./openai), [Grok](./grok), [Groq](./groq), and [OpenRouter](./openrouter). +- Vercel AI Gateway: `vercelGatewayText` from `@tanstack/ai-vercel-gateway`. See [Vercel AI Gateway](./vercel-gateway.md). +- Lovable AI Gateway: `lovableText` (plus image, video, embeddings, and speech factories) from `@tanstack/ai-lovable`. See [Lovable AI Gateway](./lovable.md). -The adapter ships inside `@tanstack/ai-openai` under the `/compatible` subpath — no extra install: +Perplexity Sonar chat uses this adapter. [`@tanstack/ai-perplexity`](./perplexity.md) is for search and grounding only. To send the `X-Pplx-Integration` attribution header of Perplexity, pass `defaultHeaders: getPerplexityIntegrationHeaders()` from that package. + +## 1. Install + +The adapter is in `@tanstack/ai-openai`, under the `/compatible` subpath: @@ -42,146 +48,110 @@ octane: @tanstack/ai-openai -## Basic Usage - -Configure the provider once with `openaiCompatible({ baseURL, apiKey, models })`, then select a model per call. The returned model name is a type-safe union of the models you declared: - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiCompatible } from "@tanstack/ai-openai/compatible"; - -const deepseek = openaiCompatible({ - name: "deepseek", // optional label shown in devtools/errors (default: "openai-compatible") - baseURL: "https://api.deepseek.com/v1", - apiKey: process.env.DEEPSEEK_API_KEY!, - models: ["deepseek-chat", "deepseek-reasoner"], -}); - -const stream = chat({ - adapter: deepseek("deepseek-chat"), - messages: [{ role: "user", content: "Hello!" }], -}); -``` - -`deepseek("deepseek-reasoner")` is valid; `deepseek("gpt-4o")` is a type error — only declared models are accepted. - -## One-Shot Usage +## 2. Set the API key -For a single model, skip the provider-factory and build the adapter inline with `openaiCompatibleText`: +Put the key of your provider in the environment of your server, with the variable name that you like: -```typescript -import { chat } from "@tanstack/ai"; -import { openaiCompatibleText } from "@tanstack/ai-openai/compatible"; - -const stream = chat({ - adapter: openaiCompatibleText("deepseek-chat", { - baseURL: "https://api.deepseek.com/v1", - apiKey: process.env.DEEPSEEK_API_KEY!, - }), - messages: [{ role: "user", content: "Hello!" }], -}); +```bash +DEEPSEEK_API_KEY=sk-... ``` -## Declaring Models +The adapter reads no environment variable itself. You pass the key as `apiKey` in the next step. -The `models` array accepts two forms, which you can mix: +## 3. Stream a chat from the server -- **A bare string** — gets optimistic defaults: `text` + `image` input, with `streaming`, `function_calling`, and `structured_outputs` support. Good for mainstream chat models. -- **A `createModel(name, capabilities)` definition** — declares precise per-model capabilities so the types match reality (e.g. a reasoning model with no image input). +Configure the provider one time with `openaiCompatible({ baseURL, apiKey, models })`. Then pick a model for each call: ```typescript +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; import { openaiCompatible } from "@tanstack/ai-openai/compatible"; -import { createModel } from "@tanstack/ai"; -const provider = openaiCompatible({ +const deepseek = openaiCompatible({ + name: "deepseek", // optional label for devtools and errors (default: "openai-compatible") baseURL: "https://api.deepseek.com/v1", apiKey: process.env.DEEPSEEK_API_KEY!, - models: [ - "deepseek-chat", // string → optimistic defaults - createModel("deepseek-reasoner", { - input: ["text"], // text only - features: ["reasoning", "structured_outputs"], - }), - ], + models: ["deepseek-chat", "deepseek-reasoner"], }); -``` -> Capabilities are enforced at the type level. If a provider rejects a feature at runtime (e.g. tools on a model that doesn't support them), declare that model with `createModel` and omit the unsupported feature so the types stop you from calling it. - -## Configuration - -`openaiCompatible` accepts every OpenAI SDK `ClientOptions` field besides `apiKey`/`baseURL` (which are required and promoted to the top level). The most useful are `defaultHeaders` and `defaultQuery`, for providers that need extra auth or routing parameters: +export async function POST(request: Request) { + const { messages, threadId, runId } = await chatParamsFromRequest(request); -```typescript -import { openaiCompatible } from "@tanstack/ai-openai/compatible"; + const stream = chat({ + adapter: deepseek("deepseek-chat"), + messages, + threadId, + runId, + }); -const provider = openaiCompatible({ - baseURL: "https://api.example.com/v1", - apiKey: process.env.EXAMPLE_API_KEY!, - models: ["some-model"], - defaultHeaders: { "X-Custom-Header": "value" }, - defaultQuery: { "api-version": "2026-01-01" }, -}); + return toServerSentEventsResponse(stream); +} ``` -Need a header on each request, or a log of each model call? The chat adapters on this page support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). - -## Chat Completions vs Responses +The model name is a type-safe union of the models that you declared. `deepseek("deepseek-reasoner")` is valid. `deepseek("gpt-4o")` is a type error. -By default the adapter targets the **Chat Completions** API (`/chat/completions`). For providers that implement the **Responses** API, select `api: "responses"`. This API choice also controls how `ChatStreamSummarizeAdapter` forwards `maxLength`, regardless of the wrapper name: +## 4. Render the chat -- Chat Completions uses `max_tokens`. -- Responses uses `max_output_tokens`. +The client is the same for every provider. Point `useChat` at your route: -An explicit token limit in `modelOptions` takes precedence over `maxLength`. +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; -```typescript -import { openaiCompatible } from "@tanstack/ai-openai/compatible"; +export function Chat() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", + }); -const provider = openaiCompatible({ - baseURL: "https://my-resource.openai.azure.com/openai/v1", - apiKey: process.env.AZURE_OPENAI_API_KEY!, - models: ["gpt-4o"], - api: "responses", // default is "chat-completions" -}); + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => ( +

+ {message.parts + .map((part) => (part.type === "text" ? part.content : "")) + .join("")} +

+ ))} + setInput(event.target.value)} /> +
+ ); +} ``` -**Reasoning:** Reasoning deltas stream as thinking content. Endpoints frozen on the pre-July-2025 OpenAI spec may still emit the legacy event name `response.reasoning.delta` (removed from the spec in favor of `response.reasoning_text.delta`); the adapter recognizes both and maps them identically. +For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat). -## Supported Providers +You now have a streaming chat with any OpenAI-compatible provider. [Tools](../tools/tools) work the same as with every adapter, on models that support function calling. -Any provider implementing the OpenAI Chat Completions API works. Common ones are below — **verify the `baseURL` and model ids against each provider's current docs**, since they change over time. Set the API key via the provider's own environment variable and pass it as `apiKey`. - -| Provider | `baseURL` | Example model | -| --- | --- | --- | -| DeepSeek | `https://api.deepseek.com/v1` | `deepseek-chat`, `deepseek-reasoner` | -| Moonshot / Kimi | `https://api.moonshot.ai/v1` | `kimi-k2-0711-preview` | -| Alibaba Qwen (DashScope, intl) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen-max`, `qwen-plus` | -| Alibaba Qwen (DashScope, China) | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen-max` | -| Together AI | `https://api.together.xyz/v1` | `meta-llama/Llama-3.3-70B-Instruct-Turbo` | -| Fireworks AI | `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/llama-v3p3-70b-instruct` | -| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | -| DeepInfra | `https://api.deepinfra.com/v1/openai` | `meta-llama/Llama-3.3-70B-Instruct` | -| Perplexity | `https://api.perplexity.ai` | `sonar`, `sonar-pro` | -| Mistral | `https://api.mistral.ai/v1` | `mistral-large-latest` | -| Nebius | `https://api.studio.nebius.ai/v1` | `meta-llama/Llama-3.3-70B-Instruct` | -| Z.AI (GLM) | `https://api.z.ai/api/paas/v4` | `glm-4.6` | -| Baseten | `https://inference.baseten.co/v1` | model-dependent | -| Hugging Face (router) | `https://router.huggingface.co/v1` | `meta-llama/Llama-3.3-70B-Instruct` | -| NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | `meta/llama-3.3-70b-instruct` | -| Kilo Gateway | `https://api.kilo.ai/api/gateway` | `anthropic/claude-opus-5.5` | -| ZenMux | `https://zenmux.ai/api/v1` | `openai/gpt-5.5` | -| Poe | `https://api.poe.com/v1` | `openai/gpt-5.5` | -| DigitalOcean | `https://inference.do-ai.run/v1` | `openai-gpt-5.5` | -| Modal | `https://inference.us-west.modal.direct/v1` | `moonshotai/Kimi-K3` | -| Snowflake Cortex | `https://.snowflakecomputing.com/api/v2/cortex/v1` | `claude-opus-5` | +## One model only -Some providers sign the user in with OAuth instead of an API key. For Poe, DigitalOcean, and Snowflake Cortex, pass the access token from your sign-in code as `apiKey`. +For a single model, skip the provider factory and build the adapter inline with `openaiCompatibleText`: +```typescript +import { chat } from "@tanstack/ai"; +import { openaiCompatibleText } from "@tanstack/ai-openai/compatible"; -On Chat Completions, `RUN_FINISHED.usage` carries the prompt cache counts that the provider reports. Cache reads arrive on `promptTokensDetails.cachedTokens`, and cache writes arrive on `promptTokensDetails.cacheWriteTokens`. Moonshot / Kimi reports both. +const stream = chat({ + adapter: openaiCompatibleText("deepseek-chat", { + baseURL: "https://api.deepseek.com/v1", + apiKey: process.env.DEEPSEEK_API_KEY!, + }), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", +}); +``` -## Local & Self-Hosted Servers +## Local and self-hosted servers Point the adapter at any local OpenAI-compatible server. The API key is usually a placeholder: @@ -204,7 +174,7 @@ const vllm = openaiCompatible({ models: ["meta-llama/Llama-3.3-70B-Instruct"], }); -// Ollama's OpenAI-compatible endpoint +// The OpenAI-compatible endpoint of Ollama const ollama = openaiCompatible({ name: "ollama", baseURL: "http://localhost:11434/v1", @@ -213,11 +183,11 @@ const ollama = openaiCompatible({ }); ``` -> Ollama also has a dedicated adapter, [`@tanstack/ai-ollama`](./ollama), which understands its native API. Use `openaiCompatible` only if you specifically want Ollama's OpenAI-compatible surface. +Ollama also has a dedicated adapter, [`@tanstack/ai-ollama`](./ollama), for its native API. If you want the OpenAI-compatible surface of Ollama, use `openaiCompatible`. -## LiteLLM Proxy +## LiteLLM proxy -[LiteLLM](https://github.com/BerriAI/litellm) is a self-hosted gateway that exposes a single OpenAI Chat Completions endpoint in front of 100+ providers (OpenAI, Anthropic, Google, Azure, AWS Bedrock, Mistral, Groq, and more). Because the proxy speaks the OpenAI wire format, it needs no dedicated package — point `openaiCompatible` at your proxy's `baseURL` (default `http://localhost:4000/v1`) and route to a provider with LiteLLM's `provider/model` naming: +[LiteLLM](https://github.com/BerriAI/litellm) is a self-hosted gateway. It gives one OpenAI Chat Completions endpoint for 100+ providers (OpenAI, Anthropic, Google, Azure, AWS Bedrock, Mistral, Groq, and more). Point `openaiCompatible` at your proxy (default `http://localhost:4000/v1`). Pick a provider with the `provider/model` names of LiteLLM: ```typescript import { openaiCompatible } from "@tanstack/ai-openai/compatible"; @@ -234,465 +204,77 @@ const litellm = openaiCompatible({ }); ``` -`litellm("anthropic/claude-sonnet-5")` selects the Anthropic route; `litellm("openai/gpt-5.5")` selects OpenAI — all through the one proxy. Declare only the model routes you configured on the proxy; for precise per-model capabilities (e.g. a reasoning route without image input), use `createModel` as shown under [Declaring Models](#declaring-models). - -> The proxy holds each upstream provider's real credentials; the `apiKey` here is the proxy's own virtual/master key, not the upstream provider's. - -## Azure OpenAI - -Use `createAzureOpenaiText` from `@tanstack/ai-openai`. It sets the `api-key` header, the endpoint, the API version, and the deployment name for you: - -```typescript -import { createAzureOpenaiText } from "@tanstack/ai-openai"; - -const adapter = createAzureOpenaiText("gpt-5.6", process.env.AZURE_OPENAI_API_KEY!, { - resourceName: "my-resource", - deploymentName: "production-chat", -}); -``` - -See [Azure OpenAI](./openai#azure-openai) for `azureOpenaiText`, which reads the key and the other values from the environment. - -## GitHub Copilot - -Your users can pay for model calls with their GitHub Copilot plan. Each user signs in with GitHub. Your server sends their GitHub token to Copilot as `Authorization: Bearer`. - -### 1. Create a GitHub OAuth app - -1. On GitHub, open **Settings > Developer settings > OAuth Apps**. Click **New OAuth App**. -2. Set **Authorization callback URL** to your callback route, for example `https://my-app.com/auth/github/callback`. -3. Clear **Expire user access tokens**. The examples below keep one token and do not refresh it. -4. If your app runs in a terminal, select **Enable Device Flow**. -5. Set `GITHUB_CLIENT_ID` to the client ID. For a web app, also generate a client secret and set `GITHUB_CLIENT_SECRET` to it. - -If you keep **Expire user access tokens** on, a token lasts 8 hours. Refresh it with its refresh token, as [Authorizing OAuth apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps) describes. - -### 2. Sign the user in - -Pick the flow for your app: - -- Web app: the [web flow](#web-app) sends the user to GitHub and back. -- Terminal or desktop app: the [device flow](#terminal-app) shows a code. The user types the code on GitHub. - -Both flows ask for the `read:user` scope only. - -#### Web app - -`copilot-auth.ts` has the two sign-in routes and a reader for your chat routes. The token goes into an `HttpOnly` cookie, so browser code cannot read it: - -```typescript -import { copilotBaseURL } from "./copilot-host"; - -function env(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`Set ${name}.`); - return value; -} - -function readCookie(request: Request, name: string): string | undefined { - for (const pair of (request.headers.get("cookie") ?? "").split(";")) { - const [key, ...rest] = pair.trim().split("="); - if (key !== name) continue; - try { - return decodeURIComponent(rest.join("=")); - } catch { - return undefined; // A malformed value counts as no cookie. - } - } - return undefined; -} - -function cookie(name: string, value: string, maxAge: number): string { - return `${name}=${encodeURIComponent(value)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAge}`; -} - -/** GET /auth/github: send the user to GitHub. */ -export function startGithubSignIn(): Response { - const state = crypto.randomUUID(); - const url = new URL("https://github.com/login/oauth/authorize"); - url.searchParams.set("client_id", env("GITHUB_CLIENT_ID")); - url.searchParams.set("scope", "read:user"); - url.searchParams.set("state", state); - return new Response(null, { - status: 302, - headers: { - Location: url.toString(), - "Set-Cookie": cookie("github_state", state, 600), - }, - }); -} - -/** GET /auth/github/callback: swap the code for a token. */ -export async function finishGithubSignIn(request: Request): Promise { - const url = new URL(request.url); - const state = url.searchParams.get("state"); - if (!state || state !== readCookie(request, "github_state")) { - return new Response("The sign-in failed. Try again.", { status: 400 }); - } - const reply = await fetch("https://github.com/login/oauth/access_token", { - method: "POST", - headers: { Accept: "application/json", "Content-Type": "application/json" }, - body: JSON.stringify({ - client_id: env("GITHUB_CLIENT_ID"), - client_secret: env("GITHUB_CLIENT_SECRET"), - code: url.searchParams.get("code"), - }), - }); - const { access_token }: { access_token?: string } = await reply.json(); - if (!access_token) { - return new Response("The sign-in failed. Try again.", { status: 400 }); - } - const baseURL = await copilotBaseURL(access_token); - if (!baseURL) { - return new Response("This GitHub account has no Copilot access.", { - status: 403, - }); - } - const month = 60 * 60 * 24 * 30; - const headers = new Headers({ Location: "/" }); - headers.append("Set-Cookie", cookie("copilot_token", access_token, month)); - headers.append("Set-Cookie", cookie("copilot_base_url", baseURL, month)); - headers.append("Set-Cookie", cookie("github_state", "", 0)); - return new Response(null, { status: 302, headers }); -} - -/** The Copilot sign-in of the request, or `undefined`. */ -export function getCopilotSignIn(request: Request) { - const token = readCookie(request, "copilot_token"); - const baseURL = readCookie(request, "copilot_base_url"); - if (!token || !baseURL || !URL.canParse(baseURL)) return undefined; - // The user can change cookies. Send the token only to a Copilot host. - const { protocol, hostname } = new URL(baseURL); - if (protocol !== "https:" || !hostname.endsWith(".githubcopilot.com")) { - return undefined; - } - return { token, baseURL }; -} -``` - -1. Call `startGithubSignIn` from your `GET /auth/github` route. -2. Call `finishGithubSignIn` from your `GET /auth/github/callback` route. -3. Link a **Sign in with GitHub** button to `/auth/github`. - -- For GitHub Enterprise, also accept your `copilot-api.` host in `getCopilotSignIn`. -- To keep the token out of the browser completely, keep it in your session store. Then put only the session id in the cookie. - -#### Terminal app - -`copilot-device.ts` shows a code. Then it waits until the user types the code on GitHub: - -```typescript -interface DeviceCode { - device_code?: string; - user_code?: string; - verification_uri?: string; - interval?: number; - error_description?: string; -} - -interface TokenReply { - access_token?: string; - error?: string; - error_description?: string; - interval?: number; -} - -async function postGithub(path: string, body: Record) { - const response = await fetch(`https://github.com${path}`, { - method: "POST", - headers: { Accept: "application/json", "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - return response.json(); -} - -export async function signInWithDevice(): Promise { - const clientId = process.env.GITHUB_CLIENT_ID; - if (!clientId) throw new Error("Set GITHUB_CLIENT_ID."); - const code: DeviceCode = await postGithub("/login/device/code", { - client_id: clientId, - scope: "read:user", - }); - if (!code.device_code) { - throw new Error(code.error_description ?? "The GitHub sign-in failed."); - } - console.log(`Open ${code.verification_uri} and type ${code.user_code}`); - - let interval = code.interval ?? 5; - for (;;) { - await new Promise((resolve) => setTimeout(resolve, interval * 1000)); - const reply: TokenReply = await postGithub("/login/oauth/access_token", { - client_id: clientId, - device_code: code.device_code, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }); - if (reply.access_token) return reply.access_token; - if (reply.error === "slow_down") { - interval = reply.interval ?? interval + 5; - } else if (reply.error !== "authorization_pending") { - throw new Error(reply.error_description ?? "The GitHub sign-in failed."); - } - } -} -``` - -- `authorization_pending`: the user did not type the code yet. The loop asks again. -- `slow_down`: GitHub wants a longer wait. The loop waits longer. -- Any other error stops the sign-in. For example, the code expires after 15 minutes. +- `litellm("anthropic/claude-sonnet-5")` goes to Anthropic, and `litellm("openai/gpt-5.5")` goes to OpenAI, through the one proxy. +- Declare only the routes that you configured on the proxy. For exact capabilities of each route, use `createModel`. See [Declaring models](#declaring-models). +- The proxy holds the real credentials of each provider. The `apiKey` here is the virtual or master key of the proxy. -Keep the token in the OS keychain, or in a file that only the user can read. With **Expire user access tokens** cleared, the token does not expire, so the user signs in one time. +## Models and options -### 3. Find the Copilot host +### Declaring models -Any GitHub account can sign in, but only some accounts have Copilot. Business and enterprise accounts can also have a different host. `copilot-host.ts` reads both from GitHub: +The `models` array takes two forms. You can mix them: -```typescript -interface CopilotUser { - chat_enabled?: boolean; - endpoints?: { api?: string }; -} - -/** - * The Copilot API host of the user, or `undefined` when the account has no - * Copilot. `githubHost` is your GitHub Enterprise domain, or `github.com`. - */ -export async function copilotBaseURL( - token: string, - githubHost = "github.com", -): Promise { - const api = - githubHost === "github.com" - ? "https://api.github.com" - : `https://api.${githubHost}`; - const response = await fetch(`${api}/copilot_internal/user`, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2025-04-01", - }, - }); - // A failed lookup uses the default host. Only GitHub can say "no Copilot". - const user: CopilotUser = response.ok ? await response.json() : {}; - if (user.chat_enabled === false) return undefined; - if (user.endpoints?.api) return user.endpoints.api.replace(/\/+$/, ""); - return githubHost === "github.com" - ? "https://api.githubcopilot.com" - : `https://copilot-api.${githubHost}`; -} -``` - -- `chat_enabled: false`: the account has no Copilot plan and no seat from an organization. -- `endpoints.api`: the host of this account. -- GitHub Enterprise: pass your domain as `githubHost`. Also put your domain in place of `github.com` in the sign-in URLs of step 2. - -`copilot_internal/user` is not a public GitHub API. GitHub can change it. - -### 4. Add the Copilot headers - -Copilot reads a set of headers on every request. One `wrapFetch` in `copilot-headers.ts` sets all of them for each route: +- **A string** gets optimistic defaults: `text` and `image` input, with `streaming`, `function_calling`, and `structured_outputs`. Good for mainstream chat models. +- **A `createModel(name, capabilities)` definition** declares the exact capabilities of a model, so the types match the model. An example is a reasoning model with no image input. ```typescript -import type { FetchWrapper } from "@tanstack/ai"; - -/** The Copilot interaction types: the main loop, a subagent, a title, or a compaction. */ -type Interaction = - | "conversation-agent" - | "conversation-subagent" - | "conversation-background" - | "conversation-compaction"; - -export function copilotHeaders( - sessionId: string, - interaction: Interaction = "conversation-agent", -): FetchWrapper { - return (next) => (input, init) => { - const body = typeof init?.body === "string" ? JSON.parse(init.body) : {}; - const items = body.input ?? body.messages ?? []; - const last = items.at(-1); - // A user turn has text from the user. A tool result alone is an agent turn. - const userTurn = - last?.role === "user" && - (!Array.isArray(last.content) || - last.content.some( - (part: { type?: string }) => part.type !== "tool_result", - )); - const url = input instanceof Request ? input.url : String(input); - const headers = new Headers(init?.headers); - headers.set("User-Agent", "my-app/1.0.0"); - headers.set("Openai-Intent", "conversation-edits"); - headers.set("X-GitHub-Api-Version", "2026-08-01"); - headers.set("X-Interaction-Type", interaction); - headers.set("X-Interaction-Id", sessionId); - headers.set( - "x-initiator", - interaction === "conversation-agent" && userTurn ? "user" : "agent", - ); - if (/"type":"(image|image_url|input_image)"/.test(JSON.stringify(items))) { - headers.set("Copilot-Vision-Request", "true"); - } - if (url.includes("/v1/messages")) { - // Keep the betas that the adapter already set. - const betas = headers.get("anthropic-beta"); - const thinking = "interleaved-thinking-2025-05-14"; - headers.set("anthropic-beta", betas ? `${betas},${thinking}` : thinking); - } - return next(input, { ...init, headers }); - }; -} -``` - -- `x-initiator`: Copilot bills the requests that a user starts (`user`). Tool loop requests and the other interaction types are `agent`. -- `X-Interaction-Id`: use one stable id for each chat session. -- `X-Interaction-Type`: use `conversation-subagent` in a subagent, `conversation-background` for a title, and `conversation-compaction` for a summary of old messages. -- `Copilot-Vision-Request`: set it when a request has images. - -### 5. Call the model - -Copilot serves each model on one of three APIs. `GET {baseURL}/models` lists the `supported_endpoints` of each model. Choose the adapter in this order: - -1. `/v1/messages`: use `createAnthropicChat` from `@tanstack/ai-anthropic`, with the token and `auth: "bearer"`. -2. `/responses`: use `openaiCompatible` with `api: "responses"`. -3. `/chat/completions`: use `openaiCompatible` without `api` and without `modelOptions`. - -The examples below read the sign-in from the web app cookies. In a terminal app, use the saved token and the host from step 3 in place of `signIn`. - -#### Claude models - -Set the Copilot host as `baseURL`. The Anthropic SDK adds `/v1/messages`. Set `auth: "bearer"`. Do not use `"oauth"`, because it adds the Claude Code headers: - -```typescript -import { - chat, - chatParamsFromRequest, - toServerSentEventsResponse, -} from "@tanstack/ai"; -import { createAnthropicChat } from "@tanstack/ai-anthropic"; -import { copilotHeaders } from "./copilot-headers"; -import { getCopilotSignIn } from "./copilot-auth"; - -export async function POST(request: Request) { - const signIn = getCopilotSignIn(request); - if (!signIn) return new Response("Sign in with GitHub.", { status: 401 }); - const params = await chatParamsFromRequest(request); - const stream = chat({ - adapter: createAnthropicChat("claude-sonnet-5-5", signIn.token, { - baseURL: signIn.baseURL, - auth: "bearer", - }), - messages: params.messages, - wrapFetch: copilotHeaders(params.threadId), - }); - return toServerSentEventsResponse(stream); -} -``` - -`auth: "bearer"` sends `Authorization: Bearer` and no `x-api-key`. With a custom `baseURL`, the adapter sends no mid-conversation betas. It adds cache markers only when you set `promptCache`. - -#### OpenAI models - -Pass the token as `apiKey`: - -```typescript -import { - chat, - chatParamsFromRequest, - toServerSentEventsResponse, -} from "@tanstack/ai"; import { openaiCompatible } from "@tanstack/ai-openai/compatible"; -import { copilotHeaders } from "./copilot-headers"; -import { getCopilotSignIn } from "./copilot-auth"; - -export async function POST(request: Request) { - const signIn = getCopilotSignIn(request); - if (!signIn) return new Response("Sign in with GitHub.", { status: 401 }); - const params = await chatParamsFromRequest(request); - const copilot = openaiCompatible({ - name: "github-copilot", - baseURL: signIn.baseURL, - apiKey: signIn.token, - api: "responses", - models: ["gpt-6.1-sol"], - }); - - const stream = chat({ - adapter: copilot("gpt-6.1-sol"), - messages: params.messages, - modelOptions: { store: false, include: ["reasoning.encrypted_content"] }, - wrapFetch: copilotHeaders(params.threadId), - }); - return toServerSentEventsResponse(stream); -} -``` - -- `store: false`: Copilot keeps no responses. With `include`, the stream gives the encrypted reasoning. The adapter sends it back on the next turn when your history keeps it. -- The Chat Completions adapter reads the thinking that Copilot streams on `delta.reasoning_text`. - -Click **Sign in with GitHub**, approve, then send a message. The model call goes to the Copilot plan of the user. - -## ChatGPT plan - -A user with a ChatGPT plan can sign in with ChatGPT instead of an API key. [Sign in with ChatGPT](./openai#sign-in-with-chatgpt-byok) adds the sign-in button and keeps the access token in the `openai` BYOK slot. Your server reads the token like a pasted key: - -```typescript -import { - chat, - chatParamsFromRequest, - toServerSentEventsResponse, -} from "@tanstack/ai"; -import { createOpenaiChat } from "@tanstack/ai-openai"; -import { openaiByok } from "@tanstack/ai-openai/byok"; -import { byokMissing, getByokKey } from "@tanstack/ai/byok/server"; +import { createModel } from "@tanstack/ai"; -export async function POST(request: Request) { - const apiKey = getByokKey(request, openaiByok); - if (!apiKey) return byokMissing(openaiByok); - const params = await chatParamsFromRequest(request); - const stream = chat({ - adapter: createOpenaiChat("gpt-6.1-sol", apiKey), - messages: params.messages, - modelOptions: { store: false }, - }); - return toServerSentEventsResponse(stream); -} +const provider = openaiCompatible({ + baseURL: "https://api.deepseek.com/v1", + apiKey: process.env.DEEPSEEK_API_KEY!, + models: [ + "deepseek-chat", // string: optimistic defaults + createModel("deepseek-reasoner", { + input: ["text"], // text only + features: ["reasoning", "structured_outputs"], + }), + ], +}); ``` -The ChatGPT route requires `store: false`. Do not set `max_output_tokens`. [Sign in with ChatGPT](./openai#sign-in-with-chatgpt-byok) lists the other limits. +The types enforce the `input` modalities, the `modelOptions`, and the provider `tools` you declare. They do not read `features`. Function tools and structured output are always allowed on this path. If a provider rejects one of them at run time (for example, tools on a model without tool support), do not pass it for that model. -## Example: With Tools +### Providers -Tools work exactly as they do with any other adapter, for models that support function calling: +Any provider with the OpenAI Chat Completions API works. Common ones are below. **Check the `baseURL` and the model ids in the current docs of each provider**, because they change. -```typescript -import { chat, toolDefinition } from "@tanstack/ai"; -import { openaiCompatible } from "@tanstack/ai-openai/compatible"; -import { z } from "zod"; - -const getWeatherDef = toolDefinition({ - name: "get_weather", - description: "Get the current weather", - inputSchema: z.object({ location: z.string() }), -}); +| Provider | `baseURL` | Example model | +| --- | --- | --- | +| DeepSeek | `https://api.deepseek.com/v1` | `deepseek-chat`, `deepseek-reasoner` | +| Moonshot / Kimi | `https://api.moonshot.ai/v1` | `kimi-k2-0711-preview` | +| Alibaba Qwen (DashScope, intl) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen-max`, `qwen-plus` | +| Alibaba Qwen (DashScope, China) | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen-max` | +| Together AI | `https://api.together.xyz/v1` | `meta-llama/Llama-3.3-70B-Instruct-Turbo` | +| Fireworks AI | `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/llama-v3p3-70b-instruct` | +| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | +| DeepInfra | `https://api.deepinfra.com/v1/openai` | `meta-llama/Llama-3.3-70B-Instruct` | +| Perplexity | `https://api.perplexity.ai` | `sonar`, `sonar-pro` | +| Mistral | `https://api.mistral.ai/v1` | `mistral-large-latest` | +| Nebius | `https://api.studio.nebius.ai/v1` | `meta-llama/Llama-3.3-70B-Instruct` | +| Z.AI (GLM) | `https://api.z.ai/api/paas/v4` | `glm-4.6` | +| Baseten | `https://inference.baseten.co/v1` | depends on the model | +| Hugging Face (router) | `https://router.huggingface.co/v1` | `meta-llama/Llama-3.3-70B-Instruct` | +| NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | `meta/llama-3.3-70b-instruct` | +| Kilo Gateway | `https://api.kilo.ai/api/gateway` | `anthropic/claude-opus-5.5` | +| ZenMux | `https://zenmux.ai/api/v1` | `openai/gpt-5.5` | +| Poe | `https://api.poe.com/v1` | `openai/gpt-5.5` | +| DigitalOcean | `https://inference.do-ai.run/v1` | `openai-gpt-5.5` | +| Modal | `https://inference.us-west.modal.direct/v1` | `moonshotai/Kimi-K3` | +| Snowflake Cortex | `https://.snowflakecomputing.com/api/v2/cortex/v1` | `claude-opus-5` | -const getWeather = getWeatherDef.server(async ({ location }) => { - return { temperature: 72, conditions: "sunny" }; -}); +- Some providers sign the user in with OAuth, not with an API key. For Poe, DigitalOcean, and Snowflake Cortex, pass the access token from your sign-in code as `apiKey`. +- On Chat Completions, `RUN_FINISHED.usage` carries the prompt cache counts that the provider reports. Cache reads go to `promptTokensDetails.cachedTokens`, and cache writes go to `promptTokensDetails.cacheWriteTokens`. Moonshot / Kimi reports both. -const deepseek = openaiCompatible({ - baseURL: "https://api.deepseek.com/v1", - apiKey: process.env.DEEPSEEK_API_KEY!, - models: ["deepseek-chat"], -}); +## Advanced -const stream = chat({ - adapter: deepseek("deepseek-chat"), - messages: [{ role: "user", content: "What's the weather in Tokyo?" }], - tools: [getWeather], -}); -``` +These topics are on [OpenAI-Compatible: Advanced](./openai-compatible-advanced): -## Next Steps +- Configuration: headers, query parameters, and `wrapFetch`. +- The Responses API (`api: "responses"`). +- Azure OpenAI. +- GitHub Copilot: let users pay with their Copilot plan. +- ChatGPT plan: let users pay with their ChatGPT plan. -- [OpenAI Adapter](./openai) - The first-class OpenAI adapter -- [OpenRouter Adapter](./openrouter) - Access 300+ models through one gateway -- [Tools Guide](../tools/tools) - Learn about tools -- [Extending Adapters](../advanced/extend-adapter) - Add custom models to any adapter +To add custom models to any adapter, see [Extending Adapters](../advanced/extend-adapter). diff --git a/docs/adapters/openai.md b/docs/adapters/openai.md index d6a0985b7c..a94b0c29c8 100644 --- a/docs/adapters/openai.md +++ b/docs/adapters/openai.md @@ -2,7 +2,7 @@ title: OpenAI id: openai-adapter order: 1 -description: "Use OpenAI models with TanStack AI — GPT-4o, GPT-5, DALL-E image generation, TTS, and Whisper transcription via @tanstack/ai-openai." +description: "Use OpenAI models in TanStack AI with @tanstack/ai-openai: GPT chat, gpt-image images, speech, Whisper transcription, embeddings, and Sora video." keywords: - tanstack ai - openai @@ -15,11 +15,11 @@ keywords: - chatgpt --- -The OpenAI adapter provides access to OpenAI's models, including GPT-4o, GPT-5, image generation (DALL-E), text-to-speech (TTS), and audio transcription (Whisper). +The OpenAI adapter calls GPT for chat, and it calls image, speech, transcription, embedding, and video models from the same package. -> Using a third-party provider that speaks the OpenAI API (DeepSeek, Moonshot/Kimi, Together, Fireworks, a local LM Studio/vLLM server, …)? See the [OpenAI-Compatible Adapter](./openai-compatible) for a generic `openaiCompatible({ baseURL, apiKey, models })` factory. +Does your provider speak the OpenAI API, for example DeepSeek, Kimi, or a local vLLM server? Use the [OpenAI-Compatible adapter](./openai-compatible). -## Installation +## 1. Install @@ -34,371 +34,307 @@ octane: @tanstack/ai-openai -## Basic Usage +## 2. Set the API key -```typescript -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; - -const stream = chat({ - adapter: openaiText("gpt-5.2"), - messages: [{ role: "user", content: "Hello!" }], -}); -``` - -## Chat Completions API - -`@tanstack/ai-openai` ships two text adapters that hit different OpenAI endpoints. `openaiText` (default) calls the Responses API (`/v1/responses`). `openaiChatCompletions` calls the older Chat Completions API (`/v1/chat/completions`). - -Pick whichever fits your wire format and feature needs: - -| | `openaiText` (Responses) | `openaiChatCompletions` (Chat Completions) | -|---|---|---| -| Endpoint | `/v1/responses` | `/v1/chat/completions` | -| Reasoning summaries | Yes. With `reasoning` set, the summary text streams as `REASONING_*` events | No. `reasoning` goes out as `reasoning_effort`, and no text streams back | -| Wire-format compatibility | OpenAI-only | Matches the older de-facto industry shape (Grok, Groq, OpenRouter, many local model servers) | -| Structured output streaming | `text.format: { type: 'json_schema', strict: true }` + `stream: true` | `response_format: { type: 'json_schema', strict: true }` + `stream: true` | - -Use `openaiText` when you want reasoning-summary streaming or OpenAI-specific Responses features. Use `openaiChatCompletions` when you're migrating off a Chat-Completions-style provider, share request-building code with other Chat-Completions adapters in your stack, or want the more battle-tested wire format. - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiChatCompletions } from "@tanstack/ai-openai"; +Put the key in the environment of your server: -const stream = chat({ - adapter: openaiChatCompletions("gpt-5.2"), - messages: [{ role: "user", content: "Hello!" }], -}); +```bash +OPENAI_API_KEY=sk-... ``` -With an explicit API key: +`openaiText` and the other short factories read `OPENAI_API_KEY`. To pass the key in code, use the `create*` factory of the same adapter: ```typescript -import { chat } from "@tanstack/ai"; -import { createOpenaiChatCompletions } from "@tanstack/ai-openai"; - -const adapter = createOpenaiChatCompletions("gpt-5.2", process.env.OPENAI_API_KEY!, { - // organization, baseURL, headers — all optional -}); +import { createOpenaiChat } from "@tanstack/ai-openai"; -const stream = chat({ - adapter, - messages: [{ role: "user", content: "Hello!" }], -}); +const adapter = createOpenaiChat("gpt-6.1-sol", process.env.OPENAI_API_KEY!); ``` -Both adapters work the same with [Structured Outputs](../structured-outputs/overview), including `stream: true`, and accept the same `modelOptions` (temperature, top_p, max_tokens, stop, and more). Both take [`reasoning`](#reasoning). `openaiChatCompletions` cannot stream the thinking text. - -## Azure OpenAI - -Your OpenAI model runs as a deployment in Azure? Use `createAzureOpenaiText`. It calls the Azure Responses API and sends your key in the `api-key` header. +## 3. Stream a chat from the server ```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; -import { createAzureOpenaiText } from "@tanstack/ai-openai"; +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; export async function POST(request: Request) { - const { messages } = await request.json(); + const { messages, threadId, runId } = await chatParamsFromRequest(request); const stream = chat({ - adapter: createAzureOpenaiText("gpt-5.6", process.env.AZURE_OPENAI_API_KEY!, { - resourceName: "my-resource", - deploymentName: "production-chat", - }), + adapter: openaiText("gpt-6.1-sol"), messages, + threadId, + runId, }); return toServerSentEventsResponse(stream); } ``` -Azure gets `production-chat` as the model. Your code still uses `gpt-5.6`. The client does not change: it reads this route like any other chat route. - -`reasoning` takes the levels of the OpenAI model name, here `gpt-5.6`. A model name that is not an OpenAI model, such as a bare deployment name, takes no `reasoning`. See [Reasoning](#reasoning). - -`createAzureOpenaiText` reads nothing from the environment. You pass the key, and you pass the endpoint as `resourceName` or `baseURL`. - -Rather keep it all in the environment? Use `azureOpenaiText`. Like `openaiText`, it reads the key from the environment: +## 4. Render the chat -```sh -AZURE_OPENAI_API_KEY=your-key -AZURE_OPENAI_RESOURCE_NAME=my-resource -AZURE_OPENAI_DEPLOYMENT_NAME_MAP=gpt-5.6=production-chat -``` +The client is the same for every provider. Point `useChat` at your route: -```typescript -import { azureOpenaiText } from "@tanstack/ai-openai"; +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; + +export function Chat() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", + }); -const adapter = azureOpenaiText("gpt-5.6"); + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => ( +

+ {message.parts + .map((part) => (part.type === "text" ? part.content : "")) + .join("")} +

+ ))} + setInput(event.target.value)} /> +
+ ); +} ``` -`azureOpenaiText` always reads the key from `AZURE_OPENAI_API_KEY`, and it throws when that is not set. For the other values, the config wins over the environment: - -- **Endpoint:** `baseURL`, then `resourceName`, then `AZURE_OPENAI_BASE_URL`, then `AZURE_OPENAI_RESOURCE_NAME`. You must set one of them. -- **API version:** `apiVersion`, then `AZURE_OPENAI_API_VERSION`, then `v1`. -- **Deployment:** `deploymentName`, then `deploymentNameMap`, then `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`, then the model name. A `deploymentNameMap` in the config replaces the environment map. The two maps do not merge. +For Vue, Solid, Svelte, Angular, and the other frameworks, see [Quick Start](../getting-started/quick-start#3-render-the-chat). -An Azure resource URL always goes to the `/openai/v1` path. A proxy URL keeps its own path. +You now have a streaming GPT chat. [Tools](../tools/tools) and [Structured Outputs](../structured-outputs/overview) work the same as with every adapter. -## Basic Usage - Custom API Key +## Images ```typescript -import { chat } from "@tanstack/ai"; -import { createOpenaiChat } from "@tanstack/ai-openai"; +import { generateImage } from "@tanstack/ai"; +import { openaiImage } from "@tanstack/ai-openai"; -const adapter = createOpenaiChat("gpt-5.2", process.env.OPENAI_API_KEY!, { - // ... your config options +const result = await generateImage({ + adapter: openaiImage("gpt-image-2.5-flare"), + prompt: "A futuristic cityscape at sunset", + threadId: "thread-1", + numberOfImages: 1, + size: "1024x1024", + modelOptions: { + quality: "high", + }, }); -const stream = chat({ - adapter, - messages: [{ role: "user", content: "Hello!" }], -}); +console.log(result.images); ``` -## Sign in with ChatGPT (BYOK) +`quality` takes `low`, `medium`, `high`, or `auto`. The `gpt-image-2.5` models also take `xhigh` and `max`. See [Image Generation](../media/image-generation). -Many of your users have a ChatGPT plan but no API key. Let them sign in with ChatGPT. The token bills their ChatGPT plan, not your account. +## Speech -The helpers in `@tanstack/ai-openai/siwc` put the access token into the `openai` [BYOK](../advanced/byok) slot. Your relay reads it like a pasted key. - -Before you start, check these limits: - -- OpenAI allows this sign-in only for open-source and locally hosted apps. For a hosted app, fill in the [interest form](https://openai.com/form/sign-in-with-chatgpt-interest/). -- OpenAI redirects only to `127.0.0.1`. Something must answer on that address. Vite listens on `::1` by default. The `ts-react-chat` example runs a small script on `127.0.0.1:3000` that redirects to `localhost:3000`. -- Keep the app on `localhost`, because passkey storage does not work on an IP address. The callback lands on `127.0.0.1`, then `completeChatGptSignIn` sends the browser back to `localhost`. -- The callback page must be at `/auth/callback`. - -### 1. Add the sign-in button - -Call `startChatGptSignIn` from a click. `agentName` is your app name. OpenAI shows it on the consent screen. +```typescript +import { generateSpeech } from "@tanstack/ai"; +import { openaiSpeech } from "@tanstack/ai-openai"; -```tsx -import { startChatGptSignIn } from "@tanstack/ai-openai/siwc"; +const result = await generateSpeech({ + adapter: openaiSpeech("tts-1"), + text: "Hello, welcome to TanStack AI!", + threadId: "thread-1", + voice: "alloy", + format: "mp3", +}); -export function ChatGptButton() { - return ( - - ); -} +// result.audio holds base64-encoded audio +console.log(result.format); // "mp3" ``` -### 2. Finish on `/auth/callback` +- Voices: `alloy`, `ash`, `ballad`, `coral`, `echo`, `fable`, `nova`, `onyx`, `sage`, `shimmer`, `verse`. +- `modelOptions.instructions` sets how the voice speaks. It does not work with `tts-1` or `tts-1-hd`. -Render this component on the `/auth/callback` route. It exchanges the code when the page loads. Then the user clicks to save. - -```tsx -import { useEffect, useRef, useState } from "react"; -import { - completeChatGptSignIn, - saveChatGptSignIn, -} from "@tanstack/ai-openai/siwc"; -import type { ChatGptSignIn } from "@tanstack/ai-openai/siwc"; -import { byok } from "./byok"; - -export function ChatGptCallback() { - const started = useRef(false); - const [signIn, setSignIn] = useState(null); - const [error, setError] = useState(""); - - useEffect(() => { - // The code works one time. Strict Mode runs effects two times. - if (started.current) return; - started.current = true; - completeChatGptSignIn().then(setSignIn, (caught: unknown) => { - setError(caught instanceof Error ? caught.message : "Sign-in failed"); - }); - }, []); - - if (error) return

{error}

; - if (!signIn) return

Finishing sign-in...

; - return ( - - ); -} -``` +See [Text-to-Speech](../media/text-to-speech). -The save needs a click because passkey storage shows a browser prompt. The refresh token goes into a second keyring slot. No send attaches that slot, so the refresh token stays in the browser. +## Transcription -### 3. Refresh the token +```typescript +import { generateTranscription } from "@tanstack/ai"; +import { openaiTranscription } from "@tanstack/ai-openai"; +import { audioFile } from "./audio"; -The access token lasts one hour. Call `refreshChatGptSignIn` on a timer: +const result = await generateTranscription({ + adapter: openaiTranscription("whisper-1"), + audio: audioFile, // File object or base64 string + language: "en", + threadId: "thread-1", +}); -```tsx -import { useEffect } from "react"; -import { refreshChatGptSignIn } from "@tanstack/ai-openai/siwc"; -import { byok } from "./byok"; - -export function useChatGptRefresh() { - useEffect(() => { - const refresh = () => { - refreshChatGptSignIn(byok).catch(console.error); - }; - refresh(); - const timer = setInterval(refresh, 60_000); - return () => clearInterval(timer); - }, []); -} +console.log(result.text); ``` -The call does nothing until the token has less than five minutes left. If OpenAI rejects the refresh token, the call clears the sign-in. Then the user must sign in again. +For speaker labels, use `gpt-4o-transcribe-diarize`. See [Speaker diarization](./openai-advanced#speaker-diarization). -### 4. Read the token on the relay - -The relay code is the same as for a pasted key. Add `store: false`, because the ChatGPT route requires it. +## Embeddings ```typescript -import { - chat, - chatParamsFromRequest, - toServerSentEventsResponse, -} from "@tanstack/ai"; -import { createOpenaiChat } from "@tanstack/ai-openai"; -import { openaiByok } from "@tanstack/ai-openai/byok"; -import { byokMissing, getByokKey } from "@tanstack/ai/byok/server"; +import { embed } from "@tanstack/ai"; +import { openaiEmbedding } from "@tanstack/ai-openai"; -export async function POST(request: Request) { - const params = await chatParamsFromRequest(request); - const apiKey = getByokKey(request, openaiByok); - if (!apiKey) return byokMissing(openaiByok); +const result = await embed({ + adapter: openaiEmbedding("text-embedding-3-small"), + input: ["a red guitar", "a blue drum kit"], +}); - const stream = chat({ - adapter: createOpenaiChat("gpt-6-astra", apiKey), - messages: params.messages, - threadId: params.threadId, - runId: params.runId, - modelOptions: { store: false }, - }); - return toServerSentEventsResponse(stream); -} +console.log(result.embeddings[0]?.vector); +console.log(result.usage?.promptTokens); ``` -`store: false` also works with an API key. A ChatGPT token does not start with `sk-`, so use that test if you must send different options. - -The ChatGPT route has these limits: +Both models (`text-embedding-3-small` and `text-embedding-3-large`) take the top-level `dimensions` option to make the vectors shorter: -- Use a model that the user's ChatGPT plan includes. -- Do not send `temperature`, `top_p`, `max_output_tokens`, `metadata`, or `previous_response_id`. -- Hosted tools do not work. This includes image generation, file search, code interpreter, computer use, and hosted MCP. -- Audio input and transcription do not work. +```typescript +import { embed } from "@tanstack/ai"; +import { openaiEmbedding } from "@tanstack/ai-openai"; -Click **Continue with ChatGPT**, approve, save, then send a message. The relay calls OpenAI on the user's ChatGPT plan. +const result = await embed({ + adapter: openaiEmbedding("text-embedding-3-large"), + input: "a red guitar", + dimensions: 1024, +}); +``` -The `ts-react-chat` example has this flow in its key dialog. +See the [Embeddings guide](../embeddings.md). -## Configuration +## Summarization ```typescript -import { createOpenaiChat, type OpenAITextConfig } from "@tanstack/ai-openai"; +import { summarize } from "@tanstack/ai"; +import { openaiSummarize } from "@tanstack/ai-openai"; -const config: Omit = { - organization: "org-...", // Optional - baseURL: "https://api.openai.com/v1", // Optional, for custom endpoints -}; +const result = await summarize({ + adapter: openaiSummarize("gpt-5-mini"), + text: "Your long text to summarize...", + threadId: "thread-1", + maxLength: 100, + style: "concise", // "concise" | "bullet-points" | "paragraph" +}); -const adapter = createOpenaiChat("gpt-5.2", process.env.OPENAI_API_KEY!, config); +console.log(result.summary); ``` -Need a header on each request, or a log of each model call? The chat adapters on this page support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). +## Video -### Tools that cannot use strict mode +`openaiVideo` makes Sora videos with `sora-2` and `sora-2-pro`. Video generation is experimental. See [Video Generation](../media/video-generation). -OpenAI holds tool arguments to the schema only when the tool is sent with `strict: true`. Some schemas cannot be strict, for example a schema that uses `$ref`, `z.any()`, or `z.record()`. The adapter sends these tools with `strict: false`, so the tool still works. The model can then return arguments that do not match the schema. +## Realtime voice -In development, the adapter logs a warning once for each of these tools: +`openaiRealtime` and `openaiRealtimeToken` run a live voice chat. See [Realtime Voice Chat](../media/realtime-chat). -```txt -tool "lookup_user" sent with strict: false: schema uses $ref, which strict mode does not support -``` +## Files + +`openaiFiles` uploads media to the OpenAI Files API, so a message can point to the file. See [Files API](../advanced/files-api). + +## Evaluate -To get strict mode back, change the schema so that the reason goes away. If you accept a non-strict tool, turn the warning off in the adapter config: +Sometimes you need an answer that your code can branch on, not chat text: a queue name, an urgency level, or a yes or no. Use `openaiDecider` with `decide()` to ask typed questions about one shared `state`. The adapter calls the OpenAI Decisions API (`/v1/decisions`): ```typescript -import { createOpenaiChat } from "@tanstack/ai-openai"; +import { decide, choice, score, boolean } from "@tanstack/ai"; +import { openaiDecider } from "@tanstack/ai-openai"; + +const ticket = { + subject: "Charged twice for the same invoice", + body: "Please refund the extra payment.", +}; -const adapter = createOpenaiChat("gpt-6-astra", process.env.OPENAI_API_KEY!, { - strictFallbackWarning: false, +const result = await decide({ + adapter: openaiDecider("gpt-6-luna"), + state: ticket, + questions: { + queue: choice({ + instructions: "Which team should handle this ticket?", + options: { + billing: "Payments, invoices, refunds", + tech: "Bugs, outages, integrations", + sales: "Pricing, upgrades, new accounts", + }, + }), + urgency: score({ + instructions: "How urgent is this ticket?", + levels: ["low", "medium", "high"], + }), + refund: boolean({ + instructions: "Is the customer asking for a refund?", + }), + }, }); -``` -The warning never runs when `NODE_ENV` is `production`. The same option works on every adapter built on the OpenAI API, for example Groq, Bedrock, and Vercel AI Gateway. - -## Example: Chat Completion +console.log(result.queue.value); // "billing" +console.log(result.urgency.value); // "medium" +console.log(result.refund.value); // true +console.log(result.meta.usage); +``` -```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; +`openaiDecider` reads `OPENAI_API_KEY`. To pass a key yourself, use `createOpenaiDecider("gpt-6-luna", "sk-...")`. -export async function POST(request: Request) { - const { messages } = await request.json(); +- `gpt-6-luna` is the only Decisions model. +- An object `state` goes to OpenAI as JSON text. +- If OpenAI refuses to answer a question, `decide()` throws an error with the name of that question. +- `boolean()` questions work. The adapter adds the meaning of true and false to the instructions. - const stream = chat({ - adapter: openaiText("gpt-5.2"), - messages, - }); +See the [Evaluate guide](../evaluate/evaluate) for the question helpers, the result shape, abort, and middleware. - return toServerSentEventsResponse(stream); -} -``` +## Provider tools -## Example: With Tools +OpenAI runs some tools itself, such as web search and a code sandbox. Import them from `@tanstack/ai-openai/tools` and add them to `tools`: ```typescript -import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai"; +import { chat } from "@tanstack/ai"; import { openaiText } from "@tanstack/ai-openai"; -import { z } from "zod"; - -const getWeatherDef = toolDefinition({ - name: "get_weather", - description: "Get the current weather", - inputSchema: z.object({ - location: z.string(), - }), -}); +import { webSearchTool } from "@tanstack/ai-openai/tools"; -const getWeather = getWeatherDef.server(async ({ location }) => { - // Fetch weather data - return { temperature: 72, conditions: "sunny" }; +const stream = chat({ + adapter: openaiText("gpt-6.1-sol"), + messages: [{ role: "user", content: "What's new in AI this week?" }], + threadId: "thread-1", + tools: [webSearchTool({ type: "web_search" })], }); +``` -export async function POST(request: Request) { - const { messages } = await request.json(); +Tools that OpenAI runs: - const stream = chat({ - adapter: openaiText("gpt-5.2"), - messages, - tools: [getWeather], - }); +- `webSearchTool` and `webSearchPreviewTool`: search the web, with citations. +- `fileSearchTool`: search your OpenAI vector stores. +- `imageGenerationTool`: make images inside the chat. +- `codeInterpreterTool`: run Python in a sandbox. +- `mcpTool`: connect a remote MCP server. - return toServerSentEventsResponse(stream); -} -``` +Tools that your app runs when the model asks: + +- `localShellTool` and `shellTool`: run shell commands. +- `applyPatchTool`: apply a file diff. +- `computerUseTool`: control a virtual desktop. +- `customTool`: a Responses API tool with your own name and format. + +Each tool, with a full sample, is on [OpenAI: Advanced](./openai-advanced#provider-tools). For which models take which tool, see [Provider Tools](../tools/provider-tools.md). + +## Models and options -## Model Options +### Model options -OpenAI supports various provider-specific options. Sampling parameters live here too — `temperature`, `top_p`, and `max_output_tokens` (the Responses API token-limit key) — rather than as root-level props on `chat()`: +Provider options go in `modelOptions`. This includes the sampling options `temperature`, `top_p`, and `max_output_tokens`: ```typescript import { chat } from "@tanstack/ai"; import { openaiText } from "@tanstack/ai-openai"; const stream = chat({ - adapter: openaiText("gpt-5.2"), + adapter: openaiText("gpt-4.1"), messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", modelOptions: { temperature: 0.7, max_output_tokens: 1000, @@ -407,11 +343,16 @@ const stream = chat({ }); ``` -> The `openaiChatCompletions` adapter targets `/v1/chat/completions`, where the token-limit key is `max_tokens` (not `max_output_tokens`). If you previously passed `temperature` / `topP` / `maxTokens` at the root of `chat()`, see [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). +- OpenAI rejects `temperature` and `top_p` on the reasoning models. `openaiText` drops the two options for these model ids: + - The `o` models (`o1`, `o3`, `o4-mini`, and so on). + - Every id that starts with `gpt-5`, except ids that end in `-chat-latest`. + - `codex-mini-latest`. +- With `openaiChatCompletions`, the token limit is `max_tokens`. +- Sampling options do not go at the root of `chat()`. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). -## Reasoning +### Reasoning -Want GPT to think harder, or skip it? Set `reasoning` on `chat()`: +Want GPT to think harder, or not at all? Set `reasoning` on `chat()`: ```typescript import { chat } from "@tanstack/ai"; @@ -420,11 +361,12 @@ import { openaiText } from "@tanstack/ai-openai"; const stream = chat({ adapter: openaiText("gpt-6.1-sol"), messages: [{ role: "user", content: "Plan a database migration." }], + threadId: "thread-1", reasoning: "xhigh", }); ``` -What goes on the wire: +What goes to OpenAI: - `openaiText` and `azureOpenaiText`: `reasoning: { effort, summary: "auto" }`. The summary text streams as thinking parts. `summary: false` drops the summary. - `openaiChatCompletions`: `reasoning_effort`. No thinking text streams back. @@ -444,796 +386,30 @@ The levels of each model: | `gpt-5.2-chat-latest` | `medium` | | `gpt-5-pro` | `high` | -The other models, such as `gpt-4.1` and `gpt-4o`, take no `reasoning`. - -### Native compaction - -OpenAI can compact a long conversation on its side, with `POST /responses/compact`. The Responses adapters (`openaiText` and `createOpenaiChat`) have a `compact` method for it. Turn it on with the `native` option of `withCompaction`: - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { withCompaction } from "@tanstack/ai-compaction"; - -const adapter = openaiText("gpt-6.1-sol"); - -const stream = chat({ - adapter, - messages: [{ role: "user", content: "Hello!" }], - middleware: [withCompaction({ maxTokens: 100_000, native: adapter })], -}); -``` - -- The result holds your user messages and one encrypted `compaction` item. -- The adapter sends that item back as it is on each later request to the same model. -- `openaiChatCompletions` has no `compact` method, so `withCompaction` uses its `strategy`. - -See [Let the provider compact](../advanced/compaction#let-the-provider-compact). - -### Answers on the next request - -OpenAI gives each answer item an `id` and a `phase`: `commentary` for text before a tool call, and `final_answer` for the answer. The Responses adapters (`openaiText` and `createOpenaiChat`) keep both in the assistant message, in `metadata.tanstack.responseItems`. When the same model gets that message again, for example on the next turn, the adapter sends each item back with its `id` and `phase`. Another model gets the plain text. - -### Prompt caching - -`chat()` sends `prompt_cache_key` by default, set to the `threadId` that you pass. OpenAI uses the key to send requests with the same start to the same cache. With `promptCache: 'long'`, it also asks for the long retention. See [Prompt Caching](../advanced/prompt-caching). - -Want your own key? Set it in `modelOptions`. Your value wins: - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; - -const stream = chat({ - adapter: openaiText("gpt-6.1-sol"), - messages: [{ role: "user", content: "Hello!" }], - modelOptions: { - prompt_cache_key: "acme-support", - }, -}); -``` - -On gpt-5.6 and later, `modelOptions.prompt_cache_options` wins over the automatic options. On earlier models, `modelOptions.prompt_cache_retention` wins over the automatic retention. `openaiChatCompletions` sends the key only to `api.openai.com` and its regional hosts (such as `eu.api.openai.com`), unless you pass `promptCache: 'long'`. +The other models, such as `gpt-4.1` and `gpt-4o`, take no `reasoning`. See [Reasoning](../chat/reasoning). -#### Tools and prompts added during a conversation +### Models -On `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `gpt-6-luna`, and `gpt-6-sol`, a tool or a system prompt that you add between model calls goes into the conversation. The cached start stays the same. +Newest first. The package exports the full lists, for example `OPENAI_CHAT_MODELS`. -What `openaiText` sends on these models: +| Activity | Factory | Models | +| --- | --- | --- | +| Chat | `openaiText` | `gpt-6.1-sol`, `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6`, `gpt-5.5`, `gpt-4.1`, `gpt-4o`, the `o` models, and more | +| Images | `openaiImage` | `gpt-image-2.5-flare`, `gpt-image-2.5-sunburst`, `gpt-image-2`, `gpt-image-1`, `gpt-image-1-mini`, `dall-e-3`, `dall-e-2` | +| Speech | `openaiSpeech` | `tts-1`, `tts-1-hd`, `gpt-4o-audio-preview` | +| Transcription | `openaiTranscription` | `whisper-1`, `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, `gpt-4o-transcribe-diarize` | +| Embeddings | `openaiEmbedding` | `text-embedding-3-small`, `text-embedding-3-large` | +| Video | `openaiVideo` | `sora-2`, `sora-2-pro` | +| Evaluate | `openaiDecider` | `gpt-6-luna` | -- `tools` and `instructions` keep the tools and the system prompts of the first call. -- An added tool goes into `input` as `{ type: "additional_tools", role: "developer", tools: [...] }`. -- An added system prompt goes into `input` as a `developer` message, at its place in the conversation. -- With a provider tool such as `webSearchTool()`, `tools` is the full list for that request. - -The channels are on by default with OpenAI's own API. A custom `baseURL`, a custom `fetch`, or `OPENAI_BASE_URL` turns them off. Set `midConversationChannels` to choose: - -```typescript -import { openaiText } from "@tanstack/ai-openai"; - -export const fullLists = openaiText("gpt-6-astra", { midConversationChannels: false }); - -export const throughProxy = openaiText("gpt-6-astra", { - baseURL: "https://llm-proxy.example.com/v1", - midConversationChannels: true, -}); -``` +`adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). -If that endpoint sends the request on to OpenAI as it is, set `true`. See [Mid-Conversation Changes](../advanced/mid-conversation-changes). +## Advanced -## Summarization - -Summarize long text content: - -```typescript -import { summarize } from "@tanstack/ai"; -import { openaiSummarize } from "@tanstack/ai-openai"; - -const result = await summarize({ - adapter: openaiSummarize("gpt-5-mini"), - text: "Your long text to summarize...", - maxLength: 100, - style: "concise", // "concise" | "bullet-points" | "paragraph" -}); - -console.log(result.summary); -``` - -## Embeddings - -Generate embedding vectors with the text-embedding-3 models: - -```typescript -import { embed } from "@tanstack/ai"; -import { openaiEmbedding } from "@tanstack/ai-openai"; - -const result = await embed({ - adapter: openaiEmbedding("text-embedding-3-small"), - input: ["a red guitar", "a blue drum kit"], -}); - -console.log(result.embeddings[0]?.vector); -console.log(result.usage?.promptTokens); -``` - -Both models support Matryoshka dimension reduction via the top-level `dimensions` option: - -```typescript -import { embed } from "@tanstack/ai"; -import { openaiEmbedding } from "@tanstack/ai-openai"; - -const result = await embed({ - adapter: openaiEmbedding("text-embedding-3-large"), - input: "a red guitar", - dimensions: 1024, -}); -``` - -See the [Embeddings guide](../embeddings.md) for the full API. - -## Image Generation - -Generate images: - -```typescript -import { generateImage } from "@tanstack/ai"; -import { openaiImage } from "@tanstack/ai-openai"; - -const result = await generateImage({ - adapter: openaiImage("gpt-image-2"), - prompt: "A futuristic cityscape at sunset", - numberOfImages: 1, - size: "1024x1024", -}); - -console.log(result.images); -``` - -### Image Model Options - -```typescript -import { generateImage } from "@tanstack/ai"; -import { openaiImage } from "@tanstack/ai-openai"; - -const result = await generateImage({ - adapter: openaiImage("gpt-image-2"), - prompt: "...", - modelOptions: { - quality: "high", // "high" | "medium" | "low" | "auto" - }, -}); -``` - -## Text-to-Speech - -Generate speech from text: - -```typescript -import { generateSpeech } from "@tanstack/ai"; -import { openaiSpeech } from "@tanstack/ai-openai"; - -const result = await generateSpeech({ - adapter: openaiSpeech("tts-1"), - text: "Hello, welcome to TanStack AI!", - voice: "alloy", - format: "mp3", -}); - -// result.audio contains base64-encoded audio -console.log(result.format); // "mp3" -``` - -### TTS Voices - -Available voices: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`, `ash`, `ballad`, `coral`, `sage`, `verse` - -### TTS Model Options - -```typescript -import { generateSpeech } from "@tanstack/ai"; -import { openaiSpeech } from "@tanstack/ai-openai"; - -const result = await generateSpeech({ - adapter: openaiSpeech("tts-1-hd"), - text: "High quality speech", - modelOptions: { - instructions: "Speak slowly and clearly.", // voice instructions (not supported by tts-1/tts-1-hd) - }, -}); -``` - -## Transcription - -Transcribe audio to text: - -```typescript -import { generateTranscription } from "@tanstack/ai"; -import { openaiTranscription } from "@tanstack/ai-openai"; -import { audioFile } from "./audio"; - -const result = await generateTranscription({ - adapter: openaiTranscription("whisper-1"), - audio: audioFile, // File object or base64 string - language: "en", -}); - -console.log(result.text); // Transcribed text -``` - -### Transcription Model Options - -```typescript -import { generateTranscription } from "@tanstack/ai"; -import { openaiTranscription } from "@tanstack/ai-openai"; -import { audioFile } from "./audio"; - -const result = await generateTranscription({ - adapter: openaiTranscription("whisper-1"), - audio: audioFile, - responseFormat: "verbose_json", - prompt: "Technical terms: API, SDK", - modelOptions: { - temperature: 0, - timestamp_granularities: ["word", "segment"], - }, -}); - -// Access the transcribed text -console.log(result.text); -``` - -### Speaker Diarization - -Use `gpt-4o-transcribe-diarize` for speaker-labeled transcripts: - -```typescript -import { generateTranscription } from "@tanstack/ai"; -import { openaiTranscription } from "@tanstack/ai-openai"; -import { meetingAudioFile } from "./audio"; - -const result = await generateTranscription({ - adapter: openaiTranscription("gpt-4o-transcribe-diarize"), - audio: meetingAudioFile, - modelOptions: { - known_speaker_names: ["agent", "customer"], - known_speaker_references: [ - "data:audio/wav;base64,...", - "data:audio/wav;base64,...", - ], - }, -}); - -for (const segment of result.segments ?? []) { - console.log(segment.speaker, segment.start, segment.end, segment.text); -} -``` - -When no response format is specified, `gpt-4o-transcribe-diarize` requests default to `response_format: "diarized_json"` and `chunking_strategy: "auto"`; passing a top-level `responseFormat` of `"json"` or `"text"` opts out of speaker segments. `known_speaker_names` and `known_speaker_references` must be provided together (up to 4, matching lengths). OpenAI does not support `prompt`, `include`, or `timestamp_granularities` with diarized transcription. - -## Evaluate - -Sometimes you need an answer that your code can branch on, not chat text. Examples are a queue name, an urgency level, or a yes or no. -Use `openaiDecider` with `decide()` to ask typed questions about one shared `state`. The adapter calls the OpenAI Decisions API (`/v1/decisions`): - -```typescript -import { decide, choice, score, boolean } from "@tanstack/ai"; -import { openaiDecider } from "@tanstack/ai-openai"; - -const ticket = { - subject: "Charged twice for the same invoice", - body: "Please refund the extra payment.", -}; - -const result = await decide({ - adapter: openaiDecider("gpt-6-luna"), - state: ticket, - questions: { - queue: choice({ - instructions: "Which team should handle this ticket?", - options: { - billing: "Payments, invoices, refunds", - tech: "Bugs, outages, integrations", - sales: "Pricing, upgrades, new accounts", - }, - }), - urgency: score({ - instructions: "How urgent is this ticket?", - levels: ["low", "medium", "high"], - }), - refund: boolean({ - instructions: "Is the customer asking for a refund?", - }), - }, -}); - -console.log(result.queue.value); // "billing" -console.log(result.urgency.value); // "medium" -console.log(result.refund.value); // true -console.log(result.meta.usage); -``` - -`openaiDecider` reads `OPENAI_API_KEY` from the environment. To pass a key yourself, use `createOpenaiDecider("gpt-6-luna", "sk-...")`. - -Good to know: - -- `gpt-6-luna` is the only Decisions model. -- An object `state` goes to OpenAI as JSON text. -- If OpenAI refuses to answer a question, `decide()` throws an error with the name of that question. -- `boolean()` criteria work. The adapter adds the true and false meanings to the instructions. - -See the [Evaluate guide](../evaluate/evaluate) for question helpers, the result shape, abort, and middleware. - -## Environment Variables - -Set your API key in environment variables: - -```bash -OPENAI_API_KEY=sk-... -``` - -## API Reference - -Every factory pair follows the same shape: the short factory (`openaiText`, `openaiImage`, …) reads `OPENAI_API_KEY` from the environment, while the `create*` variant takes an explicit API key. Both take `model` as the first argument. - -### `openaiText(model, config?)` - -Creates an OpenAI text adapter against the Responses API (`/v1/responses`) using `OPENAI_API_KEY` from the environment. - -`adapter.inputModalities` lists the input kinds of the selected model. It is `undefined` for a model that this package does not know. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). - -**Parameters:** - -- `model` - OpenAI chat model id (e.g. `"gpt-5.2"`, `"gpt-4o-mini"`) -- `config?.organization` - Organization ID (optional) -- `config?.baseURL` - Custom base URL (optional) - -### `createOpenaiChat(model, apiKey, config?)` - -Creates an OpenAI text adapter (Responses API) with an explicit API key. - -### `openaiChatCompletions(model, config?)` - -Creates an OpenAI text adapter that targets `/v1/chat/completions` instead of the Responses API. See [Chat Completions API](#chat-completions-api) for when to use this over `openaiText`. - -### `createOpenaiChatCompletions(model, apiKey, config?)` - -Creates an OpenAI chat-completions adapter with an explicit API key. - -### `openaiSummarize(model, config?)` / `createOpenaiSummarize(model, apiKey, config?)` - -Creates an OpenAI summarization adapter. - -### `openaiImage(model, config?)` / `createOpenaiImage(model, apiKey, config?)` - -Creates an OpenAI image generation adapter (DALL-E, gpt-image). - -### `openaiSpeech(model, config?)` / `createOpenaiSpeech(model, apiKey, config?)` - -Creates an OpenAI text-to-speech adapter. - -### `openaiTranscription(model, config?)` / `createOpenaiTranscription(model, apiKey, config?)` - -Creates an OpenAI transcription adapter for Whisper, GPT-4o transcription, and GPT-4o diarized transcription models. - -### `openaiVideo(model, config?)` / `createOpenaiVideo(model, apiKey, config?)` - -Creates an OpenAI video generation adapter (Sora). _Experimental._ - -### `openaiDecider(model, config?)` / `createOpenaiDecider(model, apiKey, config?)` - -Creates an OpenAI evaluate adapter for `decide()`. See [Evaluate](#evaluate) for usage. - -### `openaiRealtime(...)` / `openaiRealtimeToken(...)` - -Realtime voice adapters. See [Realtime Voice Chat](../media/realtime-chat) for usage. - -## Next Steps - -- [Getting Started](../getting-started/quick-start) - Learn the basics -- [Tools Guide](../tools/tools) - Learn about tools -- [Other Adapters](./anthropic) - Explore other providers - -## Provider Tools - -OpenAI exposes several native tools beyond user-defined function calls. -Import them from `@tanstack/ai-openai/tools` and pass them into -`chat({ tools: [...] })`. - -> For the full concept, a comparison matrix, and type-gating details, see -> [Provider Tools](../tools/provider-tools.md). - -### `webSearchTool` - -Enables the model to run a web search and return grounded results with -citations. Pass a `WebSearchToolConfig` object (typed from the OpenAI SDK) -to configure the tool. - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { webSearchTool } from "@tanstack/ai-openai/tools"; - -const stream = chat({ - adapter: openaiText("gpt-5.2"), - messages: [{ role: "user", content: "What's new in AI this week?" }], - tools: [webSearchTool({ type: "web_search" })], -}); -``` - -**Supported models:** GPT-4o, GPT-5, and Responses API-capable models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `webSearchPreviewTool` - -The preview variant of web search with additional options for controlling -search context size and user location. Use this when you want fine-grained -control over the search context sent to the model. - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { webSearchPreviewTool } from "@tanstack/ai-openai/tools"; - -const stream = chat({ - adapter: openaiText("gpt-5.2"), - messages: [{ role: "user", content: "Latest news about TypeScript" }], - tools: [ - webSearchPreviewTool({ - type: "web_search_preview_2025_03_11", - search_context_size: "high", - }), - ], -}); -``` - -**Supported models:** GPT-4o and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -When the model uses hosted web search, the assistant tool-call part includes -`metadata.providerExecuted: true` and the normalized `metadata.sources` array. -OpenAI's raw search call and URL citations stay under `metadata.openai`. The -agent loop does not execute this call in your application, and the adapter -replays the raw response items on the next turn. - -### `fileSearchTool` - -Searches OpenAI vector stores that you have pre-populated, letting the model -retrieve relevant document chunks. Provide the `vector_store_ids` to search -and optionally limit results with `max_num_results` (1–50). - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { fileSearchTool } from "@tanstack/ai-openai/tools"; - -const stream = chat({ - adapter: openaiText("gpt-5.2"), - messages: [{ role: "user", content: "What does the handbook say about PTO?" }], - tools: [ - fileSearchTool({ - type: "file_search", - vector_store_ids: ["vs_abc123"], - max_num_results: 5, - }), - ], -}); -``` - -**Supported models:** GPT-4o and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `imageGenerationTool` - -Allows the model to generate images inline during a conversation using -DALL-E/GPT-Image. Pass quality, size, and style options via the config object. - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { imageGenerationTool } from "@tanstack/ai-openai/tools"; - -const stream = chat({ - adapter: openaiText("gpt-5.2"), - messages: [{ role: "user", content: "Draw a logo for my app" }], - tools: [ - imageGenerationTool({ - quality: "high", - size: "1024x1024", - }), - ], -}); -``` - -**Supported models:** GPT-5 and GPT-Image-capable models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `codeInterpreterTool` - -Gives the model a sandboxed Python execution environment. The `container` -field configures the execution environment; pass the full -`CodeInterpreterToolConfig` object. - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { codeInterpreterTool } from "@tanstack/ai-openai/tools"; - -const stream = chat({ - adapter: openaiText("gpt-5.2"), - messages: [{ role: "user", content: "Analyse this CSV and plot a chart" }], - tools: [ - codeInterpreterTool({ type: "code_interpreter", container: { type: "auto" } }), - ], -}); -``` - -**Supported models:** GPT-4o and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `mcpTool` - -Connects the model to a remote MCP (Model Context Protocol) server, exposing -all its capabilities as callable tools. Provide either `server_url` or -`connector_id` — not both. - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { mcpTool } from "@tanstack/ai-openai/tools"; - -const stream = chat({ - adapter: openaiText("gpt-5.2"), - messages: [{ role: "user", content: "List my GitHub issues" }], - tools: [ - mcpTool({ - server_url: "https://mcp.example.com", - server_label: "github", - }), - ], -}); -``` - -**Supported models:** GPT-4o and above. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `computerUseTool` - -The model can look at a screen and use the mouse and the keyboard. Your app runs each action and sends back a screenshot. `createComputerUse` from `@tanstack/ai-computer-use` does this on a Playwright browser or an E2B desktop. Each thread gets its own machine: - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { createComputerUse } from "@tanstack/ai-computer-use"; -import { playwrightComputer } from "@tanstack/ai-computer-playwright"; - -declare const threadId: string; - -const computerUse = createComputerUse({ - driver: playwrightComputer({ startUrl: "https://tanstack.com" }), -}); - -const stream = chat({ - adapter: openaiText("gpt-6.1-sol"), - messages: [{ role: "user", content: "Find the TanStack AI docs" }], - threadId, - tools: computerUse.tools, - middleware: [computerUse.middleware], - interrupts: [computerUse.interrupt], -}); -``` - -The adapter sends the `computer` tool to OpenAI as its native computer tool. - -- For the setup, the threads, and how to close the machines, see [Computer use](../computer-use/computer-use.md). -- For the approval of OpenAI safety checks, see [Safety checks](../computer-use/safety-checks.md). -- For your own machine, see [Custom driver](../computer-use/custom-driver.md). - -#### The raw tool - -`computerUseTool` from `@tanstack/ai-openai/tools` is the native tool with no `execute`. Use it when your app runs the actions itself: - -```typescript -import { computerUseTool } from "@tanstack/ai-openai/tools"; - -const computer = computerUseTool({ type: "computer" }); -``` - -Add an `execute` that runs the actions and returns a screenshot as an image part. The input of each call has two fields: - -- `actions`: the actions to run, in order, as OpenAI sends them. For example `[{ type: "click", button: "left", x: 120, y: 340 }, { type: "type", text: "ada@example.com" }]`. -- `pending_safety_checks`: the safety checks that OpenAI attached to the actions. - -The adapter sends the first image of the result back as the screenshot. When the call does not fail, the adapter also tells OpenAI that the safety checks are accepted. When the call fails, they stay unaccepted. - -The `computer-use-preview` model takes the older tool type. Give it the display size and the environment. Each call then has one `action` field in place of `actions`: - -```typescript -import { computerUseTool } from "@tanstack/ai-openai/tools"; - -const previewTool = computerUseTool({ - type: "computer_use_preview", - display_width: 1024, - display_height: 768, - environment: "browser", -}); -``` - -**Supported models:** `type: "computer"` works with the models that list computer use, for example `gpt-6.1-sol`. `type: "computer_use_preview"` works with `computer-use-preview`. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `localShellTool` - -The model returns a `local_shell` tool call. Your app runs the command. Then your app sends the output back on the next request. `localShellTool()` takes no arguments. Add it to `tools`. - -Match `toolName` on `TOOL_CALL_START`. Read `input` on the `TOOL_CALL_END` with the same `toolCallId`. `input.command` is the command. `input.env` is the environment variables. - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { localShellTool } from "@tanstack/ai-openai/tools"; - -const stream = chat({ - adapter: openaiText("gpt-5.6"), - messages: [{ role: "user", content: "Run the test suite and summarise failures" }], - tools: [localShellTool()], -}); - -let localShellCallId: string | undefined; -for await (const chunk of stream) { - if (chunk.type === "TOOL_CALL_START" && chunk.toolName === "local_shell") { - localShellCallId = chunk.toolCallId; - } - if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === localShellCallId) { - console.log(chunk.input); - } -} -``` - -Send the command output as the tool result. Use `{ output: string }`, or a string. The next request sends `local_shell_call_output`. - -**Supported models:** GPT-5.x and other agent-capable models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `shellTool` - -`shellTool()` gives the model a shell. A container environment runs on OpenAI. A local environment runs in your app. - -When your app must run the commands, pass `environment: { type: "local" }`. Match `toolName` on `TOOL_CALL_START`. Read `input` on the `TOOL_CALL_END` with the same `toolCallId`. `input.commands` is the command list. `input.timeout_ms` is the time limit. `input.max_output_length` is the output limit. - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { shellTool } from "@tanstack/ai-openai/tools"; - -const stream = chat({ - adapter: openaiText("gpt-5.6"), - messages: [{ role: "user", content: "Count lines in all JS files" }], - tools: [shellTool({ environment: { type: "local" } })], -}); - -let shellCallId: string | undefined; -for await (const chunk of stream) { - if (chunk.type === "TOOL_CALL_START" && chunk.toolName === "shell") { - shellCallId = chunk.toolCallId; - } - if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === shellCallId) { - console.log(chunk.input); - } -} -``` - -Return one result per command: - -```typescript -const shellResult = { - output: [ - { - stdout: "12\n", - stderr: "", - outcome: { type: "exit" as const, exit_code: 0 }, - }, - ], -}; -``` - -`outcome.type` is `"exit"` or `"timeout"`. The next request sends `shell_call_output`. If the same response has no `shell_call_output`, a shell call with no environment comes back to your app. - -A container environment does not come back as a tool call. Pass `environment` to attach that container and hosted skills. - -**Supported models:** GPT-5.x and other agent-capable models. Responses API -only — Chat Completions does not support the shell tool. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -#### Attaching hosted skills - -Pass `environment.skills` to load provider-managed skill bundles into the -shell's container (Responses API only). - -```typescript -import { chat, toServerSentEventsResponse } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { shellTool } from "@tanstack/ai-openai/tools"; - -export async function POST(request: Request) { - const { messages } = await request.json(); - - const stream = chat({ - adapter: openaiText("gpt-5.2"), - messages, - tools: [ - shellTool({ - environment: { - type: "container_auto", - skills: [ - { type: "skill_reference", skill_id: "skill_abc", version: "2" }, - ], - }, - }), - ], - }); - - return toServerSentEventsResponse(stream); -} -``` - -For the full reference — skill shape, `version` string format, and the -Anthropic equivalent — see [Provider Skills](../tools/provider-skills.md). - -### `applyPatchTool` - -The model returns an `apply_patch` tool call. Your app applies the diff. Then your app sends the result back. `applyPatchTool()` takes no arguments. Add it to `tools`. - -Match `toolName` on `TOOL_CALL_START`. Read `input` on the `TOOL_CALL_END` with the same `toolCallId`. `input.operation` is one file change. `operation.type` is `create_file`, `update_file`, or `delete_file`. `operation.path` is the file path. `create_file` and `update_file` also include `operation.diff`. - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { applyPatchTool } from "@tanstack/ai-openai/tools"; - -const stream = chat({ - adapter: openaiText("gpt-5.6"), - messages: [{ role: "user", content: "Fix the import paths in src/index.ts" }], - tools: [applyPatchTool()], -}); - -let patchCallId: string | undefined; -for await (const chunk of stream) { - if (chunk.type === "TOOL_CALL_START" && chunk.toolName === "apply_patch") { - patchCallId = chunk.toolCallId; - } - if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === patchCallId) { - console.log(chunk.input); - } -} -``` - -Send this object as the tool result. The next request sends `apply_patch_call_output`. - -```typescript -const patchResult = { - status: "completed" as const, - output: "Updated src/index.ts", -}; -``` - -If the patch fails, set `status` to `"failed"`. Put the error text in `output`. Keep `applyPatchTool()` in `tools` on the next request. A patch-only turn has no text. If you use `chat({ stream: false })`, `text` is empty. Read the `apply_patch` tool call from `chunks` with the same checks as the loop above. - -**Supported models:** GPT-5.x and other agent-capable models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). - -### `customTool` - -Defines a custom Responses API tool with an explicit name, description, and -format. Use this when none of the structured tool types fits your use case. -Unlike branded provider tools, `customTool` returns a plain `Tool` and is -accepted by any chat model. - -```typescript -import { chat } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; -import { customTool } from "@tanstack/ai-openai/tools"; - -const stream = chat({ - adapter: openaiText("gpt-5.2"), - messages: [{ role: "user", content: "Look up order #1234" }], - tools: [ - customTool({ - type: "custom", - name: "lookup_order", - description: "Look up the status of a customer order by order ID", - }), - ], -}); -``` +These topics are on [OpenAI: Advanced](./openai-advanced): -**Supported models:** all Responses API models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). +- The Chat Completions API (`openaiChatCompletions`) and Azure OpenAI. +- Sign in with ChatGPT, so users pay with their ChatGPT plan. +- Prompt caching, native compaction, and tools added during a conversation. +- Every provider tool, with a full sample. +- The configuration options and the API reference. diff --git a/docs/adapters/opencode.md b/docs/adapters/opencode.md index d7cf003ca1..37b6367ab3 100644 --- a/docs/adapters/opencode.md +++ b/docs/adapters/opencode.md @@ -2,7 +2,7 @@ title: OpenCode id: opencode-adapter order: 14 -description: "Use OpenCode as a chat backend in TanStack AI — agent harness with local tool execution, token-level streaming, stateful sessions, and tool bridging via @tanstack/ai-opencode." +description: "Run the OpenCode agent inside a sandbox from TanStack AI, with any model that OpenCode supports. It edits files and runs commands in a real repo and streams each step." keywords: - tanstack ai - opencode @@ -13,122 +13,182 @@ keywords: - adapter --- -The OpenCode adapter runs [OpenCode](https://opencode.ai) as a chat backend, driving it over its local HTTP server (`@opencode-ai/sdk`). Unlike HTTP provider adapters, this is a **harness adapter**: OpenCode runs its own agent loop and executes its own tools — shell commands, file reads and edits, search — locally on your server. Each `chat()` call runs one full harness turn; assistant text and reasoning stream as true token-level deltas, and the harness's tool activity streams back as already-resolved tool-call events your UI can render. +The OpenCode adapter starts `opencode serve` in a sandbox and streams text and tool steps to `chat()`. The agent edits files and runs commands in a repo. -The adapter buffers each message part until it knows the parent message role. It streams assistant parts and skips user parts. +You need: -> **Server-only.** The adapter spawns (or attaches to) an `opencode serve` process, so it only works in a Node.js server environment — never in the browser. Treat it like giving OpenCode a shell on the machine it runs on, and configure permissions accordingly. +- A sandbox provider. The samples use Docker, so Docker must run on your machine. +- An API key for the model provider that OpenCode uses. The samples use OpenAI, so they need `OPENAI_API_KEY`. +- The `opencode` CLI inside the sandbox. A setup step in the sandbox installs it. -## Installation +## 1. Install -react: @tanstack/ai-opencode -vue: @tanstack/ai-opencode -solid: @tanstack/ai-opencode -svelte: @tanstack/ai-opencode -preact: @tanstack/ai-opencode -angular: @tanstack/ai-opencode -vanilla: @tanstack/ai-opencode -octane: @tanstack/ai-opencode +react: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +vue: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +solid: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +svelte: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +preact: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +angular: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +vanilla: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker +octane: @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-docker -The `opencode` CLI must be installed and its providers authenticated on the host: +## 2. Set the API key - - -react: install -g opencode-ai -vue: install -g opencode-ai -solid: install -g opencode-ai -svelte: install -g opencode-ai -preact: install -g opencode-ai -angular: install -g opencode-ai -vanilla: install -g opencode-ai -octane: install -g opencode-ai - - - -Then authenticate: +Put the key in the environment of your server: ```bash -opencode auth login +OPENAI_API_KEY=sk-... ``` -A runnable demo lives at [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare) — pick Claude Code, Codex, or Grok Build in the UI, with session resume, the harness tool timeline, and tool bridging, wired into a TanStack Start app on Workers. For the same wiring on plain Node with durable, refresh-surviving runs (Claude Code on Docker), see [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web) — swapping in this adapter is a one-line change (`src/sandbox-agent.ts`). +OpenCode reads the key from the environment of its process. The sandbox in step 3 passes it in as a workspace secret. This adapter has no `authMode` option. -## Models +## 3. Run the agent from the server -OpenCode is provider-agnostic: it resolves any `provider/model` id its configured providers support. Address models as `provider/model` (the adapter splits on the first `/`): - -```typescript -import { chat } from "@tanstack/ai"; -import { opencodeText } from "@tanstack/ai-opencode"; +Define the sandbox once. It clones your repo into a Docker container and installs the `opencode` CLI: -const stream = chat({ - adapter: opencodeText("anthropic/claude-sonnet-4-5", { - directory: "/path/to/project", - permissionMode: "acceptEdits", +```ts +// sandbox.ts +import { + createSecrets, + defineSandbox, + defineWorkspace, + githubRepo, +} from "@tanstack/ai-sandbox"; +import { dockerSandbox } from "@tanstack/ai-sandbox-docker"; + +export const sandbox = defineSandbox({ + id: "opencode-agent", + // The host talks to `opencode serve` on port 4096, so publish it. + provider: dockerSandbox({ image: "node:22", publishPorts: [4096] }), + workspace: defineWorkspace({ + source: githubRepo({ repo: "owner/app" }), + setup: ["npm install -g opencode-ai --include=optional && opencode --version"], + secrets: createSecrets({ + OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? "", + }), }), - messages: [{ role: "user", content: "Fix the failing test in utils.test.ts" }], }); ``` -## Configuration +Then pass the adapter and the sandbox to `chat()` in your route: + +```ts +// api/agent.ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from "@tanstack/ai"; +import { opencodeText } from "@tanstack/ai-opencode"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; + +export async function POST(request: Request) { + const { messages, threadId, runId } = await chatParamsFromRequest(request); + + const stream = chat({ + adapter: opencodeText("openai/gpt-5.2", { + permissionMode: "acceptEdits", + }), + messages, + threadId, + runId, + middleware: [withSandbox(sandbox)], + }); + + return toServerSentEventsResponse(stream); +} +``` -| Option | Description | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `directory` | Working directory for the harness session. Defaults to `process.cwd()`. | -| `baseUrl` | Attach to an already-running `opencode serve` (e.g. `http://127.0.0.1:4096`) instead of spawning a new server per turn. | -| `hostname` | Hostname for the spawned server. Defaults to the SDK default (`127.0.0.1`). | -| `port` | Port for the spawned server. Defaults to the SDK default (`4096`). | -| `permissionMode` | `'default'` (bridged tools run, everything else that prompts is rejected), `'acceptEdits'` (also auto-approves file edits), or `'bypassPermissions'` (allow all). | -| `onPermissionRequest` | Custom permission handler; replaces the default policy entirely. | -| `config` | Extra OpenCode config merged with the adapter's MCP and permission config. | +- Replace `owner/app` with your repo. +- Model ids are `provider/model`. The adapter splits the id at the first `/`. +- `permissionMode: "acceptEdits"` lets the agent edit files. See [Permissions](#permissions). -Per-call overrides — `sessionId`, `permissionMode`, `directory` — go through `modelOptions`. +## 4. Render the agent's work -## Reasoning +The client is the same as for any chat. Render the text, and show each tool that the agent ran: -Set `reasoning` on `chat()`. The adapter writes it into the model's `options` in the OpenCode server config: +```tsx +import { useState } from "react"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; -```typescript -import { chat } from "@tanstack/ai"; -import { opencodeText } from "@tanstack/ai-opencode"; +export function CodingAgent() { + const [input, setInput] = useState(""); + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/agent"), + threadId: "support-chat", + }); -const stream = chat({ - adapter: opencodeText("anthropic/claude-sonnet-4-5"), - messages: [{ role: "user", content: "Refactor the auth module" }], - reasoning: { level: "high", budgetTokens: 8000 }, -}); + return ( +
{ + event.preventDefault(); + sendMessage(input); + setInput(""); + }} + > + {messages.map((message) => + message.parts.map((part, index) => { + if (part.type === "text") { + return

{part.content}

; + } + if (part.type === "tool-call") { + return {part.name}; + } + return null; + }), + )} + setInput(event.target.value)} /> +
+ ); +} ``` -- Claude models get `thinking` with a token budget. `off` disables thinking. `anthropic/claude-sonnet-4-5` and `opencode/claude-sonnet-4-5` take `off` to `high`. `anthropic/claude-opus-4-5` takes `low`, `medium`, `high`. -- The other models get `reasoningEffort`, plus `reasoningSummary: "auto"` when the summary is on. `openai/gpt-5.2` takes `off`, `low` to `xhigh`. The `gpt-5.1-codex` models take `low`, `medium`, `high`. +Ask it "Fix the failing test in utils.test.ts". The text streams token by token, and you see a line for each tool the agent used: `read`, `edit`, `bash`, and more. The agent edited the files inside the container. + +## How it works + +- `opencodeText` declares that it needs a sandbox. `withSandbox(sandbox)` gives it one. Without a sandbox, `chat()` fails at the call site. +- The adapter starts `opencode serve` inside the sandbox and connects to it over HTTP with the `@opencode-ai/sdk` client. +- Each `chat()` call runs one full agent turn. OpenCode runs its own loop and its own tools. +- Text and reasoning stream token by token. The tools of the agent stream back as `tool-call` parts with the results attached. Your code does not run them. +- The adapter holds each message part until it knows the role of the message. It streams assistant parts and skips user parts. +- OpenCode can send a tool part before the tool input is ready. The adapter waits for the next update, so each tool call streams with its arguments. +- The running todo plan of the agent arrives as an `opencode.todo` custom event. + +To learn more about sandboxes, see [Sandboxes](../sandbox/overview). ## Permissions -OpenCode asks for permission before mutating files or running commands. A headless server has no one to answer those prompts, so the adapter applies a policy automatically — it never hangs a turn: +OpenCode asks for permission before it edits files or runs commands. A headless server has no person to answer, so the adapter answers each request from `permissionMode`. A turn never waits for an answer. -- **`'default'`** — bridged TanStack tools run; anything else that would prompt (edits, shell, web fetch) is rejected. -- **`'acceptEdits'`** — additionally auto-approves file-mutation requests (edit / write / patch). -- **`'bypassPermissions'`** — approves everything. Only use this against a sandbox or scratch directory. +| Mode | What runs | +| --- | --- | +| `'default'` | Your bridged tools run. Every other request that asks (edits, shell, web fetch) is rejected. | +| `'acceptEdits'` | Also allows file edits (edit, write, patch). | +| `'bypassPermissions'` | Allows every request. Use it only in a sandbox or a scratch directory. | -Provide `onPermissionRequest` to implement your own policy (e.g. allow-list specific commands). +To write your own rules, for example to allow only some commands, pass `onPermissionRequest`. It replaces the default policy. -## Stateful Sessions +## Resume a session -OpenCode sessions are stateful — the harness keeps the full working context (files read, commands run, conclusions reached) between turns. The adapter surfaces the session id of every fresh run as a custom stream event named `opencode.session-id`; thread it back via `modelOptions.sessionId` to resume. When resuming, only the latest user message is sent — the harness already holds the prior context. +OpenCode keeps its working context between turns: the files it read, the commands it ran, and what it found. The adapter sends the session id of each new run as a custom event named `opencode.session-id`. Send it back in `modelOptions.sessionId` to continue the session. -Server endpoint: +On the server, read the id from the request: -```typescript +```ts import { chat, chatParamsFromRequest, toServerSentEventsResponse, } from "@tanstack/ai"; import { opencodeText } from "@tanstack/ai-opencode"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; export async function POST(request: Request) { const params = await chatParamsFromRequest(request); @@ -140,11 +200,11 @@ export async function POST(request: Request) { : undefined; const stream = chat({ - adapter: opencodeText("anthropic/claude-sonnet-4-5", { - directory: "/path/to/project", - permissionMode: "acceptEdits", - }), + adapter: opencodeText("openai/gpt-5.2", { permissionMode: "acceptEdits" }), messages: params.messages, + threadId: params.threadId, + runId: params.runId, + middleware: [withSandbox(sandbox)], modelOptions: { sessionId }, }); @@ -152,20 +212,20 @@ export async function POST(request: Request) { } ``` -Client (React) — capture the session id from the custom event and send it back on subsequent requests: +On the client, keep the id from the custom event and send it with each request: -```typescript +```tsx import { useState } from "react"; -import { useChat } from "@tanstack/ai-react"; -import { fetchServerSentEvents } from "@tanstack/ai-client"; +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; -function CodingAssistant() { +export function useCodingAgent() { const [sessionId, setSessionId] = useState(undefined); - const { messages, sendMessage } = useChat({ - connection: fetchServerSentEvents("/api/chat", () => ({ + return useChat({ + connection: fetchServerSentEvents("/api/agent", () => ({ body: { sessionId }, })), + threadId: "support-chat", onCustomEvent: (name, value) => { if ( name === "opencode.session-id" && @@ -178,28 +238,22 @@ function CodingAssistant() { } }, }); - - // ... render messages; harness tool activity (bash, edit, read, ...) - // arrives as regular tool-call parts with results. } ``` -Sessions live on the server that ran them, so resuming only works against the same server instance (or a shared `baseUrl`). +- On a resumed session, the adapter sends only the latest user message. The agent already has the rest. +- OpenCode saves the session inside the sandbox. Resume works only when the next call runs in the same sandbox. See [Lifecycle & Snapshots](../sandbox/lifecycle). -## Tools +## Give the agent your own tools -Two kinds of tools flow through this adapter: +The agent has its own tools for files and the shell. It cannot reach your database or your secrets. Pass your server tools to `chat()`, and the adapter bridges them into the sandbox: -1. **Built-in harness tools** are executed by OpenCode itself and stream back as tool-call events with results already attached: `bash`, `edit`, `write`, `read`, `grep`, and the agent's running todo plan (surfaced as an `opencode.todo` custom event). Your code never executes them. - -2. **Your TanStack tools** are bridged *into* the harness: the adapter starts a short-lived Streamable-HTTP MCP server on `127.0.0.1` for the duration of the turn and registers it with OpenCode. Define tools as usual with `toolDefinition().server()`; tool-call events come back under the names you registered (OpenCode prefixes MCP tools `tanstack_…` internally, which the adapter strips). - -OpenCode can send a tool part in `pending` state before the tool input is ready. The adapter waits for the next update, so a tool call always streams with its arguments. - -```typescript group=tools +```ts group=tools import { z } from "zod"; import { chat, toolDefinition } from "@tanstack/ai"; import { opencodeText } from "@tanstack/ai-opencode"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; const lookupTicket = toolDefinition({ name: "lookup_ticket", @@ -210,24 +264,33 @@ const lookupTicket = toolDefinition({ }); const stream = chat({ - adapter: opencodeText("anthropic/claude-sonnet-4-5"), + adapter: opencodeText("openai/gpt-5.2"), messages: [{ role: "user", content: "What's the status of ticket T-123?" }], + threadId: "thread-1", tools: [lookupTicket], + middleware: [withSandbox(sandbox)], }); ``` -**Client-side and approval-gated tools are not supported.** The harness executes tools inside a live process, which cannot pause across HTTP requests to wait for a browser round-trip or a human approval. Passing a tool without a server `execute()` implementation — or one marked `needsApproval` — fails fast with a descriptive error. Run those tools outside the harness with a regular provider adapter. +- Each tool runs on your server, with its closures and secrets. The result goes back into the sandbox. +- OpenCode names the tool `tanstack_lookup_ticket` internally. The adapter removes the prefix, so the events use the name you gave. +- Your bridged tools always run, in every permission mode. +- Only server tools work here. A client tool has no server `execute()`, so the agent cannot run it. + +For how the bridge reaches your server from each provider, see [Sandbox Tools](../sandbox/tools). ## Tool choice -You want OpenCode to see just one of your tools, or none of them. Pass `toolChoice` to `chat()`: +You want OpenCode to see only one of your tools, or none of them. Pass `toolChoice` to `chat()`: -```typescript group=tools +```ts group=tools const onlyLookup = chat({ - adapter: opencodeText("anthropic/claude-opus-4-5"), + adapter: opencodeText("openai/gpt-5.2"), messages: [{ role: "user", content: "What's the status of ticket T-123?" }], + threadId: "thread-1", tools: [lookupTicket], toolChoice: { type: "tool", name: "lookup_ticket" }, + middleware: [withSandbox(sandbox)], }); ``` @@ -244,70 +307,145 @@ Each value except `'auto'` logs a warning the first time an adapter instance get For the values on other adapters, see [Choose when the model calls a tool](../tools/tools#choose-when-the-model-calls-a-tool). -## Structured Output +## Structured output -Pass `outputSchema` on `chat()`. OpenCode has no native schema flag. The adapter adds the JSON Schema to the prompt and parses the last assistant text (markdown fences are stripped). Tool activity still streams. The object arrives as `structured-output.complete`. +You want a typed report after the agent reads the repo. Pass `outputSchema` on the same `chat()` call: ```ts -import { chat } from "@tanstack/ai" -import { opencodeText } from "@tanstack/ai-opencode" -import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox" -import { dockerSandbox } from "@tanstack/ai-sandbox-docker" -import { z } from "zod" +import { chat } from "@tanstack/ai"; +import { opencodeText } from "@tanstack/ai-opencode"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { z } from "zod"; +import { sandbox } from "./sandbox"; const Report = z.object({ summary: z.string(), filesChanged: z.array(z.string()), -}) - -const sandbox = defineSandbox({ - id: "repo-report", - provider: dockerSandbox({ image: "node:22" }), -}) +}); const report = await chat({ - adapter: opencodeText("anthropic/claude-opus-4-5"), + adapter: opencodeText("openai/gpt-5.2"), messages: [{ role: "user", content: "Review this repo." }], + threadId: "thread-1", outputSchema: Report, middleware: [withSandbox(sandbox)], -}) +}); -report.summary +console.log(report.summary); ``` -This path parses JSON from the last assistant message. If extract-only is the job, use a model adapter such as `@tanstack/ai-openai`. - On the client, pass the same schema to `useChat` and read `final`. `partial` stays empty until the end. ```tsx -import { fetchServerSentEvents, useChat } from "@tanstack/ai-react" -import { z } from "zod" +import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"; +import { z } from "zod"; const Report = z.object({ summary: z.string(), filesChanged: z.array(z.string()), -}) +}); -function ReportView() { +export function ReportView() { const { final, isLoading } = useChat({ connection: fetchServerSentEvents("/api/repo-report"), + threadId: "support-chat", outputSchema: Report, - }) + }); - if (isLoading) return

The agent is inspecting the repo.

- if (!final) return null - return

{final.summary}

+ if (isLoading) return

The agent is inspecting the repo.

; + if (!final) return null; + return

{final.summary}

; } ``` -Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses). +- OpenCode has no schema flag. The adapter adds the JSON Schema to the prompt. +- The adapter parses the last assistant text as JSON. It removes markdown fences first. +- Tool activity still streams. The object arrives as `structured-output.complete`. + +Do you only need JSON from a prompt, with no repo? Use a model adapter such as `@tanstack/ai-openai`. For the full walkthrough, see [Harness Agents](../structured-outputs/harnesses). + +## Models and options + +### Models + +| Model | Reasoning levels | What `reasoning` sets | +| --- | --- | --- | +| `anthropic/claude-opus-4-5` | `low`, `medium`, `high` | `thinking` with a token budget | +| `anthropic/claude-sonnet-4-5`, `opencode/claude-sonnet-4-5` | `off`, `minimal`, `low`, `medium`, `high` | `thinking` with a token budget | +| `openai/gpt-5.2` | `off`, `low`, `medium`, `high`, `xhigh` | `reasoningEffort` | +| `openai/gpt-5.1-codex`, `opencode/gpt-5.1-codex` | `low`, `medium`, `high` | `reasoningEffort` | +| `google/gemini-3-pro-preview` | none | none | + +The package exports this list as `OPENCODE_MODELS`. The adapter also takes any other `provider/model` id that your OpenCode providers support. + +### Reasoning + +Set `reasoning` on `chat()`. The adapter writes it into the `options` of the model in the OpenCode server config: + +```ts +import { chat } from "@tanstack/ai"; +import { opencodeText } from "@tanstack/ai-opencode"; +import { withSandbox } from "@tanstack/ai-sandbox"; +import { sandbox } from "./sandbox"; + +const stream = chat({ + adapter: opencodeText("anthropic/claude-sonnet-4-5"), + messages: [{ role: "user", content: "Refactor the auth module" }], + threadId: "thread-1", + reasoning: { level: "high", budgetTokens: 8000 }, + middleware: [withSandbox(sandbox)], +}); +``` + +- Claude models get `thinking` with a token budget. `off` turns thinking off. +- The other models get `reasoningEffort`, plus `reasoningSummary: "auto"` when the summary is on. + +See [Reasoning](../chat/reasoning). + +### Adapter options + +Pass these as the second argument of `opencodeText`: + +| Option | What it does | +| --- | --- | +| `directory` | The working directory inside the sandbox. Default `/workspace`. | +| `port` | The port of `opencode serve` inside the sandbox. Default `4096`. On Docker, publish it with `publishPorts`. | +| `hostname` | The hostname that the server binds in the sandbox. Default `0.0.0.0`. | +| `permissionMode` | `'default'` (default), `'acceptEdits'`, or `'bypassPermissions'`. See [Permissions](#permissions). | +| `onPermissionRequest` | Your own permission handler. It replaces the default policy. | + +### Per-call options + +Pass these in `modelOptions` on `chat()`: + +- `sessionId`: resume a session. See [Resume a session](#resume-a-session). +- `permissionMode`: the permission mode for this call. +- `directory`: the working directory for this call. + +## Advanced + +### Ports on other providers + +The host connects to `opencode serve` over HTTP, so the sandbox must expose the port: + +- Docker: pass `publishPorts: [4096]` to `dockerSandbox()`. +- Vercel: expose the port with the `ports` option of the provider. +- Local-process and Daytona: nothing to do. + +### No run journal + +OpenCode does not write NDJSON to stdout, so this adapter writes no run journal, also on a durable run. See [Harnesses](../sandbox/harnesses#harness-output-can-go-to-a-journal). + +### Limitations -## Limitations +- **A sandbox is required.** Every call needs `withSandbox(...)`. +- **The agent owns the loop.** TanStack agent-loop strategies and per-iteration middleware do not apply inside a turn. +- **No sampling options.** Options such as `temperature` do not exist here. +- **Sessions stay in the sandbox.** Resume needs the same sandbox. +- **Cold starts.** Each call starts the OpenCode server in the sandbox and stops it after the turn. So the first token arrives later than with an HTTP adapter. +- **No `wrapFetch`.** This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the OpenCode server sends the model requests. -- **Server-only (Node).** The adapter spawns or attaches to an `opencode serve` process. -- **The harness owns the agent loop.** TanStack's agent-loop strategies and per-iteration middleware don't apply inside a harness turn. -- **No sampling controls.** `temperature`-style options don't exist here. -- **Sessions are server-local.** Resume requires hitting the same server instance (or a shared `baseUrl`). -- **Cold starts.** Spawning a server per turn adds first-token latency; point the adapter at a long-lived `baseUrl` to avoid it. +### Example apps -This adapter ignores [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call), because the harness process sends the model requests. +- [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare): a coding agent at the edge, with session resume, the tool timeline, and tool bridging. +- [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web): Claude Code on Docker, with durable runs that continue after a page refresh. To use OpenCode there, change the adapter in `src/sandbox-agent.ts`. diff --git a/docs/adapters/openrouter-advanced.md b/docs/adapters/openrouter-advanced.md new file mode 100644 index 0000000000..1342012d9a --- /dev/null +++ b/docs/adapters/openrouter-advanced.md @@ -0,0 +1,653 @@ +--- +title: "OpenRouter: Advanced" +id: openrouter-advanced +description: "Advanced OpenRouter setup in TanStack AI: retries, the Responses adapter, provider routing, prompt caching, tools with structured output, cost tracking, video, Sign in with OpenRouter, and the API reference." +keywords: + - tanstack ai + - openrouter + - provider routing + - retries + - prompt caching + - cost tracking + - sign in with openrouter + - adapter +--- + +This page has the details of the OpenRouter adapter. To get a first chat working, start at [OpenRouter](./openrouter). + +## Configuration + +```typescript +import { createOpenRouterText } from "@tanstack/ai-openrouter"; + +const adapter = createOpenRouterText( + "openai/gpt-6.1-sol", + process.env.OPENROUTER_API_KEY!, + { + serverURL: "https://openrouter.ai/api/v1", // Optional + httpReferer: "https://your-app.com", // Optional, for rankings + appTitle: "Your App Name", // Optional, for rankings + }, +); +``` + +Need a header on each request, or a log of each model call? Both text adapters support [`wrapFetch`](../advanced/middleware#change-the-http-requests-of-a-call). If you set an `httpClient`, each wrapped request still goes through it. + +## Retry rate limits + +A busy upstream provider can reply with HTTP 429. By default, the adapter retries only 5XX errors. To retry rate limits too, add `"429"` to `retryCodes`: + +```typescript +import { createOpenRouterText } from "@tanstack/ai-openrouter"; + +const adapter = createOpenRouterText( + "openai/gpt-6.1-sol", + process.env.OPENROUTER_API_KEY!, + { + retryConfig: { + strategy: "backoff", + backoff: { + initialInterval: 500, + maxInterval: 60000, + exponent: 1.5, + maxElapsedTime: 120000, + }, + retryConnectionErrors: true, + }, + retryCodes: ["429", "5XX"], + }, +); +``` + +- `retryCodes`: the HTTP status codes to retry. `"5XX"` matches every 5xx code. +- `retryConfig`: how long to wait between tries. If the response has a `Retry-After` header, the SDK waits that long. + +Now a 429 waits, then retries, and the chat continues with the next response. + +## Retry an error in the stream + +Most provider errors come as an HTTP status, and the SDK retries them as shown above. But OpenRouter can send status 200 first. Then a provider error comes as an error chunk in the stream, and the SDK cannot retry it. + +To retry that error, pass `retry` to `chat()`: + +```typescript +import { chat } from "@tanstack/ai"; +import { createOpenRouterText } from "@tanstack/ai-openrouter"; + +const adapter = createOpenRouterText( + "openai/gpt-6.1-sol", + process.env.OPENROUTER_API_KEY!, + { retryCodes: ["429", "5XX"] }, +); + +const stream = chat({ + adapter, + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + retry: { maxRetries: 3 }, +}); +``` + +When `chat()` retries: + +- Only for an error code in `retryCodes`. The default is 5XX only, so add `"429"` to retry a rate limit. +- Only when the call streamed no output yet. An error after part of the answer reaches you as a `RUN_ERROR`. +- Not when `retryConfig` has `strategy: "none"`. +- Only with the Chat Completions adapter (`openRouterText`). The Responses adapter does not do this. + +The wait is the `initialInterval` of `retryConfig` (default 500 ms). It is the same for each try and does not grow. See [Let `chat()` retry for you](../chat/stream-events#let-chat-retry-for-you). + +## Chat Completions vs Responses (beta) + +OpenRouter has two OpenAI-compatible wire formats. The package has one adapter for each: + +| Adapter | Endpoint | Status | When to use | +| --- | --- | --- | --- | +| `openRouterText` | `/v1/chat/completions` | Stable | The default for almost everything. The most models and tools. | +| `openRouterResponsesText` | `/v1/responses` | Beta | The OpenAI Responses request and response shape, with more multi-turn state on OpenAI-style models. | + +The wire format is how your app talks to OpenRouter. It does not limit which provider answers. Both adapters work with any model that OpenRouter has (`anthropic/...`, `google/...`, `meta-llama/...`, and more). + +```typescript +import { chat } from "@tanstack/ai"; +import { openRouterResponsesText } from "@tanstack/ai-openrouter"; + +const stream = chat({ + adapter: openRouterResponsesText("anthropic/claude-sonnet-5.5"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", +}); +``` + +Limits of the Responses adapter while it is in beta: + +- Function tools work. The OpenRouter server tools (web search, web fetch) do not. For those, use `openRouterText`. +- The Chat Completions endpoint covers more providers and features. If you are not sure, use `openRouterText`. + +## Provider preferences + +`modelOptions.provider` sets which upstream providers can serve the request, and in which order. The adapter sends it to OpenRouter as it is. This sample pins two providers and turns off fallbacks: + +```typescript +import { chat } from "@tanstack/ai"; +import { openRouterText } from "@tanstack/ai-openrouter"; + +const stream = chat({ + adapter: openRouterText("meta-llama/llama-4-maverick"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + modelOptions: { + provider: { + order: ["groq", "together"], // provider slugs, in priority order + allowFallbacks: false, // fail instead of routing elsewhere + }, + }, +}); +``` + +Provider slugs are the lowercase ids on the model pages of OpenRouter, for example `"anthropic"`, `"openai"`, or `"groq"`. + +Fields that limit which providers can serve the request: + +- `only` and `ignore`: an allow list or a deny list of provider slugs. OpenRouter merges them with the provider settings of your account for this request. +- `requireParameters`: send the request only to providers that support every parameter in it. Without it, OpenRouter sends each provider only the parameters it supports, and drops the rest with no error. So a `responseFormat` or a sampling option can be ignored. +- `quantizations`: only providers that serve the model at the given quantization levels, for example `"fp8"` or `"int4"`. +- `dataCollection`: `"deny"` uses only providers that do not collect user data. +- `zdr`: `true` uses only zero-data-retention endpoints. + +Fields that set the order and the price: + +- `sort`: `"price"`, `"throughput"`, or `"latency"`, or an object with `by` and `partition`. It applies when `order` is not set. It turns off load balancing. +- `maxPrice`: the highest price you accept. It takes `prompt` and `completion` in USD for each million tokens. It also takes `request` (price for each request) and `image` (price for each image) where a provider has them. OpenRouter skips providers above the limit. + +> [!NOTE] +> If you stream with `requireParameters: true`, also set `modelOptions.streamOptions: { includeUsage: false }`. Otherwise the adapter sends `stream_options`, and OpenRouter returns a 404 for providers that do not support that parameter. + +## Model variants + +`variant` adds the `:variant` suffix of OpenRouter to the model id. The adapter does not send it in the request body: + +```typescript +import { chat } from "@tanstack/ai"; +import { openRouterText } from "@tanstack/ai-openrouter"; + +const stream = chat({ + adapter: openRouterText("deepseek/deepseek-v4-pro"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + modelOptions: { + variant: "free", // requests deepseek/deepseek-v4-pro:free + }, +}); +``` + +The values are `"free"`, `"nitro"`, `"online"`, `"exacto"`, `"extended"`, and `"thinking"`. The OpenRouter catalog sets which variants a model has. + +## Plugins + +`plugins` turns on the request plugins of OpenRouter. Each entry has an `id`, plus the options of that plugin: + +```typescript +import { chat } from "@tanstack/ai"; +import { openRouterText } from "@tanstack/ai-openrouter"; + +const stream = chat({ + adapter: openRouterText("openai/gpt-6-astra"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", + modelOptions: { + plugins: [{ id: "web", maxResults: 5 }], + }, +}); +``` + +Plugin ids include `web`, `file-parser`, `response-healing`, `moderation`, and `auto-router`. For the options of each plugin, see the OpenRouter documentation. Web fetch is a server tool (`openrouter:web_fetch`), not a plugin. For web search and fetch as tools that the model can call, see [Provider tools](#provider-tools). + +## Session and metadata + +The adapter sends these fields to OpenRouter with no change: + +- `user`: a unique id for the user. +- `sessionId`: the sticky routing key. Requests with the same `sessionId` go to the same provider, which gives more prompt-cache hits. OpenRouter also groups them for observability. +- `metadata`: up to 16 key-value pairs. + +## Prompt caching + +By default, `chat()` sets `sessionId` to the cache key, which is the `threadId` that you pass. So one conversation stays on one provider and reads its cache. + +- `anthropic/*` models also get cache markers on the system message, the last tool, and the last message. +- `promptCache: 'long'` asks for the 1-hour cache. +- `promptCache: 'none'` sends nothing. + +Your own `modelOptions.sessionId` wins. If you add a cache marker of your own, the adapter adds no automatic markers to that request. See [Prompt Caching](../advanced/prompt-caching). + +## JSON mode + +Some OpenRouter models reject strict JSON schema. For these models, `chat({ outputSchema })` sends `json_object` and adds your schema to the system prompt. + +To send JSON mode to any other model, set `modelOptions.responseFormat`: + +```typescript +import { chat } from "@tanstack/ai"; +import { openRouterText } from "@tanstack/ai-openrouter"; +import { z } from "zod"; + +const person = await chat({ + adapter: openRouterText("openai/gpt-5.6-sol"), + messages: [{ role: "user", content: "Return Ada Lovelace, age 36." }], + threadId: "thread-1", + modelOptions: { responseFormat: { type: "json_object" } }, + outputSchema: z.object({ name: z.string(), age: z.number() }), +}); +``` + +Read [When strict JSON schema is rejected](../structured-outputs/overview.md#when-strict-json-schema-is-rejected). + +## Tools and structured output in one call + +You can pass both `tools` and `outputSchema` on one `chat()` call. For some upstream models, OpenRouter returns the typed object in that same streaming request. Then the engine does not make a second call to get the final object. + +The rules: + +- This happens only when every model that can get the request is in `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS`. +- Each model sync makes this set from the OpenRouter catalog. It holds every chat model whose `supported_parameters` include `structured_outputs`, `tools`, and `tool_choice`. Examples are Claude 4.5 and later, Gemini 2.5 and later, GPT-4o and later, Grok 4, DeepSeek V3 and later, and Llama 3.1 and later. +- A model that OpenRouter does not flag, such as `anthropic/claude-opus-4.1`, uses two calls. +- If a fallback in `modelOptions.models` is not in the set, OpenRouter uses two calls. +- Routing suffixes such as `:nitro` do not change the result. + +Import the set from `@tanstack/ai-openrouter/model-meta` to check a model before you send: + +```typescript +import { OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS } from "@tanstack/ai-openrouter/model-meta"; + +console.log(OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS.has("openai/gpt-5.5")); // true +``` + +Both text adapters attach the schema on this path. The server with the Chat Completions adapter: + +```typescript +import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai"; +import { openRouterText } from "@tanstack/ai-openrouter"; +import { z } from "zod"; + +const getWeather = toolDefinition({ + name: "get_weather", + description: "Get the current weather", + inputSchema: z.object({ location: z.string() }), +}).server(async ({ location }) => { + return { temperature: 72, conditions: "sunny", location }; +}); + +const AnswerSchema = z.object({ + summary: z.string(), + location: z.string(), +}); + +export async function POST(request: Request) { + const { messages, threadId } = await request.json(); + + const stream = chat({ + adapter: openRouterText("openai/gpt-5.5"), + messages, + threadId, + tools: [getWeather], + outputSchema: AnswerSchema, + stream: true, + }); + + return toServerSentEventsResponse(stream); +} +``` + +The server with the Responses adapter has the same `tools` and `outputSchema`: + +```typescript +import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai"; +import { openRouterResponsesText } from "@tanstack/ai-openrouter"; +import { z } from "zod"; + +const getWeather = toolDefinition({ + name: "get_weather", + description: "Get the current weather", + inputSchema: z.object({ location: z.string() }), +}).server(async ({ location }) => { + return { temperature: 72, conditions: "sunny", location }; +}); + +const AnswerSchema = z.object({ + summary: z.string(), + location: z.string(), +}); + +export async function POST(request: Request) { + const { messages, threadId } = await request.json(); + + const stream = chat({ + adapter: openRouterResponsesText("openai/gpt-5.5"), + messages, + threadId, + tools: [getWeather], + outputSchema: AnswerSchema, + stream: true, + }); + + return toServerSentEventsResponse(stream); +} +``` + +The client does not change. `useChat({ outputSchema })` still reads `partial` and `final`: + +```tsx +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; +import { z } from "zod"; + +const AnswerSchema = z.object({ + summary: z.string(), + location: z.string(), +}); + +export function useAnswer() { + const { sendMessage, partial, final } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", + outputSchema: AnswerSchema, + }); + + return { sendMessage, partial, final }; +} +``` + +See [Structured Outputs with tools](../structured-outputs/with-tools) for the event order, and [Middleware](../advanced/middleware) for how the `structuredOutput` phase works on this path. + +To try this in a browser, run `examples/ts-react-chat` and open `/generations/openrouter-combined`. The page shows the tool call, the typed object, and the call counts of the adapter. `structuredOutputStream` must stay at 0. + +## Provider tools + +OpenRouter runs web search and web fetch for any chat model. Import the tools from `@tanstack/ai-openrouter/tools`. For how provider tools compare across adapters, see [Provider Tools](../tools/provider-tools.md). + +### `webSearchTool` + +`webSearchTool` adds web search to any OpenRouter chat model. It takes the `WebSearchConfig` of OpenRouter: + +- `engine`: `auto`, `native`, `exa`, `firecrawl`, `parallel`, or `perplexity`. +- `maxResults` and `maxTotalResults`: the number of results. +- `allowedDomains` and `excludedDomains`: which sites can be in the results. +- `searchContextSize` and `userLocation`: more control of the search. + +```typescript +import { chat } from "@tanstack/ai"; +import { openRouterText } from "@tanstack/ai-openrouter"; +import { webSearchTool } from "@tanstack/ai-openrouter/tools"; + +const stream = chat({ + adapter: openRouterText("openai/gpt-6.1-sol"), + messages: [{ role: "user", content: "What's new in AI this week?" }], + threadId: "thread-1", + tools: [ + webSearchTool({ + engine: "exa", + maxResults: 5, + allowedDomains: ["arxiv.org", "openai.com"], + }), + ], +}); +``` + +Supported models: all OpenRouter chat models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +### `webFetchTool` + +`webFetchTool` lets any OpenRouter chat model read the full content of a URL that the model picks. It takes the `WebFetchServerToolConfig` of OpenRouter: + +- `engine`: `auto` (the default), `native`, `openrouter`, `exa`, `firecrawl`, or `parallel`. +- `maxContentTokens`: how much page content the model gets. +- `maxUses`: how many fetches the model can make in one request. +- `allowedDomains` and `blockedDomains`: which URLs the model can fetch. + +```typescript +import { chat } from "@tanstack/ai"; +import { openRouterText } from "@tanstack/ai-openrouter"; +import { webFetchTool } from "@tanstack/ai-openrouter/tools"; + +const stream = chat({ + adapter: openRouterText("openai/gpt-6.1-sol"), + messages: [ + { role: "user", content: "Summarize https://example.com/article" }, + ], + threadId: "thread-1", + tools: [ + webFetchTool({ + engine: "openrouter", + maxContentTokens: 4000, + allowedDomains: ["example.com"], + }), + ], +}); +``` + +> [!NOTE] +> The `native` engine uses the fetch of the upstream provider, for example the Anthropic `web_fetch` on Claude models. Native fetch is not the same on each provider, so it can ignore `allowedDomains` and `blockedDomains`. For the same behavior on all models, use `openrouter`, `exa`, or `firecrawl`. + +Supported models: all OpenRouter chat models. See [Provider Tools](../tools/provider-tools.md#which-models-support-which-tools). + +## Cost tracking + +OpenRouter reports the real cost of each request in the streamed response. The adapter puts it on the last `RUN_FINISHED` event: + +- `usage.cost`: the cost of the request. +- `usage.costDetails`: the breakdown of that cost from OpenRouter. + +TanStack AI does not calculate this cost from token counts. It comes from OpenRouter, so it includes routing, fallback providers, BYOK, and cached-token prices. For the meaning and units of the fields, see [Usage Accounting](https://openrouter.ai/docs/use-cases/usage-accounting) in the OpenRouter docs. + +```typescript ignore +import { chat, type RunFinishedEvent, type StreamChunk } from "@tanstack/ai"; +import { openRouterText } from "@tanstack/ai-openrouter"; + +function isRunFinished(chunk: StreamChunk): chunk is RunFinishedEvent { + return "finishReason" in chunk; +} + +for await (const chunk of chat({ + adapter: openRouterText("openai/gpt-6.1-sol"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", +})) { + if (isRunFinished(chunk)) { + console.log("cost:", chunk.usage?.cost); + console.log("breakdown:", chunk.usage?.costDetails); + } +} +``` + +- Middleware gets the same `usage`, with `cost` and `costDetails`, in the `onUsage` and `onFinish` hooks. +- If OpenRouter reports no cost, the fields are not there, and the stream completes as usual. +- Both `openRouterText` and `openRouterResponsesText` fill in the cost when OpenRouter returns it. + +How usage works when a call fails: + +- The `structuredOutputStream()` method of `openRouterText` puts the usage that it got on its last `RUN_ERROR` event. This covers parse failures, cut-off responses, empty responses, and SDK errors. +- When `chat()` uses this path, middleware `onChunk` gets the error event, also when the promise rejects. +- The `onUsage` hook reports successful calls. Each adapter call reports usage on one last event. +- A reported `completionTokensDetails.reasoningTokens` value of `0` stays zero. A missing count stays missing. +- An error before any usage arrives has no `usage` field. If the consumer stops reading the stream, it cannot get later events. + +## Video generation + +`openRouterVideo` uses the async video API of OpenRouter (`POST /api/v1/videos`). You get Seedance, Veo 3.1, Wan, Kling, and more with your one OpenRouter key. Video generation is experimental. + +Like every TanStack AI video adapter, it creates a job, then you poll the job. OpenRouter returns the finished video as a stream, not a URL. Persistence stores the stream and sets `url`, so pass the same options to both calls. + +The server: + +```typescript +import { generateVideo, getVideoJobStatus } from "@tanstack/ai"; +import { openRouterVideo } from "@tanstack/ai-openrouter"; +import { withGenerationPersistence } from "@tanstack/ai-persistence"; +import { persistence } from "./persistence"; + +const adapter = openRouterVideo("bytedance/seedance-2.5"); + +const hosting = { + threadId: "videos", + middleware: [ + withGenerationPersistence(persistence, { + artifactUrl: (ref) => `/api/artifacts/${ref.artifactId}`, + }), + ], +}; + +const { jobId } = await generateVideo({ + adapter, + ...hosting, + prompt: [ + { type: "text", content: "Animate this product shot, slow push-in" }, + { + type: "image", + source: { type: "url", value: "https://your-cdn.com/product.png" }, + metadata: { role: "start_frame" }, + }, + ], + size: "1280x720", + // `duration` is typed per model. Snap raw seconds with adapter.snapDuration(), + // or list the valid values with adapter.availableDurations(). + duration: 8, +}); + +let status = await getVideoJobStatus({ adapter, jobId, ...hosting }); +while (status.status !== "completed" && status.status !== "failed") { + await new Promise((r) => setTimeout(r, 5000)); + status = await getVideoJobStatus({ adapter, jobId, ...hosting }); +} + +// status.url is your artifact route. +// status.usage?.cost is the real cost that OpenRouter billed. +console.log(status.url, status.usage?.cost); +``` + +The client tracks the job with `useGenerateVideo`: + +```tsx +import { useGenerateVideo, fetchServerSentEvents } from "@tanstack/ai-react"; + +export function VideoResult() { + const { result } = useGenerateVideo({ + connection: fetchServerSentEvents("/api/generate/video"), + threadId: "video-studio", + }); + + return result?.url ?