diff --git a/.changeset/autofix-adapters.md b/.changeset/autofix-adapters.md new file mode 100644 index 0000000000..aaa71acebf --- /dev/null +++ b/.changeset/autofix-adapters.md @@ -0,0 +1,5 @@ +--- +"@openuidev/server": minor +--- + +Add native OpenAI Responses, LangGraph SDK, and Eve Autofix adapters to the server client. Preserve SDK event shapes and final text boundaries, support cancellable ReadableStream sources, and emit Responses SSE, named LangGraph SSE, or Eve NDJSON as appropriate. diff --git a/.changeset/server-client.md b/.changeset/server-client.md new file mode 100644 index 0000000000..e4fd3881bc --- /dev/null +++ b/.changeset/server-client.md @@ -0,0 +1,7 @@ +--- +"@openuidev/server": minor +--- + +Add `createServerClient` with shared Gateway configuration, default `THESYS_API_KEY` lookup, and provider-specific Autofix namespaces for existing Chat Completions and AI SDK adapters. The OpenAI client exposes Chat Completions conversation persistence. + +Deprecate standalone Autofix, history persistence, and message conversion helpers while retaining compatibility wrappers. Accept library specs per Autofix operation and propagate cancellation through persistence. diff --git a/.changeset/server-storage-adapters.md b/.changeset/server-storage-adapters.md new file mode 100644 index 0000000000..28b413b4d2 --- /dev/null +++ b/.changeset/server-storage-adapters.md @@ -0,0 +1,5 @@ +--- +"@openuidev/server": minor +--- + +Add native Responses, Vercel AI SDK UIMessage, and LangGraph SDK message persistence through client.conversations.appendMessages. Retain Completions as the OpenAI default and require format: "responses" for native Responses items. Preserve text/media and tool ordering, share cancellable append transport, and reject unsupported representations before writing. diff --git a/docs/content/docs/api-reference/index.mdx b/docs/content/docs/api-reference/index.mdx index cf25f52026..6c9a10b583 100644 --- a/docs/content/docs/api-reference/index.mdx +++ b/docs/content/docs/api-reference/index.mdx @@ -17,7 +17,7 @@ The OpenUI SDK is split into packages that build on each other: - **`@openuidev/langchain`** — LangChain and LangGraph integration primitives for transforming protocol-v2 agent streams into AG-UI events and relaying them to an OpenUI frontend over Web-standard Server-Sent Events. -- **`@openuidev/server`** — Server utilities for OpenUI Gateway. Repair invalid OpenUI Lang with Autofix, and persist a completed turn as Conversations API items. Import from `/openai` or `/vercel`. +- **`@openuidev/server`** — Server client for OpenUI Gateway. Repair invalid OpenUI Lang, persist Chat Completions turns, and execute customer tools or sandbox scripts. Import from `/openai`, `/vercel`, or the package root for tools. - **`@openuidev/react-email`** — Pre-built email component library and prompt options for model-generated emails that can be rendered to HTML with React Email. diff --git a/docs/content/docs/api-reference/server.mdx b/docs/content/docs/api-reference/server.mdx index 8a71448575..0dcc8a2a23 100644 --- a/docs/content/docs/api-reference/server.mdx +++ b/docs/content/docs/api-reference/server.mdx @@ -1,10 +1,9 @@ --- title: "@openuidev/server" -description: API reference for Autofix helpers and conversation history persistence on the OpenUI Gateway. +description: Server client for Autofix and conversation persistence on OpenUI Gateway. --- -Server utilities for OpenUI Gateway. Validate and repair model-generated OpenUI Lang, and persist a completed turn as Conversations API items. -Import from the subpath that matches your SDK. The helpers call [Autofix HTTP API](/docs/gateway/api/autofix). +Configure one client for [Autofix](/docs/gateway/api/autofix) and message persistence. Import from the path matching your model SDK. ## Install @@ -26,106 +25,69 @@ npm install @openuidev/server ## Import paths -| Import from | Autofix | History helpers | -| :------------------------- | :----------------------------------------- | :------------------------------------------------------------ | -| `@openuidev/server/openai` | `completions.fix` and `completions.stream` | `storeChatCompletionHistory`, `chatCompletionMessagesToItems` | -| `@openuidev/server/vercel` | `ai.fix` and `ai.stream` | — | +| Import | Capabilities | +| ----------------------------- | -------------------------------------------------------------------------- | +| `@openuidev/server/openai` | `autofix.completions`, `autofix.responses`, `conversations.appendMessages` | +| `@openuidev/server/vercel` | `autofix.ai`, `autofix.eve`, `conversations.appendMessages` | +| `@openuidev/server/langchain` | `autofix.langgraph`, `conversations.appendMessages` | -Pair each Autofix stream with the matching frontend adapter: `openAIAdapter()` or `useChat`. +## `createServerClient(options?)` -## `createAutofix(options)` +```ts tab="OpenAI" tab-group="server-sdk" +import { createServerClient } from "@openuidev/server/openai"; -Use the spec from `openui generate --spec` for the same library as your renderer, including its `schema`. - -```ts tab="OpenAI" tab-group="autofix-sdk" -import { createAutofix } from "@openuidev/server/openai"; -import library from "./openui.spec.json"; - -const autofix = createAutofix({ - apiKey: process.env.THESYS_API_KEY!, - library, -}); +const openui = createServerClient(); ``` -```ts tab="Vercel AI SDK" tab-group="autofix-sdk" -import { createAutofix } from "@openuidev/server/vercel"; -import library from "./openui.spec.json"; +```ts tab="Vercel AI SDK" tab-group="server-sdk" +import { createServerClient } from "@openuidev/server/vercel"; -const autofix = createAutofix({ - apiKey: process.env.THESYS_API_KEY!, - library, -}); +const openui = createServerClient(); ``` -```ts tab="Non-streaming" tab-group="autofix-sdk" -import { createAutofix } from "@openuidev/server/openai"; -// import { createAutofix } from "@openuidev/server/vercel"; -import library from "./openui.spec.json"; - -const autofix = createAutofix({ - apiKey: process.env.THESYS_API_KEY!, - library, -}); -``` +The client reads `process.env.THESYS_API_KEY` by default. Explicit options take precedence. A missing or empty key throws `ServerClientError` with `code: "missing_api_key"` at creation. Keep the key on the server. ```ts -function createAutofix(options: { - apiKey: string; - library: LibrarySpec & { schema: LibraryJSONSchema }; - apiBaseUrl?: string; // defaults to https://api.thesys.dev +interface ServerClientOptions { + apiKey?: string; + apiBaseUrl?: string; // Gateway origin, without /v1. Default: https://api.thesys.dev. fetch?: typeof globalThis.fetch; -}): Autofix; +} ``` -From `@openuidev/server/openai`, `Autofix` is `{ completions: { fix, stream } }`. From `@openuidev/server/vercel`, it is `{ ai: { fix, stream } }`. - -## `fix(input)` +Libraries, conversation IDs, and abort signals belong to individual operations. The client can be reused across requests. -Validate completed OpenUI text. Valid output is returned as-is. Invalid output is sent to `POST /v1/autofix`. +## Autofix -```ts tab="OpenAI" tab-group="autofix-sdk" -const result = await autofix.completions.fix({ generation, messages, signal }); +Use the spec from `openui generate --spec` for the same library as your renderer, including its `schema`. -if (result.content !== null) { - // already_valid or fixed — use result.content -} else { - // fix_failed — choose a fallback - console.warn(result.unfixedErrors); -} -``` +### `autofix.completions.fix(input)` -```ts tab="Vercel AI SDK" tab-group="autofix-sdk" -const result = await autofix.ai.fix({ generation, messages, signal }); +Validate completed OpenUI text locally and request repair only when invalid. The Vercel client's `autofix.ai.fix` has the same completed-text contract. -if (result.content !== null) { - // already_valid or fixed — use result.content -} else { - // fix_failed — choose a fallback - console.warn(result.unfixedErrors); -} -``` +```ts +import library from "./openui.spec.json"; -```ts tab="Non-streaming" tab-group="autofix-sdk" -const result = await autofix.completions.fix({ generation, messages, signal }); -// const result = await autofix.ai.fix({ generation, messages, signal }); +const result = await openui.autofix.completions.fix({ + library, + generation, + messages, + signal, +}); if (result.content !== null) { - // already_valid or fixed — use result.content + // Render or persist result.content. } else { - // fix_failed — choose a fallback console.warn(result.unfixedErrors); } ``` -```ts -function fix(input: { - generation: string; - messages?: ChatCompletionMessageParam[]; - signal?: AbortSignal; -}): Promise; -``` - -`generation` is the completed OpenUI text. Optional `messages` is the conversation before that generation. On `fixed`, `content` is the program the Autofix API returned. +| Input | Purpose | +| ------------ | -------------------------------------------------------------------------- | +| `library` | Required library spec with schema. | +| `generation` | Required completed OpenUI text. | +| `messages` | Optional repair context in Chat Completions format, before the generation. | +| `signal` | Optional cancellation signal. | ```ts type AutofixResult = { @@ -145,64 +107,32 @@ interface AutofixDiagnostic { } ``` -## `stream(input)` +### `autofix.completions.stream(input)` -Wrap a native SDK stream. The helper forwards provider events and inserts a repair before the stream completes when the finished UI is invalid. +Wrap a native Chat Completions stream. The client preserves provider events and inserts any correction before successful completion. ```ts -function stream(input: { - stream: AsyncIterable & { controller?: AbortController }; - messages?: ChatCompletionMessageParam[]; - signal?: AbortSignal; -}): AutofixStream; -``` - -Use `autofix.completions.stream()` with a native Chat Completions stream, or `autofix.ai.stream()` with `toUIMessageStream({ stream: result.stream })` — not the raw `result.stream`. +const source = await model.chat.completions.create( + { model: "gpt-5.5", messages, stream: true }, + { signal: request.signal }, +); -```ts -interface AutofixStream { - chunks: AsyncIterable; - toResponse(): Response; - result: Promise; -} +return openui.autofix.completions + .stream({ library, stream: source, messages, signal: request.signal }) + .toResponse(); ``` -Use either `chunks` or `toResponse()` once. `result` settles after that consumer finishes. Persist `result.content` when it is a string — that is the Autofix program, not the joined stream text. `result` is `null` when Autofix did not run. +Pair the response with the frontend `openAIAdapter()`. -```ts title="app/api/chat/route.ts" tab="OpenAI" tab-group="autofix-sdk" -import OpenAI from "openai"; -import { createAutofix } from "@openuidev/server/openai"; -import library from "./openui.spec.json"; +### `autofix.ai.stream(input)` -const model = new OpenAI(); -const autofix = createAutofix({ - apiKey: process.env.THESYS_API_KEY!, - library, -}); +The Vercel client accepts AI SDK UI message events, rather than raw model events: -export async function POST(request: Request) { - const { messages } = await request.json(); - const source = await model.chat.completions.create( - { model: "openai/gpt-5.5", messages, stream: true }, - { signal: request.signal }, - ); - - return autofix.completions - .stream({ stream: source, messages, signal: request.signal }) - .toResponse(); -} -``` - -```ts title="app/api/chat/route.ts" tab="Vercel AI SDK" tab-group="autofix-sdk" +```ts import { streamText, toUIMessageStream } from "ai"; -import { createAutofix } from "@openuidev/server/vercel"; -import library from "./openui.spec.json"; - -const autofix = createAutofix({ - apiKey: process.env.THESYS_API_KEY!, - library, -}); +import { createServerClient } from "@openuidev/server/vercel"; +const openui = createServerClient(); const result = streamText({ model: "openai/gpt-5.5", system: systemPrompt, @@ -210,123 +140,92 @@ const result = streamText({ abortSignal: signal, }); -return autofix.ai +return openui.autofix.ai .stream({ + library, stream: toUIMessageStream({ stream: result.stream }), signal, }) .toResponse(); ``` -```ts title="server.ts" tab="Non-streaming" tab-group="autofix-sdk" -const result = await autofix.completions.fix({ generation, messages, signal }); -// const result = await autofix.ai.fix({ generation, messages, signal }); +`toResponse()` uses the UI message SSE protocol consumed by `useChat`. -if (result.content !== null) { - // already_valid or fixed — use result.content -} else { - console.warn(result.unfixedErrors); -} -``` +### Stream consumption and errors -```ts tab="OpenAI" tab-group="autofix-sdk" -const output = autofix.completions.stream({ stream: source, messages, signal }); - -for await (const chunk of output.chunks) { - // Native SDK events, including any appended correction -} +All stream methods take `library`, `stream`, optional `messages`, and optional `signal`. The source is an `AsyncIterable` of the selected provider's native events, optionally with an abort controller. -const settled = await output.result; -if (settled?.content) { - // Persist settled.content +```ts +interface AutofixStream { + chunks: AsyncIterable; + toResponse(): Response; + result: Promise; } ``` -```ts tab="Vercel AI SDK" tab-group="autofix-sdk" -const output = autofix.ai.stream({ - stream: toUIMessageStream({ stream: result.stream }), - signal, -}); +Use either `chunks` or `toResponse()` once. `result` settles after consumption and is `null` when Autofix validation did not run. Persist settled `result.content` when present, rather than joined deltas. -for await (const chunk of output.chunks) { - // Native SDK events, including any appended correction -} +| Autofix error code | Meaning | +| ---------------------- | ---------------------------------------------------------------- | +| `invalid_library` | Library schema is missing. | +| `generation_too_large` | Generation exceeds 100,000 characters. | +| `http_error` | Repair request failed; `status` is set. | +| `invalid_response` | Repair response could not be read. | +| `stream_consumed` | The stream was consumed more than once. | +| `fix_failed` | Streamed output could not be repaired; diagnostics are attached. | -const settled = await output.result; -if (settled?.content) { - // Persist settled.content -} -``` +## Additional Autofix adapters -```ts tab="Non-streaming" tab-group="autofix-sdk" -if (result.content) { - // Persist result.content -} -``` - -A failed repair on a stream throws with `code: "fix_failed"`. Use `fix()` on the same helper when you already have completed text. - -### Stream errors +Use the same `{ library, stream, messages?, signal? }` contract with native SDK events: -Thrown errors include a `code` string: +| Import | Method | HTTP framing | +| ----------------------------- | -------------------------- | ------------------- | +| `@openuidev/server/openai` | `autofix.responses.stream` | Responses SSE | +| `@openuidev/server/vercel` | `autofix.eve.stream` | Eve NDJSON | +| `@openuidev/server/langchain` | `autofix.langgraph.stream` | Named LangGraph SSE | -| `code` | When | -| :--------------------- | :------------------------------------------------- | -| `invalid_library` | `library.schema` is missing | -| `generation_too_large` | Generation exceeds 100,000 characters | -| `http_error` | Autofix HTTP request failed (`status` is set) | -| `invalid_response` | Autofix response could not be read | -| `stream_consumed` | `chunks` or `toResponse()` was used more than once | -| `fix_failed` | Streamed generation could not be repaired | +All streams accept `AsyncIterable` or `ReadableStream`. Use `.toResponse()` for HTTP output, or consume `.chunks` once. Each namespace also exposes `.fix()` for completed text. Repair runs at successful final text boundaries; tool-bearing, failed, or interrupted turns pass through. ## Conversation history -Persist only the new turn — not the full `messages` array — or items will be duplicated. Use the master API key. System and developer messages are skipped. +### `conversations.appendMessages(input)` -### `storeChatCompletionHistory(options)` +Accepts native history for the selected client. OpenAI defaults to Completions; pass `format: "responses"` with native Responses items. Vercel accepts `UIMessage[]`; LangChain accepts serialized SDK `Message[]`. Text, user media, and function calls/results preserve their order. System/developer instructions are skipped. Unsupported representations throw `ServerClientError` (`unsupported_message`) before any write. ```ts -import { storeChatCompletionHistory } from "@openuidev/server/openai"; - -await storeChatCompletionHistory({ - apiKey: process.env.THESYS_API_KEY!, +await openui.conversations.appendMessages({ conversationId: threadId, messages: [ { role: "user", content: lastUserText }, - { role: "assistant", content: assistantText }, + { role: "assistant", content: correctedAssistantText }, ], + signal, }); ``` ```ts -function storeChatCompletionHistory(options: { - apiKey: string; +type AppendMessagesInput = { conversationId: string; - messages: ChatCompletionMessageParam[]; - apiBaseUrl?: string; - fetch?: typeof fetch; -}): Promise; + signal?: AbortSignal; +} & ( + | { format?: "completions"; messages: ChatCompletionMessageParam[] } + | { format: "responses"; messages: (ResponseInputItem | ResponseOutputItem)[] } +); ``` -### `chatCompletionMessagesToItems(messages)` +Returns a `ConversationItemList`. The conversation must already exist. Pass only the new turn, not full replay; replay duplicates items. Writes are not retried automatically. Responses generation using `conversation` and `store: true` already persists its turn and must not append it again. -Convert Chat Completions messages to Conversations API items without posting. +Vercel stores completed text and reasoning, user files/images, and static or dynamic tool calls/results. It skips `step-start` and application `data-*` metadata. Unsupported source parts and streaming/incomplete parts are rejected. LangGraph stores human/AI/tool messages, including user images and explicit tool-call IDs; removal directives, legacy function messages, and unparsed tool calls are rejected. Each provider entry point exports its own `AppendMessagesInput`. -```ts -import { chatCompletionMessagesToItems } from "@openuidev/server/openai"; +## Deprecated helpers -chatCompletionMessagesToItems([ - { role: "user", content: "hello" }, - { role: "assistant", content: "hi" }, -]); -``` +`createAutofix`, `storeChatCompletionHistory`, and `chatCompletionMessagesToItems` remain exported for compatibility and are marked deprecated. -## Exports +| Previous call | Client replacement | +| ------------------------------------------------------------------ | ------------------------------------------------------------------- | +| `createAutofix({ apiKey, library }).completions.fix(input)` | `openui.autofix.completions.fix({ ...input, library })` | +| `createAutofix({ apiKey, library }).completions.stream(input)` | `openui.autofix.completions.stream({ ...input, library })` | +| Vercel `createAutofix({ apiKey, library }).ai.stream(input)` | `openui.autofix.ai.stream({ ...input, library })` | +| `storeChatCompletionHistory({ apiKey, conversationId, messages })` | `openui.conversations.appendMessages({ conversationId, messages })` | -| Entry point | Export | Description | -| :------------------------- | :---------------------------------- | :----------------------------------------------------- | -| `@openuidev/server/openai` | `createAutofix` | Chat Completions Autofix: `completions.fix` / `stream` | -| `@openuidev/server/openai` | `storeChatCompletionHistory` | Persist a Chat Completions turn | -| `@openuidev/server/openai` | `chatCompletionMessagesToItems` | Convert messages to Conversations API items | -| `@openuidev/server/openai` | `StoreChatCompletionHistoryOptions` | History helper configuration | -| `@openuidev/server/vercel` | `createAutofix` | Vercel AI SDK Autofix: `ai.fix` / `ai.stream` | +Provider entry points export their inferred `ServerClient` type, `ServerClientOptions`, `ServerClientError`, Autofix input/result types, and `AutofixError`. The OpenAI entry point exports `AppendMessagesInput`. diff --git a/docs/content/docs/autofix.mdx b/docs/content/docs/autofix.mdx index ad28375fd0..9388484a7f 100644 --- a/docs/content/docs/autofix.mdx +++ b/docs/content/docs/autofix.mdx @@ -24,42 +24,24 @@ flowchart LR ## Use the server package -Import `createAutofix` from `@openuidev/server/openai` for Chat Completions or `@openuidev/server/vercel` for the Vercel AI SDK. Use the spec from `openui generate --spec` for the same library as your renderer. Keep the API key on the server. +Create a server client from `@openuidev/server/openai` or `@openuidev/server/vercel`. It reads `process.env.THESYS_API_KEY` by default. Use the spec from `openui generate --spec` for the same library as your renderer, including its schema. Keep the key on the server. -```ts title="lib/autofix.ts" tab="OpenAI" tab-group="autofix-sdk" -import { createAutofix } from "@openuidev/server/openai"; -import library from "./openui.spec.json"; - -export const autofix = createAutofix({ - apiKey: process.env.THESYS_API_KEY!, - library, -}); -``` +```ts title="lib/server.ts" tab="OpenAI" tab-group="autofix-sdk" +import { createServerClient } from "@openuidev/server/openai"; -```ts title="lib/autofix.ts" tab="Vercel AI SDK" tab-group="autofix-sdk" -import { createAutofix } from "@openuidev/server/vercel"; -import library from "./openui.spec.json"; - -export const autofix = createAutofix({ - apiKey: process.env.THESYS_API_KEY!, - library, -}); +export const openui = createServerClient(); ``` -```ts title="lib/autofix.ts" tab="Non-streaming" tab-group="autofix-sdk" -import { createAutofix } from "@openuidev/server/openai"; -// import { createAutofix } from "@openuidev/server/vercel"; -import library from "./openui.spec.json"; +```ts title="lib/server.ts" tab="Vercel AI SDK" tab-group="autofix-sdk" +import { createServerClient } from "@openuidev/server/vercel"; -export const autofix = createAutofix({ - apiKey: process.env.THESYS_API_KEY!, - library, -}); +export const openui = createServerClient(); ``` ```ts title="app/api/chat/route.ts" tab="OpenAI" tab-group="autofix-sdk" import OpenAI from "openai"; -import { autofix } from "../../../lib/autofix"; +import { openui } from "../../../lib/server"; +import library from "../../../generated/openui.spec.json"; const model = new OpenAI(); @@ -70,27 +52,29 @@ export async function POST(request: Request) { { signal: request.signal }, ); - return autofix.completions - .stream({ stream: source, messages, signal: request.signal }) + return openui.autofix.completions + .stream({ library, stream: source, messages, signal: request.signal }) .toResponse(); } ``` ```ts title="app/api/chat/route.ts" tab="Vercel AI SDK" tab-group="autofix-sdk" import { streamText, toUIMessageStream } from "ai"; -import { autofix } from "../../../lib/autofix"; +import { openui } from "../../../lib/server"; +import library from "../../../generated/openui.spec.json"; export async function POST(request: Request) { const { messages } = await request.json(); const result = streamText({ model: "openai/gpt-5.5", - system: systemPrompt, // Generated from the same OpenUI component library. + system: systemPrompt, // Generated from the same component library. messages, abortSignal: request.signal, }); - return autofix.ai + return openui.autofix.ai .stream({ + library, stream: toUIMessageStream({ stream: result.stream }), signal: request.signal, }) @@ -98,36 +82,21 @@ export async function POST(request: Request) { } ``` -```ts title="server.ts" tab="Non-streaming" tab-group="autofix-sdk" -import { autofix } from "./lib/autofix"; - -const result = await autofix.completions.fix({ generation, messages, signal }); -// const result = await autofix.ai.fix({ generation, messages, signal }); - -if (result.status === "fix_failed") { - console.warn(result.unfixedErrors); -} else { - // Render or persist result.content. -} -``` - -OpenAI: return SSE for the frontend `openAIAdapter()`. - -Vercel: pass `toUIMessageStream({ stream: result.stream })`. `toResponse()` is the UI message SSE protocol used by `useChat`. +Pair Chat Completions with `openAIAdapter()` and AI SDK UI message events with `useChat`. -Non-streaming: `generation` is the completed OpenUI text. Optional `messages` is the conversation before that generation. Use `completions.fix` or `ai.fix` to match the import. +For completed output, use `openui.autofix.completions.fix({ library, generation, messages, signal })` or `ai.fix`. Optional `messages` is repair context in Chat Completions format, before the generation. On `fix_failed`, choose a fallback; otherwise render or persist `result.content`. ### Consume the stream -Use either `chunks` or `toResponse()` once. `result` settles after that consumer finishes. +Use either `chunks` or `toResponse()` once. `result` settles after consumption. -| Member | Purpose | -| -------------- | ----------------------------------------------------------------------- | -| `chunks` | Native SDK events, including any correction. | -| `toResponse()` | SSE `Response` for the matching frontend. | -| `result` | Settled Autofix result. Persist `content` when present, not joined text. | +| Member | Purpose | +| -------------- | -------------------------------------------------------------------------------- | +| `chunks` | Native SDK events, including any correction. | +| `toResponse()` | SSE `Response` for the matching frontend. | +| `result` | Settled Autofix result. Persist `content` when present, rather than joined text. | -`result` is `null` when Autofix did not run. A failed repair throws with `code: "fix_failed"`. Use `fix()` on the same helper when you already have completed text. +`result` is `null` when Autofix validation did not run. A failed streamed repair throws with `code: "fix_failed"`. The previous `createAutofix` helper remains available as a deprecated compatibility wrapper. See the [server client reference](/docs/api-reference/server) for configuration, persistence, and tool execution. ## Call the API directly diff --git a/docs/content/docs/getting-started.mdx b/docs/content/docs/getting-started.mdx index 06e010e9c3..78cfbf9b36 100644 --- a/docs/content/docs/getting-started.mdx +++ b/docs/content/docs/getting-started.mdx @@ -74,11 +74,11 @@ const completion = await gateway.chat.completions.create({ ```ts title="server.ts" tab="OpenAI" tab-group="call-model" import OpenAI from "openai"; import { generateSystemPrompt } from "@openuidev/lang-core"; -import { createAutofix } from "@openuidev/server/openai"; +import { createServerClient } from "@openuidev/server/openai"; import library from "./lib/openui.spec.json"; const model = new OpenAI(); -const autofix = createAutofix({ apiKey: process.env.THESYS_API_KEY!, library }); +const openui = createServerClient(); const messages = [{ role: "user" as const, content: "Compare quarterly revenue by region." }]; @@ -90,7 +90,9 @@ const source = await model.chat.completions.create({ // Autofix adds the library itself, so pass only the conversation. // Return this from your API route as a server-sent events response. -const response = autofix.completions.stream({ stream: source, messages }).toResponse(); +const response = openui.autofix.completions + .stream({ library, stream: source, messages }) + .toResponse(); ``` Both examples use a `THESYS_API_KEY`. Run the command below to sign in with Thesys, create a key, and write it to your `.env` file. Keep the key on the server and never expose it to browser code. diff --git a/packages/server/README.md b/packages/server/README.md index 6111c087ad..230a94b27f 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -1,31 +1,51 @@ # `@openuidev/server` -Server utilities for OpenUI & OpenUI Gateway. +A configured server client for OpenUI Gateway Autofix and conversation persistence. **Links:** [Package docs](https://openui.com/docs/api-reference/server) | [Autofix API](https://openui.com/docs/gateway/api/autofix) | [GitHub repo](https://github.com/thesysdev/openui) -## Autofix +## Configure once -Prefer this package over calling the endpoint yourself. Import `createAutofix` from `@openuidev/server/openai` for Chat Completions or `@openuidev/server/vercel` for the Vercel AI SDK. Use the spec from `openui generate --spec` for the same library as your renderer. Keep the API key on the server. +```ts +import { createServerClient } from "@openuidev/server/openai"; -### Configure once +export const openui = createServerClient(); +``` -```ts -import { createAutofix } from "@openuidev/server/openai"; -import library from "./openui.spec.json"; +Reads `process.env.THESYS_API_KEY`. Keep the key on the server. Explicit options override the environment: -export const autofix = createAutofix({ - apiKey: process.env.THESYS_API_KEY!, - library, +```ts +const openui = createServerClient({ + apiKey: "your-server-key", + apiBaseUrl: "https://api.thesys.dev", // Origin, without /v1. + fetch: customFetch, }); ``` -### Fix a completed generation +A missing or empty key throws `ServerClientError` with `code: "missing_api_key"` when the client is created. Each operation receives its own library, conversation, and cancellation signal; these are not shared between requests. + +| Import | Capabilities | +| ----------------------------- | -------------------------------------------------------------------------- | +| `@openuidev/server/openai` | `autofix.completions`, `autofix.responses`, `conversations.appendMessages` | +| `@openuidev/server/vercel` | `autofix.ai`, `autofix.eve`, `conversations.appendMessages` | +| `@openuidev/server/langchain` | `autofix.langgraph`, `conversations.appendMessages` | + +## Autofix + +Use the spec from `openui generate --spec` for the same library as your renderer. Supply the spec, including its `schema`, per operation. + +### Fix completed output ```ts -import { autofix } from "./lib/autofix"; +import library from "./openui.spec.json"; +import { openui } from "./server"; -const result = await autofix.completions.fix({ generation, messages, signal }); +const result = await openui.autofix.completions.fix({ + library, + generation, + messages, + signal, +}); if (result.status === "fix_failed") { console.warn(result.unfixedErrors); @@ -34,100 +54,110 @@ if (result.status === "fix_failed") { } ``` -`generation` is the completed OpenUI text. Optional `messages` is the conversation before that generation. +The statuses are `already_valid`, `fixed`, and `fix_failed`. `content` is `null` on repair failure. Optional `messages` supplies repair context in Chat Completions format, before the generated assistant message. `ai.fix` use the same completed-text repair contract. -### Wrap a Chat Completions stream +### Chat Completions stream ```ts -import OpenAI from "openai"; -import { autofix } from "../../../lib/autofix"; +const source = await model.chat.completions.create( + { model: "gpt-5.5", messages, stream: true }, + { signal: request.signal }, +); -const model = new OpenAI(); - -export async function POST(request: Request) { - const { messages } = await request.json(); - const source = await model.chat.completions.create( - { model: "gpt-5.5", messages, stream: true }, - { signal: request.signal }, - ); - - return autofix.completions - .stream({ stream: source, messages, signal: request.signal }) - .toResponse(); -} +return openui.autofix.completions + .stream({ library, stream: source, messages, signal: request.signal }) + .toResponse(); ``` -Return SSE for the frontend `openAIAdapter()`. `autofix.responses` is not supported yet. +Returns SSE for the frontend `openAIAdapter()`. -### Wrap a Vercel AI SDK stream +### Vercel AI SDK stream ```ts import { streamText, toUIMessageStream } from "ai"; -import { createAutofix } from "@openuidev/server/vercel"; -import library from "./openui.spec.json"; - -const autofix = createAutofix({ - apiKey: process.env.THESYS_API_KEY!, - library, -}); +import { createServerClient } from "@openuidev/server/vercel"; +const openui = createServerClient(); const result = streamText({ model: "openai/gpt-5.5", - system: systemPrompt, // Generated from the same OpenUI component library. + system: systemPrompt, prompt: "Show a greeting card", abortSignal: signal, }); -return autofix.ai +return openui.autofix.ai .stream({ + library, stream: toUIMessageStream({ stream: result.stream }), signal, }) .toResponse(); ``` -Pass `toUIMessageStream({ stream: result.stream })`. `toResponse()` is the UI message SSE protocol used by `useChat`. +Pass AI SDK UI message events, not the raw model event stream. `toResponse()` uses the UI message SSE protocol consumed by `useChat`. + +### Consume once -### Consume the stream +| Member | Purpose | +| -------------- | ------------------------------------------------------------------------------- | +| `chunks` | Native SDK events, including any correction. | +| `toResponse()` | SSE `Response` for the selected protocol. | +| `result` | Autofix result after consumption, or `null` when repair validation did not run. | -Use either `chunks` or `toResponse()` once. `result` settles after that consumer finishes. +Use either `chunks` or `toResponse()` once. `result` settles after that consumer finishes. Persist `result.content` when present, rather than joined deltas. A failed streamed repair throws `AutofixError` with `code: "fix_failed"` and repair diagnostics. -| Member | Purpose | -| -------------- | ------------------------------------------------------------------------ | -| `chunks` | Native SDK events, including any correction. | -| `toResponse()` | SSE `Response` for the matching frontend. | -| `result` | Settled Autofix result. Persist `content` when present, not joined text. | +### Responses, LangGraph, and Eve streams -`result` is `null` when Autofix did not run. A failed repair throws with `code: "fix_failed"`. Use `fix()` on the same helper when you already have completed text. +```ts +// @openuidev/server/openai +openui.autofix.responses.stream({ library, stream: responseEvents, signal }); -`apiBaseUrl` overrides the Gateway origin. `fetch` supports custom transports or mocks. +// @openuidev/server/langchain +openui.autofix.langgraph.stream({ library, stream: graphEvents, signal }); -## Conversation history +// @openuidev/server/vercel +openui.autofix.eve.stream({ library, stream: eveEvents, signal }); +``` -Persist a Chat Completions turn as Conversations API items. Pass only the new turn — -last user message plus the assembled assistant reply — not the full `messages` array. -Use the master API key. +Use `.toResponse()` to forward the selected protocol: Responses SSE, named LangGraph SSE, or Eve NDJSON. Streams accept an `AsyncIterable` or `ReadableStream`. Pair Responses with `openAIResponsesAdapter()` and LangGraph with `langGraphAdapter()` on the frontend. Repair runs at successful final text boundaries; tool-bearing, failed, or interrupted turns pass through. Each adapter also exposes `.fix()` for completed text with the same repair contract. -```ts -import { storeChatCompletionHistory } from "@openuidev/server/openai"; +## Conversation persistence + +`appendMessages` accepts the native history format for each client. Completions is the OpenAI default; set `format: "responses"` for Responses input/output items. Vercel accepts completed AI SDK `UIMessage[]`; LangChain accepts serialized LangGraph SDK `Message[]`. + +Text, user images/files, and function calls/results become Conversations API items in their original order. System/developer instructions are skipped. An unsupported message or incomplete UI part throws `ServerClientError` with `code: "unsupported_message"` before writing any items. Vercel `step-start` and application `data-*` parts are metadata and are skipped; reasoning summaries are stored. LangGraph removal directives and legacy function messages are rejected. -await storeChatCompletionHistory({ - apiKey: process.env.THESYS_API_KEY!, +```ts +await openui.conversations.appendMessages({ conversationId: threadId, messages: [ { role: "user", content: lastUserText }, - { role: "assistant", content: assistantText }, + { role: "assistant", content: correctedAssistantText }, ], + signal, }); ``` -Convert without posting: - ```ts -import { chatCompletionMessagesToItems } from "@openuidev/server/openai"; +// OpenAI Responses client: append native response.output or input items. +await openui.conversations.appendMessages({ + conversationId: threadId, + format: "responses", + messages: response.output, + signal, +}); -chatCompletionMessagesToItems([ - { role: "user", content: "hello" }, - { role: "assistant", content: "hi" }, -]); +// Vercel or LangChain clients: their native completed messages. +await openui.conversations.appendMessages({ conversationId: threadId, messages, signal }); ``` + +The conversation must already exist. Append only the new turn, not the full conversation replay, or items will be duplicated. Writes are not retried automatically. If Responses generation already uses `conversation` and `store: true`, do not append that same turn again. + +## Migration + +`createAutofix`, `storeChatCompletionHistory`, and `chatCompletionMessagesToItems` remain available and are marked deprecated. Existing published examples can continue using them until they move to a client-capable package release. + +- Replace `createAutofix({ apiKey, library })` with `createServerClient({ apiKey })`. Add `library` to each `fix` or `stream` call and use `openui.autofix.completions` or `openui.autofix.ai`. +- Replace `storeChatCompletionHistory({ apiKey, conversationId, messages })` with `openui.conversations.appendMessages({ conversationId, messages })`. + +`ServerClientError` exposes `code` and optional HTTP `status`. Autofix keeps its existing `AutofixError` and domain results. Both error types and the client/input types are exported from the relevant entry points. diff --git a/packages/server/package.json b/packages/server/package.json index b239205747..837bd5ae2a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -45,6 +45,16 @@ "types": "./dist/vercel/index.d.cts", "default": "./dist/vercel/index.cjs" } + }, + "./langchain": { + "import": { + "types": "./dist/langchain/index.d.mts", + "default": "./dist/langchain/index.mjs" + }, + "require": { + "types": "./dist/langchain/index.d.cts", + "default": "./dist/langchain/index.cjs" + } } }, "scripts": { @@ -83,7 +93,9 @@ "ai": "^7.0.10", "openai": "^7.20.0", "tsdown": "^0.21.7", - "vitest": "^4.1.0" + "vitest": "^4.1.0", + "@langchain/langgraph-sdk": "^1.12.1", + "eve": "^0.64.0" }, "typesVersions": { "*": { @@ -92,7 +104,22 @@ ], "vercel": [ "dist/vercel/index.d.cts" + ], + "langchain": [ + "dist/langchain/index.d.cts" ] } + }, + "peerDependencies": { + "@langchain/langgraph-sdk": "^1.12.1", + "eve": "^0.64.0" + }, + "peerDependenciesMeta": { + "@langchain/langgraph-sdk": { + "optional": true + }, + "eve": { + "optional": true + } } } diff --git a/packages/server/src/conversations/append.ts b/packages/server/src/conversations/append.ts new file mode 100644 index 0000000000..028ff7252a --- /dev/null +++ b/packages/server/src/conversations/append.ts @@ -0,0 +1,49 @@ +import type { ConversationItemList } from "openai/resources/conversations/items"; +import { postJSON, ServerClientError, type ServerClientConfig } from "../shared/client"; + +/** Internal Gateway wire item; adapters retain native fields where supported. */ +export type ConversationItem = { type: string; [key: string]: unknown }; + +export interface AppendMessagesOptions { + conversationId: string; + signal?: AbortSignal; +} + +export function invalidMessage(message: string): never { + throw new ServerClientError(message, "unsupported_message"); +} + +export function jsonValue(value: unknown): string { + try { + const result = JSON.stringify(value); + if (result !== undefined) return result; + } catch { + // Report a domain error before sending any part of the turn. + } + return invalidMessage("Message contains a value that cannot be stored as JSON"); +} + +/** Convert the whole turn before writing once; never retry a potentially committed append. */ +export function createConversations( + config: ServerClientConfig, + convert: (input: Input) => object[], +) { + return { + async appendMessages(input: Input & AppendMessagesOptions): Promise { + input.signal?.throwIfAborted(); + if (!input.conversationId?.trim()) { + throw new ServerClientError("conversationId is required", "invalid_input"); + } + const items = convert(input); + if (items.length === 0) { + return { object: "list", data: [], first_id: "", last_id: "", has_more: false }; + } + return (await postJSON( + config, + `/v1/conversations/${encodeURIComponent(input.conversationId)}/items`, + { items }, + input.signal, + )) as ConversationItemList; + }, + }; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index cb0ff5c3b5..5962593338 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1 +1,2 @@ -export {}; +export { ServerClientError } from "./shared/client"; +export type { ServerClientOptions } from "./shared/client"; diff --git a/packages/server/src/langchain/adapter.ts b/packages/server/src/langchain/adapter.ts new file mode 100644 index 0000000000..a08ec03f2a --- /dev/null +++ b/packages/server/src/langchain/adapter.ts @@ -0,0 +1,190 @@ +import type { AIMessage, MessagesTupleStreamEvent } from "@langchain/langgraph-sdk"; +import { MAX_AUTOFIX_GENERATION_LENGTH, type StreamAdapter } from "../shared/types"; +import { isUIOutput, splitClosedFence, unwrapOpenUIFence } from "../shared/utils"; +import type { LangGraphStreamEvent } from "./types"; + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function textContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((part: unknown) => { + const block = record(part); + return block?.["type"] === "text" && typeof block["text"] === "string" ? block["text"] : ""; + }) + .join(""); +} + +function hasItems(value: unknown): boolean { + return Array.isArray(value) && value.length > 0; +} + +type Message = { + key: string; + text: string | null; + emitted: number; + closing?: string; + template: LangGraphStreamEvent; + payload: AIMessage; + metadata: MessagesTupleStreamEvent["data"][1]; + hasTools: boolean; + failed: boolean; +}; + +/** Emit a text-only delta with the same message identity and graph routing metadata. */ +function delta(message: Message, content: string): LangGraphStreamEvent { + const payload = { + type: "ai", + ...(message.payload.id !== undefined && { id: message.payload.id }), + content, + }; + return { + event: message.template.event, + data: [payload, message.metadata], + }; +} + +/** Repair the final assistant message before end or clean EOF; preserve all other events. */ +export const langGraphAdapter: StreamAdapter = { + protocol: "langgraph", + async *transform(source, fix) { + let current: Message | undefined; + let failed = false; + + async function* release(repair = false): AsyncGenerator { + if (!current) return; + if (repair && !failed && !current.failed && !current.hasTools && current.text != null) { + if (isUIOutput(current.text)) { + const result = await fix(current.text); + if (result.status === "fixed") { + yield delta(current, `\n${unwrapOpenUIFence(result.content)}\n`); + } + } + } + if (current.closing) yield delta(current, current.closing); + current = undefined; + } + + for await (const event of source) { + if (event.event === "metadata") { + yield* release(); + failed = false; + } + if ( + event.event === "error" || + (event.event === "updates" && record(event.data)?.["__interrupt__"] !== undefined) + ) { + failed = true; + yield* release(); + } + if (event.event === "end") { + yield* release(true); + yield event; + continue; + } + if (event.event !== "messages") { + yield event; + continue; + } + + const parts: unknown[] | undefined = Array.isArray(event.data) ? event.data : undefined; + const wireMessage = record(parts?.[0]); + const routing = record(parts?.[1]); + if (!wireMessage || !routing) { + yield event; + continue; + } + if (wireMessage["type"] !== "ai") { + // A tool result closes the preceding model turn without repairing it. + if (wireMessage["type"] === "tool") yield* release(); + yield event; + continue; + } + const [payload, metadata] = event.data as MessagesTupleStreamEvent["data"] & + [AIMessage, unknown]; + + const key = JSON.stringify([ + payload.id, + routing?.["langgraph_step"], + routing?.["langgraph_node"], + routing?.["langgraph_checkpoint_ns"], + ]); + if (current && current.key !== key) yield* release(); + current ??= { + key, + text: "", + emitted: 0, + template: event, + payload, + metadata, + hasTools: false, + failed: false, + }; + current.template = event; + current.payload = payload; + current.metadata = metadata; + current.hasTools ||= + hasItems(payload.tool_calls) || + hasItems(wireMessage["tool_call_chunks"]) || + hasItems(payload.invalid_tool_calls) || + hasItems(record(payload.additional_kwargs)?.["tool_calls"]); + const responseMetadata = record(payload.response_metadata); + const finish = responseMetadata?.["finish_reason"] ?? responseMetadata?.["stop_reason"]; + current.failed ||= + (finish != null && !["stop", "end_turn", "STOP"].includes(String(finish))) || + record(payload.additional_kwargs)?.["refusal"] != null; + + const incoming = textContent(payload.content); + const previous = current.text; + if (previous != null) { + const combined = previous + incoming; + if (combined.length > MAX_AUTOFIX_GENERATION_LENGTH) { + current.text = null; + if (current.closing) yield delta(current, current.closing); + current.closing = undefined; + } else { + current.text = combined; + const split = splitClosedFence(combined); + // Hold one or two trailing backticks too: the closer can span token chunks. + const body = + split?.body ?? + (combined.includes("```openui") ? combined.replace(/`{1,2}$/, "") : combined); + const closing = combined.slice(body.length); + if (closing || current.closing) { + // A held partial closer that became ordinary text must be released first. + const pending = body.slice(current.emitted, previous.length); + if (pending) yield delta(current, pending); + current.closing = closing || undefined; + current.emitted = body.length; + // Preserve non-text content blocks while holding the UI closing fence. + let remaining = Math.max(0, body.length - previous.length); + const content = + typeof payload.content === "string" + ? body.slice(previous.length) + : (payload.content as unknown[]).map((part: unknown) => { + const block = record(part); + if (block?.["type"] !== "text") return part; + const text = textContent([part]); + const emit = text.slice(0, remaining); + remaining -= emit.length; + return { ...block, text: emit }; + }); + yield { + ...event, + data: [{ ...payload, content }, metadata], + }; + continue; + } + current.emitted = combined.length; + } + } + yield event; + } + yield* release(true); + }, +}; diff --git a/packages/server/src/langchain/index.ts b/packages/server/src/langchain/index.ts new file mode 100644 index 0000000000..937ebb2804 --- /dev/null +++ b/packages/server/src/langchain/index.ts @@ -0,0 +1,41 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { createConversations, type AppendMessagesOptions } from "../conversations/append"; +import { resolveClientOptions, type ServerClientOptions } from "../shared/client"; +import { createClientAutofix } from "../shared/client-autofix"; +import type { AutofixOptions } from "../shared/types"; +import { langGraphAdapter } from "./adapter"; +import { langGraphMessagesToItems } from "./messages-to-items"; + +export { ServerClientError } from "../shared/client"; +export type { ServerClientOptions } from "../shared/client"; +export type { ClientAutofixInput, ClientAutofixStreamInput } from "../shared/client-autofix"; +export { AutofixError } from "../shared/types"; +export type { AutofixResult, AutofixStream } from "../shared/types"; +export type { LangGraphStreamEvent } from "./types"; + +/** Configure native LangGraph SDK event streams. */ +export function createServerClient(options: ServerClientOptions = {}) { + const config = resolveClientOptions(options); + return { + conversations: createConversations(config, (input: AppendMessagesInput) => + langGraphMessagesToItems(input.messages), + ), + autofix: { langgraph: createClientAutofix(config, langGraphAdapter) }, + }; +} +export type ServerClient = ReturnType; + +/** @deprecated Use createServerClient().autofix.langgraph with a library per operation. */ +export function createAutofix(options: AutofixOptions) { + const { langgraph } = createServerClient(options).autofix; + return { + langgraph: { + fix: (input: Omit[0], "library">) => + langgraph.fix({ ...input, library: options.library }), + stream: (input: Omit[0], "library">) => + langgraph.stream({ ...input, library: options.library }), + }, + }; +} + +export type AppendMessagesInput = AppendMessagesOptions & { messages: Message[] }; diff --git a/packages/server/src/langchain/messages-to-items.ts b/packages/server/src/langchain/messages-to-items.ts new file mode 100644 index 0000000000..29a1f9e6e6 --- /dev/null +++ b/packages/server/src/langchain/messages-to-items.ts @@ -0,0 +1,60 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { invalidMessage, jsonValue, type ConversationItem } from "../conversations/append"; + +function contentOf(message: Message, role: "user" | "assistant"): Record[] { + if (typeof message.content === "string") { + return message.content + ? [{ type: role === "user" ? "input_text" : "output_text", text: message.content }] + : []; + } + return message.content.map((part) => { + if (part.type === "text") + return { type: role === "user" ? "input_text" : "output_text", text: part.text }; + if (part.type === "image_url" && role === "user") { + const image = typeof part.image_url === "string" ? { url: part.image_url } : part.image_url; + return { type: "input_image", image_url: image.url, detail: image.detail ?? "auto" }; + } + return invalidMessage(`LangGraph '${part.type}' content cannot be stored for ${role}`); + }); +} + +/** Accept serialized LangGraph SDK messages, including explicit function-call IDs. */ +export function langGraphMessagesToItems(messages: Message[]): ConversationItem[] { + const items: ConversationItem[] = []; + for (const message of messages) { + if (message.type === "system") continue; + if (message.type === "human" || message.type === "ai") { + const role = message.type === "human" ? "user" : "assistant"; + const content = contentOf(message, role); + if (content.length) items.push({ type: "message", role, content }); + if (message.type === "ai") { + if ( + message.invalid_tool_calls?.length || + (message.additional_kwargs?.["tool_calls"] && !message.tool_calls?.length) + ) { + invalidMessage( + "LangGraph tool calls must be parsed into valid tool_calls before storage", + ); + } + for (const call of message.tool_calls ?? []) { + if (!call.id || !call.name) + invalidMessage("LangGraph tool calls require an explicit ID and name"); + items.push({ + type: "function_call", + call_id: call.id, + name: call.name, + arguments: jsonValue(call.args), + }); + } + } + } else if (message.type === "tool") { + if (!message.tool_call_id) invalidMessage("LangGraph tool results require tool_call_id"); + const output = + typeof message.content === "string" ? message.content : contentOf(message, "user"); + items.push({ type: "function_call_output", call_id: message.tool_call_id, output }); + } else { + invalidMessage(`LangGraph '${message.type}' messages cannot be appended to Gateway history`); + } + } + return items; +} diff --git a/packages/server/src/langchain/types.ts b/packages/server/src/langchain/types.ts new file mode 100644 index 0000000000..16202829bf --- /dev/null +++ b/packages/server/src/langchain/types.ts @@ -0,0 +1,5 @@ +import type { Client } from "@langchain/langgraph-sdk"; + +/** Native LangGraph SDK event envelope, shared by runs.stream() and runs.joinStream(). */ +export type LangGraphStreamEvent = + ReturnType extends AsyncIterable ? Event : never; diff --git a/packages/server/src/openai/client.ts b/packages/server/src/openai/client.ts new file mode 100644 index 0000000000..4c3a936697 --- /dev/null +++ b/packages/server/src/openai/client.ts @@ -0,0 +1,26 @@ +import { createConversations } from "../conversations/append"; +import { resolveClientOptions, type ServerClientOptions } from "../shared/client"; +import { createClientAutofix } from "../shared/client-autofix"; +import { openAIAdapter } from "./adapter"; +import { messagesToItems } from "./messages-to-items"; +import { openAIResponsesAdapter } from "./responses-adapter"; +import { responsesToItems } from "./responses-to-items"; +import type { AppendMessagesInput } from "./types"; + +/** Configure OpenAI-format Autofix and conversation persistence. */ +export function createServerClient(options: ServerClientOptions = {}) { + const config = resolveClientOptions(options); + return { + autofix: { + completions: createClientAutofix(config, openAIAdapter), + responses: createClientAutofix(config, openAIResponsesAdapter), + }, + conversations: createConversations(config, (input: AppendMessagesInput) => + input.format === "responses" + ? responsesToItems(input.messages) + : messagesToItems(input.messages), + ), + }; +} + +export type ServerClient = ReturnType; diff --git a/packages/server/src/openai/index.ts b/packages/server/src/openai/index.ts index 4fe533b43a..ec61fd7d48 100644 --- a/packages/server/src/openai/index.ts +++ b/packages/server/src/openai/index.ts @@ -1,16 +1,29 @@ -import { createAutofix as createAutofixPipeline } from "../shared/create-autofix"; import type { AutofixOptions } from "../shared/types"; -import { openAIAdapter } from "./adapter"; +import { createServerClient } from "./client"; + +export { ServerClientError } from "../shared/client"; +export type { ServerClientOptions } from "../shared/client"; +export type { ClientAutofixInput, ClientAutofixStreamInput } from "../shared/client-autofix"; +export { AutofixError } from "../shared/types"; +export type { AutofixResult, AutofixStream } from "../shared/types"; +export { createServerClient } from "./client"; +export type { ServerClient } from "./client"; export { chatCompletionMessagesToItems, storeChatCompletionHistory, } from "./store-conversation-items"; -export type { StoreChatCompletionHistoryOptions } from "./types"; +export type { AppendMessagesInput, StoreChatCompletionHistoryOptions } from "./types"; -// Create validation and repair helpers for OpenAI Chat Completions and Responses. +/** @deprecated Use createServerClient().autofix.completions with a library per operation. */ export function createAutofix(options: AutofixOptions) { + const { completions } = createServerClient(options).autofix; return { - completions: createAutofixPipeline(options, openAIAdapter), + completions: { + fix: (input: Omit[0], "library">) => + completions.fix({ ...input, library: options.library }), + stream: (input: Omit[0], "library">) => + completions.stream({ ...input, library: options.library }), + }, }; } diff --git a/packages/server/src/openai/messages-to-items.ts b/packages/server/src/openai/messages-to-items.ts new file mode 100644 index 0000000000..20ed7b701a --- /dev/null +++ b/packages/server/src/openai/messages-to-items.ts @@ -0,0 +1,107 @@ +import type { + ChatCompletionAssistantMessageParam, + ChatCompletionMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +} from "openai/resources/chat/completions"; +import type { + EasyInputMessage, + ResponseFunctionToolCall, + ResponseInputItem, + ResponseInputMessageContentList, +} from "openai/resources/responses/responses"; + +function userContentOf( + content: ChatCompletionUserMessageParam["content"], +): ResponseInputMessageContentList { + if (typeof content === "string") { + return content ? [{ type: "input_text", text: content }] : []; + } + + const parts: ResponseInputMessageContentList = []; + for (const part of content) { + if (part.type === "text" && part.text) { + parts.push({ type: "input_text", text: part.text }); + continue; + } + if (part.type === "image_url" && part.image_url.url) { + parts.push({ + type: "input_image", + image_url: part.image_url.url, + detail: part.image_url.detail ?? "auto", + }); + } + } + return parts; +} + +function assistantTextOf(content: ChatCompletionAssistantMessageParam["content"]): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((part) => (part.type === "text" ? part.text : "")) + .filter(Boolean) + .join(""); +} + +function toolOutputOf(content: ChatCompletionToolMessageParam["content"]): string { + if (typeof content === "string") return content; + return content.map((part) => part.text).join(""); +} + +/** + * Break Chat Completions messages into Conversations API items. + * Pass the new turn, not the full replay, or items will be duplicated. + * System / developer messages are skipped (instructions, not history). + */ +export function messagesToItems(messages: ChatCompletionMessageParam[]): ResponseInputItem[] { + const items: ResponseInputItem[] = []; + + for (const message of messages) { + if (message.role === "system" || message.role === "developer") continue; + + if (message.role === "user") { + const content = userContentOf(message.content); + if (content.length === 0) continue; + items.push({ + type: "message", + role: "user", + content, + } satisfies EasyInputMessage); + continue; + } + + if (message.role === "assistant") { + const text = assistantTextOf(message.content); + if (text) { + items.push({ + type: "message", + role: "assistant", + content: [{ type: "output_text", text }], + } as ResponseInputItem); + } + for (const call of message.tool_calls ?? []) { + if (call.type !== "function") continue; + if (!call.id || !call.function.name) continue; + items.push({ + type: "function_call", + call_id: call.id, + name: call.function.name, + arguments: call.function.arguments?.trim() ? call.function.arguments : "{}", + } satisfies ResponseFunctionToolCall); + } + continue; + } + + if (message.role === "tool") { + if (!message.tool_call_id) continue; + items.push({ + type: "function_call_output", + call_id: message.tool_call_id, + output: toolOutputOf(message.content) || "{}", + } satisfies ResponseInputItem.FunctionCallOutput); + } + } + + return items; +} diff --git a/packages/server/src/openai/responses-adapter.test.ts b/packages/server/src/openai/responses-adapter.test.ts new file mode 100644 index 0000000000..7b93df78ca --- /dev/null +++ b/packages/server/src/openai/responses-adapter.test.ts @@ -0,0 +1,231 @@ +import type { + ResponseStreamEvent, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, +} from "openai/resources/responses/responses"; +import { describe, expect, it, vi } from "vitest"; +import { createAutofixStream } from "../shared/stream"; +import { MAX_AUTOFIX_GENERATION_LENGTH, type AutofixResult } from "../shared/types"; +import { openAIResponsesAdapter } from "./responses-adapter"; + +const program = '```openui\nroot = Card("broken")\n```'; +const corrected = '```openui\nroot = Card("fixed")\n```'; +const fixed: AutofixResult = { + status: "fixed", + original: program, + content: corrected, + fixedErrors: [], + unfixedErrors: [], +}; + +function delta(text: string, itemId = "msg_1", outputIndex = 2): ResponseTextDeltaEvent { + return { + type: "response.output_text.delta", + item_id: itemId, + output_index: outputIndex, + content_index: 0, + sequence_number: 7, + logprobs: [], + delta: text, + }; +} + +function done(text = program, itemId = "msg_1", outputIndex = 2): ResponseTextDoneEvent { + return { + type: "response.output_text.done", + item_id: itemId, + output_index: outputIndex, + content_index: 0, + sequence_number: 8, + logprobs: [], + text, + }; +} + +// Only the discriminator is read for lifecycle events; text fixtures use full SDK types. +function boundary( + type: "response.completed" | "response.failed" | "response.incomplete" | "error", +): ResponseStreamEvent { + return { type } as ResponseStreamEvent; +} + +async function* source(events: ResponseStreamEvent[]) { + yield* events; +} +async function collect(events: ResponseStreamEvent[], fix = vi.fn().mockResolvedValue(fixed)) { + const output: ResponseStreamEvent[] = []; + for await (const event of openAIResponsesAdapter.transform(source(events), fix)) + output.push(event); + return output; +} +function textOf(events: ResponseStreamEvent[]) { + return events + .filter((event) => event.type === "response.output_text.delta") + .map((event) => event.delta) + .join(""); +} + +describe("Responses Autofix adapter", () => { + it("inserts a repair inside the fence before native closing events and preserves routing", async () => { + const end = done(); + const part = { type: "response.content_part.done" } as ResponseStreamEvent; + const item = { type: "response.output_item.done" } as ResponseStreamEvent; + const complete = boundary("response.completed"); + const fix = vi.fn().mockResolvedValue(fixed); + const output = await collect([delta(program), end, part, item, complete], fix); + expect(fix).toHaveBeenCalledExactlyOnceWith(program); + expect(textOf(output)).toBe('```openui\nroot = Card("broken")\n\nroot = Card("fixed")\n```'); + expect(output.slice(-4)).toEqual([end, part, item, complete]); + const correction = output[1] as ResponseTextDeltaEvent; + expect(correction).toMatchObject({ + item_id: "msg_1", + output_index: 2, + content_index: 0, + sequence_number: 8, + logprobs: [], + }); + }); + + it.each(["response.failed", "response.incomplete", "error", "eof"] as const)( + "preserves text without repair on %s", + async (type) => { + const fix = vi.fn().mockResolvedValue(fixed); + const events = [delta(program), done(), ...(type === "eof" ? [] : [boundary(type)])]; + const output = await collect(events, fix); + expect(fix).not.toHaveBeenCalled(); + expect(textOf(output)).toBe(program); + expect(output.slice(-events.length + 1)).toEqual(events.slice(1)); + }, + ); + + it.each(["function_call", "web_search_call", "mcp_call"])( + "does not repair a response containing a %s item", + async (type) => { + const tool = { type: "response.output_item.added", item: { type } } as ResponseStreamEvent; + const fix = vi.fn().mockResolvedValue(fixed); + const output = await collect( + [delta(program), done(), tool, boundary("response.completed")], + fix, + ); + expect(fix).not.toHaveBeenCalled(); + expect(output).toContain(tool); + expect(textOf(output)).toBe(program); + }, + ); + + it("only repairs the final text item", async () => { + const last = 'root = Card("last")'; + const fix = vi.fn().mockResolvedValue(fixed); + const output = await collect( + [ + delta(program), + done(), + delta(last, "msg_2", 3), + done(last, "msg_2", 3), + boundary("response.completed"), + ], + fix, + ); + expect(fix).toHaveBeenCalledExactlyOnceWith(last); + expect( + output.indexOf( + output.find( + (event) => event.type === "response.output_text.done" && event.item_id === "msg_1", + )!, + ), + ).toBeLessThan( + output.indexOf( + output.find( + (event) => event.type === "response.output_text.delta" && event.item_id === "msg_2", + )!, + ), + ); + expect( + output.filter( + (event) => + event.type === "response.output_text.delta" && event.delta.includes('Card("fixed")'), + ), + ).toEqual([expect.objectContaining({ item_id: "msg_2", output_index: 3 })]); + }); + + it.each(["plain", "valid", "oversized"])( + "passes through %s output without adding corrections", + async (kind) => { + const text = + kind === "plain" + ? "Hello" + : kind === "oversized" + ? "root = " + "x".repeat(MAX_AUTOFIX_GENERATION_LENGTH) + : program; + const fix = vi.fn().mockResolvedValue({ ...fixed, status: "already_valid", content: text }); + const output = await collect([delta(text), done(text), boundary("response.completed")], fix); + expect(textOf(output)).toBe(text); + expect(fix).toHaveBeenCalledTimes(kind === "valid" ? 1 : 0); + }, + ); + + it("serializes Responses SSE and settles the repair result", async () => { + const fix = vi.fn().mockResolvedValue(fixed); + const stream = createAutofixStream( + { stream: source([delta(program), done(), boundary("response.completed")]) }, + fix, + openAIResponsesAdapter, + ); + const response = stream.toResponse(); + expect(response.headers.get("Content-Type")).toBe("text/event-stream"); + const wire = await response.text(); + expect(wire.endsWith("data: [DONE]\n\n")).toBe(true); + expect(wire).toContain('"type":"response.output_text.delta"'); + expect(await stream.result).toEqual(fixed); + await expect(async () => { + for await (const _event of stream.chunks) { + } + }).rejects.toMatchObject({ code: "stream_consumed" }); + }); + + it("surfaces failed repair diagnostics through chunks and result", async () => { + const result: AutofixResult = { + ...fixed, + status: "fix_failed", + content: null, + unfixedErrors: [{ code: "unknown-component", message: "Unknown Card" }], + }; + const stream = createAutofixStream( + { stream: source([delta(program), done(), boundary("response.completed")]) }, + vi.fn().mockResolvedValue(result), + openAIResponsesAdapter, + ); + await expect(async () => { + for await (const _event of stream.chunks) { + } + }).rejects.toMatchObject({ code: "fix_failed", result }); + await expect(stream.result).rejects.toMatchObject({ code: "fix_failed", result }); + }); + + it("cancels a stalled upstream stream before repair starts", async () => { + const request = new AbortController(); + const upstream = new AbortController(); + const fix = vi.fn().mockResolvedValue(fixed); + const stalled = { + controller: upstream, + async *[Symbol.asyncIterator]() { + yield delta('root = Card("partial")'); + await new Promise(() => {}); + }, + }; + const stream = createAutofixStream( + { stream: stalled, signal: request.signal }, + fix, + openAIResponsesAdapter, + ); + const iterator = stream.chunks[Symbol.asyncIterator](); + expect((await iterator.next()).done).toBe(false); + const pending = iterator.next(); + const reason = new Error("request disconnected"); + request.abort(reason); + await expect(pending).rejects.toBe(reason); + await expect(stream.result).rejects.toBe(reason); + expect(upstream.signal.aborted).toBe(true); + expect(fix).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/openai/responses-adapter.ts b/packages/server/src/openai/responses-adapter.ts new file mode 100644 index 0000000000..460db7bdf8 --- /dev/null +++ b/packages/server/src/openai/responses-adapter.ts @@ -0,0 +1,162 @@ +import type { + ResponseStreamEvent, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, +} from "openai/resources/responses/responses"; +import { MAX_AUTOFIX_GENERATION_LENGTH, type StreamAdapter } from "../shared/types"; +import { isUIOutput, splitClosedFence, unwrapOpenUIFence } from "../shared/utils"; + +const TOOL_ITEM_TYPES = new Set([ + "function_call", + "function_call_output", + "web_search_call", + "mcp_call", + "mcp_list_tools", + "file_search_call", + "computer_call", +]); + +/** Create a text delta with the original item's routing fields. */ +export function correctionDelta( + template: ResponseTextDeltaEvent | ResponseTextDoneEvent, + delta: string, +): ResponseTextDeltaEvent { + if (template.type === "response.output_text.delta") return { ...template, delta, logprobs: [] }; + return { + type: "response.output_text.delta", + item_id: template.item_id, + output_index: template.output_index, + content_index: template.content_index, + sequence_number: template.sequence_number, + logprobs: [], + delta, + }; +} + +function isToolItemType(type: string | undefined): boolean { + return type != null && TOOL_ITEM_TYPES.has(type); +} + +/** Preserve Responses events and repair text before a successful response closes. */ +export const openAIResponsesAdapter: StreamAdapter = { + protocol: "openai-responses", + // Track text within each output item and wait for completed before attempting repair. + async *transform(source, fix) { + const texts = new Map(); + const closings = new Map(); + const templates = new Map(); + let held: ResponseStreamEvent[] = []; + let hasTools = false; + let failed = false; + + // Release deferred endings, optionally inserting a repair before a held closing fence. + async function* release(repair = false): AsyncGenerator { + for (const event of held) { + // Insert repair and closer just before the text part ends. + if (event.type === "response.output_text.done") { + const text = texts.get(event.item_id); + // Repair only a successful final-item UI generation. + if (repair && text != null && isUIOutput(text)) { + const result = await fix(text); + // Only append when Autofix actually changed the text. + if (result.status === "fixed") { + yield correctionDelta( + templates.get(event.item_id) ?? event, + `\n${unwrapOpenUIFence(result.content)}\n`, + ); + } + } + const closing = closings.get(event.item_id); + // Emit the held fence closer after any repair. + if (closing) yield correctionDelta(templates.get(event.item_id) ?? event, closing); + } + yield event; + } + held = []; + texts.clear(); + closings.clear(); + templates.clear(); + } + + for await (const event of source) { + // Tools mean this response is not the final UI answer. + if (event.type === "response.output_item.added" && isToolItemType(event.item.type)) { + hasTools = true; + } + if ( + event.type === "response.function_call_arguments.delta" || + event.type === "response.mcp_call_arguments.delta" + ) { + hasTools = true; + } + // Failed or incomplete run; flush held events without repair. + if ( + event.type === "error" || + event.type === "response.failed" || + event.type === "response.incomplete" + ) { + failed = true; + yield* release(); + yield event; + continue; + } + // Accumulate text and maybe hold a closing fence. + if (event.type === "response.output_text.delta") { + // A new text item; the previous one was not the final answer. + if (!texts.has(event.item_id) && held.length) yield* release(); + templates.set(event.item_id, event); + const previous = texts.get(event.item_id); + if (previous === undefined) texts.set(event.item_id, ""); + const tracked = texts.get(event.item_id); + // Still tracking this text part. + if (tracked != null) { + const combined = tracked + event.delta; + // Too large to send to Autofix; stop holding. + if (combined.length > MAX_AUTOFIX_GENERATION_LENGTH) { + texts.set(event.item_id, null); + const closing = closings.get(event.item_id); + closings.delete(event.item_id); + yield closing ? { ...event, delta: closing + event.delta } : event; + continue; + } + texts.set(event.item_id, combined); + const split = splitClosedFence(combined); + // Hold the closing fence so a later repair stays inside it. + if (split) { + closings.set(event.item_id, split.closing); + const emit = split.body.slice(tracked.length); + // Emit the body slice if anything remains after holding the closer. + if (emit) yield { ...event, delta: emit }; + continue; + } + closings.delete(event.item_id); + } + yield event; + continue; + } + // Hold the text closer until the response finishes. + if (event.type === "response.output_text.done") { + templates.set(event.item_id, event); + held.push(event); + continue; + } + // Hold trailing item-close events that follow text.done. + if ( + held.length && + (event.type === "response.content_part.done" || event.type === "response.output_item.done") + ) { + held.push(event); + continue; + } + // Stream done; repair only on a clean complete without tools or errors. + if (event.type === "response.completed") { + yield* release(!failed && !hasTools); + yield event; + continue; + } + yield event; + } + // EOF without a successful complete preserves events without attempting repair. + yield* release(); + }, +}; diff --git a/packages/server/src/openai/responses-to-items.ts b/packages/server/src/openai/responses-to-items.ts new file mode 100644 index 0000000000..c8fe965ba7 --- /dev/null +++ b/packages/server/src/openai/responses-to-items.ts @@ -0,0 +1,44 @@ +import type { ResponseInputItem, ResponseOutputItem } from "openai/resources/responses/responses"; +import { invalidMessage, type ConversationItem } from "../conversations/append"; + +// Types accepted by the Gateway Conversations endpoint. References and unsupported +// provider tools must not become empty or misleading history entries. +const itemTypes = new Set([ + "function_call", + "function_call_output", + "web_search_call", + "mcp_list_tools", + "mcp_approval_request", + "mcp_approval_response", + "mcp_call", + "reasoning", +]); + +export function responsesToItems( + messages: (ResponseInputItem | ResponseOutputItem)[], +): ConversationItem[] { + const items: ConversationItem[] = []; + for (const message of messages) { + if ("role" in message && (message.role === "system" || message.role === "developer")) continue; + const type = message.type ?? "message"; + if (type !== "message" && !itemTypes.has(type)) { + invalidMessage(`Responses item type '${type}' cannot be appended to Gateway history`); + } + // Gateway assigns new item IDs; output item IDs belong to the source response. + const item = { ...message } as unknown as ConversationItem; + delete item["id"]; + if (type === "message") { + if (item["role"] !== "user" && item["role"] !== "assistant") { + invalidMessage("Responses history messages require a user or assistant role"); + } + // EasyInputMessage permits shorthand text; normalize it to the wire content type. + if (typeof item["content"] === "string") { + item["content"] = [ + { type: item["role"] === "user" ? "input_text" : "output_text", text: item["content"] }, + ]; + } + } + items.push({ ...item, type }); + } + return items; +} diff --git a/packages/server/src/openai/store-conversation-items.ts b/packages/server/src/openai/store-conversation-items.ts index 5315b1880e..b85e0d540a 100644 --- a/packages/server/src/openai/store-conversation-items.ts +++ b/packages/server/src/openai/store-conversation-items.ts @@ -1,160 +1,28 @@ -import type { - ChatCompletionAssistantMessageParam, - ChatCompletionMessageParam, - ChatCompletionToolMessageParam, - ChatCompletionUserMessageParam, -} from "openai/resources/chat/completions"; -import type { ConversationItemList } from "openai/resources/conversations/items"; -import type { - EasyInputMessage, - ResponseFunctionToolCall, - ResponseInputItem, - ResponseInputMessageContentList, -} from "openai/resources/responses/responses"; -import { THESYS_API_BASE_URL } from "../shared/constants.js"; -import type { StoreChatCompletionHistoryOptions } from "./types.js"; - -function userContentOf( - content: ChatCompletionUserMessageParam["content"], -): ResponseInputMessageContentList { - if (typeof content === "string") { - return content ? [{ type: "input_text", text: content }] : []; - } - - const parts: ResponseInputMessageContentList = []; - for (const part of content) { - if (part.type === "text" && part.text) { - parts.push({ type: "input_text", text: part.text }); - continue; - } - if (part.type === "image_url" && part.image_url.url) { - parts.push({ - type: "input_image", - image_url: part.image_url.url, - detail: part.image_url.detail ?? "auto", - }); +import { ServerClientError } from "../shared/client"; +import { createServerClient } from "./client"; +import { messagesToItems } from "./messages-to-items"; +import type { StoreChatCompletionHistoryOptions } from "./types"; + +/** @deprecated Prefer createServerClient().conversations.appendMessages for persistence. */ +export const chatCompletionMessagesToItems = messagesToItems; + +/** @deprecated Use createServerClient().conversations.appendMessages instead. */ +export async function storeChatCompletionHistory(options: StoreChatCompletionHistoryOptions) { + try { + return await createServerClient({ + apiKey: options.apiKey, + apiBaseUrl: options.apiBaseUrl, + fetch: options.fetch as typeof globalThis.fetch | undefined, + }).conversations.appendMessages(options); + } catch (error) { + // Preserve the legacy helper's HTTP error message for existing callers. + if (error instanceof ServerClientError && error.status !== undefined) { + throw new ServerClientError( + `store chat completion history failed: ${error.status} ${error.message}`, + error.code, + error.status, + ); } + throw error; } - return parts; -} - -function assistantTextOf(content: ChatCompletionAssistantMessageParam["content"]): string { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - return content - .map((part) => (part.type === "text" ? part.text : "")) - .filter(Boolean) - .join(""); -} - -function toolOutputOf(content: ChatCompletionToolMessageParam["content"]): string { - if (typeof content === "string") return content; - return content.map((part) => part.text).join(""); -} - -/** - * Break Chat Completions messages into Conversations API items. - * Pass the new turn, not the full replay, or items will be duplicated. - * System / developer messages are skipped (instructions, not history). - */ -export function chatCompletionMessagesToItems( - messages: ChatCompletionMessageParam[], -): ResponseInputItem[] { - const items: ResponseInputItem[] = []; - - for (const message of messages) { - if (message.role === "system" || message.role === "developer") continue; - - if (message.role === "user") { - const content = userContentOf(message.content); - if (content.length === 0) continue; - items.push({ - type: "message", - role: "user", - content, - } satisfies EasyInputMessage); - continue; - } - - if (message.role === "assistant") { - const text = assistantTextOf(message.content); - if (text) { - items.push({ - type: "message", - role: "assistant", - content: [{ type: "output_text", text }], - } as ResponseInputItem); - } - for (const call of message.tool_calls ?? []) { - if (call.type !== "function") continue; - if (!call.id || !call.function.name) continue; - items.push({ - type: "function_call", - call_id: call.id, - name: call.function.name, - arguments: call.function.arguments?.trim() ? call.function.arguments : "{}", - } satisfies ResponseFunctionToolCall); - } - continue; - } - - if (message.role === "tool") { - if (!message.tool_call_id) continue; - items.push({ - type: "function_call_output", - call_id: message.tool_call_id, - output: toolOutputOf(message.content) || "{}", - } satisfies ResponseInputItem.FunctionCallOutput); - } - } - - return items; -} - -const EMPTY_ITEM_LIST: ConversationItemList = { - object: "list", - data: [], - first_id: "", - last_id: "", - has_more: false, -}; - -/** - * POST Chat Completions messages as Conversations API items. - * - * Call this after a turn (typically the new user message plus - * the assembled assistant reply) so OpenUI Cloud storage can - * reload the thread. - * - * The conversation must already exist — AgentInterface's Cloud storage - * creates it via `POST /v1/conversations`. Pass only the new turn, not the - * full replay, or items will be duplicated. - */ -export async function storeChatCompletionHistory( - options: StoreChatCompletionHistoryOptions, -): Promise { - const items = chatCompletionMessagesToItems(options.messages); - if (items.length === 0) { - return EMPTY_ITEM_LIST; - } - - const base = (options.apiBaseUrl ?? THESYS_API_BASE_URL).replace(/\/+$/, ""); - const fetchFn = options.fetch ?? (globalThis.fetch as typeof fetch); - const res = await fetchFn( - `${base}/v1/conversations/${encodeURIComponent(options.conversationId)}/items`, - { - method: "POST", - headers: { - Authorization: `Bearer ${options.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ items }), - }, - ); - - if (!res.ok) { - throw new Error(`store chat completion history failed: ${res.status} ${await res.text()}`); - } - - return (await res.json()) as ConversationItemList; } diff --git a/packages/server/src/openai/types.ts b/packages/server/src/openai/types.ts index ed580e3490..957f4f82d2 100644 --- a/packages/server/src/openai/types.ts +++ b/packages/server/src/openai/types.ts @@ -1,10 +1,22 @@ import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; +import type { ResponseInputItem, ResponseOutputItem } from "openai/resources/responses/responses"; +import type { AppendMessagesOptions } from "../conversations/append"; + +/** Completions remains the default; select Responses explicitly for native items. */ +export type AppendMessagesInput = AppendMessagesOptions & + ( + | { format?: "completions"; messages: ChatCompletionMessageParam[] } + | { format: "responses"; messages: (ResponseInputItem | ResponseOutputItem)[] } + ); + +/** @deprecated Use ServerClientOptions and AppendMessagesInput instead. */ export type StoreChatCompletionHistoryOptions = { apiKey: string; conversationId: string; /** Chat Completions messages for the new turn. */ messages: ChatCompletionMessageParam[]; + signal?: AbortSignal; /** Defaults to `https://api.thesys.dev`. */ apiBaseUrl?: string; fetch?: ( diff --git a/packages/server/src/shared/abort.ts b/packages/server/src/shared/abort.ts new file mode 100644 index 0000000000..ba78200594 --- /dev/null +++ b/packages/server/src/shared/abort.ts @@ -0,0 +1,14 @@ +/** Stop waiting even when an upstream request or customer tool ignores cancellation. */ +export async function abortable(promise: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted(); + let abort!: () => void; + const cancelled = new Promise((_, reject) => { + abort = () => reject(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + }); + try { + return await Promise.race([promise, cancelled]); + } finally { + signal.removeEventListener("abort", abort); + } +} diff --git a/packages/server/src/shared/client-autofix.ts b/packages/server/src/shared/client-autofix.ts new file mode 100644 index 0000000000..5f9a5d7b97 --- /dev/null +++ b/packages/server/src/shared/client-autofix.ts @@ -0,0 +1,31 @@ +import type { ServerClientConfig } from "./client"; +import { createAutofix } from "./create-autofix"; +import type { AutofixInput, AutofixOptions, AutofixStreamInput, StreamAdapter } from "./types"; + +export type ClientAutofixInput = AutofixInput & Pick; +export type ClientAutofixStreamInput = AutofixStreamInput & + Pick; + +/** Bind provider events to the shared client; library state is isolated by spec. */ +export function createClientAutofix( + config: ServerClientConfig, + adapter: StreamAdapter, +) { + const pipelines = new WeakMap< + AutofixOptions["library"], + ReturnType> + >(); + const pipeline = (library: AutofixOptions["library"]) => { + let existing = pipelines.get(library); + if (!existing) { + existing = createAutofix({ ...config, library }, adapter); + pipelines.set(library, existing); + } + return existing; + }; + return { + fix: ({ library, ...input }: ClientAutofixInput) => pipeline(library).fix(input), + stream: ({ library, ...input }: ClientAutofixStreamInput) => + pipeline(library).stream(input), + }; +} diff --git a/packages/server/src/shared/client.ts b/packages/server/src/shared/client.ts new file mode 100644 index 0000000000..480ecb4436 --- /dev/null +++ b/packages/server/src/shared/client.ts @@ -0,0 +1,92 @@ +import { THESYS_API_BASE_URL } from "./constants"; + +export interface ServerClientOptions { + /** Defaults to process.env.THESYS_API_KEY. */ + apiKey?: string; + /** Gateway origin, without /v1. */ + apiBaseUrl?: string; + fetch?: typeof globalThis.fetch; +} + +export interface ServerClientConfig { + apiKey: string; + apiBaseUrl: string; + fetch: typeof globalThis.fetch; +} + +export class ServerClientError extends Error { + constructor( + message: string, + readonly code: string, + readonly status?: number, + ) { + super(message); + this.name = "ServerClientError"; + } +} + +/** Resolve server configuration once, without retaining request or user state. */ +export function resolveClientOptions(options: ServerClientOptions = {}): ServerClientConfig { + const apiKey = + options.apiKey ?? (typeof process !== "undefined" ? process.env["THESYS_API_KEY"] : undefined); + if (!apiKey?.trim()) { + throw new ServerClientError( + "Set THESYS_API_KEY or pass apiKey to createServerClient", + "missing_api_key", + ); + } + return { + apiKey, + apiBaseUrl: (options.apiBaseUrl ?? THESYS_API_BASE_URL).replace(/\/+$/, ""), + fetch: options.fetch ?? globalThis.fetch, + }; +} + +/** Send a Gateway JSON request without retrying writes or tool execution. */ +export async function postJSON( + config: ServerClientConfig, + path: string, + body: unknown, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const response = await config.fetch(`${config.apiBaseUrl}${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${config.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal, + }); + if (!response.ok) { + const text = await response.text(); + signal?.throwIfAborted(); + let code = "http_error"; + let message = text; + try { + const parsed = JSON.parse(text) as { error?: { code?: string; message?: string } }; + if (typeof parsed?.error?.code === "string") code = parsed.error.code; + if (typeof parsed?.error?.message === "string") message = parsed.error.message; + } catch { + // Preserve plain-text upstream errors. + } + throw new ServerClientError( + message || `Gateway request failed: HTTP ${response.status}`, + code, + response.status, + ); + } + try { + const result: unknown = await response.json(); + signal?.throwIfAborted(); + return result; + } catch (error) { + signal?.throwIfAborted(); + throw new ServerClientError( + `Could not read Gateway response: ${String(error)}`, + "invalid_response", + response.status, + ); + } +} diff --git a/packages/server/src/shared/iterate.ts b/packages/server/src/shared/iterate.ts new file mode 100644 index 0000000000..74bcb23dc1 --- /dev/null +++ b/packages/server/src/shared/iterate.ts @@ -0,0 +1,33 @@ +import type { StreamSource } from "./types"; + +/** Normalize native event streams and release a reader when consumption stops. */ +export async function* iterateSource( + source: StreamSource, + signal: AbortSignal, +): AsyncGenerator { + if (!("getReader" in source)) { + yield* source as AsyncIterable; + return; + } + const reader = (source as ReadableStream).getReader(); + const cancel = () => { + void reader.cancel(signal.reason).catch(() => {}); + }; + signal.addEventListener("abort", cancel, { once: true }); + if (signal.aborted) cancel(); + let ended = false; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + ended = true; + return; + } + yield value; + } + } finally { + signal.removeEventListener("abort", cancel); + if (!ended) await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} diff --git a/packages/server/src/shared/sse.ts b/packages/server/src/shared/sse.ts index 99ae50eeec..aed1e25370 100644 --- a/packages/server/src/shared/sse.ts +++ b/packages/server/src/shared/sse.ts @@ -1,6 +1,6 @@ import type { StreamAdapter } from "./types"; -// Convert a supported event stream to SSE without changing its event objects. +// Convert a supported event stream to SSE or Eve NDJSON without changing its event objects. export function toSSE( chunks: AsyncIterable, protocol: StreamAdapter["protocol"], @@ -8,6 +8,7 @@ export function toSSE( ): Response { const iterator = chunks[Symbol.asyncIterator](); const encoder = new TextEncoder(); + const ndjson = protocol === "eve"; return new Response( new ReadableStream({ // Pull and frame one native event at a time, respecting consumer backpressure. @@ -15,8 +16,20 @@ export function toSSE( try { const next = await iterator.next(); if (next.done) { - controller.enqueue(encoder.encode("data: [DONE]\n\n")); + // Completions, Responses, and AI SDK SSE end with [DONE]; Eve NDJSON just closes. + if (!ndjson && protocol !== "langgraph") + controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.close(); + } else if (ndjson) { + controller.enqueue(encoder.encode(`${JSON.stringify(next.value)}\n`)); + } else if (protocol === "langgraph") { + const event = next.value as { event: string; data: unknown; id?: string }; + const id = event.id === undefined ? "" : `id: ${event.id.replace(/[\r\n]/g, "")}\n`; + controller.enqueue( + encoder.encode( + `${id}event: ${event.event.replace(/[\r\n]/g, "")}\ndata: ${JSON.stringify(event.data)}\n\n`, + ), + ); } else { controller.enqueue(encoder.encode(`data: ${JSON.stringify(next.value)}\n\n`)); } @@ -32,7 +45,7 @@ export function toSSE( }), { headers: { - "Content-Type": "text/event-stream", + "Content-Type": ndjson ? "application/x-ndjson" : "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no", ...(protocol === "vercel-ai" && { "x-vercel-ai-ui-message-stream": "v1" }), diff --git a/packages/server/src/shared/stream.ts b/packages/server/src/shared/stream.ts index 0b779dacd8..7338426341 100644 --- a/packages/server/src/shared/stream.ts +++ b/packages/server/src/shared/stream.ts @@ -1,3 +1,5 @@ +import { abortable } from "./abort"; +import { iterateSource } from "./iterate"; import { toSSE } from "./sse"; import type { AutofixInput, @@ -8,21 +10,6 @@ import type { } from "./types"; import { AutofixError } from "./types"; -// Stop waiting on cancellation even when an upstream iterator or request is stalled. -async function abortable(promise: Promise, signal: AbortSignal): Promise { - signal.throwIfAborted(); - let abort!: () => void; - const cancelled = new Promise((_, reject) => { - abort = () => reject(signal.reason); - signal.addEventListener("abort", abort, { once: true }); - }); - try { - return await Promise.race([promise, cancelled]); - } finally { - signal.removeEventListener("abort", abort); - } -} - // Bind the shared fix function to an adapter and expose native chunks or HTTP streaming. export function createAutofixStream( input: AutofixStreamInput, @@ -75,21 +62,24 @@ export function createAutofixStream( if (input.signal?.aborted) forwardAbort(); else input.signal?.addEventListener("abort", forwardAbort, { once: true }); - const iterator = adapter.transform(input.stream, async (generation) => { - const next = await fix({ generation, messages: input.messages, signal }); - signal.throwIfAborted(); - last = next; - // Surface a failed repair as a stream error with the Autofix payload. - if (next.status === "fix_failed") { - throw new AutofixError( - "Could not repair the streamed generation", - "fix_failed", - undefined, - next, - ); - } - return next; - }); + const iterator = adapter.transform( + iterateSource(input.stream, signal), + async (generation) => { + const next = await fix({ generation, messages: input.messages, signal }); + signal.throwIfAborted(); + last = next; + // Surface a failed repair as a stream error with the Autofix payload. + if (next.status === "fix_failed") { + throw new AutofixError( + "Could not repair the streamed generation", + "fix_failed", + undefined, + next, + ); + } + return next; + }, + ); let ended = false; try { diff --git a/packages/server/src/shared/types.ts b/packages/server/src/shared/types.ts index 9758c41b6b..d90f037e07 100644 --- a/packages/server/src/shared/types.ts +++ b/packages/server/src/shared/types.ts @@ -38,20 +38,25 @@ export interface AutofixInput extends BaseAutofixInput { generation: string; } -export type StreamSource = AsyncIterable & { controller?: AbortController }; +export type StreamSource = (AsyncIterable | ReadableStream) & { + controller?: AbortController; +}; export interface AutofixStreamInput extends BaseAutofixInput { /** Native SDK events for the selected import path. */ stream: StreamSource; } +export type AutofixStreamProtocol = + "openai-chat-completions" | "openai-responses" | "vercel-ai" | "eve" | "langgraph"; + /** Wrap provider events with validation and repair while preserving the selected output protocol. */ export interface StreamAdapter { - /** SSE format for emitted events; transform must produce chunks compatible with this protocol. */ - protocol: "openai-chat-completions" | "vercel-ai"; + /** Wire format for emitted events; transform must produce chunks compatible with this protocol. */ + protocol: AutofixStreamProtocol; /** Forward events and use fix to validate completed UI before emitting its completion marker. */ transform( - source: StreamSource, + source: AsyncIterable, fix: (generation: string) => Promise, ): AsyncGenerator; } @@ -59,7 +64,7 @@ export interface StreamAdapter { export interface AutofixStream { /** Single consumer. Preserves provider chunks and appends the corrected program before UI completion. */ chunks: AsyncIterable; - /** Alternative to consuming chunks: SSE in the selected adapter's protocol. */ + /** Alternative to consuming chunks: SSE or Eve NDJSON in the selected adapter's protocol. */ toResponse(): Response; /** Settles after chunks or toResponse() finish. Null when Autofix did not run. Persist content, not joined deltas. */ result: Promise; diff --git a/packages/server/src/vercel/eve-adapter.test.ts b/packages/server/src/vercel/eve-adapter.test.ts new file mode 100644 index 0000000000..c3ddebaf4a --- /dev/null +++ b/packages/server/src/vercel/eve-adapter.test.ts @@ -0,0 +1,250 @@ +import type { MessageStreamEvent } from "eve/client"; +import { describe, expect, it, vi } from "vitest"; +import { createAutofixStream } from "../shared/stream"; +import { MAX_AUTOFIX_GENERATION_LENGTH, type AutofixResult } from "../shared/types"; +import { eveStreamAdapter } from "./eve-adapter"; + +const program = '```openui\nroot = Card("broken")\n```'; +const corrected = '```openui\nroot = Card("fixed")\n```'; +const fixed: AutofixResult = { + status: "fixed", + original: program, + content: corrected, + fixedErrors: [], + unfixedErrors: [], +}; +const meta = { at: "2026-10-04T16:00:00Z", id: "event_1" }; +const routing = { sequence: 4, stepIndex: 2, turnId: "turn_1" }; + +function appended(messageDelta: string, stepIndex = 2): MessageStreamEvent { + return { type: "message.appended", data: { ...routing, stepIndex, messageDelta }, meta }; +} +function completed( + message = program, + stepIndex = 2, + finishReason: "stop" | "length" | "content-filter" | "tool-calls" = "stop", +): MessageStreamEvent { + return { + type: "message.completed", + data: { ...routing, stepIndex, message, finishReason }, + meta, + }; +} +// Failure/boundary payloads are opaque to Autofix and must pass through unchanged. +function boundary( + type: + | "turn.completed" + | "session.completed" + | "turn.failed" + | "session.failed" + | "step.failed" + | "turn.cancelled" + | "session.waiting", +): MessageStreamEvent { + return { type, data: { sequence: 5, turnId: "turn_1" }, meta } as MessageStreamEvent; +} +async function* source(events: MessageStreamEvent[]) { + yield* events; +} +async function collect(events: MessageStreamEvent[], fix = vi.fn().mockResolvedValue(fixed)) { + const output: MessageStreamEvent[] = []; + for await (const event of eveStreamAdapter.transform(source(events), fix)) output.push(event); + return output; +} +function textOf(events: MessageStreamEvent[]) { + return events + .filter((event) => event.type === "message.appended") + .map((event) => event.data.messageDelta) + .join(""); +} + +describe("Eve Autofix adapter", () => { + it("repairs inside a held fence before message/turn/session completion, once", async () => { + const end = completed(); + const turn = boundary("turn.completed"); + const session = boundary("session.completed"); + const fix = vi.fn().mockResolvedValue(fixed); + const output = await collect([appended(program), end, turn, session], fix); + expect(fix).toHaveBeenCalledExactlyOnceWith(program); + expect(textOf(output)).toBe('```openui\nroot = Card("broken")\n\nroot = Card("fixed")\n```'); + expect(output.slice(-3)).toEqual([end, turn, session]); + expect(output[1]).toEqual({ + type: "message.appended", + data: { ...routing, messageDelta: '\nroot = Card("fixed")\n' }, + meta, + }); + }); + + it("supports completed-only text with the original routing and metadata", async () => { + const fix = vi.fn().mockResolvedValue(fixed); + const output = await collect([completed(), boundary("session.completed")], fix); + expect(fix).toHaveBeenCalledExactlyOnceWith(program); + expect(textOf(output)).toBe('```openui\nroot = Card("broken")\n\nroot = Card("fixed")\n```'); + for (const event of output) { + if (event.type === "message.appended") { + expect(event.data).toMatchObject(routing); + expect(event.meta).toEqual(meta); + } + } + }); + + it.each([ + "turn.failed", + "session.failed", + "step.failed", + "turn.cancelled", + "session.waiting", + "eof", + ] as const)("preserves text without repair on %s", async (type) => { + const fix = vi.fn().mockResolvedValue(fixed); + const events = [appended(program), completed(), ...(type === "eof" ? [] : [boundary(type)])]; + const output = await collect(events, fix); + expect(fix).not.toHaveBeenCalled(); + expect(textOf(output)).toBe(program); + expect(output.slice(-events.length + 1)).toEqual(events.slice(1)); + }); + + it("preserves tool requests and suppresses repair for tool turns", async () => { + const tool: MessageStreamEvent = { + type: "actions.requested", + data: { + actions: [{ kind: "tool-call", toolName: "lookup", callId: "call_1", input: {} }], + ...routing, + }, + meta, + }; + const fix = vi.fn().mockResolvedValue(fixed); + const output = await collect( + [appended(program), completed(), tool, boundary("turn.completed")], + fix, + ); + expect(fix).not.toHaveBeenCalled(); + expect(output).toContain(tool); + expect(textOf(output)).toBe(program); + }); + + it.each(["length", "content-filter", "tool-calls"] as const)( + "skips non-stop message completion (%s)", + async (reason) => { + const fix = vi.fn().mockResolvedValue(fixed); + const output = await collect( + [appended(program), completed(program, 2, reason), boundary("turn.completed")], + fix, + ); + expect(fix).not.toHaveBeenCalled(); + expect(textOf(output)).toBe(program); + }, + ); + + it.each(["plain", "valid", "oversized", "oversized-completed-only"])( + "passes through %s text without corrections or duplicate deltas", + async (kind) => { + const text = + kind === "plain" + ? "Hello" + : kind.startsWith("oversized") + ? "root = " + "x".repeat(MAX_AUTOFIX_GENERATION_LENGTH) + : program; + const fix = vi.fn().mockResolvedValue({ ...fixed, status: "already_valid", content: text }); + const output = await collect( + [ + ...(kind === "oversized-completed-only" ? [] : [appended(text)]), + completed(text), + boundary("turn.completed"), + ], + fix, + ); + expect(textOf(output).length).toBe(text.length); + expect(textOf(output) === text).toBe(true); + expect(fix).toHaveBeenCalledTimes(kind === "valid" ? 1 : 0); + }, + ); + + it("releases intermediate steps without repair and only repairs the final step", async () => { + const last = 'root = Card("last")'; + const step = { + type: "step.started", + data: { ...routing, stepIndex: 3 }, + meta, + } as MessageStreamEvent; + const fix = vi.fn().mockResolvedValue(fixed); + const output = await collect( + [ + appended(program), + completed(), + step, + appended(last, 3), + completed(last, 3), + boundary("turn.completed"), + ], + fix, + ); + expect(fix).toHaveBeenCalledExactlyOnceWith(last); + expect( + output.indexOf( + output.find((event) => event.type === "message.completed" && event.data.stepIndex === 2)!, + ), + ).toBeLessThan(output.indexOf(step)); + }); + + it("accepts ReadableStream events and emits Eve NDJSON without an SSE terminator", async () => { + const events = [appended(program), completed(), boundary("turn.completed")]; + const input = new ReadableStream({ + start(controller) { + for (const event of events) controller.enqueue(event); + controller.close(); + }, + }); + const stream = createAutofixStream( + { stream: input }, + vi.fn().mockResolvedValue(fixed), + eveStreamAdapter, + ); + const response = stream.toResponse(); + expect(response.headers.get("Content-Type")).toBe("application/x-ndjson"); + const wire = await response.text(); + expect(wire).not.toContain("data:"); + expect(wire).not.toContain("[DONE]"); + expect(wire.endsWith("\n")).toBe(true); + const output = wire + .trim() + .split("\n") + .map((line) => JSON.parse(line) as MessageStreamEvent); + expect(output.at(-1)).toEqual(events.at(-1)); + expect(textOf(output)).toContain('Card("fixed")'); + expect(await stream.result).toEqual(fixed); + expect(input.locked).toBe(false); + }); + + it.each(["turn.failed", "turn.completed"] as const)( + "resets %s state before a subsequent turn", + async (firstBoundary) => { + const nextTurn: MessageStreamEvent = { + type: "turn.started", + data: { sequence: 6, turnId: "turn_2" }, + meta, + }; + const fix = vi.fn().mockResolvedValue(fixed); + await collect( + [ + appended(program), + completed(), + boundary(firstBoundary), + nextTurn, + ...[appended(program), completed(), boundary("turn.completed")].map( + (event) => + ("data" in event + ? { + ...event, + data: { ...event.data, turnId: "turn_2" }, + } + : event) as MessageStreamEvent, + ), + ], + fix, + ); + expect(fix).toHaveBeenCalledTimes(firstBoundary === "turn.failed" ? 1 : 2); + expect(fix).toHaveBeenLastCalledWith(program); + }, + ); +}); diff --git a/packages/server/src/vercel/eve-adapter.ts b/packages/server/src/vercel/eve-adapter.ts new file mode 100644 index 0000000000..a10ae4c962 --- /dev/null +++ b/packages/server/src/vercel/eve-adapter.ts @@ -0,0 +1,178 @@ +import type { MessageStreamEvent } from "eve/client"; +import { MAX_AUTOFIX_GENERATION_LENGTH, type StreamAdapter } from "../shared/types"; +import { isUIOutput, splitClosedFence, unwrapOpenUIFence } from "../shared/utils"; + +type EveStreamEvent = MessageStreamEvent; +type TextEvent = Extract; + +function isTextEvent(event: EveStreamEvent): event is TextEvent { + return event.type === "message.appended" || event.type === "message.completed"; +} + +function stepOf(event: EveStreamEvent): number { + return isTextEvent(event) ? event.data.stepIndex : 0; +} + +function appended( + template: EveStreamEvent | undefined, + step: number, + messageDelta: string, +): EveStreamEvent { + const text = template && isTextEvent(template) ? template : undefined; + return { + type: "message.appended", + data: { + messageDelta, + sequence: text?.data.sequence ?? 0, + stepIndex: step, + turnId: text?.data.turnId ?? "", + }, + meta: text?.meta ?? { at: "", id: "" }, + }; +} + +/** Preserve Eve NDJSON events and repair text before a successful turn closes. */ +export const eveStreamAdapter: StreamAdapter = { + protocol: "eve", + // Track text within each step and wait for the turn boundary before attempting repair. + async *transform(source, fix) { + const texts = new Map(); + const closings = new Map(); + const templates = new Map(); + let held: EveStreamEvent[] = []; + let hasTools = false; + let failed = false; + let attempted = false; + + // Release deferred endings, optionally inserting a repair before a held closing fence. + async function* release(repair = false): AsyncGenerator { + for (const event of held) { + // Insert repair and closer just before the assistant message completes. + if (event.type === "message.completed") { + const step = stepOf(event); + const text = texts.get(step); + // Repair only a successful final-step UI generation. + if (repair && event.data.finishReason === "stop" && text != null && isUIOutput(text)) { + const result = await fix(text); + // Only append when Autofix actually changed the text. + if (result.status === "fixed") { + yield appended(templates.get(step), step, `\n${unwrapOpenUIFence(result.content)}\n`); + } + } + const closing = closings.get(step); + // Emit the held fence closer after any repair. + if (closing) yield appended(templates.get(step), step, closing); + } + yield event; + } + held = []; + texts.clear(); + closings.clear(); + templates.clear(); + } + + for await (const event of source) { + // A new step releases earlier text; only the final step is eligible for repair. + if (event.type === "step.started" || event.type === "turn.started") { + yield* release(); + hasTools = false; + if (event.type === "turn.started") { + failed = false; + attempted = false; + } + } + // Tools mean this turn is not the final UI answer. + if (event.type === "actions.requested") { + if (event.data.actions.some((action) => action.kind === "tool-call")) hasTools = true; + } + // Failed or cancelled turn; flush held events without repair. + if ( + event.type === "turn.failed" || + event.type === "session.failed" || + event.type === "step.failed" || + event.type === "turn.cancelled" + ) { + failed = true; + yield* release(); + yield event; + continue; + } + // Accumulate text and maybe hold a closing fence. + if (event.type === "message.appended") { + const step = event.data.stepIndex; + const incoming = event.data.messageDelta; + templates.set(step, event); + if (!texts.has(step)) texts.set(step, ""); + const previous = texts.get(step); + // Still tracking this step. + if (previous != null) { + const combined = previous + incoming; + // Too large to send to Autofix; stop holding. + if (combined.length > MAX_AUTOFIX_GENERATION_LENGTH) { + texts.set(step, null); + const closing = closings.get(step); + closings.delete(step); + yield closing + ? { ...event, data: { ...event.data, messageDelta: closing + incoming } } + : event; + continue; + } + texts.set(step, combined); + const split = splitClosedFence(combined); + // Hold the closing fence so a later repair stays inside it. + if (split) { + closings.set(step, split.closing); + const emit = split.body.slice(previous.length); + // Emit the body slice if anything remains after holding the closer. + if (emit) yield { ...event, data: { ...event.data, messageDelta: emit } }; + continue; + } + closings.delete(step); + } + yield event; + continue; + } + // Hold the message closer until the turn finishes; stream a non-delta body first. + if (event.type === "message.completed") { + const step = event.data.stepIndex; + templates.set(step, event); + const existing = texts.get(step); + // null means tracking stopped at the size limit; never replay that text. + if (existing === undefined || existing === "") { + const full = event.data.message ?? ""; + const oversized = full.length > MAX_AUTOFIX_GENERATION_LENGTH; + texts.set(step, oversized ? null : full); + const split = oversized ? null : splitClosedFence(full); + if (split) { + closings.set(step, split.closing); + if (split.body) yield appended(event, step, split.body); + } else if (full) { + yield appended(event, step, full); + } + } + held.push(event); + continue; + } + // Turn or session success; repair once on a clean finish without tools or errors. + if (event.type === "turn.completed" || event.type === "session.completed") { + if (!attempted) { + yield* release(!failed && !hasTools); + attempted = true; + } else { + yield* release(); + } + yield event; + continue; + } + // Waiting is a turn boundary without success; preserve events without repair. + if (event.type === "session.waiting") { + yield* release(); + yield event; + continue; + } + yield event; + } + // EOF without a successful boundary preserves events without attempting repair. + yield* release(); + }, +}; diff --git a/packages/server/src/vercel/index.ts b/packages/server/src/vercel/index.ts index b9c8ae26c0..69f025a208 100644 --- a/packages/server/src/vercel/index.ts +++ b/packages/server/src/vercel/index.ts @@ -1,10 +1,47 @@ -import { createAutofix as createAutofixPipeline } from "../shared/create-autofix"; +import type { UIMessage } from "ai"; +import { createConversations, type AppendMessagesOptions } from "../conversations/append"; +import { resolveClientOptions, type ServerClientOptions } from "../shared/client"; +import { createClientAutofix } from "../shared/client-autofix"; import type { AutofixOptions } from "../shared/types"; import { vercelAIAdapter } from "./adapter"; +import { eveStreamAdapter } from "./eve-adapter"; +import { vercelMessagesToItems } from "./messages-to-items"; -// Create validation and repair helpers for Vercel AI SDK UI message streams. +export { ServerClientError } from "../shared/client"; +export type { ServerClientOptions } from "../shared/client"; +export type { ClientAutofixInput, ClientAutofixStreamInput } from "../shared/client-autofix"; +export { AutofixError } from "../shared/types"; +export type { AutofixResult, AutofixStream } from "../shared/types"; + +/** Configure AI SDK UI message Autofix. */ +export function createServerClient(options: ServerClientOptions = {}) { + const config = resolveClientOptions(options); + return { + conversations: createConversations(config, (input: AppendMessagesInput) => + vercelMessagesToItems(input.messages), + ), + autofix: { + ai: createClientAutofix(config, vercelAIAdapter), + eve: createClientAutofix(config, eveStreamAdapter), + }, + }; +} + +export type ServerClient = ReturnType; + +/** @deprecated Use createServerClient().autofix.ai with a library per operation. */ export function createAutofix(options: AutofixOptions) { + const { ai } = createServerClient(options).autofix; return { - ai: createAutofixPipeline(options, vercelAIAdapter), + ai: { + fix: (input: Omit[0], "library">) => + ai.fix({ ...input, library: options.library }), + stream: (input: Omit[0], "library">) => + ai.stream({ ...input, library: options.library }), + }, }; } + +export type { MessageStreamEvent as EveStreamEvent } from "eve/client"; + +export type AppendMessagesInput = AppendMessagesOptions & { messages: UIMessage[] }; diff --git a/packages/server/src/vercel/messages-to-items.ts b/packages/server/src/vercel/messages-to-items.ts new file mode 100644 index 0000000000..19642a70cf --- /dev/null +++ b/packages/server/src/vercel/messages-to-items.ts @@ -0,0 +1,100 @@ +import type { UIMessage } from "ai"; +import { invalidMessage, jsonValue, type ConversationItem } from "../conversations/append"; + +/** Store completed UI messages, preserving text/tool ordering across model steps. */ +export function vercelMessagesToItems(messages: UIMessage[]): ConversationItem[] { + const items: ConversationItem[] = []; + for (const message of messages) { + if (message.role === "system") continue; + let content: Record[] = []; + const flush = () => { + if (content.length) items.push({ type: "message", role: message.role, content }); + content = []; + }; + for (const part of message.parts) { + if (part.type === "text") { + if (part.state === "streaming") + invalidMessage("Append completed text, not streaming UI message parts"); + content.push({ + type: message.role === "user" ? "input_text" : "output_text", + text: part.text, + }); + } else if (part.type === "file" && message.role === "user") { + content.push( + part.mediaType.startsWith("image/") + ? { type: "input_image", image_url: part.url, detail: "auto" } + : { + type: "input_file", + file_url: part.url, + ...(part.filename && { filename: part.filename }), + }, + ); + } else if (part.type === "step-start") { + flush(); + } else if (part.type === "reasoning") { + if (part.state === "streaming") + invalidMessage("Append completed reasoning, not streaming UI message parts"); + flush(); + items.push({ type: "reasoning", summary: [{ type: "summary_text", text: part.text }] }); + } else if (part.type === "dynamic-tool" || part.type.startsWith("tool-")) { + if (message.role !== "assistant") + invalidMessage("Tool parts require an assistant UI message"); + flush(); + const tool = part as unknown as { + type: string; + toolName?: string; + toolCallId: string; + input?: unknown; + output?: unknown; + errorText?: string; + state: string; + }; + const name = tool.type === "dynamic-tool" ? tool.toolName : tool.type.slice(5); + if ( + !name || + !tool.toolCallId || + !["input-available", "output-available", "output-error", "output-denied"].includes( + tool.state, + ) + ) { + invalidMessage("Tool parts require a name, call ID, and complete arguments"); + } + if (tool.input === undefined) + invalidMessage("Tool arguments are missing; append a completed tool part"); + items.push({ + type: "function_call", + name, + call_id: tool.toolCallId, + arguments: jsonValue(tool.input), + }); + if (tool.state === "output-available") { + if (!Object.hasOwn(tool, "output")) invalidMessage("Completed tool output is missing"); + items.push({ + type: "function_call_output", + call_id: tool.toolCallId, + output: typeof tool.output === "string" ? tool.output : jsonValue(tool.output), + }); + } else if (tool.state === "output-error" || tool.state === "output-denied") { + items.push({ + type: "function_call_output", + call_id: tool.toolCallId, + output: jsonValue({ + error: + tool.errorText ?? + (tool.state === "output-denied" + ? "Tool execution denied" + : "Tool execution failed"), + }), + }); + } + } else if (part.type.startsWith("data-")) { + // Application data parts are UI metadata, not model conversation content. + continue; + } else { + invalidMessage(`UI message part '${part.type}' cannot be appended to Gateway history`); + } + } + flush(); + } + return items; +} diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts index 1ba4a4023b..84a096a1da 100644 --- a/packages/server/tsdown.config.ts +++ b/packages/server/tsdown.config.ts @@ -30,4 +30,10 @@ export default defineConfig([ outDir: "dist/vercel", clean: false, }, + { + ...shared, + entry: { index: "src/langchain/index.ts" }, + outDir: "dist/langchain", + clean: false, + }, ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83471e93ac..71c2bff3f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,7 +87,6 @@ overrides: uuid@<11.1.1: ^11.1.1 importers: - .: devDependencies: '@arethetypeswrong/cli': @@ -930,12 +929,18 @@ importers: specifier: workspace:^ version: link:../lang-core devDependencies: + '@langchain/langgraph-sdk': + specifier: ^1.12.1 + version: 1.12.1(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ai: specifier: ^7.0.10 version: 7.0.10(zod@4.4.3) + eve: + specifier: ^0.64.0 + version: 0.64.1(@opentelemetry/api@1.9.1)(ai@7.0.10(zod@4.4.3)) openai: specifier: ^7.20.0 - version: 7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(ws@8.21.1)(zod@4.4.3) + version: 7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3) tsdown: specifier: ^0.21.7 version: 0.21.10(@arethetypeswrong/core@0.18.5)(@typescript/native-preview@7.0.0-dev.20260523.1)(oxc-resolver@11.24.2)(publint@0.3.22)(synckit@0.11.13)(typescript@6.0.3) @@ -1015,7 +1020,6 @@ importers: version: 2.2.12(typescript@5.9.3) packages: - '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} @@ -11135,8 +11139,199 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} -snapshots: + '@langchain/langgraph-sdk@1.12.1': + resolution: {integrity: sha512-V/XSE7auTpfS5p3ByggDM6bKvRLeEjImb7/H+ng9qHkwCrYCGrZpVcuve2lH9pHQVOQlEtnHUsRZKQTWumcdhw==} + peerDependencies: + '@langchain/core': ^1.1.48 + react: ^18 || ^19 + react-dom: ^18 || ^19 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + '@langchain/protocol@0.0.19': + resolution: {integrity: sha512-9hKcRrH7cBX6gfutdfXPoft1OCchHe4FEpALoDJMl5Qu+n/YG5ynZmyu8+8cxORlPwHBoKTxggvXz+76M1yX1Q==} + + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + + '@rolldown/binding-android-arm-eabi@1.2.12': + resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.12': + resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.12': + resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.12': + resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.12': + resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.12': + resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.12': + resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.12': + resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.12': + resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.12': + resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + db0@0.4.1: + resolution: {integrity: sha512-6RBY/bSn42UrqATwsiULj2uYFyEykB3XeA/NLIVQeHNXlYV6D4Idxx3/aa6k5Y45Dwzx7sygeLotnqHWSeURYA==} + + env-runner@0.2.3: + resolution: {integrity: sha512-S8EFBKyznRwPVwayK187cYjvnpgC92xy+q0BIF++V965pfjMVVRbQ/HoTYGA/1EHmZ7E68k6Heq+4WnvVhOL5w==} + hasBin: true + + eve@0.64.1: + resolution: {integrity: sha512-dOvgtqg6RAx1I8x5cuzQeKyMXx8YXhEwixQGjdDRJUne6g46tcHNHz4IObOq92+SfSiL4wdS0SqnZnMrH4oOeQ==} + engines: {node: '>=24'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + ai: ^7.0.105 + braintrust: ^3.0.0 + chat: ^4.41.0 + dd-trace: ^6.13.0 + just-bash: ^3.1.0 + microsandbox: ^0.5.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + braintrust: + optional: true + chat: + optional: true + dd-trace: + optional: true + just-bash: + optional: true + microsandbox: + optional: true + + h3@2.0.1: + resolution: {integrity: sha512-trUtZ82iNkfYi8An9M2kIM/SPlomO59pTRMt4zMBffuVPdwv5v1Bxs7uShxi7bOIAuZne6zq6TsD9hVYdNgo+A==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + crossws: ^0.4.12 + ocache: '>=0.3.0' + peerDependenciesMeta: + crossws: + optional: true + ocache: + optional: true + + nitro@3.0.260903-beta: + resolution: {integrity: sha512-54gANPi62O8rfMvepiJUVuIzEIinYfpeHbebywlLxvVTgIYXDwSvpaU9Id+0sJOBjBx0wC1/CVYXJkH7SY+l0g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + ocache@0.3.0: + resolution: {integrity: sha512-RS/9P0zeBb0gDJmadGERLH8lZNXK7xbufTDhclkXGvFGTsj0G4M5/cLk+mizcERH29mLXNnocfB5wjcK70wGJg==} + + rolldown@1.2.12: + resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + + rou3@1.0.0: + resolution: {integrity: sha512-ySeaQkSrua+ut9g+65I7FHqOFBaY8tV0FmccXItoq0q21se32Q3u4YiW7/RqXSosWgYJaaKwRHXy3BST9PeCNQ==} + engines: {node: '>=20.19.0'} + srvx@1.0.5: + resolution: {integrity: sha512-KvSKRpgPG/oaq3cyT614OQ2bAa7DynuGamivCZeoZzIUULOdbQz6wUtnnVuC4+DCx2Zglzo8v5gBYmWIWYhDxA==} + engines: {node: '>=20.16.0'} + hasBin: true + + undici@8.9.0: + resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} + engines: {node: '>=22.19.0'} + + unstorage@2.0.0-alpha.10: + resolution: {integrity: sha512-6h1veZp8gnp4dGllf0tDijoXxDYymJu251IpKKkrSVH+QHL6tXFbwG85KM+CBHSgpkkgtPuIiZ9oLCLP5+1Evw==} + +snapshots: '@adobe/css-tools@4.5.0': {} '@ag-ui/core@0.0.53': @@ -22383,3 +22578,204 @@ snapshots: use-sync-external-store: 1.6.0(react@19.2.4) zwitch@2.0.4: {} + '@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1)': + dependencies: + '@cfworker/json-schema': 4.1.1 + '@standard-schema/spec': 1.1.0 + js-tiktoken: 1.0.21 + langsmith: 0.6.3(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1) + mustache: 4.2.0 + p-queue: 6.6.2 + zod: 4.4.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - ws + + '@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1))': + dependencies: + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1) + + '@langchain/langgraph-sdk@1.12.1(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1) + '@langchain/protocol': 0.0.19 + '@types/json-schema': 7.0.15 + p-queue: 9.3.3 + p-retry: 7.1.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@langchain/langgraph-sdk@1.9.28(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vue@3.5.40(typescript@6.0.3))': + dependencies: + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1) + '@langchain/protocol': 0.0.18 + '@types/json-schema': 7.0.15 + p-queue: 9.3.3 + p-retry: 7.1.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + svelte: 5.56.8(@typescript-eslint/types@8.65.0) + vue: 3.5.40(typescript@6.0.3) + + '@langchain/langgraph@1.4.8(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vue@3.5.40(typescript@6.0.3))(zod@4.4.3)': + dependencies: + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1) + '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1)) + '@langchain/langgraph-sdk': 1.9.28(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(svelte@5.56.8(@typescript-eslint/types@8.65.0))(vue@3.5.40(typescript@6.0.3)) + '@langchain/protocol': 0.0.18 + '@standard-schema/spec': 1.1.0 + zod: 4.4.3 + transitivePeerDependencies: + - react + - react-dom + - svelte + - vue + + '@langchain/protocol@0.0.19': {} + + '@oxc-project/types@0.152.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.12': + optional: true + + '@rolldown/binding-android-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-x64@1.2.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.12': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.12': + optional: true + + crossws@0.4.12(srvx@1.0.5): + optionalDependencies: + srvx: 1.0.5 + + db0@0.4.1: {} + + env-runner@0.2.3: + dependencies: + crossws: 0.4.12(srvx@1.0.5) + exsolve: 1.1.1 + httpxy: 0.5.5 + srvx: 1.0.5 + + eve@0.64.1(@opentelemetry/api@1.9.1)(ai@7.0.10(zod@4.4.3)): + dependencies: + ai: 7.0.10(zod@4.4.3) + nitro: 3.0.260903-beta + undici: 8.9.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + + h3@2.0.1(crossws@0.4.12(srvx@1.0.5))(ocache@0.3.0): + dependencies: + rou3: 1.0.0 + srvx: 1.0.5 + optionalDependencies: + crossws: 0.4.12(srvx@1.0.5) + ocache: 0.3.0 + + langsmith@0.6.3(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1): + dependencies: + p-queue: 6.6.2 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + openai: 7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3) + ws: 8.21.1 + + nitro@3.0.260903-beta: + dependencies: + consola: 3.4.2 + crossws: 0.4.12(srvx@1.0.5) + db0: 0.4.1 + env-runner: 0.2.3 + h3: 2.0.1(crossws@0.4.12(srvx@1.0.5))(ocache@0.3.0) + hookable: 6.1.1 + nf3: 0.3.24 + ocache: 0.3.0 + rolldown: 1.2.12 + rou3: 0.9.2 + srvx: 1.0.5 + unenv: 2.0.0-rc.24 + unstorage: 2.0.0-alpha.10 + + ocache@0.3.0: {} + + openai@7.20.0(@aws-sdk/credential-provider-node@3.972.73)(@smithy/signature-v4@5.6.11)(undici@8.9.0)(ws@8.21.1)(zod@4.4.3): + optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.73 + '@smithy/signature-v4': 5.6.11 + undici: 8.9.0 + ws: 8.21.1 + zod: 4.4.3 + + rolldown@1.2.12: + dependencies: + '@oxc-project/types': 0.152.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.12 + '@rolldown/binding-android-arm64': 1.2.12 + '@rolldown/binding-darwin-arm64': 1.2.12 + '@rolldown/binding-darwin-x64': 1.2.12 + '@rolldown/binding-freebsd-x64': 1.2.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 + '@rolldown/binding-linux-arm64-gnu': 1.2.12 + '@rolldown/binding-linux-arm64-musl': 1.2.12 + '@rolldown/binding-linux-ppc64-gnu': 1.2.12 + '@rolldown/binding-linux-s390x-gnu': 1.2.12 + '@rolldown/binding-linux-x64-gnu': 1.2.12 + '@rolldown/binding-linux-x64-musl': 1.2.12 + '@rolldown/binding-openharmony-arm64': 1.2.12 + '@rolldown/binding-win32-arm64-msvc': 1.2.12 + '@rolldown/binding-win32-x64-msvc': 1.2.12 + + rou3@0.9.2: {} + + rou3@1.0.0: {} + + srvx@1.0.5: {} + + undici@8.9.0: {} + + unstorage@2.0.0-alpha.10: {}