Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions docs/content/docs/agent/reference/adapters-and-formats.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,17 @@ import {
openAIAdapter,
openAIReadableStreamAdapter,
openAIResponsesAdapter,
vercelAIAdapter,
langGraphAdapter,
openAIMessageFormat,
openAIConversationMessageFormat,
vercelAIMessageFormat,
langGraphMessageFormat,
identityMessageFormat,
} from "@openuidev/react-ui";
```

<Callout type="warn">All five stream adapters are **factory functions** — always call them with `()`. `streamAdapter: openAIAdapter()` is correct; a bare `streamAdapter: openAIAdapter` is wrong.</Callout>
<Callout type="warn">All six stream adapters are **factory functions** — always call them with `()`. `streamAdapter: openAIAdapter()` is correct; a bare `streamAdapter: openAIAdapter` is wrong.</Callout>

## The two channels

Expand Down Expand Up @@ -349,9 +351,9 @@ interface StreamProtocolAdapter {
}
```

`fetchLLM` calls `parse` on the `Response` your route returns and consumes the yielded AG-UI events. All five bundled adapters are factories you call to get an instance — `agUIAdapter()`, `openAIAdapter()`, `openAIReadableStreamAdapter()`, `openAIResponsesAdapter()`, `langGraphAdapter()`; only `langGraphAdapter()` accepts options.
`fetchLLM` calls `parse` on the `Response` your route returns and consumes the yielded AG-UI events. All six bundled adapters are factories you call to get an instance — `agUIAdapter()`, `openAIAdapter()`, `openAIReadableStreamAdapter()`, `openAIResponsesAdapter()`, `vercelAIAdapter()`, `langGraphAdapter()`; only `langGraphAdapter()` accepts options.

<Callout type="info">A malformed line in the stream is logged to the console and skipped — it does not abort the whole stream. Provider-level failures should be surfaced as a `RUN_ERROR` event (the OpenAI Responses and LangGraph adapters do this for their error events).</Callout>
<Callout type="info">The line-oriented adapters log and skip malformed lines. `vercelAIAdapter()` delegates validation to the Vercel AI SDK and rejects invalid UIMessage chunks. Provider-level failures should be surfaced as a `RUN_ERROR` event (the OpenAI Responses, Vercel AI SDK, and LangGraph adapters do this for their error events).</Callout>

### Selection guide

Expand All @@ -362,6 +364,7 @@ Start from your provider and how the route streams its reply. The stream adapter
| OpenAI **Chat Completions** SSE (`data: {…}` lines) | `openAIAdapter()` | `openAIMessageFormat` |
| OpenAI SDK `stream.toReadableStream()` (NDJSON, no `data:` prefix) | `openAIReadableStreamAdapter()` | `openAIMessageFormat` |
| OpenAI **Responses / Conversations** API SSE | `openAIResponsesAdapter()` | `openAIConversationMessageFormat` |
| **Vercel AI SDK v6** UIMessage SSE (`toUIMessageStreamResponse()`) | `vercelAIAdapter()` | `vercelAIMessageFormat` |
| **LangGraph** named-event SSE (`event: messages\ndata: …`) | `langGraphAdapter()` | `langGraphMessageFormat` |
| Already AG-UI events (your route emits AG-UI SSE directly) | `agUIAdapter()` | `identityMessageFormat` (default) |

Expand Down Expand Up @@ -427,6 +430,25 @@ const llm = fetchLLM({
- **What it handles:** `response.output_item.added` (assistant message start, `function_call` start, and server-side `function_call_output` → `TOOL_CALL_RESULT`), `response.output_text.delta` / `.done` for text, `response.function_call_arguments.delta` / `.done` for tool-call arguments (mapping `item_id` → `call_id`), and `error` / `response.failed` → `RUN_ERROR`. Lifecycle/metadata events (`response.created`, `response.completed`, etc.) are ignored.
- **When to use:** You call the newer Responses or Conversations API rather than Chat Completions. Pair with `openAIConversationMessageFormat`.

### `vercelAIAdapter`

```ts
import { fetchLLM, vercelAIAdapter, vercelAIMessageFormat } from "@openuidev/react-ui";

const llm = fetchLLM({
url: "/api/chat",
streamAdapter: vercelAIAdapter(),
messageFormat: vercelAIMessageFormat,
});
```

- **Wire shape:** Vercel AI SDK v6 UIMessage SSE, as returned by `result.toUIMessageStreamResponse()`.
- **What it handles:** streamed text, streamed or complete tool input, successful, failed, or denied tool output, and SDK error chunks. The adapter delegates SSE decoding and chunk validation to the Vercel AI SDK, then projects the validated chunks into AG-UI events.
- **Current scope:** reasoning, sources, assistant files, custom data, metadata, and tool-approval-request chunks have no equivalent in the current AgentInterface stream processor and are ignored. Approval-gated tools therefore need a custom flow today. Provider-executed tool chunks (`providerExecuted: true`) are rejected because AG-UI cannot preserve their assistant-contained result semantics.
- **When to use:** Your route uses Vercel AI SDK `streamText()` and returns its UIMessage stream response. Pair with `vercelAIMessageFormat` so the route receives `UIMessage[]` that can be passed to `convertToModelMessages()`.

<Callout type="info">Install `ai@^6` alongside `@openuidev/react-ui` or `@openuidev/react-headless` when using this integration. It is an optional peer dependency, so apps using other adapters do not need the Vercel AI SDK.</Callout>

### `langGraphAdapter`

```ts
Expand Down Expand Up @@ -518,6 +540,17 @@ import { openAIConversationMessageFormat } from "@openuidev/react-ui";
- **Conversions:** assistant messages are *flattened* into sibling items — a text `message` plus one `function_call` item per tool call; tool results become `function_call_output` items. Inbound, adjacent assistant `message` + `function_call` items are regrouped into one `AssistantMessage`, and Conversations-specific content parts (`reasoning_text`, `summary_text`, `refusal`, …) are folded into text.
- **When to use:** Pair with `openAIResponsesAdapter()`.

### `vercelAIMessageFormat`

```ts
import { vercelAIMessageFormat } from "@openuidev/react-ui";
```

- **Shape:** Vercel AI SDK v6 `UIMessage[]`, whose content is represented as typed `parts`.
- **Conversions:** text and file inputs become UIMessage parts; AG-UI tool calls become `dynamic-tool` parts; separate AG-UI tool-result messages are folded into the matching tool part as an available or errored output. Inbound conversion accepts both `dynamic-tool` and statically named `tool-*` parts and expands completed outputs back into separate tool messages. (`developer` maps to `system`.)
- **Unsupported and lossy fields:** this integration does not currently preserve reasoning, sources, custom data, assistant files, or approvals when converting stored messages. Provider-executed tool parts (`providerExecuted: true`) are rejected rather than converted; other provider metadata is not preserved.
- **When to use:** Pair with `vercelAIAdapter()` and pass the received messages to Vercel AI SDK's `convertToModelMessages()` in your route.

### `langGraphMessageFormat`

```ts
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/agent/reference/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Your route always receives a JSON body of `{ threadId, messages }` and returns a
| OpenAI Chat Completions, NDJSON from `stream.toReadableStream()` | `openAIReadableStreamAdapter()` | `openAIMessageFormat` |
| OpenAI Chat Completions, raw SSE (`data: {…}\n\n`, `data: [DONE]`) | `openAIAdapter()` | `openAIMessageFormat` |
| OpenAI **Responses / Conversations** API stream | `openAIResponsesAdapter()` | `openAIConversationMessageFormat` |
| Vercel AI SDK v6 UIMessage stream from `toUIMessageStreamResponse()` | `vercelAIAdapter()` | `vercelAIMessageFormat` |
| LangGraph stream (named SSE events) | `langGraphAdapter()` | `langGraphMessageFormat` |
| A backend that already emits AG-UI events | `agUIAdapter()` | depends on what your route expects |

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/api-reference/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The OpenUI SDK is split into packages that build on each other:

- **`@openuidev/react-lang`** — React runtime. Define component libraries with Zod schemas, generate system prompts, parse OpenUI Lang, and render streamed output to React. This is the foundation for React integrations.

- **`@openuidev/react-headless`** — Headless chat state management. Provides `ChatProvider`, thread/message hooks, streaming protocol adapters (OpenAI, AG-UI), and message format converters. Use this when you want full control over your chat UI.
- **`@openuidev/react-headless`** — Headless chat state management. Provides `ChatProvider`, thread/message hooks, streaming protocol adapters (OpenAI, Vercel AI SDK, AG-UI), and message format converters. Use this when you want full control over your chat UI.

- **`@openuidev/react-ui`** — `AgentInterface`, a ready-to-use artifact chat surface with thread history, plus two built-in component libraries (general-purpose and chat-optimized). Depends on both packages above. Use this for the fastest path to a working chat interface.

Expand Down
8 changes: 8 additions & 0 deletions docs/content/docs/api-reference/react-headless.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ import {
openAIAdapter,
openAIResponsesAdapter,
openAIReadableStreamAdapter,
vercelAIAdapter,
agUIAdapter,
openAIMessageFormat,
openAIConversationMessageFormat,
vercelAIMessageFormat,
identityMessageFormat,
processStreamedMessage,
MessageProvider,
Expand Down Expand Up @@ -105,6 +107,7 @@ Adapters referenced in integration guides:
function openAIAdapter(): StreamProtocolAdapter; // OpenAI Chat Completions stream
function openAIResponsesAdapter(): StreamProtocolAdapter; // OpenAI Responses stream
function openAIReadableStreamAdapter(): StreamProtocolAdapter; // OpenAI ReadableStream
function vercelAIAdapter(): StreamProtocolAdapter; // Vercel AI SDK v6 UIMessage stream
function agUIAdapter(): StreamProtocolAdapter; // AG-UI protocol stream
```

Expand All @@ -123,6 +126,7 @@ Converters referenced in integration guides:
```ts
const openAIMessageFormat: MessageFormat; // Chat Completions format
const openAIConversationMessageFormat: MessageFormat; // Responses/Conversations item format
const vercelAIMessageFormat: MessageFormat; // Vercel AI SDK v6 UIMessage format
const identityMessageFormat: MessageFormat; // Pass-through (no conversion)
```

Expand All @@ -135,6 +139,10 @@ interface MessageFormat {
}
```

The Vercel AI SDK integration supports app-executed tools. Provider-executed tools
(`providerExecuted: true`) are rejected because AG-UI messages cannot preserve
their assistant-contained result semantics.

## Message types

```ts
Expand Down
26 changes: 25 additions & 1 deletion packages/react-headless/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,19 @@ pnpm add @openuidev/react-headless

**Peer dependencies:** `react >=19.0.0`, `react-dom >=19.0.0`, `zustand ^4.5.5`

The Vercel AI SDK integration has one optional peer dependency:

```bash
npm install ai@^6
```

## Overview

Use `@openuidev/react-headless` when you want OpenUI's chat behavior without OpenUI's visual components:

- **`ChatProvider`** manages threads, messages, and streaming state through a Zustand store.
- **Selector hooks** expose thread and thread-list state without coupling you to a layout.
- **Streaming adapters** parse SSE or SDK responses from OpenAI, AG-UI, or custom backends.
- **Streaming adapters** parse SSE or SDK responses from OpenAI, Vercel AI SDK, AG-UI, or custom backends.
- **Message formats** convert between your API shape and OpenUI's internal AG-UI shape.

## Quick Start
Expand Down Expand Up @@ -179,6 +185,23 @@ const llm = fetchLLM({ url: "/api/chat", streamAdapter: openAIAdapter() });
| `openAIAdapter()` | Parses OpenAI Chat Completions streaming (`ChatCompletionChunk`) |
| `openAIResponsesAdapter()` | Parses OpenAI Responses API streaming (`ResponseStreamEvent`) |
| `openAIReadableStreamAdapter()` | Parses OpenAI SDK's `Stream.toReadableStream()` NDJSON output |
| `vercelAIAdapter()` | Parses Vercel AI SDK v6 UIMessage streams from `toUIMessageStreamResponse()` |

For a Vercel AI SDK route, use its stream adapter and message format together:

```tsx
import { fetchLLM, vercelAIAdapter, vercelAIMessageFormat } from "@openuidev/react-headless";

const llm = fetchLLM({
url: "/api/chat",
streamAdapter: vercelAIAdapter(),
messageFormat: vercelAIMessageFormat,
});
```

This integration supports app-executed tools. Provider-executed tools
(`providerExecuted: true`, such as provider-hosted built-ins) throw an error because
the AG-UI message model cannot preserve their assistant-contained result semantics.

### Custom adapter

Expand Down Expand Up @@ -213,6 +236,7 @@ const llm = fetchLLM({
| `identityMessageFormat` | Default format when messages are already AG-UI shaped |
| `openAIMessageFormat` | Converts to/from OpenAI `ChatCompletionMessageParam[]` |
| `openAIConversationMessageFormat` | Converts to/from OpenAI Responses API `ResponseInputItem[]` |
| `vercelAIMessageFormat` | Converts to/from Vercel AI SDK v6 `UIMessage[]` |

### Custom format

Expand Down
10 changes: 9 additions & 1 deletion packages/react-headless/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@openuidev/react-headless",
"version": "0.9.6",
"description": "Headless React primitives for AI chat — state management, streaming adapters for OpenAI and AG-UI, message format converters, and thread management for OpenUI generative UI apps",
"description": "Headless React primitives for AI chat — state management, streaming adapters for OpenAI, Vercel AI SDK, and AG-UI, message format converters, and thread management for OpenUI generative UI apps",
"license": "MIT",
"type": "module",
"main": "dist/index.cjs",
Expand Down Expand Up @@ -39,11 +39,18 @@
"ci": "pnpm run lint:check && pnpm run format:check"
},
"peerDependencies": {
"ai": "^6.0.0",
"react": "catalog:",
"zustand": "catalog:"
},
"peerDependenciesMeta": {
"ai": {
"optional": true
}
},
"devDependencies": {
"@types/react": "catalog:",
"ai": "^6.0.236",
"openai": "^6.22.0",
"vitest": "^4.1.0"
},
Expand All @@ -57,6 +64,7 @@
"generative-ui",
"llm",
"openai",
"vercel-ai-sdk",
"zustand",
"state-management",
"sse",
Expand Down
2 changes: 2 additions & 0 deletions packages/react-headless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ export {
openAIAdapter,
openAIReadableStreamAdapter,
openAIResponsesAdapter,
vercelAIAdapter,
} from "./stream/adapters";
export {
langGraphMessageFormat,
openAIConversationMessageFormat,
openAIMessageFormat,
vercelAIMessageFormat,
} from "./stream/formats";
export { processStreamedMessage } from "./stream/processStreamedMessage";

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { convertToModelMessages, type UIMessage } from "ai";
import { beforeAll, describe, expect, it } from "vitest";
import type { Message } from "../../types";
import { vercelAIAdapter } from "../adapters/vercel-ai-sdk";
import { vercelAIMessageFormat } from "../formats/vercel-ai-message-format";
import { processStreamedMessage } from "../processStreamedMessage";

beforeAll(() => {
const globalWithAnimationFrame = globalThis as unknown as {
requestAnimationFrame?: (callback: FrameRequestCallback) => number;
cancelAnimationFrame?: (id: number) => void;
};

if (typeof globalWithAnimationFrame.requestAnimationFrame !== "function") {
globalWithAnimationFrame.requestAnimationFrame = (callback: FrameRequestCallback) =>
setTimeout(() => callback(performance.now()), 0) as unknown as number;
globalWithAnimationFrame.cancelAnimationFrame = (id: number) => clearTimeout(id);
}
});

function sse(chunk: unknown): string {
return `data: ${JSON.stringify(chunk)}\n\n`;
}

function responseFromChunks(...chunks: unknown[]): Response {
return new Response(chunks.map(sse).join(""), {
headers: { "Content-Type": "text/event-stream" },
});
}

describe("Vercel AI SDK stream integration", () => {
it("keeps multiple text parts in one model step as one assistant message", async () => {
const messages: Message[] = [];

await processStreamedMessage({
response: responseFromChunks(
{ type: "start-step" },
{ type: "text-start", id: "text-1" },
{ type: "text-delta", id: "text-1", delta: "one" },
{ type: "text-end", id: "text-1" },
{ type: "text-start", id: "text-2" },
{ type: "text-delta", id: "text-2", delta: "two" },
{ type: "text-end", id: "text-2" },
{ type: "finish-step" },
),
adapter: vercelAIAdapter(),
createMessage: (message) => messages.push(message),
updateMessage: (message) => {
const index = messages.findIndex((candidate) => candidate.id === message.id);
if (index !== -1) messages[index] = message;
},
});

expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({ role: "assistant", content: "onetwo" });

expect(
vercelAIMessageFormat.fromApi([
{
id: "assistant-1",
role: "assistant",
parts: [
{ type: "step-start" },
{ type: "text", text: "one" },
{ type: "text", text: "two" },
],
},
]),
).toEqual([{ id: "assistant-1", role: "assistant", content: "onetwo" }]);
});

it("preserves tool-only step order and emits no empty model text blocks", async () => {
const messages: Message[] = [];

await processStreamedMessage({
response: responseFromChunks(
{ type: "start", messageId: "assistant-1" },
{ type: "start-step" },
{
type: "tool-input-available",
toolCallId: "call-1",
toolName: "lookup",
input: { id: "first" },
},
{
type: "tool-output-available",
toolCallId: "call-1",
output: { nextId: "second" },
},
{ type: "finish-step" },
{ type: "start-step" },
{
type: "tool-input-available",
toolCallId: "call-2",
toolName: "lookup",
input: { id: "second" },
},
{
type: "tool-output-available",
toolCallId: "call-2",
output: { value: "done" },
},
{ type: "finish-step" },
{ type: "finish", finishReason: "stop" },
),
adapter: vercelAIAdapter(),
createMessage: (message) => messages.push(message),
updateMessage: (message) => {
const index = messages.findIndex((candidate) => candidate.id === message.id);
if (index !== -1) messages[index] = message;
},
});

expect(messages.map((message) => message.role)).toEqual([
"assistant",
"tool",
"assistant",
"tool",
]);

const uiMessages = vercelAIMessageFormat.toApi(messages) as UIMessage[];
expect(
uiMessages.flatMap((message) => message.parts).filter((part) => part.type === "text"),
).toEqual([]);

const modelMessages = await convertToModelMessages(uiMessages);
expect(modelMessages.map((message) => message.role)).toEqual([
"assistant",
"tool",
"assistant",
"tool",
]);
expect(
modelMessages
.filter((message) => message.role === "assistant")
.flatMap((message) => message.content)
.filter((part) => part.type === "text"),
).toEqual([]);
});
});
Loading
Loading