diff --git a/examples/README.md b/examples/README.md index 08223c876..9217850fb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,6 +9,7 @@ Choose an example based on your use case: - **Want a full-stack TypeScript app?** → [TanStack Chat (ts-react-chat)](#tanstack-chat-ts-react-chat) - **Need a vanilla JS frontend?** → [Vanilla Chat](#vanilla-chat) - **Multi-User TypeScript chat app?** → [Group Chat (ts-group-chat)](#group-chat-ts-group-chat) +- **Polyglot AG-UI backends (Go/Rust/PHP/Zig/Bash/Python)?** → [AG-UI Polyglot Echo (ag-ui)](#ag-ui-polyglot-echo-ag-ui) ## TypeScript Examples @@ -122,6 +123,45 @@ pnpm start --- +### AG-UI Polyglot Echo (ag-ui) + +A React SPA that connects to **Go, Rust, PHP, Zig, Bash, and Python chat servers** over the AG-UI SSE protocol, with each backend streaming OpenAI or Anthropic completions. Toolchain detection writes `public/servers.json`; unavailable backends show setup instructions in the UI. + +**Tech Stack:** + +- React + Vite (SPA) +- `@tanstack/ai-react` + `@tanstack/ai-react-ui` +- Go chat server (`net/http`, `:8001`) +- Rust chat server (Axum, `:8002`) +- PHP chat server (built-in server + curl, `:8003`) +- Zig chat server (stdlib HTTP, `:8004`) +- Bash chat server (socat + curl + jq, `:8005`) +- Python chat server (stdlib HTTP + urllib, `:8006`) + +**Features:** + +- ✅ Backend picker (Go | Rust | PHP | Zig | Bash | Python) +- ✅ Toolchain-gated `dev:all` + `servers.json` availability +- ✅ Setup instructions when a runtime is missing (or disabled via `AGUI_DISABLE_SERVERS`) +- ✅ Provider picker (OpenAI | Anthropic) +- ✅ Hand-rolled AG-UI SSE in six languages +- ✅ Streaming LLM responses via env API keys + +**Getting Started:** + +```bash +cd examples/ag-ui +pnpm install +cp .env.example .env +pnpm dev:all +``` + +Install whichever backends you want to run locally (Go, Rust, PHP, Zig, Bash, Python) plus provider API keys. The Bash server uses Bash 4+, curl, jq, and socat (`brew install bash jq socat`). To simulate missing runtimes: `AGUI_DISABLE_SERVERS=php,zig,bash,python pnpm dev:all`. + +📖 [Full Documentation](ag-ui/README.md) + +--- + ## Architecture Patterns ### Full-Stack TypeScript diff --git a/examples/ag-ui/.env.example b/examples/ag-ui/.env.example new file mode 100644 index 000000000..154165a59 --- /dev/null +++ b/examples/ag-ui/.env.example @@ -0,0 +1,10 @@ +# OpenAI +OPENAI_API_KEY=your_openai_api_key_here + +# Anthropic +ANTHROPIC_API_KEY=your_anthropic_api_key_here + +# Optional: comma-separated backend ids to treat as unavailable even when +# the toolchain is installed (useful for testing setup instructions). +# Example: AGUI_DISABLE_SERVERS=php,zig +AGUI_DISABLE_SERVERS= diff --git a/examples/ag-ui/.gitignore b/examples/ag-ui/.gitignore new file mode 100644 index 000000000..25bdc3ee9 --- /dev/null +++ b/examples/ag-ui/.gitignore @@ -0,0 +1,8 @@ +dist +node_modules +servers/rust/target +servers/zig/.zig-cache +servers/zig/zig-out +servers/python/__pycache__ + +servers/go/go diff --git a/examples/ag-ui/README.md b/examples/ag-ui/README.md new file mode 100644 index 000000000..770997aaf --- /dev/null +++ b/examples/ag-ui/README.md @@ -0,0 +1,122 @@ +# AG-UI Polyglot Chat + +A React SPA that talks to **Go, Rust, PHP, Zig, Bash, and Python AG-UI backends** over Server-Sent Events (SSE). Each backend streams simple chat completions from **OpenAI** or **Anthropic** — no TanStack packages on the server, just the AG-UI wire protocol. + +This example shows that any backend can serve `@tanstack/ai-react` clients as long as it speaks [AG-UI](https://docs.ag-ui.com/): accept `RunAgentInput` via POST, stream AG-UI events as SSE. + +## Tech stack + +| Layer | Stack | +| ------------- | ------------------------------------------------------------------------ | +| Client | React, Vite, `@tanstack/ai-react`, `@tanstack/ai-react-ui` | +| Go server | `net/http`, hand-rolled AG-UI SSE, OpenAI/Anthropic streaming on `:8001` | +| Rust server | Axum, hand-rolled AG-UI SSE, OpenAI/Anthropic streaming on `:8002` | +| PHP server | Built-in PHP server + curl, hand-rolled AG-UI SSE on `:8003` | +| Zig server | Stdlib HTTP + HTTPS client, hand-rolled AG-UI SSE on `:8004` | +| Bash server | Bash + socat + curl + jq, hand-rolled AG-UI SSE on `:8005` | +| Python server | Stdlib HTTP + urllib, hand-rolled AG-UI SSE on `:8006` | + +## Prerequisites + +- Node.js + pnpm (from repo root) +- [Go 1.22+](https://go.dev/dl/) (optional — UI shows setup if missing) +- [Rust stable](https://rustup.rs/) (optional) +- [PHP 8.2+ CLI with curl](https://www.php.net/downloads) (optional) +- [Zig](https://ziglang.org/download/) (optional) +- Bash 4+, curl, jq, and socat (`brew install bash jq socat`) (optional) +- [Python 3.9+](https://www.python.org/downloads/) (optional) +- OpenAI and/or Anthropic API keys + +Only backends whose toolchains are detected on PATH are started by `pnpm dev:all`. The UI reads `public/servers.json` to show which servers are available and displays setup instructions for missing ones. + +## Quick start + +From the monorepo root: + +```bash +pnpm install +cd examples/ag-ui +cp .env.example .env +# Add OPENAI_API_KEY and/or ANTHROPIC_API_KEY +pnpm dev:all +``` + +Open [http://localhost:3000](http://localhost:3000), pick a backend tab, choose a provider/model, and chat. + +### Simulate missing toolchains + +If your machine has every runtime installed but you want to test the setup UI: + +```bash +AGUI_DISABLE_SERVERS=php,zig pnpm dev:all +``` + +PHP and Zig tabs will show install instructions; Go and Rust still connect normally. + +### Run pieces separately + +```bash +# Terminal 1 — Vite dev server (proxies /api/* and serves servers.json) +pnpm dev + +# Terminal 2+ — individual backends +pnpm dev:go +pnpm dev:rust +pnpm dev:php +pnpm dev:zig +pnpm dev:bash +pnpm dev:python +``` + +`pnpm dev:all` loads `.env`, writes `public/servers.json`, and starts the client plus every detected backend. + +Refresh availability without restarting everything: + +```bash +pnpm detect-servers +``` + +## Architecture + +``` +React SPA (useChat + fetchServerSentEvents) + GET /servers.json ──► generated backend availability + POST /api/go ──► Vite proxy ──► Go :8001 ──► OpenAI / Anthropic + POST /api/rust ──► Vite proxy ──► Rust :8002 ──► OpenAI / Anthropic + POST /api/php ──► Vite proxy ──► PHP :8003 ──► OpenAI / Anthropic + POST /api/zig ──► Vite proxy ──► Zig :8004 ──► OpenAI / Anthropic + POST /api/bash ──► Vite proxy ──► Bash :8005 ──► OpenAI / Anthropic + POST /api/python ──► Vite proxy ──► Python :8006 ──► OpenAI / Anthropic +``` + +The client sends AG-UI `RunAgentInput` with `forwardedProps: { provider, model }`. Each server converts simple text messages, streams the provider response, and emits: + +``` +RUN_STARTED +TEXT_MESSAGE_START +TEXT_MESSAGE_CONTENT (streamed) +TEXT_MESSAGE_END +RUN_FINISHED +data: [DONE] +``` + +## Project layout + +``` +examples/ag-ui/ +├── public/servers.json generated backend availability (fallback committed) +├── scripts/ detect toolchains, write servers.json, dev:all +├── src/ React SPA +├── servers/go/ Go AG-UI + LLM server +├── servers/rust/ Rust AG-UI + LLM server +├── servers/php/ PHP AG-UI + LLM server +├── servers/zig/ Zig AG-UI + LLM server +├── servers/bash/ Bash AG-UI + LLM server +└── servers/python/ Python AG-UI + LLM server +``` + +## Related docs + +- [AG-UI compliance migration](../../docs/migration/ag-ui-compliance.md) +- [Connection adapters](../../docs/chat/connection-adapters.md) +- [Chat architecture](../../packages/ai/docs/chat-architecture.md) diff --git a/examples/ag-ui/bash-server.png b/examples/ag-ui/bash-server.png new file mode 100644 index 000000000..7e488dfc3 Binary files /dev/null and b/examples/ag-ui/bash-server.png differ diff --git a/examples/ag-ui/index.html b/examples/ag-ui/index.html new file mode 100644 index 000000000..fbfbc195f --- /dev/null +++ b/examples/ag-ui/index.html @@ -0,0 +1,12 @@ + + + + + + TanStack AI - AG-UI + + +
+ + + diff --git a/examples/ag-ui/package.json b/examples/ag-ui/package.json new file mode 100644 index 000000000..593810f58 --- /dev/null +++ b/examples/ag-ui/package.json @@ -0,0 +1,39 @@ +{ + "name": "ag-ui", + "private": true, + "type": "module", + "version": "0.0.1", + "scripts": { + "detect-servers": "node scripts/detect-servers.mjs", + "predev": "node scripts/detect-servers.mjs", + "dev": "vite --port 3000", + "dev:go": "go run -C servers/go .", + "dev:rust": "cargo run --manifest-path servers/rust/Cargo.toml", + "dev:php": "php -S 127.0.0.1:8003 -t servers/php servers/php/router.php", + "dev:zig": "zig build run --build-file servers/zig/build.zig", + "dev:bash": "bash servers/bash/server.sh", + "dev:python": "python3 servers/python/server.py", + "dev:all": "node scripts/dev-all.mjs", + "build": "vite build", + "preview": "vite preview", + "test:types": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/ai-client": "workspace:*", + "@tanstack/ai-react": "workspace:*", + "@tanstack/ai-react-ui": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.18", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "concurrently": "^9.1.2", + "dotenv": "^17.2.3", + "tailwindcss": "^4.1.18", + "typescript": "5.9.3", + "vite": "^7.3.3" + } +} diff --git a/examples/ag-ui/public/servers.json b/examples/ag-ui/public/servers.json new file mode 100644 index 000000000..e4e991c0f --- /dev/null +++ b/examples/ag-ui/public/servers.json @@ -0,0 +1,83 @@ +{ + "generatedAt": "2026-07-20T00:20:05.964Z", + "servers": [ + { + "id": "go", + "label": "Go", + "port": 8001, + "available": true, + "disabledByEnv": false, + "setup": { + "summary": "Install Go 1.22+ and ensure `go` is on PATH.", + "installUrl": "https://go.dev/dl/", + "verify": "go version", + "run": "pnpm dev:go" + } + }, + { + "id": "rust", + "label": "Rust", + "port": 8002, + "available": true, + "disabledByEnv": false, + "setup": { + "summary": "Install Rust stable via rustup and ensure `cargo` is on PATH.", + "installUrl": "https://rustup.rs/", + "verify": "cargo --version", + "run": "pnpm dev:rust" + } + }, + { + "id": "php", + "label": "PHP", + "port": 8003, + "available": true, + "disabledByEnv": false, + "setup": { + "summary": "Install PHP 8.2+ CLI and ensure `php` is on PATH.", + "installUrl": "https://www.php.net/downloads", + "verify": "php -v", + "run": "pnpm dev:php" + } + }, + { + "id": "zig", + "label": "Zig", + "port": 8004, + "available": true, + "disabledByEnv": false, + "setup": { + "summary": "Install Zig and ensure `zig` is on PATH.", + "installUrl": "https://ziglang.org/download/", + "verify": "zig version", + "run": "pnpm dev:zig" + } + }, + { + "id": "bash", + "label": "Bash", + "port": 8005, + "available": true, + "disabledByEnv": false, + "setup": { + "summary": "Install Bash 4+, curl, jq, and socat and ensure they are on PATH.", + "installUrl": "https://brew.sh/", + "verify": "bash --version && curl --version && jq --version && socat -V", + "run": "pnpm dev:bash" + } + }, + { + "id": "python", + "label": "Python", + "port": 8006, + "available": true, + "disabledByEnv": false, + "setup": { + "summary": "Install Python 3.9+ and ensure `python3` is on PATH.", + "installUrl": "https://www.python.org/downloads/", + "verify": "python3 --version", + "run": "pnpm dev:python" + } + } + ] +} diff --git a/examples/ag-ui/python-server.png b/examples/ag-ui/python-server.png new file mode 100644 index 000000000..624d9f84d Binary files /dev/null and b/examples/ag-ui/python-server.png differ diff --git a/examples/ag-ui/scripts/detect-servers.mjs b/examples/ag-ui/scripts/detect-servers.mjs new file mode 100644 index 000000000..a39e9dfcc --- /dev/null +++ b/examples/ag-ui/scripts/detect-servers.mjs @@ -0,0 +1,10 @@ +import { + detectServers, + logDetectionSummary, + outputPath, + writeServersJson, +} from './servers.mjs' + +const { servers } = writeServersJson() +logDetectionSummary(detectServers()) +console.log(`[ag-ui] wrote ${outputPath}`) diff --git a/examples/ag-ui/scripts/dev-all.mjs b/examples/ag-ui/scripts/dev-all.mjs new file mode 100644 index 000000000..1cf64141e --- /dev/null +++ b/examples/ag-ui/scripts/dev-all.mjs @@ -0,0 +1,62 @@ +import { spawn } from 'node:child_process' +import { config as loadEnv } from 'dotenv' +import { + detectServers, + exampleDir, + logDetectionSummary, + writeServersJson, +} from './servers.mjs' + +loadEnv({ path: `${exampleDir}/.env`, quiet: true }) + +const detected = detectServers() +writeServersJson() +logDetectionSummary(detected) + +const available = detected.filter((server) => server.available) +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' + +const names = ['client', ...available.map((server) => server.id)] +const colors = [ + 'cyan', + 'green', + 'yellow', + 'magenta', + 'blue', + 'red', + 'white', +].slice(0, names.length) +const commands = [ + 'pnpm dev', + ...available.map((server) => `pnpm ${server.devScript}`), +] + +const args = [ + 'exec', + 'concurrently', + '-n', + names.join(','), + '-c', + colors.join(','), +] +for (const command of commands) { + args.push(command) +} + +console.log( + `[ag-ui] starting ${commands.length} process(es): ${names.join(', ')}`, +) + +const child = spawn(pnpm, args, { + cwd: exampleDir, + env: process.env, + stdio: 'inherit', + shell: process.platform === 'win32', +}) + +child.on('exit', (code) => { + process.exit(code ?? 0) +}) + +process.on('SIGINT', () => child.kill('SIGINT')) +process.on('SIGTERM', () => child.kill('SIGTERM')) diff --git a/examples/ag-ui/scripts/servers.mjs b/examples/ag-ui/scripts/servers.mjs new file mode 100644 index 000000000..5adf7c91d --- /dev/null +++ b/examples/ag-ui/scripts/servers.mjs @@ -0,0 +1,176 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const exampleDir = path.resolve(fileURLToPath(new URL('..', import.meta.url))) +const outputPath = path.join(exampleDir, 'public', 'servers.json') + +/** @type {const} */ +export const SERVER_CATALOG = [ + { + id: 'go', + label: 'Go', + port: 8001, + detect: [['go', 'version']], + devScript: 'dev:go', + setup: { + summary: 'Install Go 1.22+ and ensure `go` is on PATH.', + installUrl: 'https://go.dev/dl/', + verify: 'go version', + run: 'pnpm dev:go', + }, + }, + { + id: 'rust', + label: 'Rust', + port: 8002, + detect: [['cargo', '--version']], + devScript: 'dev:rust', + setup: { + summary: 'Install Rust stable via rustup and ensure `cargo` is on PATH.', + installUrl: 'https://rustup.rs/', + verify: 'cargo --version', + run: 'pnpm dev:rust', + }, + }, + { + id: 'php', + label: 'PHP', + port: 8003, + detect: [['php', '-v']], + devScript: 'dev:php', + setup: { + summary: 'Install PHP 8.2+ CLI and ensure `php` is on PATH.', + installUrl: 'https://www.php.net/downloads', + verify: 'php -v', + run: 'pnpm dev:php', + }, + }, + { + id: 'zig', + label: 'Zig', + port: 8004, + detect: [['zig', 'version']], + devScript: 'dev:zig', + setup: { + summary: 'Install Zig and ensure `zig` is on PATH.', + installUrl: 'https://ziglang.org/download/', + verify: 'zig version', + run: 'pnpm dev:zig', + }, + }, + { + id: 'bash', + label: 'Bash', + port: 8005, + detect: [ + ['bash', '-c', '(( BASH_VERSINFO[0] >= 4 ))'], + ['curl', '--version'], + ['jq', '--version'], + ['socat', '-V'], + ], + devScript: 'dev:bash', + setup: { + summary: + 'Install Bash 4+, curl, jq, and socat and ensure they are on PATH.', + installUrl: 'https://brew.sh/', + verify: 'bash --version && curl --version && jq --version && socat -V', + run: 'pnpm dev:bash', + }, + }, + { + id: 'python', + label: 'Python', + port: 8006, + detect: [['python3', '--version']], + devScript: 'dev:python', + setup: { + summary: 'Install Python 3.9+ and ensure `python3` is on PATH.', + installUrl: 'https://www.python.org/downloads/', + verify: 'python3 --version', + run: 'pnpm dev:python', + }, + }, +] + +function parseDisabledServers(env) { + const raw = env.AGUI_DISABLE_SERVERS ?? '' + return new Set( + raw + .split(',') + .map((item) => item.trim().toLowerCase()) + .filter(Boolean), + ) +} + +function hasToolchain(detectCommands) { + return detectCommands.every(([command, ...args]) => { + try { + execFileSync(command, args, { stdio: 'ignore' }) + return true + } catch { + return false + } + }) +} + +export function detectServers(env = process.env) { + const disabled = parseDisabledServers(env) + + return SERVER_CATALOG.map((server) => { + const disabledByEnv = disabled.has(server.id) + const toolchainPresent = hasToolchain(server.detect) + const available = toolchainPresent && !disabledByEnv + + return { + id: server.id, + label: server.label, + port: server.port, + available, + disabledByEnv, + toolchainPresent, + devScript: server.devScript, + setup: server.setup, + } + }) +} + +export function writeServersJson(env = process.env) { + const servers = detectServers(env).map( + ({ devScript: _devScript, toolchainPresent: _toolchainPresent, ...rest }) => + rest, + ) + + const payload = { + generatedAt: new Date().toISOString(), + servers, + } + + mkdirSync(path.dirname(outputPath), { recursive: true }) + writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') + + return { payload, outputPath, servers } +} + +export function logDetectionSummary(servers) { + for (const server of servers) { + if (server.available) { + console.log(`[ag-ui] ${server.label} available on :${server.port}`) + continue + } + + if (server.disabledByEnv) { + console.log( + `[ag-ui] ${server.label} disabled via AGUI_DISABLE_SERVERS (toolchain ${server.toolchainPresent ? 'present' : 'missing'})`, + ) + continue + } + + console.log( + `[ag-ui] ${server.label} unavailable — ${server.setup.verify} not found`, + ) + } +} + +export { exampleDir, outputPath } diff --git a/examples/ag-ui/servers/bash/README.md b/examples/ag-ui/servers/bash/README.md new file mode 100644 index 000000000..acc726a8c --- /dev/null +++ b/examples/ag-ui/servers/bash/README.md @@ -0,0 +1,35 @@ +# Bash AG-UI server + +A deliberately minimal AG-UI server written in Bash. It uses: + +- `socat` to accept concurrent HTTP connections +- `curl` to stream OpenAI or Anthropic responses +- `jq` to translate JSON and emit AG-UI SSE events + +## Prerequisites + +On macOS: + +```bash +brew install bash jq socat +``` + +The server expects Bash 4+ and also requires `curl`. + +## Run + +From `examples/ag-ui`: + +```bash +OPENAI_API_KEY=... pnpm dev:bash +``` + +The server listens on `http://127.0.0.1:8005`. Its endpoints are: + +- `GET /health` +- `POST /` with an AG-UI `RunAgentInput` body +- `OPTIONS /` for CORS preflight + +This server exists to demonstrate the portability of the AG-UI wire protocol. +It is intentionally a local development example, not a production HTTP +server. diff --git a/examples/ag-ui/servers/bash/server.sh b/examples/ag-ui/servers/bash/server.sh new file mode 100644 index 000000000..1bd1fa9e0 --- /dev/null +++ b/examples/ag-ui/servers/bash/server.sh @@ -0,0 +1,424 @@ +#!/usr/bin/env bash + +set -uo pipefail + +PORT="${PORT:-8005}" +SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +DEFAULT_OPENAI_MODEL="gpt-4o" +DEFAULT_ANTHROPIC_MODEL="claude-sonnet-4-6" + +json_event() { + jq -nc "$@" +} + +write_event() { + printf 'data: %s\n\n' "$1" +} + +write_done() { + printf 'data: [DONE]\n\n' +} + +write_error_response() { + local status="$1" + local message="$2" + printf 'HTTP/1.1 %s\r\n' "$status" + printf 'Content-Type: text/plain; charset=utf-8\r\n' + printf 'Access-Control-Allow-Origin: *\r\n' + printf 'Connection: close\r\n' + printf '\r\n' + printf '%s' "$message" +} + +normalize_messages() { + jq -c ' + def message_text: + if (.parts? | type) == "array" then + [.parts[]? | + select( + type == "object" and + .type == "text" and + (.content | type) == "string" + ) | + .content + ] | join("") + elif (.content? | type) == "string" then + .content + elif (.content? | type) == "array" then + [.content[]? | + select( + type == "object" and + .type == "text" and + (.text | type) == "string" + ) | + .text + ] | join("") + else + "" + end; + + [.messages[]? | + select(type == "object") | + (if .role == "developer" then "system" else (.role // "") end) as $role | + message_text as $content | + select( + ($role == "system" or $role == "user" or $role == "assistant") and + ($content | length) > 0 + ) | + { role: $role, content: $content } + ] + ' +} + +process_openai_stream() { + local message_id="$1" + local line data event + + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + [[ "$line" == "data: "* ]] || continue + data="${line#data: }" + [[ "$data" == "[DONE]" ]] && continue + + event="$( + jq -c --arg messageId "$message_id" ' + .choices[0].delta.content? as $delta | + select(($delta | type) == "string" and ($delta | length) > 0) | + { + type: "TEXT_MESSAGE_CONTENT", + messageId: $messageId, + delta: $delta + } + ' <<<"$data" 2>/dev/null + )" || continue + + [[ -n "$event" ]] && write_event "$event" + done +} + +process_anthropic_stream() { + local message_id="$1" + local line data event + + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + [[ "$line" == "data: "* ]] || continue + data="${line#data: }" + + event="$( + jq -c --arg messageId "$message_id" ' + select( + .type == "content_block_delta" and + .delta.type == "text_delta" and + (.delta.text | type) == "string" and + (.delta.text | length) > 0 + ) | + { + type: "TEXT_MESSAGE_CONTENT", + messageId: $messageId, + delta: .delta.text + } + ' <<<"$data" 2>/dev/null + )" || continue + + [[ -n "$event" ]] && write_event "$event" + done +} + +stream_openai() { + local payload="$1" + local message_id="$2" + + curl --silent --show-error --no-buffer --fail \ + https://api.openai.com/v1/chat/completions \ + -H "Authorization: Bearer ${OPENAI_API_KEY}" \ + -H 'Content-Type: application/json' \ + --data-binary "$payload" | + process_openai_stream "$message_id" + + return "${PIPESTATUS[0]}" +} + +stream_anthropic() { + local payload="$1" + local message_id="$2" + + curl --silent --show-error --no-buffer --fail \ + https://api.anthropic.com/v1/messages \ + -H "x-api-key: ${ANTHROPIC_API_KEY}" \ + -H 'anthropic-version: 2023-06-01' \ + -H 'Content-Type: application/json' \ + --data-binary "$payload" | + process_anthropic_stream "$message_id" + + return "${PIPESTATUS[0]}" +} + +handle_chat() { + local body="$1" + local thread_id run_id provider model normalized system chat message_id payload + local stream_status + + thread_id="$(jq -r 'if (.threadId? | type) == "string" then .threadId else "" end' <<<"$body")" + run_id="$(jq -r 'if (.runId? | type) == "string" then .runId else "" end' <<<"$body")" + + if [[ -z "$thread_id" || -z "$run_id" ]]; then + write_error_response '400 Bad Request' 'threadId and runId are required' + return + fi + + provider="$( + jq -r ' + (.forwardedProps // .data // {}) as $props | + if ($props.provider? | type) == "string" and ($props.provider | length) > 0 + then $props.provider + else "openai" + end + ' <<<"$body" + )" + model="$( + jq -r ' + (.forwardedProps // .data // {}) as $props | + if ($props.model? | type) == "string" then $props.model else "" end + ' <<<"$body" + )" + + normalized="$(normalize_messages <<<"$body")" + system="$( + jq -r '[.[] | select(.role == "system") | .content] | join("\n\n")' \ + <<<"$normalized" + )" + chat="$(jq -c '[.[] | select(.role == "user" or .role == "assistant")]' <<<"$normalized")" + + printf 'HTTP/1.1 200 OK\r\n' + printf 'Content-Type: text/event-stream\r\n' + printf 'Cache-Control: no-cache\r\n' + printf 'Connection: close\r\n' + printf 'Access-Control-Allow-Origin: *\r\n' + printf '\r\n' + + message_id="msg-${run_id}" + write_event "$( + json_event \ + --arg threadId "$thread_id" \ + --arg runId "$run_id" \ + '{ type: "RUN_STARTED", threadId: $threadId, runId: $runId }' + )" + + if [[ "$(jq 'length' <<<"$chat")" -eq 0 ]]; then + write_event "$( + json_event \ + --arg threadId "$thread_id" \ + --arg runId "$run_id" \ + --arg message 'no user or assistant messages to send' \ + '{ + type: "RUN_ERROR", + threadId: $threadId, + runId: $runId, + error: { message: $message } + }' + )" + write_done + return + fi + + write_event "$( + json_event \ + --arg messageId "$message_id" \ + '{ type: "TEXT_MESSAGE_START", messageId: $messageId, role: "assistant" }' + )" + + case "$provider" in + openai) + if [[ -z "${OPENAI_API_KEY:-}" ]]; then + stream_status=1 + else + [[ -n "$model" ]] || model="$DEFAULT_OPENAI_MODEL" + payload="$( + jq -nc \ + --arg model "$model" \ + --arg system "$system" \ + --argjson messages "$chat" ' + { + model: $model, + messages: + (if ($system | length) > 0 + then [{ role: "system", content: $system }] + $messages + else $messages + end), + stream: true + } + ' + )" + stream_openai "$payload" "$message_id" + stream_status=$? + fi + ;; + anthropic) + if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then + stream_status=1 + else + [[ -n "$model" ]] || model="$DEFAULT_ANTHROPIC_MODEL" + payload="$( + jq -nc \ + --arg model "$model" \ + --arg system "$system" \ + --argjson messages "$chat" ' + { + model: $model, + max_tokens: 4096, + messages: $messages, + stream: true + } + + (if ($system | length) > 0 then { system: $system } else {} end) + ' + )" + stream_anthropic "$payload" "$message_id" + stream_status=$? + fi + ;; + *) + stream_status=1 + ;; + esac + + if [[ "$stream_status" -ne 0 ]]; then + local error_message + case "$provider" in + openai) + error_message="${OPENAI_API_KEY:+OpenAI request failed}" + error_message="${error_message:-OPENAI_API_KEY is not set}" + ;; + anthropic) + error_message="${ANTHROPIC_API_KEY:+Anthropic request failed}" + error_message="${error_message:-ANTHROPIC_API_KEY is not set}" + ;; + *) + error_message="unsupported provider \"${provider}\" (expected openai or anthropic)" + ;; + esac + + write_event "$( + json_event \ + --arg threadId "$thread_id" \ + --arg runId "$run_id" \ + --arg message "$error_message" \ + '{ + type: "RUN_ERROR", + threadId: $threadId, + runId: $runId, + error: { message: $message } + }' + )" + write_done + return + fi + + write_event "$( + json_event \ + --arg messageId "$message_id" \ + '{ type: "TEXT_MESSAGE_END", messageId: $messageId }' + )" + write_event "$( + json_event \ + --arg threadId "$thread_id" \ + --arg runId "$run_id" \ + '{ + type: "RUN_FINISHED", + threadId: $threadId, + runId: $runId, + finishReason: "stop" + }' + )" + write_done +} + +handle_connection() { + local request_line method target version line header_name header_value + local content_length=0 body='' + + IFS= read -r request_line || return + request_line="${request_line%$'\r'}" + read -r method target version <<<"$request_line" + + while IFS= read -r line; do + line="${line%$'\r'}" + [[ -z "$line" ]] && break + + header_name="${line%%:*}" + header_value="${line#*:}" + header_value="${header_value#"${header_value%%[![:space:]]*}"}" + if [[ "${header_name,,}" == 'content-length' ]]; then + content_length="$header_value" + fi + done + + if ! [[ "$content_length" =~ ^[0-9]+$ ]]; then + write_error_response '400 Bad Request' 'invalid Content-Length' + return + fi + + if [[ "$content_length" -gt 0 ]]; then + body="$(dd bs=1 count="$content_length" 2>/dev/null)" + fi + + target="${target%%\?*}" + + if [[ "$method" == 'OPTIONS' ]]; then + printf 'HTTP/1.1 204 No Content\r\n' + printf 'Access-Control-Allow-Origin: *\r\n' + printf 'Access-Control-Allow-Methods: POST, OPTIONS\r\n' + printf 'Access-Control-Allow-Headers: Content-Type\r\n' + printf 'Connection: close\r\n' + printf '\r\n' + return + fi + + if [[ "$method" == 'GET' && "$target" == '/health' ]]; then + printf 'HTTP/1.1 200 OK\r\n' + printf 'Content-Type: text/plain; charset=utf-8\r\n' + printf 'Content-Length: 2\r\n' + printf 'Connection: close\r\n' + printf '\r\n' + printf 'ok' + return + fi + + if [[ "$method" != 'POST' ]]; then + write_error_response '405 Method Not Allowed' 'method not allowed' + return + fi + + if [[ "$target" != '/' ]]; then + write_error_response '404 Not Found' 'not found' + return + fi + + if ! jq -e 'type == "object"' >/dev/null 2>&1 <<<"$body"; then + write_error_response '400 Bad Request' 'invalid JSON body' + return + fi + + handle_chat "$body" +} + +check_dependencies() { + local dependency + for dependency in bash curl jq socat; do + if ! command -v "$dependency" >/dev/null 2>&1; then + printf '[bash] missing dependency: %s\n' "$dependency" >&2 + return 1 + fi + done +} + +if [[ "${1:-}" == '--handle' ]]; then + handle_connection + exit 0 +fi + +check_dependencies || exit 1 +printf '[bash] listening on http://127.0.0.1:%s\n' "$PORT" +exec socat \ + "TCP-LISTEN:${PORT},bind=127.0.0.1,reuseaddr,fork" \ + "SYSTEM:exec bash '${SCRIPT_PATH}' --handle" diff --git a/examples/ag-ui/servers/go/README.md b/examples/ag-ui/servers/go/README.md new file mode 100644 index 000000000..b7371df0a --- /dev/null +++ b/examples/ag-ui/servers/go/README.md @@ -0,0 +1,35 @@ +# Go AG-UI chat server + +AG-UI SSE server using the Go standard library. Streams simple chat completions from OpenAI or Anthropic. + +## Run + +From `examples/ag-ui`: + +```bash +export OPENAI_API_KEY=... +export ANTHROPIC_API_KEY=... +pnpm dev:go +# or +go run -C servers/go . +``` + +Listens on `http://127.0.0.1:8001`. + +## Protocol + +- **Endpoint:** `POST /` +- **Request body:** AG-UI `RunAgentInput` JSON +- **Provider selection:** `forwardedProps.provider` (`openai` | `anthropic`) and optional `forwardedProps.model` +- **Response:** `Content-Type: text/event-stream` + +Supports simple `user` / `assistant` / `system` messages with string `content` or TanStack-style text `parts`. Tool and reasoning fan-out entries are ignored. + +## Environment + +| Variable | Required when | +| ------------------- | --------------------- | +| `OPENAI_API_KEY` | `provider: openai` | +| `ANTHROPIC_API_KEY` | `provider: anthropic` | + +Default models: `gpt-4o`, `claude-sonnet-4-6`. diff --git a/examples/ag-ui/servers/go/agui.go b/examples/ag-ui/servers/go/agui.go new file mode 100644 index 000000000..e8f10912a --- /dev/null +++ b/examples/ag-ui/servers/go/agui.go @@ -0,0 +1,211 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" +) + +const listenAddr = ":8001" + +const ( + defaultOpenAIModel = "gpt-4o" + defaultAnthropicModel = "claude-sonnet-4-6" +) + +type runAgentInput struct { + ThreadID string `json:"threadId"` + RunID string `json:"runId"` + Messages []incomingMessage `json:"messages"` + ForwardedProps map[string]any `json:"forwardedProps"` + Data map[string]any `json:"data"` +} + +type incomingMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Parts []messagePart `json:"parts"` +} + +type messagePart struct { + Type string `json:"type"` + Content string `json:"content"` +} + +type chatMessage struct { + Role string + Content string +} + +type providerConfig struct { + Provider string + Model string +} + +type sseStream struct { + w http.ResponseWriter + flusher http.Flusher + threadID string + runID string + messageID string + started bool + textOpened bool +} + +func propsFromInput(input runAgentInput) map[string]any { + if len(input.ForwardedProps) > 0 { + return input.ForwardedProps + } + return input.Data +} + +func providerFromInput(input runAgentInput) providerConfig { + props := propsFromInput(input) + provider := "openai" + model := "" + + if raw, ok := props["provider"].(string); ok && raw != "" { + provider = raw + } + if raw, ok := props["model"].(string); ok && raw != "" { + model = raw + } + + switch provider { + case "anthropic": + if model == "" { + model = defaultAnthropicModel + } + default: + provider = "openai" + if model == "" { + model = defaultOpenAIModel + } + } + + return providerConfig{Provider: provider, Model: model} +} + +func beginSSE(w http.ResponseWriter) (*sseStream, bool) { + flusher, ok := w.(http.Flusher) + if !ok { + return nil, false + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(http.StatusOK) + + return &sseStream{w: w, flusher: flusher}, true +} + +func (s *sseStream) initRun(threadID, runID string) { + s.threadID = threadID + s.runID = runID + s.messageID = fmt.Sprintf("msg-%s", runID) +} + +func (s *sseStream) writeEvent(payload map[string]any) error { + data, err := json.Marshal(payload) + if err != nil { + return err + } + _, err = fmt.Fprintf(s.w, "data: %s\n\n", data) + if err != nil { + return err + } + s.flusher.Flush() + return nil +} + +func (s *sseStream) writeDone() { + _, _ = fmt.Fprint(s.w, "data: [DONE]\n\n") + s.flusher.Flush() +} + +func (s *sseStream) startRun() error { + if s.started { + return nil + } + s.started = true + return s.writeEvent(map[string]any{ + "type": "RUN_STARTED", + "threadId": s.threadID, + "runId": s.runID, + }) +} + +func (s *sseStream) startText() error { + if s.textOpened { + return nil + } + s.textOpened = true + return s.writeEvent(map[string]any{ + "type": "TEXT_MESSAGE_START", + "messageId": s.messageID, + "role": "assistant", + }) +} + +func (s *sseStream) writeDelta(delta string) error { + if delta == "" { + return nil + } + return s.writeEvent(map[string]any{ + "type": "TEXT_MESSAGE_CONTENT", + "messageId": s.messageID, + "delta": delta, + }) +} + +func (s *sseStream) finishSuccess() error { + if s.textOpened { + if err := s.writeEvent(map[string]any{ + "type": "TEXT_MESSAGE_END", + "messageId": s.messageID, + }); err != nil { + return err + } + } + return s.writeEvent(map[string]any{ + "type": "RUN_FINISHED", + "threadId": s.threadID, + "runId": s.runID, + "finishReason": "stop", + }) +} + +func (s *sseStream) finishError(message string) error { + if !s.started { + if err := s.startRun(); err != nil { + return err + } + } + return s.writeEvent(map[string]any{ + "type": "RUN_ERROR", + "threadId": s.threadID, + "runId": s.runID, + "error": map[string]any{ + "message": message, + }, + }) +} + +func (s *sseStream) streamText(stream func(emit func(string) error) error) error { + if err := s.startRun(); err != nil { + return err + } + if err := s.startText(); err != nil { + return err + } + if err := stream(s.writeDelta); err != nil { + return err + } + if err := s.finishSuccess(); err != nil { + return err + } + s.writeDone() + return nil +} diff --git a/examples/ag-ui/servers/go/anthropic.go b/examples/ag-ui/servers/go/anthropic.go new file mode 100644 index 000000000..f6af1d3e6 --- /dev/null +++ b/examples/ag-ui/servers/go/anthropic.go @@ -0,0 +1,105 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +func streamAnthropic( + ctx context.Context, + model string, + system string, + messages []chatMessage, + emit func(string) error, +) error { + apiKey := os.Getenv("ANTHROPIC_API_KEY") + if apiKey == "" { + return fmt.Errorf("ANTHROPIC_API_KEY is not set") + } + + anthropicMessages := make([]map[string]string, 0, len(messages)) + for _, msg := range messages { + anthropicMessages = append(anthropicMessages, map[string]string{ + "role": msg.Role, + "content": msg.Content, + }) + } + + body := map[string]any{ + "model": model, + "max_tokens": 4096, + "messages": anthropicMessages, + "stream": true, + } + if system != "" { + body["system"] = system + } + + payload, err := json.Marshal(body) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + "https://api.anthropic.com/v1/messages", + bytes.NewReader(payload), + ) + if err != nil { + return err + } + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("anthropic request failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + if ctx.Err() != nil { + return ctx.Err() + } + + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + + data := strings.TrimPrefix(line, "data: ") + var event struct { + Type string `json:"type"` + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"delta"` + } + if err := json.Unmarshal([]byte(data), &event); err != nil { + continue + } + if event.Type != "content_block_delta" || event.Delta.Type != "text_delta" { + continue + } + if err := emit(event.Delta.Text); err != nil { + return err + } + } + + return scanner.Err() +} diff --git a/examples/ag-ui/servers/go/go.mod b/examples/ag-ui/servers/go/go.mod new file mode 100644 index 000000000..e2195649a --- /dev/null +++ b/examples/ag-ui/servers/go/go.mod @@ -0,0 +1,3 @@ +module github.com/tanstack/ai/examples/ag-ui/servers/go + +go 1.22 diff --git a/examples/ag-ui/servers/go/llm.go b/examples/ag-ui/servers/go/llm.go new file mode 100644 index 000000000..9eb20381b --- /dev/null +++ b/examples/ag-ui/servers/go/llm.go @@ -0,0 +1,23 @@ +package main + +import ( + "context" + "fmt" +) + +func streamCompletion( + ctx context.Context, + config providerConfig, + system string, + messages []chatMessage, + emit func(string) error, +) error { + switch config.Provider { + case "openai": + return streamOpenAI(ctx, config.Model, system, messages, emit) + case "anthropic": + return streamAnthropic(ctx, config.Model, system, messages, emit) + default: + return fmt.Errorf("unsupported provider %q (expected openai or anthropic)", config.Provider) + } +} diff --git a/examples/ag-ui/servers/go/main.go b/examples/ag-ui/servers/go/main.go new file mode 100644 index 000000000..228d3769c --- /dev/null +++ b/examples/ag-ui/servers/go/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "encoding/json" + "io" + "log" + "net/http" +) + +func main() { + mux := http.NewServeMux() + mux.HandleFunc("/", handleChat) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + log.Printf("AG-UI Go server listening on http://127.0.0.1%s", listenAddr) + if err := http.ListenAndServe(listenAddr, mux); err != nil { + log.Fatal(err) + } +} + +func handleChat(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.WriteHeader(http.StatusNoContent) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var input runAgentInput + if err := json.Unmarshal(body, &input); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + + if input.ThreadID == "" || input.RunID == "" { + http.Error(w, "threadId and runId are required", http.StatusBadRequest) + return + } + + stream, ok := beginSSE(w) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + stream.initRun(input.ThreadID, input.RunID) + + config := providerFromInput(input) + system, messages := toChatMessages(input.Messages) + if len(messages) == 0 { + _ = stream.finishError("no user or assistant messages to send") + stream.writeDone() + return + } + + err = stream.streamText(func(emit func(string) error) error { + return streamCompletion(r.Context(), config, system, messages, emit) + }) + if err != nil { + log.Printf("[go] chat error: %v", err) + _ = stream.finishError(err.Error()) + stream.writeDone() + } +} diff --git a/examples/ag-ui/servers/go/messages.go b/examples/ag-ui/servers/go/messages.go new file mode 100644 index 000000000..6a2facdc6 --- /dev/null +++ b/examples/ag-ui/servers/go/messages.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "strings" +) + +func textFromMessage(msg incomingMessage) string { + if len(msg.Parts) > 0 { + var parts []string + for _, part := range msg.Parts { + if part.Type == "text" && part.Content != "" { + parts = append(parts, part.Content) + } + } + if len(parts) > 0 { + return strings.Join(parts, "") + } + } + + if len(msg.Content) == 0 { + return "" + } + + var asString string + if err := json.Unmarshal(msg.Content, &asString); err == nil { + return asString + } + + var asArray []map[string]any + if err := json.Unmarshal(msg.Content, &asArray); err == nil { + var parts []string + for _, item := range asArray { + if item["type"] == "text" { + if text, ok := item["text"].(string); ok { + parts = append(parts, text) + } + } + } + return strings.Join(parts, "") + } + + return strings.TrimSpace(string(msg.Content)) +} + +func toChatMessages(messages []incomingMessage) (system string, chat []chatMessage) { + for _, msg := range messages { + role := msg.Role + switch role { + case "developer": + role = "system" + case "tool", "reasoning": + continue + } + + text := textFromMessage(msg) + if text == "" { + continue + } + + switch role { + case "system": + if system != "" { + system += "\n\n" + } + system += text + case "user", "assistant": + chat = append(chat, chatMessage{Role: role, Content: text}) + } + } + + return system, chat +} diff --git a/examples/ag-ui/servers/go/openai.go b/examples/ag-ui/servers/go/openai.go new file mode 100644 index 000000000..c73c54491 --- /dev/null +++ b/examples/ag-ui/servers/go/openai.go @@ -0,0 +1,108 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +func streamOpenAI( + ctx context.Context, + model string, + system string, + messages []chatMessage, + emit func(string) error, +) error { + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + return fmt.Errorf("OPENAI_API_KEY is not set") + } + + openAIMessages := make([]map[string]string, 0, len(messages)+1) + if system != "" { + openAIMessages = append(openAIMessages, map[string]string{ + "role": "system", + "content": system, + }) + } + for _, msg := range messages { + openAIMessages = append(openAIMessages, map[string]string{ + "role": msg.Role, + "content": msg.Content, + }) + } + + payload, err := json.Marshal(map[string]any{ + "model": model, + "messages": openAIMessages, + "stream": true, + }) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + "https://api.openai.com/v1/chat/completions", + bytes.NewReader(payload), + ) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("openai request failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + if ctx.Err() != nil { + return ctx.Err() + } + + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + if len(chunk.Choices) == 0 { + continue + } + if err := emit(chunk.Choices[0].Delta.Content); err != nil { + return err + } + } + + return scanner.Err() +} diff --git a/examples/ag-ui/servers/php/README.md b/examples/ag-ui/servers/php/README.md new file mode 100644 index 000000000..72e5ce444 --- /dev/null +++ b/examples/ag-ui/servers/php/README.md @@ -0,0 +1,28 @@ +# PHP AG-UI server + +Hand-rolled AG-UI SSE server on `http://127.0.0.1:8003`. + +## Run + +From `examples/ag-ui`: + +```bash +pnpm dev:php +``` + +Requires PHP 8.2+ with the `curl` extension enabled. + +## Environment + +- `OPENAI_API_KEY` +- `ANTHROPIC_API_KEY` + +## Endpoints + +| Method | Path | Description | +| ------- | --------- | ------------------- | +| POST | `/` | AG-UI chat (SSE) | +| GET | `/health` | Health check (`ok`) | +| OPTIONS | `/` | CORS preflight | + +The client sends `forwardedProps: { provider, model }` in the AG-UI `RunAgentInput` body. diff --git a/examples/ag-ui/servers/php/lib/Agui.php b/examples/ag-ui/servers/php/lib/Agui.php new file mode 100644 index 000000000..b00705b7f --- /dev/null +++ b/examples/ag-ui/servers/php/lib/Agui.php @@ -0,0 +1,169 @@ + 'anthropic', + 'model' => $model !== '' ? $model : DEFAULT_ANTHROPIC_MODEL, + ]; + } + + return [ + 'provider' => 'openai', + 'model' => $model !== '' ? $model : DEFAULT_OPENAI_MODEL, + ]; +} + +function sse_event(array $payload): string +{ + return 'data: ' . json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n\n"; +} + +function sse_done(): string +{ + return "data: [DONE]\n\n"; +} + +function run_started(string $threadId, string $runId): string +{ + return sse_event([ + 'type' => 'RUN_STARTED', + 'threadId' => $threadId, + 'runId' => $runId, + ]); +} + +function text_message_start(string $messageId): string +{ + return sse_event([ + 'type' => 'TEXT_MESSAGE_START', + 'messageId' => $messageId, + 'role' => 'assistant', + ]); +} + +function text_message_content(string $messageId, string $delta): string +{ + return sse_event([ + 'type' => 'TEXT_MESSAGE_CONTENT', + 'messageId' => $messageId, + 'delta' => $delta, + ]); +} + +function text_message_end(string $messageId): string +{ + return sse_event([ + 'type' => 'TEXT_MESSAGE_END', + 'messageId' => $messageId, + ]); +} + +function run_finished(string $threadId, string $runId): string +{ + return sse_event([ + 'type' => 'RUN_FINISHED', + 'threadId' => $threadId, + 'runId' => $runId, + 'finishReason' => 'stop', + ]); +} + +function run_error(string $threadId, string $runId, string $message): string +{ + return sse_event([ + 'type' => 'RUN_ERROR', + 'threadId' => $threadId, + 'runId' => $runId, + 'error' => ['message' => $message], + ]); +} + +function begin_sse(): void +{ + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache'); + header('Connection: keep-alive'); + header('Access-Control-Allow-Origin: *'); + http_response_code(200); +} + +function write_sse(string $frame): void +{ + echo $frame; + if (ob_get_level() > 0) { + ob_flush(); + } + flush(); +} + +function cors_preflight(): void +{ + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Methods: POST, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type'); + http_response_code(204); +} + +function stream_text( + string $threadId, + string $runId, + callable $stream +): void { + begin_sse(); + $messageId = 'msg-' . $runId; + + write_sse(run_started($threadId, $runId)); + write_sse(text_message_start($messageId)); + + $emit = static function (string $delta) use ($messageId): void { + if ($delta === '') { + return; + } + write_sse(text_message_content($messageId, $delta)); + }; + + try { + $stream($emit); + write_sse(text_message_end($messageId)); + write_sse(run_finished($threadId, $runId)); + write_sse(sse_done()); + } catch (Throwable $error) { + error_log('[php] chat error: ' . $error->getMessage()); + write_sse(run_error($threadId, $runId, $error->getMessage())); + write_sse(sse_done()); + } +} + +function stream_error_only(string $threadId, string $runId, string $message): void +{ + begin_sse(); + write_sse(run_started($threadId, $runId)); + write_sse(run_error($threadId, $runId, $message)); + write_sse(sse_done()); +} diff --git a/examples/ag-ui/servers/php/lib/Messages.php b/examples/ag-ui/servers/php/lib/Messages.php new file mode 100644 index 000000000..a6cc3e2e7 --- /dev/null +++ b/examples/ag-ui/servers/php/lib/Messages.php @@ -0,0 +1,85 @@ + $role, 'content' => $text]; + break; + } + } + + return [$system, $chat]; +} diff --git a/examples/ag-ui/servers/php/lib/Providers.php b/examples/ag-ui/servers/php/lib/Providers.php new file mode 100644 index 000000000..e6e36764c --- /dev/null +++ b/examples/ag-ui/servers/php/lib/Providers.php @@ -0,0 +1,190 @@ + 'system', 'content' => $system]; + } + foreach ($messages as $message) { + $openaiMessages[] = [ + 'role' => $message['role'], + 'content' => $message['content'], + ]; + } + + $payload = json_encode([ + 'model' => $model, + 'messages' => $openaiMessages, + 'stream' => true, + ], JSON_UNESCAPED_UNICODE); + + if ($payload === false) { + throw new RuntimeException('failed to encode OpenAI request'); + } + + stream_provider_sse( + 'https://api.openai.com/v1/chat/completions', + [ + 'Authorization: Bearer ' . $apiKey, + 'Content-Type: application/json', + ], + $payload, + static function (array $parsed) use ($emit): void { + $delta = $parsed['choices'][0]['delta']['content'] ?? null; + if (is_string($delta) && $delta !== '') { + $emit($delta); + } + } + ); +} + +function stream_anthropic( + string $model, + string $system, + array $messages, + callable $emit +): void { + $apiKey = getenv('ANTHROPIC_API_KEY') ?: ''; + if ($apiKey === '') { + throw new RuntimeException('ANTHROPIC_API_KEY is not set'); + } + + $anthropicMessages = []; + foreach ($messages as $message) { + $anthropicMessages[] = [ + 'role' => $message['role'], + 'content' => $message['content'], + ]; + } + + $body = [ + 'model' => $model, + 'max_tokens' => 4096, + 'messages' => $anthropicMessages, + 'stream' => true, + ]; + if ($system !== '') { + $body['system'] = $system; + } + + $payload = json_encode($body, JSON_UNESCAPED_UNICODE); + if ($payload === false) { + throw new RuntimeException('failed to encode Anthropic request'); + } + + stream_provider_sse( + 'https://api.anthropic.com/v1/messages', + [ + 'x-api-key: ' . $apiKey, + 'anthropic-version: 2023-06-01', + 'Content-Type: application/json', + ], + $payload, + static function (array $parsed) use ($emit): void { + if (($parsed['type'] ?? '') !== 'content_block_delta') { + return; + } + $delta = $parsed['delta'] ?? null; + if (!is_array($delta) || ($delta['type'] ?? '') !== 'text_delta') { + return; + } + $text = $delta['text'] ?? null; + if (is_string($text) && $text !== '') { + $emit($text); + } + } + ); +} + +function stream_completion( + array $config, + string $system, + array $messages, + callable $emit +): void { + switch ($config['provider']) { + case 'openai': + stream_openai($config['model'], $system, $messages, $emit); + return; + case 'anthropic': + stream_anthropic($config['model'], $system, $messages, $emit); + return; + default: + throw new RuntimeException( + 'unsupported provider "' . $config['provider'] . '" (expected openai or anthropic)' + ); + } +} + +function stream_provider_sse( + string $url, + array $headers, + string $payload, + callable $handleEvent +): void { + $buffer = ''; + + $ch = curl_init($url); + if ($ch === false) { + throw new RuntimeException('failed to initialize curl'); + } + + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_RETURNTRANSFER => false, + CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$buffer, $handleEvent): int { + $buffer .= $chunk; + + while (($lineEnd = strpos($buffer, "\n")) !== false) { + $line = rtrim(substr($buffer, 0, $lineEnd), "\r"); + $buffer = substr($buffer, $lineEnd + 1); + + if (!str_starts_with($line, 'data: ')) { + continue; + } + + $data = substr($line, 6); + if ($data === '[DONE]') { + return strlen($chunk); + } + + $parsed = json_decode($data, true); + if (!is_array($parsed)) { + continue; + } + + $handleEvent($parsed); + } + + return strlen($chunk); + }, + ]); + + $ok = curl_exec($ch); + if ($ok === false) { + $error = curl_error($ch); + curl_close($ch); + throw new RuntimeException($error !== '' ? $error : 'provider request failed'); + } + + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($status >= 400) { + throw new RuntimeException('provider request failed (' . $status . ')'); + } +} diff --git a/examples/ag-ui/servers/php/router.php b/examples/ag-ui/servers/php/router.php new file mode 100644 index 000000000..5a745ed65 --- /dev/null +++ b/examples/ag-ui/servers/php/router.php @@ -0,0 +1,68 @@ + str: + parts = message.get("parts") + if isinstance(parts, list): + text_parts = [ + part["content"] + for part in parts + if isinstance(part, dict) + and part.get("type") == "text" + and isinstance(part.get("content"), str) + ] + if text_parts: + return "".join(text_parts) + + content = message.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + return "".join( + item["text"] + for item in content + if isinstance(item, dict) + and item.get("type") == "text" + and isinstance(item.get("text"), str) + ) + return "" + + +def to_chat_messages( + messages: Any, +) -> tuple[str, list[dict[str, str]]]: + system_parts: list[str] = [] + chat: list[dict[str, str]] = [] + + if not isinstance(messages, list): + return "", chat + + for message in messages: + if not isinstance(message, dict): + continue + + role = message.get("role") + if role == "developer": + role = "system" + if role not in ("system", "user", "assistant"): + continue + + text = text_from_message(message) + if not text: + continue + + if role == "system": + system_parts.append(text) + else: + chat.append({"role": role, "content": text}) + + return "\n\n".join(system_parts), chat + + +def props_from_input(input_data: dict[str, Any]) -> dict[str, Any]: + forwarded = input_data.get("forwardedProps") + if isinstance(forwarded, dict) and forwarded: + return forwarded + + data = input_data.get("data") + if isinstance(data, dict): + return data + + return {} + + +def provider_from_input(input_data: dict[str, Any]) -> tuple[str, str]: + props = props_from_input(input_data) + provider = props.get("provider", "openai") + model = props.get("model", "") + + if not isinstance(provider, str) or not provider: + provider = "openai" + if not isinstance(model, str): + model = "" + + if provider == "openai": + return provider, model or DEFAULT_OPENAI_MODEL + if provider == "anthropic": + return provider, model or DEFAULT_ANTHROPIC_MODEL + raise RuntimeError( + f'unsupported provider "{provider}" (expected openai or anthropic)' + ) + + +def stream_provider_sse( + url: str, + headers: dict[str, str], + payload: dict[str, Any], +) -> Iterable[dict[str, Any]]: + request = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers=headers, + method="POST", + ) + + try: + with urllib.request.urlopen(request, timeout=120) as response: + for raw_line in response: + line = raw_line.decode("utf-8").rstrip("\r\n") + if not line.startswith("data: "): + continue + data = line[6:] + if data == "[DONE]": + return + try: + parsed = json.loads(data) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict): + yield parsed + except urllib.error.HTTPError as error: + raise RuntimeError( + f"provider request failed ({error.code})" + ) from error + except urllib.error.URLError as error: + raise RuntimeError(f"provider request failed: {error.reason}") from error + + +def stream_openai( + model: str, + system: str, + messages: list[dict[str, str]], + emit: Callable[[str], None], +) -> None: + api_key = os.environ.get("OPENAI_API_KEY", "") + if not api_key: + raise RuntimeError("OPENAI_API_KEY is not set") + + openai_messages = list(messages) + if system: + openai_messages.insert(0, {"role": "system", "content": system}) + + for event in stream_provider_sse( + "https://api.openai.com/v1/chat/completions", + { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + { + "model": model, + "messages": openai_messages, + "stream": True, + }, + ): + choices = event.get("choices") + if not isinstance(choices, list) or not choices: + continue + choice = choices[0] + if not isinstance(choice, dict): + continue + delta = choice.get("delta") + if not isinstance(delta, dict): + continue + content = delta.get("content") + if isinstance(content, str) and content: + emit(content) + + +def stream_anthropic( + model: str, + system: str, + messages: list[dict[str, str]], + emit: Callable[[str], None], +) -> None: + api_key = os.environ.get("ANTHROPIC_API_KEY", "") + if not api_key: + raise RuntimeError("ANTHROPIC_API_KEY is not set") + + payload: dict[str, Any] = { + "model": model, + "max_tokens": 4096, + "messages": messages, + "stream": True, + } + if system: + payload["system"] = system + + for event in stream_provider_sse( + "https://api.anthropic.com/v1/messages", + { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + }, + payload, + ): + if event.get("type") != "content_block_delta": + continue + delta = event.get("delta") + if not isinstance(delta, dict) or delta.get("type") != "text_delta": + continue + text = delta.get("text") + if isinstance(text, str) and text: + emit(text) + + +def stream_completion( + provider: str, + model: str, + system: str, + messages: list[dict[str, str]], + emit: Callable[[str], None], +) -> None: + if provider == "openai": + stream_openai(model, system, messages, emit) + return + if provider == "anthropic": + stream_anthropic(model, system, messages, emit) + return + raise RuntimeError( + f'unsupported provider "{provider}" (expected openai or anthropic)' + ) + + +class AguiHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, format: str, *args: Any) -> None: + sys.stderr.write(f"[python] {format % args}\n") + + def send_text(self, status: int, message: str) -> None: + body = message.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self) -> None: + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self) -> None: + if self.path.split("?", 1)[0] == "/health": + self.send_text(200, "ok") + return + self.send_text(405, "method not allowed") + + def do_POST(self) -> None: + if self.path.split("?", 1)[0] != "/": + self.send_text(404, "not found") + return + + try: + content_length = int(self.headers.get("Content-Length", "0")) + input_data = json.loads(self.rfile.read(content_length)) + except (ValueError, json.JSONDecodeError, UnicodeDecodeError): + self.send_text(400, "invalid JSON body") + return + + if not isinstance(input_data, dict): + self.send_text(400, "invalid JSON body") + return + + thread_id = input_data.get("threadId") + run_id = input_data.get("runId") + if not isinstance(thread_id, str) or not thread_id or not isinstance( + run_id, str + ) or not run_id: + self.send_text(400, "threadId and runId are required") + return + + system, messages = to_chat_messages(input_data.get("messages")) + self.begin_sse() + self.write_event( + {"type": "RUN_STARTED", "threadId": thread_id, "runId": run_id} + ) + + if not messages: + self.write_run_error( + thread_id, run_id, "no user or assistant messages to send" + ) + self.write_done() + return + + message_id = f"msg-{run_id}" + self.write_event( + { + "type": "TEXT_MESSAGE_START", + "messageId": message_id, + "role": "assistant", + } + ) + + try: + provider, model = provider_from_input(input_data) + stream_completion( + provider, + model, + system, + messages, + lambda delta: self.write_event( + { + "type": "TEXT_MESSAGE_CONTENT", + "messageId": message_id, + "delta": delta, + } + ), + ) + self.write_event( + {"type": "TEXT_MESSAGE_END", "messageId": message_id} + ) + self.write_event( + { + "type": "RUN_FINISHED", + "threadId": thread_id, + "runId": run_id, + "finishReason": "stop", + } + ) + self.write_done() + except Exception as error: + self.log_message("chat error: %s", error) + self.write_run_error(thread_id, run_id, str(error)) + self.write_done() + + def begin_sse(self) -> None: + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Connection", "close") + self.end_headers() + + def write_event(self, event: dict[str, Any]) -> None: + frame = f"data: {json.dumps(event, ensure_ascii=False)}\n\n" + self.wfile.write(frame.encode("utf-8")) + self.wfile.flush() + + def write_run_error( + self, thread_id: str, run_id: str, message: str + ) -> None: + self.write_event( + { + "type": "RUN_ERROR", + "threadId": thread_id, + "runId": run_id, + "error": {"message": message}, + } + ) + + def write_done(self) -> None: + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + +def main() -> None: + server = ThreadingHTTPServer((HOST, PORT), AguiHandler) + print( + f"[python] listening on http://{HOST}:{PORT}", + flush=True, + ) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/examples/ag-ui/servers/rust/Cargo.lock b/examples/ag-ui/servers/rust/Cargo.lock new file mode 100644 index 000000000..a3915898f --- /dev/null +++ b/examples/ag-ui/servers/rust/Cargo.lock @@ -0,0 +1,1451 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ag-ui-echo-rust" +version = "0.1.0" +dependencies = [ + "async-stream", + "axum", + "futures-util", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-stream", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.0", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.0", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/ag-ui/servers/rust/Cargo.toml b/examples/ag-ui/servers/rust/Cargo.toml new file mode 100644 index 000000000..3cd620fb3 --- /dev/null +++ b/examples/ag-ui/servers/rust/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ag-ui-echo-rust" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-stream = "0.3" +axum = "0.8" +futures-util = "0.3" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio-stream = "0.1" diff --git a/examples/ag-ui/servers/rust/README.md b/examples/ag-ui/servers/rust/README.md new file mode 100644 index 000000000..93055d0c2 --- /dev/null +++ b/examples/ag-ui/servers/rust/README.md @@ -0,0 +1,35 @@ +# Rust AG-UI chat server + +AG-UI SSE server using Axum. Streams simple chat completions from OpenAI or Anthropic. + +## Run + +From `examples/ag-ui`: + +```bash +export OPENAI_API_KEY=... +export ANTHROPIC_API_KEY=... +pnpm dev:rust +# or +cargo run --manifest-path servers/rust/Cargo.toml +``` + +Listens on `http://127.0.0.1:8002`. + +## Protocol + +- **Endpoint:** `POST /` +- **Request body:** AG-UI `RunAgentInput` JSON +- **Provider selection:** `forwardedProps.provider` (`openai` | `anthropic`) and optional `forwardedProps.model` +- **Response:** `Content-Type: text/event-stream` + +Supports simple `user` / `assistant` / `system` messages with string `content` or TanStack-style text `parts`. Tool and reasoning fan-out entries are ignored. + +## Environment + +| Variable | Required when | +| ------------------- | --------------------- | +| `OPENAI_API_KEY` | `provider: openai` | +| `ANTHROPIC_API_KEY` | `provider: anthropic` | + +Default models: `gpt-4o`, `claude-sonnet-4-6`. diff --git a/examples/ag-ui/servers/rust/src/agui.rs b/examples/ag-ui/servers/rust/src/agui.rs new file mode 100644 index 000000000..3ecdf1660 --- /dev/null +++ b/examples/ag-ui/servers/rust/src/agui.rs @@ -0,0 +1,134 @@ +use serde::Deserialize; +use serde_json::{json, Value}; + +pub const DEFAULT_OPENAI_MODEL: &str = "gpt-4o"; +pub const DEFAULT_ANTHROPIC_MODEL: &str = "claude-sonnet-4-6"; + +#[derive(Debug, Deserialize)] +pub struct RunAgentInput { + #[serde(rename = "threadId")] + pub thread_id: String, + #[serde(rename = "runId")] + pub run_id: String, + pub messages: Vec, + #[serde(default)] + #[serde(rename = "forwardedProps")] + pub forwarded_props: Value, + #[serde(default)] + pub data: Value, +} + +#[derive(Debug, Deserialize)] +pub struct IncomingMessage { + pub role: String, + pub content: Option, + pub parts: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct MessagePart { + #[serde(rename = "type")] + pub part_type: String, + pub content: Option, +} + +#[derive(Debug, Clone)] +pub struct ChatMessage { + pub role: String, + pub content: String, +} + +#[derive(Debug, Clone)] +pub struct ProviderConfig { + pub provider: String, + pub model: String, +} + +pub fn provider_from_input(input: &RunAgentInput) -> ProviderConfig { + let props = if input.forwarded_props.is_object() { + &input.forwarded_props + } else { + &input.data + }; + + let provider = props + .get("provider") + .and_then(Value::as_str) + .unwrap_or("openai") + .to_string(); + + let model = props + .get("model") + .and_then(Value::as_str) + .map(str::to_string); + + match provider.as_str() { + "anthropic" => ProviderConfig { + provider, + model: model.unwrap_or_else(|| DEFAULT_ANTHROPIC_MODEL.to_string()), + }, + _ => ProviderConfig { + provider: "openai".to_string(), + model: model.unwrap_or_else(|| DEFAULT_OPENAI_MODEL.to_string()), + }, + } +} + +pub fn sse_event(payload: Value) -> String { + format!("data: {}\n\n", payload) +} + +pub fn sse_done() -> String { + "data: [DONE]\n\n".to_string() +} + +pub fn run_started(thread_id: &str, run_id: &str) -> String { + sse_event(json!({ + "type": "RUN_STARTED", + "threadId": thread_id, + "runId": run_id, + })) +} + +pub fn text_message_start(message_id: &str) -> String { + sse_event(json!({ + "type": "TEXT_MESSAGE_START", + "messageId": message_id, + "role": "assistant", + })) +} + +pub fn text_message_content(message_id: &str, delta: &str) -> String { + sse_event(json!({ + "type": "TEXT_MESSAGE_CONTENT", + "messageId": message_id, + "delta": delta, + })) +} + +pub fn text_message_end(message_id: &str) -> String { + sse_event(json!({ + "type": "TEXT_MESSAGE_END", + "messageId": message_id, + })) +} + +pub fn run_finished(thread_id: &str, run_id: &str) -> String { + sse_event(json!({ + "type": "RUN_FINISHED", + "threadId": thread_id, + "runId": run_id, + "finishReason": "stop", + })) +} + +pub fn run_error(thread_id: &str, run_id: &str, message: &str) -> String { + sse_event(json!({ + "type": "RUN_ERROR", + "threadId": thread_id, + "runId": run_id, + "error": { + "message": message, + }, + })) +} diff --git a/examples/ag-ui/servers/rust/src/main.rs b/examples/ag-ui/servers/rust/src/main.rs new file mode 100644 index 000000000..8451ffb29 --- /dev/null +++ b/examples/ag-ui/servers/rust/src/main.rs @@ -0,0 +1,170 @@ +mod agui; +mod messages; +mod providers; + +use agui::{ + provider_from_input, run_error, run_finished, run_started, sse_done, text_message_content, + text_message_end, text_message_start, RunAgentInput, +}; +use async_stream::stream; +use axum::{ + body::{Body, Bytes}, + extract::{Request, State}, + http::{header, Method, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Router, +}; +use futures_util::stream; +use messages::to_chat_messages; +use providers::stream_completion; +use reqwest::Client; +use std::convert::Infallible; +use tokio::sync::mpsc; + +const LISTEN_ADDR: &str = "127.0.0.1:8002"; + +#[tokio::main] +async fn main() { + let client = Client::new(); + let app = Router::new() + .route("/", post(handle_chat)) + .route("/health", get(|| async { "ok" })) + .with_state(client); + + let listener = tokio::net::TcpListener::bind(LISTEN_ADDR) + .await + .expect("failed to bind rust server"); + + println!("AG-UI Rust server listening on http://{LISTEN_ADDR}"); + + axum::serve(listener, app) + .await + .expect("rust server failed"); +} + +async fn handle_chat(State(client): State, request: Request) -> Response { + if request.method() == Method::OPTIONS { + return options_response(); + } + + let body = axum::body::to_bytes(request.into_body(), 1024 * 1024) + .await + .unwrap_or_default(); + + let input: RunAgentInput = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(_) => { + return (StatusCode::BAD_REQUEST, "invalid JSON body").into_response(); + } + }; + + if input.thread_id.is_empty() || input.run_id.is_empty() { + return ( + StatusCode::BAD_REQUEST, + "threadId and runId are required", + ) + .into_response(); + } + + let config = provider_from_input(&input); + let message_id = format!("msg-{}", input.run_id); + let (system, messages) = to_chat_messages(&input.messages); + + if messages.is_empty() { + let frames = vec![ + run_started(&input.thread_id, &input.run_id), + run_error( + &input.thread_id, + &input.run_id, + "no user or assistant messages to send", + ), + sse_done(), + ]; + return sse_response(stream::iter( + frames + .into_iter() + .map(|frame| Ok::(Bytes::from(frame))), + )); + } + + let thread_id = input.thread_id.clone(); + let run_id = input.run_id.clone(); + let provider = config.provider.clone(); + let model = config.model.clone(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + + let llm_client = client.clone(); + let llm_message_id = message_id.clone(); + let llm_system = system.clone(); + let llm_messages = messages.clone(); + + tokio::spawn(async move { + let result = stream_completion( + &llm_client, + &provider, + &model, + &llm_system, + &llm_messages, + |delta| { + if delta.is_empty() { + return Ok(()); + } + tx.send(text_message_content(&llm_message_id, &delta)) + .map_err(|_| "client disconnected".to_string()) + }, + ) + .await; + + let _ = match result { + Ok(()) => tx.send(format!( + "{}{}{}", + text_message_end(&llm_message_id), + run_finished(&thread_id, &run_id), + sse_done() + )), + Err(message) => { + eprintln!("[rust] chat error: {message}"); + tx.send(format!( + "{}{}", + run_error(&thread_id, &run_id, &message), + sse_done() + )) + } + }; + }); + + let body_stream = stream! { + yield Ok(Bytes::from(run_started(&input.thread_id, &input.run_id))); + yield Ok(Bytes::from(text_message_start(&message_id))); + + while let Some(frame) = rx.recv().await { + yield Ok(Bytes::from(frame)); + } + }; + + sse_response(body_stream) +} + +fn sse_response( + body_stream: impl futures_util::Stream> + Send + 'static, +) -> Response { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/event-stream") + .header(header::CACHE_CONTROL, "no-cache") + .header(header::CONNECTION, "keep-alive") + .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .body(Body::from_stream(body_stream)) + .unwrap() +} + +fn options_response() -> Response { + Response::builder() + .status(StatusCode::NO_CONTENT) + .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .header(header::ACCESS_CONTROL_ALLOW_METHODS, "POST, OPTIONS") + .header(header::ACCESS_CONTROL_ALLOW_HEADERS, "Content-Type") + .body(Body::empty()) + .unwrap() +} diff --git a/examples/ag-ui/servers/rust/src/messages.rs b/examples/ag-ui/servers/rust/src/messages.rs new file mode 100644 index 000000000..ddb164fd9 --- /dev/null +++ b/examples/ag-ui/servers/rust/src/messages.rs @@ -0,0 +1,77 @@ +use crate::agui::{ChatMessage, IncomingMessage}; + +pub fn to_chat_messages(messages: &[IncomingMessage]) -> (String, Vec) { + let mut system = String::new(); + let mut chat = Vec::new(); + + for message in messages { + let mut role = message.role.as_str(); + match role { + "developer" => role = "system", + "tool" | "reasoning" => continue, + _ => {} + } + + let text = text_from_message(message); + if text.is_empty() { + continue; + } + + match role { + "system" => { + if !system.is_empty() { + system.push_str("\n\n"); + } + system.push_str(&text); + } + "user" | "assistant" => chat.push(ChatMessage { + role: role.to_string(), + content: text, + }), + _ => {} + } + } + + (system, chat) +} + +fn text_from_message(message: &IncomingMessage) -> String { + if let Some(parts) = &message.parts { + let joined = parts + .iter() + .filter(|part| part.part_type == "text") + .filter_map(|part| part.content.as_deref()) + .collect::>() + .join(""); + if !joined.is_empty() { + return joined; + } + } + + let Some(content) = &message.content else { + return String::new(); + }; + + if let Some(text) = content.as_str() { + return text.to_string(); + } + + if let Some(items) = content.as_array() { + let joined = items + .iter() + .filter_map(|item| { + if item.get("type")?.as_str()? == "text" { + item.get("text")?.as_str().map(str::to_string) + } else { + None + } + }) + .collect::>() + .join(""); + if !joined.is_empty() { + return joined; + } + } + + content.to_string() +} diff --git a/examples/ag-ui/servers/rust/src/providers.rs b/examples/ag-ui/servers/rust/src/providers.rs new file mode 100644 index 000000000..27f19e412 --- /dev/null +++ b/examples/ag-ui/servers/rust/src/providers.rs @@ -0,0 +1,189 @@ +use crate::agui::ChatMessage; +use futures_util::StreamExt; +use reqwest::Client; +use serde_json::{json, Value}; +use std::env; + +pub async fn stream_openai( + client: &Client, + model: &str, + system: &str, + messages: &[ChatMessage], + mut emit: impl FnMut(String) -> Result<(), String>, +) -> Result<(), String> { + let api_key = env::var("OPENAI_API_KEY") + .map_err(|_| "OPENAI_API_KEY is not set".to_string())?; + + let mut openai_messages: Vec = Vec::new(); + if !system.is_empty() { + openai_messages.push(json!({ + "role": "system", + "content": system, + })); + } + for message in messages { + openai_messages.push(json!({ + "role": message.role, + "content": message.content, + })); + } + + let response = client + .post("https://api.openai.com/v1/chat/completions") + .bearer_auth(api_key) + .json(&json!({ + "model": model, + "messages": openai_messages, + "stream": true, + })) + .send() + .await + .map_err(|error| error.to_string())?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(format!( + "openai request failed ({status}): {}", + body.trim() + )); + } + + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| error.to_string())?; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some(line_end) = buffer.find('\n') { + let line = buffer[..line_end].trim_end_matches('\r').to_string(); + buffer = buffer[line_end + 1..].to_string(); + + if !line.starts_with("data: ") { + continue; + } + let data = line.trim_start_matches("data: "); + if data == "[DONE]" { + return Ok(()); + } + + let parsed: Value = match serde_json::from_str(data) { + Ok(value) => value, + Err(_) => continue, + }; + if let Some(delta) = parsed + .pointer("/choices/0/delta/content") + .and_then(Value::as_str) + { + emit(delta.to_string())?; + } + } + } + + Ok(()) +} + +pub async fn stream_anthropic( + client: &Client, + model: &str, + system: &str, + messages: &[ChatMessage], + mut emit: impl FnMut(String) -> Result<(), String>, +) -> Result<(), String> { + let api_key = env::var("ANTHROPIC_API_KEY") + .map_err(|_| "ANTHROPIC_API_KEY is not set".to_string())?; + + let anthropic_messages: Vec = messages + .iter() + .map(|message| { + json!({ + "role": message.role, + "content": message.content, + }) + }) + .collect(); + + let mut body = json!({ + "model": model, + "max_tokens": 4096, + "messages": anthropic_messages, + "stream": true, + }); + if !system.is_empty() { + body["system"] = json!(system); + } + + let response = client + .post("https://api.anthropic.com/v1/messages") + .header("x-api-key", api_key) + .header("anthropic-version", "2023-06-01") + .json(&body) + .send() + .await + .map_err(|error| error.to_string())?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(format!( + "anthropic request failed ({status}): {}", + body.trim() + )); + } + + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| error.to_string())?; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some(line_end) = buffer.find('\n') { + let line = buffer[..line_end].trim_end_matches('\r').to_string(); + buffer = buffer[line_end + 1..].to_string(); + + if !line.starts_with("data: ") { + continue; + } + let data = line.trim_start_matches("data: "); + let parsed: Value = match serde_json::from_str(data) { + Ok(value) => value, + Err(_) => continue, + }; + + if parsed.get("type").and_then(Value::as_str) != Some("content_block_delta") { + continue; + } + if parsed + .pointer("/delta/type") + .and_then(Value::as_str) + != Some("text_delta") + { + continue; + } + if let Some(text) = parsed.pointer("/delta/text").and_then(Value::as_str) { + emit(text.to_string())?; + } + } + } + + Ok(()) +} + +pub async fn stream_completion( + client: &Client, + provider: &str, + model: &str, + system: &str, + messages: &[ChatMessage], + emit: impl FnMut(String) -> Result<(), String>, +) -> Result<(), String> { + match provider { + "openai" => stream_openai(client, model, system, messages, emit).await, + "anthropic" => stream_anthropic(client, model, system, messages, emit).await, + other => Err(format!( + "unsupported provider {other:?} (expected openai or anthropic)" + )), + } +} diff --git a/examples/ag-ui/servers/zig/README.md b/examples/ag-ui/servers/zig/README.md new file mode 100644 index 000000000..1bd95c8f5 --- /dev/null +++ b/examples/ag-ui/servers/zig/README.md @@ -0,0 +1,28 @@ +# Zig AG-UI server + +Hand-rolled AG-UI SSE server on `http://127.0.0.1:8004`. + +## Run + +From `examples/ag-ui`: + +```bash +pnpm dev:zig +``` + +Requires Zig with stdlib HTTP client support (tested with Zig 0.16). + +## Environment + +- `OPENAI_API_KEY` +- `ANTHROPIC_API_KEY` + +## Endpoints + +| Method | Path | Description | +| ------- | --------- | ------------------- | +| POST | `/` | AG-UI chat (SSE) | +| GET | `/health` | Health check (`ok`) | +| OPTIONS | `/` | CORS preflight | + +The client sends `forwardedProps: { provider, model }` in the AG-UI `RunAgentInput` body. diff --git a/examples/ag-ui/servers/zig/build.zig b/examples/ag-ui/servers/zig/build.zig new file mode 100644 index 000000000..802fe4f63 --- /dev/null +++ b/examples/ag-ui/servers/zig/build.zig @@ -0,0 +1,23 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + const exe = b.addExecutable(.{ + .name = "ag-ui-zig", + .root_module = root_module, + }); + + b.installArtifact(exe); + + const run_step = b.addRunArtifact(exe); + const run_cmd = b.step("run", "Run the AG-UI Zig server"); + run_cmd.dependOn(&run_step.step); +} diff --git a/examples/ag-ui/servers/zig/src/agui.zig b/examples/ag-ui/servers/zig/src/agui.zig new file mode 100644 index 000000000..5f954326e --- /dev/null +++ b/examples/ag-ui/servers/zig/src/agui.zig @@ -0,0 +1,152 @@ +const std = @import("std"); + +pub const DEFAULT_OPENAI_MODEL = "gpt-4o"; +pub const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6"; + +pub const RunAgentInput = struct { + threadId: []const u8, + runId: []const u8, + messages: []IncomingMessage, + forwardedProps: std.json.Value = .null, + data: std.json.Value = .null, +}; + +pub const IncomingMessage = struct { + role: []const u8, + content: ?std.json.Value = null, + parts: ?[]MessagePart = null, +}; + +pub const MessagePart = struct { + @"type": []const u8, + content: ?[]const u8 = null, +}; + +pub const ChatMessage = struct { + role: []const u8, + content: []const u8, +}; + +pub const ProviderConfig = struct { + provider: []const u8, + model: []const u8, +}; + +pub fn providerFromInput(input: *const RunAgentInput, allocator: std.mem.Allocator) !ProviderConfig { + const props = if (input.forwardedProps == .object) input.forwardedProps else input.data; + + var provider: []const u8 = "openai"; + var model: ?[]const u8 = null; + + if (props == .object) { + if (props.object.get("provider")) |value| { + if (value == .string and value.string.len > 0) { + provider = value.string; + } + } + if (props.object.get("model")) |value| { + if (value == .string) { + model = value.string; + } + } + } + + if (std.mem.eql(u8, provider, "anthropic")) { + return ProviderConfig{ + .provider = "anthropic", + .model = if (model) |m| try allocator.dupe(u8, m) else try allocator.dupe(u8, DEFAULT_ANTHROPIC_MODEL), + }; + } + + return ProviderConfig{ + .provider = "openai", + .model = if (model) |m| try allocator.dupe(u8, m) else try allocator.dupe(u8, DEFAULT_OPENAI_MODEL), + }; +} + +fn sseEvent(allocator: std.mem.Allocator, payload: anytype) ![]u8 { + const json = try std.json.Stringify.valueAlloc(allocator, payload, .{}); + defer allocator.free(json); + return std.fmt.allocPrint(allocator, "data: {s}\n\n", .{json}); +} + +pub fn sseDone(allocator: std.mem.Allocator) ![]u8 { + return try allocator.dupe(u8, "data: [DONE]\n\n"); +} + +pub fn runStarted(allocator: std.mem.Allocator, thread_id: []const u8, run_id: []const u8) ![]u8 { + return sseEvent(allocator, .{ + .type = "RUN_STARTED", + .threadId = thread_id, + .runId = run_id, + }); +} + +pub fn textMessageStart(allocator: std.mem.Allocator, message_id: []const u8) ![]u8 { + return sseEvent(allocator, .{ + .type = "TEXT_MESSAGE_START", + .messageId = message_id, + .role = "assistant", + }); +} + +pub fn textMessageContent(allocator: std.mem.Allocator, message_id: []const u8, delta: []const u8) ![]u8 { + return sseEvent(allocator, .{ + .type = "TEXT_MESSAGE_CONTENT", + .messageId = message_id, + .delta = delta, + }); +} + +pub fn textMessageEnd(allocator: std.mem.Allocator, message_id: []const u8) ![]u8 { + return sseEvent(allocator, .{ + .type = "TEXT_MESSAGE_END", + .messageId = message_id, + }); +} + +pub fn runFinished(allocator: std.mem.Allocator, thread_id: []const u8, run_id: []const u8) ![]u8 { + return sseEvent(allocator, .{ + .type = "RUN_FINISHED", + .threadId = thread_id, + .runId = run_id, + .finishReason = "stop", + }); +} + +pub fn runError(allocator: std.mem.Allocator, thread_id: []const u8, run_id: []const u8, message: []const u8) ![]u8 { + const RunErrorPayload = struct { + type: []const u8, + threadId: []const u8, + runId: []const u8, + err: struct { message: []const u8 }, + + pub fn jsonStringify(self: @This(), jw: anytype) !void { + try jw.beginObject(); + try jw.objectField("type"); + try jw.write(self.type); + try jw.objectField("threadId"); + try jw.write(self.threadId); + try jw.objectField("runId"); + try jw.write(self.runId); + try jw.objectField("error"); + try jw.beginObject(); + try jw.objectField("message"); + try jw.write(self.err.message); + try jw.endObject(); + try jw.endObject(); + } + }; + + return sseEvent(allocator, RunErrorPayload{ + .type = "RUN_ERROR", + .threadId = thread_id, + .runId = run_id, + .err = .{ .message = message }, + }); +} + +pub fn writeSse(response: *std.http.BodyWriter, frame: []const u8) !void { + try response.writer.writeAll(frame); + try response.flush(); +} diff --git a/examples/ag-ui/servers/zig/src/main.zig b/examples/ag-ui/servers/zig/src/main.zig new file mode 100644 index 000000000..be736807b --- /dev/null +++ b/examples/ag-ui/servers/zig/src/main.zig @@ -0,0 +1,258 @@ +const std = @import("std"); +const Io = std.Io; + +const agui = @import("agui.zig"); +const messages = @import("messages.zig"); +const providers = @import("providers.zig"); + +const LISTEN_ADDR = "127.0.0.1"; +const LISTEN_PORT: u16 = 8004; + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + + const address = try Io.net.IpAddress.parse(LISTEN_ADDR, LISTEN_PORT); + var listener = try address.listen(io, .{ .reuse_address = true }); + defer listener.socket.close(io); + + std.debug.print("AG-UI Zig server listening on http://127.0.0.1:{d}\n", .{LISTEN_PORT}); + + while (true) { + const stream = try listener.accept(io); + handleConnection(allocator, io, stream) catch |err| { + std.log.err("connection error: {}", .{err}); + }; + } +} + +fn handleConnection(allocator: std.mem.Allocator, io: Io, stream: Io.net.Stream) !void { + defer stream.close(io); + + var recv_buffer: [8192]u8 = undefined; + var send_buffer: [8192]u8 = undefined; + var conn_reader = stream.reader(io, &recv_buffer); + var conn_writer = stream.writer(io, &send_buffer); + var server = std.http.Server.init(&conn_reader.interface, &conn_writer.interface); + + while (server.reader.state == .ready) { + var request = server.receiveHead() catch |err| switch (err) { + error.HttpConnectionClosing => return, + else => return err, + }; + + try serveRequest(allocator, io, &request); + } +} + +fn serveRequest(allocator: std.mem.Allocator, io: Io, request: *std.http.Server.Request) !void { + if (request.head.method == .OPTIONS) { + try sendOptions(request); + return; + } + + if (request.head.method == .GET and std.mem.eql(u8, request.head.target, "/health")) { + try request.respond("ok", .{ + .extra_headers = &.{ + .{ .name = "content-type", .value = "text/plain" }, + .{ .name = "access-control-allow-origin", .value = "*" }, + }, + }); + return; + } + + if (request.head.method == .POST and std.mem.eql(u8, request.head.target, "/")) { + try handleChat(allocator, io, request); + return; + } + + if (request.head.method == .POST) { + try sendPlainError(request, .not_found, "not found"); + return; + } + + try sendPlainError(request, .method_not_allowed, "method not allowed"); +} + +fn sendOptions(request: *std.http.Server.Request) !void { + try request.respond("", .{ + .status = .no_content, + .extra_headers = &.{ + .{ .name = "access-control-allow-origin", .value = "*" }, + .{ .name = "access-control-allow-methods", .value = "POST, OPTIONS" }, + .{ .name = "access-control-allow-headers", .value = "Content-Type" }, + }, + }); +} + +fn sendPlainError(request: *std.http.Server.Request, status: std.http.Status, message: []const u8) !void { + try request.respond(message, .{ + .status = status, + .extra_headers = &.{ + .{ .name = "content-type", .value = "text/plain" }, + }, + }); +} + +const EmitCtx = struct { + allocator: std.mem.Allocator, + response: *std.http.BodyWriter, + message_id: []const u8, + + fn emit(ctx: *anyopaque, delta: []const u8) !void { + const self: *EmitCtx = @ptrCast(@alignCast(ctx)); + if (delta.len == 0) return; + const frame = try agui.textMessageContent(self.allocator, self.message_id, delta); + defer self.allocator.free(frame); + try agui.writeSse(self.response, frame); + } +}; + +fn handleChat(allocator: std.mem.Allocator, io: Io, request: *std.http.Server.Request) !void { + var body_reader_buffer: [4096]u8 = undefined; + const body_reader = request.readerExpectContinue(&body_reader_buffer) catch { + try sendPlainError(request, .bad_request, "failed to read body"); + return; + }; + + const body = body_reader.allocRemaining(allocator, .limited(1024 * 1024)) catch { + try sendPlainError(request, .bad_request, "failed to read body"); + return; + }; + defer allocator.free(body); + + const parsed = std.json.parseFromSlice(agui.RunAgentInput, allocator, body, .{ + .ignore_unknown_fields = true, + }) catch { + try sendPlainError(request, .bad_request, "invalid JSON body"); + return; + }; + defer parsed.deinit(); + + const input = parsed.value; + if (input.threadId.len == 0 or input.runId.len == 0) { + try sendPlainError(request, .bad_request, "threadId and runId are required"); + return; + } + + const config = agui.providerFromInput(&input, allocator) catch { + try sendPlainError(request, .internal_server_error, "failed to parse provider config"); + return; + }; + defer allocator.free(config.model); + + const chat_messages = messages.toChatMessages(allocator, input.messages) catch { + try sendPlainError(request, .internal_server_error, "failed to parse messages"); + return; + }; + defer messages.freeChatMessages(allocator, chat_messages.system, chat_messages.chat); + + if (chat_messages.chat.len == 0) { + try streamErrorOnly(allocator, request, input.threadId, input.runId, "no user or assistant messages to send"); + return; + } + + var send_buffer: [4096]u8 = undefined; + var response = request.respondStreaming(&send_buffer, .{ + .respond_options = .{ + .extra_headers = &.{ + .{ .name = "content-type", .value = "text/event-stream" }, + .{ .name = "cache-control", .value = "no-cache" }, + .{ .name = "connection", .value = "keep-alive" }, + .{ .name = "access-control-allow-origin", .value = "*" }, + }, + }, + }) catch { + try sendPlainError(request, .internal_server_error, "streaming unsupported"); + return; + }; + + const message_id = try std.fmt.allocPrint(allocator, "msg-{s}", .{input.runId}); + defer allocator.free(message_id); + + const started = try agui.runStarted(allocator, input.threadId, input.runId); + defer allocator.free(started); + try agui.writeSse(&response, started); + + const text_start = try agui.textMessageStart(allocator, message_id); + defer allocator.free(text_start); + try agui.writeSse(&response, text_start); + + var emit_ctx = EmitCtx{ + .allocator = allocator, + .response = &response, + .message_id = message_id, + }; + + providers.streamCompletion( + allocator, + io, + config, + chat_messages.system, + chat_messages.chat, + &emit_ctx, + EmitCtx.emit, + ) catch |err| { + const message = switch (err) { + error.MissingOpenAIKey => "OPENAI_API_KEY is not set", + error.MissingAnthropicKey => "ANTHROPIC_API_KEY is not set", + error.UnsupportedProvider => "unsupported provider (expected openai or anthropic)", + error.UnsupportedCompressionMethod => "unsupported provider compression", + else => "provider request failed", + }; + const frame = try agui.runError(allocator, input.threadId, input.runId, message); + defer allocator.free(frame); + try agui.writeSse(&response, frame); + const done = try agui.sseDone(allocator); + defer allocator.free(done); + try agui.writeSse(&response, done); + try response.end(); + return; + }; + + const text_end = try agui.textMessageEnd(allocator, message_id); + defer allocator.free(text_end); + try agui.writeSse(&response, text_end); + + const finished = try agui.runFinished(allocator, input.threadId, input.runId); + defer allocator.free(finished); + try agui.writeSse(&response, finished); + + const done = try agui.sseDone(allocator); + defer allocator.free(done); + try agui.writeSse(&response, done); + try response.end(); +} + +fn streamErrorOnly( + allocator: std.mem.Allocator, + request: *std.http.Server.Request, + thread_id: []const u8, + run_id: []const u8, + message: []const u8, +) !void { + var send_buffer: [4096]u8 = undefined; + var response = try request.respondStreaming(&send_buffer, .{ + .respond_options = .{ + .extra_headers = &.{ + .{ .name = "content-type", .value = "text/event-stream" }, + .{ .name = "cache-control", .value = "no-cache" }, + .{ .name = "connection", .value = "keep-alive" }, + .{ .name = "access-control-allow-origin", .value = "*" }, + }, + }, + }); + + const started = try agui.runStarted(allocator, thread_id, run_id); + defer allocator.free(started); + try agui.writeSse(&response, started); + + const frame = try agui.runError(allocator, thread_id, run_id, message); + defer allocator.free(frame); + try agui.writeSse(&response, frame); + + const done = try agui.sseDone(allocator); + defer allocator.free(done); + try agui.writeSse(&response, done); + try response.end(); +} diff --git a/examples/ag-ui/servers/zig/src/messages.zig b/examples/ag-ui/servers/zig/src/messages.zig new file mode 100644 index 000000000..42163a020 --- /dev/null +++ b/examples/ag-ui/servers/zig/src/messages.zig @@ -0,0 +1,105 @@ +const std = @import("std"); +const agui = @import("agui.zig"); + +pub fn textFromMessageAlloc(allocator: std.mem.Allocator, message: *const agui.IncomingMessage) ![]const u8 { + if (message.parts) |parts| { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(allocator); + for (parts) |part| { + if (std.mem.eql(u8, part.@"type", "text")) { + if (part.content) |content| { + if (content.len > 0) { + try out.appendSlice(allocator, content); + } + } + } + } + if (out.items.len > 0) { + return out.toOwnedSlice(allocator); + } + } + + if (message.content) |content| { + switch (content) { + .string => |text| return try allocator.dupe(u8, text), + .array => |items| { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(allocator); + for (items.items) |item| { + if (item != .object) continue; + if (item.object.get("type")) |type_value| { + if (type_value != .string or !std.mem.eql(u8, type_value.string, "text")) continue; + } else continue; + if (item.object.get("text")) |text_value| { + if (text_value == .string) { + try out.appendSlice(allocator, text_value.string); + } + } + } + if (out.items.len > 0) { + return out.toOwnedSlice(allocator); + } + }, + else => {}, + } + } + + return try allocator.dupe(u8, ""); +} + +pub fn toChatMessages( + allocator: std.mem.Allocator, + messages: []const agui.IncomingMessage, +) !struct { system: []const u8, chat: []agui.ChatMessage } { + var system_parts: std.ArrayList([]const u8) = .empty; + defer { + for (system_parts.items) |part| allocator.free(part); + system_parts.deinit(allocator); + } + + var chat: std.ArrayList(agui.ChatMessage) = .empty; + errdefer chat.deinit(allocator); + + for (messages) |message| { + var role = message.role; + if (std.mem.eql(u8, role, "developer")) { + role = "system"; + } else if (std.mem.eql(u8, role, "tool") or std.mem.eql(u8, role, "reasoning")) { + continue; + } + + const text = try textFromMessageAlloc(allocator, &message); + defer allocator.free(text); + if (text.len == 0) continue; + + if (std.mem.eql(u8, role, "system")) { + try system_parts.append(allocator, try allocator.dupe(u8, text)); + } else if (std.mem.eql(u8, role, "user") or std.mem.eql(u8, role, "assistant")) { + try chat.append(allocator, .{ + .role = try allocator.dupe(u8, role), + .content = try allocator.dupe(u8, text), + }); + } + } + + var system: std.ArrayList(u8) = .empty; + errdefer system.deinit(allocator); + for (system_parts.items, 0..) |part, index| { + if (index > 0) try system.appendSlice(allocator, "\n\n"); + try system.appendSlice(allocator, part); + } + + return .{ + .system = try system.toOwnedSlice(allocator), + .chat = try chat.toOwnedSlice(allocator), + }; +} + +pub fn freeChatMessages(allocator: std.mem.Allocator, system: []const u8, chat: []agui.ChatMessage) void { + allocator.free(system); + for (chat) |message| { + allocator.free(message.role); + allocator.free(message.content); + } + allocator.free(chat); +} diff --git a/examples/ag-ui/servers/zig/src/providers.zig b/examples/ag-ui/servers/zig/src/providers.zig new file mode 100644 index 000000000..7a2b4536f --- /dev/null +++ b/examples/ag-ui/servers/zig/src/providers.zig @@ -0,0 +1,297 @@ +const std = @import("std"); +const agui = @import("agui.zig"); + +pub const MissingOpenAIKey = error.MissingOpenAIKey; +pub const MissingAnthropicKey = error.MissingAnthropicKey; +pub const UnsupportedProvider = error.UnsupportedProvider; +pub const ProviderRequestFailed = error.ProviderRequestFailed; +pub const UnsupportedCompressionMethod = error.UnsupportedCompressionMethod; + +const EmitFn = *const fn (ctx: *anyopaque, delta: []const u8) anyerror!void; +const HandleDataLineFn = *const fn (allocator: std.mem.Allocator, data: []const u8, ctx: *anyopaque, emit: EmitFn) anyerror!void; + +const OpenAIChunk = struct { + choices: []struct { + delta: struct { + content: ?[]const u8 = null, + }, + }, +}; + +const AnthropicChunk = struct { + type: []const u8, + delta: ?struct { + type: []const u8, + text: ?[]const u8 = null, + } = null, +}; + +fn shiftLine(remaining: *std.ArrayList(u8), line_end: usize) void { + const remainder_start = line_end + 1; + const remainder_len = remaining.items.len - remainder_start; + if (remainder_len > 0) { + std.mem.copyForwards(u8, remaining.items[0..remainder_len], remaining.items[remainder_start..]); + } + remaining.items.len = remainder_len; +} + +fn handleOpenAILine(allocator: std.mem.Allocator, data: []const u8, ctx: *anyopaque, emit: EmitFn) !void { + var parsed = std.json.parseFromSlice(OpenAIChunk, allocator, data, .{ + .ignore_unknown_fields = true, + }) catch return; + defer parsed.deinit(); + if (parsed.value.choices.len == 0) return; + if (parsed.value.choices[0].delta.content) |content| { + if (content.len > 0) try emit(ctx, content); + } +} + +fn handleAnthropicLine(allocator: std.mem.Allocator, data: []const u8, ctx: *anyopaque, emit: EmitFn) !void { + var parsed = std.json.parseFromSlice(AnthropicChunk, allocator, data, .{ + .ignore_unknown_fields = true, + }) catch return; + defer parsed.deinit(); + if (!std.mem.eql(u8, parsed.value.type, "content_block_delta")) return; + const delta = parsed.value.delta orelse return; + if (!std.mem.eql(u8, delta.type, "text_delta")) return; + if (delta.text) |text| { + if (text.len > 0) try emit(ctx, text); + } +} + +fn processSseLine( + allocator: std.mem.Allocator, + line: []const u8, + ctx: *anyopaque, + emit: EmitFn, + handleDataLine: HandleDataLineFn, +) !bool { + if (!std.mem.startsWith(u8, line, "data: ")) return false; + const data = std.mem.trim(u8, line[6..], " \t"); + if (std.mem.eql(u8, data, "[DONE]")) return true; + try handleDataLine(allocator, data, ctx, emit); + return false; +} + +fn processSseBuffer( + allocator: std.mem.Allocator, + remaining: *std.ArrayList(u8), + ctx: *anyopaque, + emit: EmitFn, + handleDataLine: HandleDataLineFn, +) !bool { + while (std.mem.indexOfScalar(u8, remaining.items, '\n')) |line_end| { + const line = std.mem.trimEnd(u8, remaining.items[0..line_end], "\r"); + if (try processSseLine(allocator, line, ctx, emit, handleDataLine)) return true; + shiftLine(remaining, line_end); + } + return false; +} + +fn streamProviderSse( + allocator: std.mem.Allocator, + io: std.Io, + url: []const u8, + headers: []const std.http.Header, + payload: []const u8, + handleDataLine: HandleDataLineFn, + ctx: *anyopaque, + emit: EmitFn, +) !void { + var client: std.http.Client = .{ + .allocator = allocator, + .io = io, + }; + defer client.deinit(); + + const uri = try std.Uri.parse(url); + var req = try std.http.Client.request(&client, .POST, uri, .{ + .extra_headers = headers, + }); + defer req.deinit(); + + req.transfer_encoding = .{ .content_length = payload.len }; + var body = try req.sendBodyUnflushed(&.{}); + try body.writer.writeAll(payload); + try body.end(); + try req.connection.?.flush(); + + var response = try req.receiveHead(&.{}); + if (response.head.status.class() != .success) { + var error_buffer: [4096]u8 = undefined; + var transfer_buffer: [4096]u8 = undefined; + const reader = response.reader(&transfer_buffer); + const error_len = reader.readSliceShort(&error_buffer) catch 0; + if (error_len > 0) { + std.log.err("[zig] provider request failed ({d}): {s}", .{ + @intFromEnum(response.head.status), + error_buffer[0..error_len], + }); + } + return error.ProviderRequestFailed; + } + + var transfer_buffer: [4096]u8 = undefined; + var decompress: std.http.Decompress = undefined; + const decompress_buffer: []u8 = switch (response.head.content_encoding) { + .identity => &.{}, + .zstd => try allocator.alloc(u8, std.compress.zstd.default_window_len), + .deflate, .gzip => try allocator.alloc(u8, std.compress.flate.max_window_len), + .compress => return error.UnsupportedCompressionMethod, + }; + defer if (decompress_buffer.len > 0) allocator.free(decompress_buffer); + + const reader = response.readerDecompressing(&transfer_buffer, &decompress, decompress_buffer); + + var carry: std.ArrayList(u8) = .empty; + defer carry.deinit(allocator); + + var read_buffer: [4096]u8 = undefined; + while (true) { + const n = reader.readSliceShort(&read_buffer) catch |err| switch (err) { + error.ReadFailed => return response.bodyErr().?, + }; + if (n == 0) break; + try carry.appendSlice(allocator, read_buffer[0..n]); + + if (try processSseBuffer(allocator, &carry, ctx, emit, handleDataLine)) return; + } + + _ = try processSseBuffer(allocator, &carry, ctx, emit, handleDataLine); +} + +fn encodeChatPayload( + allocator: std.mem.Allocator, + model: []const u8, + system: []const u8, + messages: []const agui.ChatMessage, + provider: []const u8, +) ![]const u8 { + const OpenAIMessage = struct { role: []const u8, content: []const u8 }; + var openai_messages: std.ArrayList(OpenAIMessage) = .empty; + defer openai_messages.deinit(allocator); + + if (system.len > 0) { + try openai_messages.append(allocator, .{ .role = "system", .content = system }); + } + for (messages) |message| { + try openai_messages.append(allocator, .{ + .role = message.role, + .content = message.content, + }); + } + + if (std.mem.eql(u8, provider, "anthropic")) { + const Payload = struct { + model: []const u8, + max_tokens: u16 = 4096, + messages: []OpenAIMessage, + stream: bool = true, + system: ?[]const u8 = null, + }; + return std.json.Stringify.valueAlloc(allocator, Payload{ + .model = model, + .messages = openai_messages.items, + .system = if (system.len > 0) system else null, + }, .{}); + } + + const Payload = struct { + model: []const u8, + messages: []OpenAIMessage, + stream: bool = true, + }; + return std.json.Stringify.valueAlloc(allocator, Payload{ + .model = model, + .messages = openai_messages.items, + }, .{}); +} + +fn requireEnv(comptime name: [:0]const u8, missing: anyerror) ![:0]const u8 { + const value = std.c.getenv(name) orelse return missing; + return std.mem.span(value); +} + +pub fn streamOpenAI( + allocator: std.mem.Allocator, + io: std.Io, + model: []const u8, + system: []const u8, + messages: []const agui.ChatMessage, + ctx: *anyopaque, + emit: EmitFn, +) !void { + const api_key = try requireEnv("OPENAI_API_KEY", error.MissingOpenAIKey); + + const payload = try encodeChatPayload(allocator, model, system, messages, "openai"); + defer allocator.free(payload); + + const auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{api_key}); + defer allocator.free(auth_header); + + const headers = [_]std.http.Header{ + .{ .name = "authorization", .value = auth_header }, + .{ .name = "content-type", .value = "application/json" }, + }; + + try streamProviderSse( + allocator, + io, + "https://api.openai.com/v1/chat/completions", + &headers, + payload, + handleOpenAILine, + ctx, + emit, + ); +} + +pub fn streamAnthropic( + allocator: std.mem.Allocator, + io: std.Io, + model: []const u8, + system: []const u8, + messages: []const agui.ChatMessage, + ctx: *anyopaque, + emit: EmitFn, +) !void { + const api_key = try requireEnv("ANTHROPIC_API_KEY", error.MissingAnthropicKey); + + const payload = try encodeChatPayload(allocator, model, system, messages, "anthropic"); + defer allocator.free(payload); + + const headers = [_]std.http.Header{ + .{ .name = "x-api-key", .value = api_key }, + .{ .name = "anthropic-version", .value = "2023-06-01" }, + .{ .name = "content-type", .value = "application/json" }, + }; + + try streamProviderSse( + allocator, + io, + "https://api.anthropic.com/v1/messages", + &headers, + payload, + handleAnthropicLine, + ctx, + emit, + ); +} + +pub fn streamCompletion( + allocator: std.mem.Allocator, + io: std.Io, + config: agui.ProviderConfig, + system: []const u8, + messages: []const agui.ChatMessage, + ctx: *anyopaque, + emit: EmitFn, +) !void { + if (std.mem.eql(u8, config.provider, "openai")) { + return streamOpenAI(allocator, io, config.model, system, messages, ctx, emit); + } + if (std.mem.eql(u8, config.provider, "anthropic")) { + return streamAnthropic(allocator, io, config.model, system, messages, ctx, emit); + } + return error.UnsupportedProvider; +} diff --git a/examples/ag-ui/src/App.tsx b/examples/ag-ui/src/App.tsx new file mode 100644 index 000000000..8fca0f93a --- /dev/null +++ b/examples/ag-ui/src/App.tsx @@ -0,0 +1,311 @@ +import { useEffect, useMemo, useState } from 'react' +import { fetchServerSentEvents } from '@tanstack/ai-react' +import type { UIMessage } from '@tanstack/ai-react' +import { + Chat, + ChatInput, + ChatMessage, + ChatMessages, +} from '@tanstack/ai-react-ui' + +type Backend = 'go' | 'rust' | 'php' | 'zig' | 'bash' | 'python' +type Provider = 'openai' | 'anthropic' + +type ServerSetup = { + summary: string + installUrl: string + verify: string + run: string +} + +type ServerInfo = { + id: Backend + label: string + port: number + available: boolean + disabledByEnv: boolean + setup: ServerSetup +} + +type ServersManifest = { + generatedAt: string + servers: ServerInfo[] +} + +const PROVIDERS: Array<{ + id: Provider + label: string + defaultModel: string +}> = [ + { id: 'openai', label: 'OpenAI', defaultModel: 'gpt-4o' }, + { + id: 'anthropic', + label: 'Anthropic', + defaultModel: 'claude-sonnet-4-6', + }, +] + +function SetupInstructions({ server }: { server: ServerInfo }) { + return ( +
+
+
+

+ Setup required +

+

+ {server.label} server unavailable +

+

{server.setup.summary}

+ {server.disabledByEnv ? ( +

+ This backend is disabled on this machine via{' '} + + AGUI_DISABLE_SERVERS + + . Remove{' '} + + {server.id} + {' '} + from that variable and restart{' '} + + pnpm dev:all + + . +

+ ) : null} +
+ +
+ + +
+

Verify

+ + {server.setup.verify} + +
+ +
+

Run this backend

+ + {server.setup.run} + +
+ +
+

Refresh availability

+ + node scripts/detect-servers.mjs + +
+
+
+
+ ) +} + +export function App() { + const [servers, setServers] = useState([]) + const [serversLoading, setServersLoading] = useState(true) + const [backend, setBackend] = useState('go') + const [provider, setProvider] = useState('openai') + const [model, setModel] = useState(PROVIDERS[0].defaultModel) + + useEffect(() => { + let cancelled = false + + async function loadServers() { + try { + const response = await fetch('/servers.json') + if (!response.ok) { + throw new Error(`Failed to load servers.json (${response.status})`) + } + const manifest = (await response.json()) as ServersManifest + if (cancelled) return + setServers(manifest.servers) + if (manifest.servers.length > 0) { + setBackend((current) => + manifest.servers.some((server) => server.id === current) + ? current + : manifest.servers[0].id, + ) + } + } catch (error) { + console.error('[ag-ui] failed to load servers.json', error) + } finally { + if (!cancelled) { + setServersLoading(false) + } + } + } + + void loadServers() + + return () => { + cancelled = true + } + }, []) + + const active = + servers.find((item) => item.id === backend) ?? + servers[0] ?? + ({ + id: 'go', + label: 'Go', + port: 8001, + available: false, + disabledByEnv: false, + setup: { + summary: 'Install Go 1.22+ and ensure `go` is on PATH.', + installUrl: 'https://go.dev/dl/', + verify: 'go version', + run: 'pnpm dev:go', + }, + } satisfies ServerInfo) + + const activeProvider = + PROVIDERS.find((item) => item.id === provider) ?? PROVIDERS[0] + + const connection = useMemo( + () => + active.available + ? fetchServerSentEvents(`/api/${backend}`, () => ({ + body: { provider, model }, + })) + : null, + [active.available, backend, provider, model], + ) + + const handleProviderChange = (nextProvider: Provider) => { + setProvider(nextProvider) + const next = PROVIDERS.find((item) => item.id === nextProvider) + if (next) { + setModel(next.defaultModel) + } + } + + const subtitle = active.available + ? `${active.label} server on :${active.port} → ${activeProvider.label}` + : `${active.label} not available — setup required` + + return ( +
+
+
+
+
+

+ TanStack AI +

+

+ AG-UI Polyglot Chat +

+

{subtitle}

+
+ +
+ {(serversLoading ? [] : servers).map((item) => ( + + ))} +
+
+ + {active.available ? ( +
+ + + +
+ ) : null} +
+
+ +
+ {serversLoading ? ( +
+ Loading backend availability… +
+ ) : active.available && connection ? ( + + + Chat with {active.label} over AG-UI SSE using{' '} + {activeProvider.label}. +
+ } + > + {(message: UIMessage) => } + +
+ +
+ + ) : ( + + )} + + + ) +} diff --git a/examples/ag-ui/src/main.tsx b/examples/ag-ui/src/main.tsx new file mode 100644 index 000000000..8661d46e9 --- /dev/null +++ b/examples/ag-ui/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './styles.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/examples/ag-ui/src/styles.css b/examples/ag-ui/src/styles.css new file mode 100644 index 000000000..0599b3a3a --- /dev/null +++ b/examples/ag-ui/src/styles.css @@ -0,0 +1,11 @@ +@import 'tailwindcss'; + +@layer base { + html { + @apply bg-slate-950; + } + + body { + @apply min-h-screen antialiased; + } +} diff --git a/examples/ag-ui/tsconfig.json b/examples/ag-ui/tsconfig.json new file mode 100644 index 000000000..ff180cf6c --- /dev/null +++ b/examples/ag-ui/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "types": ["vite/client"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/ag-ui/vite.config.ts b/examples/ag-ui/vite.config.ts new file mode 100644 index 000000000..3b5ff6ad4 --- /dev/null +++ b/examples/ag-ui/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +const backendProxies = [ + { id: 'go', port: 8001 }, + { id: 'rust', port: 8002 }, + { id: 'php', port: 8003 }, + { id: 'zig', port: 8004 }, + { id: 'bash', port: 8005 }, + { id: 'python', port: 8006 }, +] as const + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 3000, + proxy: Object.fromEntries( + backendProxies.map(({ id, port }) => [ + `/api/${id}`, + { + target: `http://127.0.0.1:${port}`, + changeOrigin: true, + rewrite: (path) => + path.replace(new RegExp(`^/api/${id}/?$`), '/') || '/', + }, + ]), + ), + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d92af110..e570c8946 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,52 @@ importers: specifier: ^4.0.14 version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + examples/ag-ui: + dependencies: + '@tanstack/ai-client': + specifier: workspace:* + version: link:../../packages/ai-client + '@tanstack/ai-react': + specifier: workspace:* + version: link:../../packages/ai-react + '@tanstack/ai-react-ui': + specifier: workspace:* + version: link:../../packages/ai-react-ui + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.1.18(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.1.2 + version: 5.1.2(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + concurrently: + specifier: ^9.1.2 + version: 9.2.1 + dotenv: + specifier: ^17.2.3 + version: 17.2.3 + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.3 + version: 7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + examples/sandbox-cloudflare: dependencies: '@cloudflare/sandbox': @@ -16905,7 +16951,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.28.6(@babel/core@7.28.5) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -16918,7 +16964,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -16947,7 +16993,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 debug: 4.4.3 lodash.debounce: 4.0.8 resolve: 1.22.11 @@ -16995,7 +17041,7 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-module-imports': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -17004,7 +17050,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -17087,8 +17133,8 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -17125,8 +17171,8 @@ snapshots: '@babel/helpers@7.28.4': dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 '@babel/helpers@7.29.7': dependencies: @@ -17135,7 +17181,7 @@ snapshots: '@babel/parser@7.28.5': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/parser@7.29.0': dependencies: @@ -17167,7 +17213,7 @@ snapshots: '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-export-default-from@7.29.7(@babel/core@7.29.0)': dependencies: @@ -17177,12 +17223,12 @@ snapshots: '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': dependencies: @@ -17202,12 +17248,12 @@ snapshots: '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': dependencies: @@ -17294,13 +17340,13 @@ snapshots: '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.28.5) '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.0)': @@ -17422,25 +17468,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.0)': dependencies: @@ -17476,7 +17512,7 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) transitivePeerDependencies: @@ -17487,7 +17523,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) transitivePeerDependencies: @@ -23299,24 +23335,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/braces@3.0.5': {} @@ -23760,9 +23796,9 @@ snapshots: '@vitejs/plugin-react@5.1.2(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) '@rolldown/pluginutils': 1.0.0-beta.53 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 @@ -29134,7 +29170,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.7 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4