From d35a9982fe303138156de3a60d83058995fc95ea Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Sun, 19 Jul 2026 11:45:15 -0700 Subject: [PATCH 1/6] feat(examples): add AG-UI polyglot chat example with Go and Rust backends Introduce examples/ag-ui, a React SPA using @tanstack/ai-react-ui against hand-rolled AG-UI SSE servers in Go and Rust that stream OpenAI and Anthropic chat completions. Co-authored-by: Cursor --- examples/README.md | 34 + examples/ag-ui/.env.example | 5 + examples/ag-ui/.gitignore | 5 + examples/ag-ui/README.md | 83 + examples/ag-ui/index.html | 12 + examples/ag-ui/package.json | 33 + examples/ag-ui/scripts/dev-all.mjs | 36 + examples/ag-ui/servers/go/README.md | 35 + examples/ag-ui/servers/go/agui.go | 211 +++ examples/ag-ui/servers/go/anthropic.go | 105 ++ examples/ag-ui/servers/go/go.mod | 3 + examples/ag-ui/servers/go/llm.go | 23 + examples/ag-ui/servers/go/main.go | 79 + examples/ag-ui/servers/go/messages.go | 73 + examples/ag-ui/servers/go/openai.go | 108 ++ examples/ag-ui/servers/rust/Cargo.lock | 1451 ++++++++++++++++++ examples/ag-ui/servers/rust/Cargo.toml | 14 + examples/ag-ui/servers/rust/README.md | 35 + examples/ag-ui/servers/rust/src/agui.rs | 134 ++ examples/ag-ui/servers/rust/src/main.rs | 170 ++ examples/ag-ui/servers/rust/src/messages.rs | 77 + examples/ag-ui/servers/rust/src/providers.rs | 189 +++ examples/ag-ui/src/App.tsx | 155 ++ examples/ag-ui/src/main.tsx | 10 + examples/ag-ui/src/styles.css | 11 + examples/ag-ui/tsconfig.json | 21 + examples/ag-ui/vite.config.ts | 22 + pnpm-lock.yaml | 118 +- 28 files changed, 3211 insertions(+), 41 deletions(-) create mode 100644 examples/ag-ui/.env.example create mode 100644 examples/ag-ui/.gitignore create mode 100644 examples/ag-ui/README.md create mode 100644 examples/ag-ui/index.html create mode 100644 examples/ag-ui/package.json create mode 100644 examples/ag-ui/scripts/dev-all.mjs create mode 100644 examples/ag-ui/servers/go/README.md create mode 100644 examples/ag-ui/servers/go/agui.go create mode 100644 examples/ag-ui/servers/go/anthropic.go create mode 100644 examples/ag-ui/servers/go/go.mod create mode 100644 examples/ag-ui/servers/go/llm.go create mode 100644 examples/ag-ui/servers/go/main.go create mode 100644 examples/ag-ui/servers/go/messages.go create mode 100644 examples/ag-ui/servers/go/openai.go create mode 100644 examples/ag-ui/servers/rust/Cargo.lock create mode 100644 examples/ag-ui/servers/rust/Cargo.toml create mode 100644 examples/ag-ui/servers/rust/README.md create mode 100644 examples/ag-ui/servers/rust/src/agui.rs create mode 100644 examples/ag-ui/servers/rust/src/main.rs create mode 100644 examples/ag-ui/servers/rust/src/messages.rs create mode 100644 examples/ag-ui/servers/rust/src/providers.rs create mode 100644 examples/ag-ui/src/App.tsx create mode 100644 examples/ag-ui/src/main.tsx create mode 100644 examples/ag-ui/src/styles.css create mode 100644 examples/ag-ui/tsconfig.json create mode 100644 examples/ag-ui/vite.config.ts diff --git a/examples/README.md b/examples/README.md index 08223c876..cdd28251b 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)?** → [AG-UI Polyglot Echo (ag-ui)](#ag-ui-polyglot-echo-ag-ui) ## TypeScript Examples @@ -122,6 +123,39 @@ pnpm start --- +### AG-UI Polyglot Echo (ag-ui) + +A React SPA that connects to **Go and Rust chat servers** over the AG-UI SSE protocol, with each backend streaming OpenAI or Anthropic completions. + +**Tech Stack:** + +- React + Vite (SPA) +- `@tanstack/ai-react` + `@tanstack/ai-react-ui` +- Go chat server (`net/http`, `:8001`) +- Rust chat server (Axum, `:8002`) + +**Features:** + +- ✅ Backend picker (Go | Rust) +- ✅ Provider picker (OpenAI | Anthropic) +- ✅ Hand-rolled AG-UI SSE in two languages +- ✅ Streaming LLM responses via env API keys + +**Getting Started:** + +```bash +cd examples/ag-ui +pnpm install +cp .env.example .env +pnpm dev:all +``` + +Requires Go 1.22+, Rust stable, and provider API keys. + +📖 [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..658737af2 --- /dev/null +++ b/examples/ag-ui/.env.example @@ -0,0 +1,5 @@ +# OpenAI +OPENAI_API_KEY=your_openai_api_key_here + +# Anthropic +ANTHROPIC_API_KEY=your_anthropic_api_key_here diff --git a/examples/ag-ui/.gitignore b/examples/ag-ui/.gitignore new file mode 100644 index 000000000..f32f6f5c7 --- /dev/null +++ b/examples/ag-ui/.gitignore @@ -0,0 +1,5 @@ +dist +node_modules +servers/rust/target + +servers/go/go diff --git a/examples/ag-ui/README.md b/examples/ag-ui/README.md new file mode 100644 index 000000000..461842001 --- /dev/null +++ b/examples/ag-ui/README.md @@ -0,0 +1,83 @@ +# AG-UI Polyglot Chat + +A React SPA that talks to **Go and Rust 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` | + +## Prerequisites + +- Node.js + pnpm (from repo root) +- [Go 1.22+](https://go.dev/dl/) +- [Rust stable](https://rustup.rs/) +- OpenAI and/or Anthropic API keys + +## 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 **Go** or **Rust**, choose a provider/model, and chat. + +### Run pieces separately + +```bash +# Terminal 1 — Vite dev server (proxies /api/go and /api/rust) +pnpm dev + +# Terminal 2 — Go server (loads keys from your shell env) +pnpm dev:go + +# Terminal 3 — Rust server +pnpm dev:rust +``` + +`pnpm dev:all` loads `.env` automatically via `scripts/dev-all.mjs`. + +## Architecture + +``` +React SPA (useChat + fetchServerSentEvents) + POST /api/go ──► Vite proxy ──► Go :8001 ──► OpenAI / Anthropic + POST /api/rust ──► Vite proxy ──► Rust :8002 ──► 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/ +├── src/ React SPA +├── servers/go/ Go AG-UI + LLM server +└── servers/rust/ Rust 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/index.html b/examples/ag-ui/index.html new file mode 100644 index 000000000..d20fd4bb9 --- /dev/null +++ b/examples/ag-ui/index.html @@ -0,0 +1,12 @@ + + + + + + TanStack AI — AG-UI Polyglot Echo + + +
+ + + diff --git a/examples/ag-ui/package.json b/examples/ag-ui/package.json new file mode 100644 index 000000000..158228154 --- /dev/null +++ b/examples/ag-ui/package.json @@ -0,0 +1,33 @@ +{ + "name": "ag-ui", + "private": true, + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "vite --port 3000", + "dev:go": "go run -C servers/go .", + "dev:rust": "cargo run --manifest-path servers/rust/Cargo.toml", + "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/scripts/dev-all.mjs b/examples/ag-ui/scripts/dev-all.mjs new file mode 100644 index 000000000..31ab4b681 --- /dev/null +++ b/examples/ag-ui/scripts/dev-all.mjs @@ -0,0 +1,36 @@ +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import { config as loadEnv } from 'dotenv' + +const exampleDir = path.resolve(fileURLToPath(new URL('..', import.meta.url))) +loadEnv({ path: path.join(exampleDir, '.env'), quiet: true }) + +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +const child = spawn( + pnpm, + [ + 'exec', + 'concurrently', + '-n', + 'client,go,rust', + '-c', + 'cyan,green,yellow', + 'pnpm dev', + 'pnpm dev:go', + 'pnpm dev:rust', + ], + { + 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/servers/go/README.md b/examples/ag-ui/servers/go/README.md new file mode 100644 index 000000000..03bb1ee73 --- /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/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..8a11969cc --- /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/src/App.tsx b/examples/ag-ui/src/App.tsx new file mode 100644 index 000000000..d29306966 --- /dev/null +++ b/examples/ag-ui/src/App.tsx @@ -0,0 +1,155 @@ +import { 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' +type Provider = 'openai' | 'anthropic' + +const BACKENDS: Array<{ id: Backend; label: string; port: number }> = [ + { id: 'go', label: 'Go', port: 8001 }, + { id: 'rust', label: 'Rust', port: 8002 }, +] + +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', + }, +] + +export function App() { + const [backend, setBackend] = useState('go') + const [provider, setProvider] = useState('openai') + const [model, setModel] = useState(PROVIDERS[0].defaultModel) + + const active = BACKENDS.find((item) => item.id === backend) ?? BACKENDS[0] + const activeProvider = + PROVIDERS.find((item) => item.id === provider) ?? PROVIDERS[0] + + const connection = useMemo( + () => + fetchServerSentEvents(`/api/${backend}`, () => ({ + body: { provider, model }, + })), + [backend, provider, model], + ) + + const handleProviderChange = (nextProvider: Provider) => { + setProvider(nextProvider) + const next = PROVIDERS.find((item) => item.id === nextProvider) + if (next) { + setModel(next.defaultModel) + } + } + + return ( +
+
+
+
+
+

+ TanStack AI +

+

+ AG-UI Polyglot Chat +

+

+ {active.label} server on :{active.port} → {activeProvider.label} +

+
+ +
+ {BACKENDS.map((item) => ( + + ))} +
+
+ +
+ + + +
+
+
+ +
+ + + 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..503c2b29e --- /dev/null +++ b/examples/ag-ui/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 3000, + proxy: { + '/api/go': { + target: 'http://127.0.0.1:8001', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/go\/?$/, '/') || '/', + }, + '/api/rust': { + target: 'http://127.0.0.1:8002', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/rust\/?$/, '/') || '/', + }, + }, + }, +}) 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 From 0321dfba2451407f9bd20a1ae78ce44e1b288d37 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Sun, 19 Jul 2026 17:02:03 -0700 Subject: [PATCH 2/6] feat(examples): expand AG-UI polyglot backends Add PHP and Zig servers with runtime detection so the example can expose every available backend and guide setup for missing toolchains. Co-authored-by: Cursor --- examples/README.md | 14 +- examples/ag-ui/.env.example | 5 + examples/ag-ui/.gitignore | 2 + examples/ag-ui/README.md | 53 +++- examples/ag-ui/package.json | 4 + examples/ag-ui/public/servers.json | 57 ++++ examples/ag-ui/scripts/detect-servers.mjs | 10 + examples/ag-ui/scripts/dev-all.mjs | 54 ++-- examples/ag-ui/scripts/servers.mjs | 142 +++++++++ examples/ag-ui/servers/php/README.md | 28 ++ examples/ag-ui/servers/php/lib/Agui.php | 169 +++++++++++ examples/ag-ui/servers/php/lib/Messages.php | 85 ++++++ examples/ag-ui/servers/php/lib/Providers.php | 190 ++++++++++++ examples/ag-ui/servers/php/router.php | 68 +++++ examples/ag-ui/servers/zig/README.md | 28 ++ examples/ag-ui/servers/zig/build.zig | 23 ++ examples/ag-ui/servers/zig/src/agui.zig | 152 ++++++++++ examples/ag-ui/servers/zig/src/main.zig | 258 ++++++++++++++++ examples/ag-ui/servers/zig/src/messages.zig | 105 +++++++ examples/ag-ui/servers/zig/src/providers.zig | 297 +++++++++++++++++++ examples/ag-ui/src/App.tsx | 283 +++++++++++++----- examples/ag-ui/vite.config.ts | 29 +- 22 files changed, 1936 insertions(+), 120 deletions(-) create mode 100644 examples/ag-ui/public/servers.json create mode 100644 examples/ag-ui/scripts/detect-servers.mjs create mode 100644 examples/ag-ui/scripts/servers.mjs create mode 100644 examples/ag-ui/servers/php/README.md create mode 100644 examples/ag-ui/servers/php/lib/Agui.php create mode 100644 examples/ag-ui/servers/php/lib/Messages.php create mode 100644 examples/ag-ui/servers/php/lib/Providers.php create mode 100644 examples/ag-ui/servers/php/router.php create mode 100644 examples/ag-ui/servers/zig/README.md create mode 100644 examples/ag-ui/servers/zig/build.zig create mode 100644 examples/ag-ui/servers/zig/src/agui.zig create mode 100644 examples/ag-ui/servers/zig/src/main.zig create mode 100644 examples/ag-ui/servers/zig/src/messages.zig create mode 100644 examples/ag-ui/servers/zig/src/providers.zig diff --git a/examples/README.md b/examples/README.md index cdd28251b..4a06a8698 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,7 +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)?** → [AG-UI Polyglot Echo (ag-ui)](#ag-ui-polyglot-echo-ag-ui) +- **Polyglot AG-UI backends (Go/Rust/PHP/Zig)?** → [AG-UI Polyglot Echo (ag-ui)](#ag-ui-polyglot-echo-ag-ui) ## TypeScript Examples @@ -125,7 +125,7 @@ pnpm start ### AG-UI Polyglot Echo (ag-ui) -A React SPA that connects to **Go and Rust chat servers** over the AG-UI SSE protocol, with each backend streaming OpenAI or Anthropic completions. +A React SPA that connects to **Go, Rust, PHP, and Zig 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:** @@ -133,12 +133,16 @@ A React SPA that connects to **Go and Rust chat servers** over the AG-UI SSE pro - `@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`) **Features:** -- ✅ Backend picker (Go | Rust) +- ✅ Backend picker (Go | Rust | PHP | Zig) +- ✅ 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 two languages +- ✅ Hand-rolled AG-UI SSE in four languages - ✅ Streaming LLM responses via env API keys **Getting Started:** @@ -150,7 +154,7 @@ cp .env.example .env pnpm dev:all ``` -Requires Go 1.22+, Rust stable, and provider API keys. +Install whichever backends you want to run locally (Go, Rust, PHP, Zig) plus provider API keys. To simulate missing runtimes: `AGUI_DISABLE_SERVERS=php,zig pnpm dev:all`. 📖 [Full Documentation](ag-ui/README.md) diff --git a/examples/ag-ui/.env.example b/examples/ag-ui/.env.example index 658737af2..154165a59 100644 --- a/examples/ag-ui/.env.example +++ b/examples/ag-ui/.env.example @@ -3,3 +3,8 @@ 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 index f32f6f5c7..c03e558d6 100644 --- a/examples/ag-ui/.gitignore +++ b/examples/ag-ui/.gitignore @@ -1,5 +1,7 @@ dist node_modules servers/rust/target +servers/zig/.zig-cache +servers/zig/zig-out servers/go/go diff --git a/examples/ag-ui/README.md b/examples/ag-ui/README.md index 461842001..40fd7cd22 100644 --- a/examples/ag-ui/README.md +++ b/examples/ag-ui/README.md @@ -1,6 +1,6 @@ # AG-UI Polyglot Chat -A React SPA that talks to **Go and Rust 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. +A React SPA that talks to **Go, Rust, PHP, and Zig 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. @@ -11,14 +11,20 @@ This example shows that any backend can serve `@tanstack/ai-react` clients as lo | 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` | ## Prerequisites - Node.js + pnpm (from repo root) -- [Go 1.22+](https://go.dev/dl/) -- [Rust stable](https://rustup.rs/) +- [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) - 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: @@ -31,29 +37,48 @@ cp .env.example .env pnpm dev:all ``` -Open [http://localhost:3000](http://localhost:3000), pick **Go** or **Rust**, choose a provider/model, and chat. +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/go and /api/rust) +# Terminal 1 — Vite dev server (proxies /api/* and serves servers.json) pnpm dev -# Terminal 2 — Go server (loads keys from your shell env) +# Terminal 2+ — individual backends pnpm dev:go - -# Terminal 3 — Rust server pnpm dev:rust +pnpm dev:php +pnpm dev:zig ``` -`pnpm dev:all` loads `.env` automatically via `scripts/dev-all.mjs`. +`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 ``` The client sends AG-UI `RunAgentInput` with `forwardedProps: { provider, model }`. Each server converts simple text messages, streams the provider response, and emits: @@ -71,9 +96,13 @@ data: [DONE] ``` examples/ag-ui/ -├── src/ React SPA -├── servers/go/ Go AG-UI + LLM server -└── servers/rust/ Rust AG-UI + LLM server +├── 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 ``` ## Related docs diff --git a/examples/ag-ui/package.json b/examples/ag-ui/package.json index 158228154..c2367edf1 100644 --- a/examples/ag-ui/package.json +++ b/examples/ag-ui/package.json @@ -4,9 +4,13 @@ "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:all": "node scripts/dev-all.mjs", "build": "vite build", "preview": "vite preview", diff --git a/examples/ag-ui/public/servers.json b/examples/ag-ui/public/servers.json new file mode 100644 index 000000000..9bc68f636 --- /dev/null +++ b/examples/ag-ui/public/servers.json @@ -0,0 +1,57 @@ +{ + "generatedAt": "2026-07-19T23:53:50.649Z", + "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" + } + } + ] +} 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 index 31ab4b681..59241f1f0 100644 --- a/examples/ag-ui/scripts/dev-all.mjs +++ b/examples/ag-ui/scripts/dev-all.mjs @@ -1,32 +1,38 @@ import { spawn } from 'node:child_process' -import { fileURLToPath } from 'node:url' -import path from 'node:path' import { config as loadEnv } from 'dotenv' +import { + detectServers, + exampleDir, + logDetectionSummary, + writeServersJson, +} from './servers.mjs' -const exampleDir = path.resolve(fileURLToPath(new URL('..', import.meta.url))) -loadEnv({ path: path.join(exampleDir, '.env'), quiet: true }) +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 child = spawn( - pnpm, - [ - 'exec', - 'concurrently', - '-n', - 'client,go,rust', - '-c', - 'cyan,green,yellow', - 'pnpm dev', - 'pnpm dev:go', - 'pnpm dev:rust', - ], - { - cwd: exampleDir, - env: process.env, - stdio: 'inherit', - shell: process.platform === 'win32', - }, -) + +const names = ['client', ...available.map((server) => server.id)] +const colors = ['cyan', 'green', 'yellow', 'magenta', 'blue'].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) diff --git a/examples/ag-ui/scripts/servers.mjs b/examples/ag-ui/scripts/servers.mjs new file mode 100644 index 000000000..c2b0f4bb7 --- /dev/null +++ b/examples/ag-ui/scripts/servers.mjs @@ -0,0 +1,142 @@ +import { execSync } 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', + }, + }, +] + +function parseDisabledServers() { + const raw = process.env.AGUI_DISABLE_SERVERS ?? '' + return new Set( + raw + .split(',') + .map((item) => item.trim().toLowerCase()) + .filter(Boolean), + ) +} + +function hasToolchain(detect) { + try { + execSync(detect.join(' '), { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +export function detectServers(env = process.env) { + const disabled = parseDisabledServers() + + 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/php/README.md b/examples/ag-ui/servers/php/README.md new file mode 100644 index 000000000..0d20e9f2e --- /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 @@ + 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 index d29306966..2ac73c8f6 100644 --- a/examples/ag-ui/src/App.tsx +++ b/examples/ag-ui/src/App.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { fetchServerSentEvents } from '@tanstack/ai-react' import type { UIMessage } from '@tanstack/ai-react' import { @@ -8,13 +8,29 @@ import { ChatMessages, } from '@tanstack/ai-react-ui' -type Backend = 'go' | 'rust' +type Backend = 'go' | 'rust' | 'php' | 'zig' type Provider = 'openai' | 'anthropic' -const BACKENDS: Array<{ id: Backend; label: string; port: number }> = [ - { id: 'go', label: 'Go', port: 8001 }, - { id: 'rust', label: 'Rust', port: 8002 }, -] +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 @@ -29,21 +45,139 @@ const PROVIDERS: Array<{ }, ] +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) - const active = BACKENDS.find((item) => item.id === backend) ?? BACKENDS[0] + 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( () => - fetchServerSentEvents(`/api/${backend}`, () => ({ - body: { provider, model }, - })), - [backend, provider, model], + active.available + ? fetchServerSentEvents(`/api/${backend}`, () => ({ + body: { provider, model }, + })) + : null, + [active.available, backend, provider, model], ) const handleProviderChange = (nextProvider: Provider) => { @@ -54,6 +188,10 @@ export function App() { } } + const subtitle = active.available + ? `${active.label} server on :${active.port} → ${activeProvider.label}` + : `${active.label} not available — setup required` + return (
@@ -66,89 +204,100 @@ export function App() {

AG-UI Polyglot Chat

-

- {active.label} server on :{active.port} → {activeProvider.label} -

+

{subtitle}

- {BACKENDS.map((item) => ( + {(serversLoading ? [] : servers).map((item) => ( ))}
-
- - - -
+ {active.available ? ( +
+ + + +
+ ) : null}
- - - Chat with {active.label} over AG-UI SSE using{' '} - {activeProvider.label}. - - } - > - {(message: UIMessage) => } - -
- + {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/vite.config.ts b/examples/ag-ui/vite.config.ts index 503c2b29e..52650552b 100644 --- a/examples/ag-ui/vite.config.ts +++ b/examples/ag-ui/vite.config.ts @@ -2,21 +2,26 @@ 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 }, +] as const + export default defineConfig({ plugins: [react(), tailwindcss()], server: { port: 3000, - proxy: { - '/api/go': { - target: 'http://127.0.0.1:8001', - changeOrigin: true, - rewrite: (path) => path.replace(/^\/api\/go\/?$/, '/') || '/', - }, - '/api/rust': { - target: 'http://127.0.0.1:8002', - changeOrigin: true, - rewrite: (path) => path.replace(/^\/api\/rust\/?$/, '/') || '/', - }, - }, + 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}/?$`), '/') || '/', + }, + ]), + ), }, }) From 47a54cc469a7c0f652b4a7e1a2f4358469ad9b11 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Sun, 19 Jul 2026 17:02:15 -0700 Subject: [PATCH 3/6] docs(examples): simplify AG-UI page title Co-authored-by: Cursor --- examples/ag-ui/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/ag-ui/index.html b/examples/ag-ui/index.html index d20fd4bb9..fbfbc195f 100644 --- a/examples/ag-ui/index.html +++ b/examples/ag-ui/index.html @@ -3,7 +3,7 @@ - TanStack AI — AG-UI Polyglot Echo + TanStack AI - AG-UI
From f89218098ce78b8a2eeb7c867f6853849c56ebfb Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Sun, 19 Jul 2026 17:47:01 -0700 Subject: [PATCH 4/6] feat(examples): add Bash and Python AG-UI backends Expand the polyglot example with dependency-light servers and toolchain-aware local startup so the same AG-UI client can demonstrate both runtimes. Co-authored-by: Cursor --- examples/README.md | 12 +- examples/ag-ui/README.md | 28 +- examples/ag-ui/bash-server.png | Bin 0 -> 29506 bytes examples/ag-ui/package.json | 2 + examples/ag-ui/public/servers.json | 28 +- examples/ag-ui/python-server.png | Bin 0 -> 31586 bytes examples/ag-ui/scripts/dev-all.mjs | 30 +- examples/ag-ui/scripts/servers.mjs | 64 ++- examples/ag-ui/servers/bash/README.md | 35 ++ examples/ag-ui/servers/bash/server.sh | 424 ++++++++++++++++++ examples/ag-ui/servers/python/README.md | 28 ++ .../python/__pycache__/server.cpython-314.pyc | Bin 0 -> 20595 bytes examples/ag-ui/servers/python/server.py | 389 ++++++++++++++++ examples/ag-ui/src/App.tsx | 13 +- examples/ag-ui/vite.config.ts | 5 +- 15 files changed, 1019 insertions(+), 39 deletions(-) create mode 100644 examples/ag-ui/bash-server.png create mode 100644 examples/ag-ui/python-server.png create mode 100644 examples/ag-ui/servers/bash/README.md create mode 100644 examples/ag-ui/servers/bash/server.sh create mode 100644 examples/ag-ui/servers/python/README.md create mode 100644 examples/ag-ui/servers/python/__pycache__/server.cpython-314.pyc create mode 100644 examples/ag-ui/servers/python/server.py diff --git a/examples/README.md b/examples/README.md index 4a06a8698..9217850fb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,7 +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)?** → [AG-UI Polyglot Echo (ag-ui)](#ag-ui-polyglot-echo-ag-ui) +- **Polyglot AG-UI backends (Go/Rust/PHP/Zig/Bash/Python)?** → [AG-UI Polyglot Echo (ag-ui)](#ag-ui-polyglot-echo-ag-ui) ## TypeScript Examples @@ -125,7 +125,7 @@ pnpm start ### AG-UI Polyglot Echo (ag-ui) -A React SPA that connects to **Go, Rust, PHP, and Zig 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. +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:** @@ -135,14 +135,16 @@ A React SPA that connects to **Go, Rust, PHP, and Zig chat servers** over the AG - 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) +- ✅ 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 four languages +- ✅ Hand-rolled AG-UI SSE in six languages - ✅ Streaming LLM responses via env API keys **Getting Started:** @@ -154,7 +156,7 @@ cp .env.example .env pnpm dev:all ``` -Install whichever backends you want to run locally (Go, Rust, PHP, Zig) plus provider API keys. To simulate missing runtimes: `AGUI_DISABLE_SERVERS=php,zig 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) diff --git a/examples/ag-ui/README.md b/examples/ag-ui/README.md index 40fd7cd22..770997aaf 100644 --- a/examples/ag-ui/README.md +++ b/examples/ag-ui/README.md @@ -1,18 +1,20 @@ # AG-UI Polyglot Chat -A React SPA that talks to **Go, Rust, PHP, and Zig 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. +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` | +| 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 @@ -21,6 +23,8 @@ This example shows that any backend can serve `@tanstack/ai-react` clients as lo - [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. @@ -60,6 +64,8 @@ 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. @@ -79,6 +85,8 @@ React SPA (useChat + fetchServerSentEvents) 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: @@ -102,7 +110,9 @@ examples/ag-ui/ ├── 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/zig/ Zig AG-UI + LLM server +├── servers/bash/ Bash AG-UI + LLM server +└── servers/python/ Python AG-UI + LLM server ``` ## Related docs diff --git a/examples/ag-ui/bash-server.png b/examples/ag-ui/bash-server.png new file mode 100644 index 0000000000000000000000000000000000000000..7e488dfc3f67beb2e32e257f2cad51f4c4b20914 GIT binary patch literal 29506 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 D3d;jH literal 0 HcmV?d00001 diff --git a/examples/ag-ui/scripts/dev-all.mjs b/examples/ag-ui/scripts/dev-all.mjs index 59241f1f0..1cf64141e 100644 --- a/examples/ag-ui/scripts/dev-all.mjs +++ b/examples/ag-ui/scripts/dev-all.mjs @@ -17,15 +17,35 @@ 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'].slice(0, names.length) -const commands = ['pnpm dev', ...available.map((server) => `pnpm ${server.devScript}`)] - -const args = ['exec', 'concurrently', '-n', names.join(','), '-c', colors.join(',')] +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(', ')}`) +console.log( + `[ag-ui] starting ${commands.length} process(es): ${names.join(', ')}`, +) const child = spawn(pnpm, args, { cwd: exampleDir, diff --git a/examples/ag-ui/scripts/servers.mjs b/examples/ag-ui/scripts/servers.mjs index c2b0f4bb7..5adf7c91d 100644 --- a/examples/ag-ui/scripts/servers.mjs +++ b/examples/ag-ui/scripts/servers.mjs @@ -1,4 +1,4 @@ -import { execSync } from 'node:child_process' +import { execFileSync } from 'node:child_process' import { mkdirSync, writeFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -12,7 +12,7 @@ export const SERVER_CATALOG = [ id: 'go', label: 'Go', port: 8001, - detect: ['go', 'version'], + detect: [['go', 'version']], devScript: 'dev:go', setup: { summary: 'Install Go 1.22+ and ensure `go` is on PATH.', @@ -25,7 +25,7 @@ export const SERVER_CATALOG = [ id: 'rust', label: 'Rust', port: 8002, - detect: ['cargo', '--version'], + detect: [['cargo', '--version']], devScript: 'dev:rust', setup: { summary: 'Install Rust stable via rustup and ensure `cargo` is on PATH.', @@ -38,7 +38,7 @@ export const SERVER_CATALOG = [ id: 'php', label: 'PHP', port: 8003, - detect: ['php', '-v'], + detect: [['php', '-v']], devScript: 'dev:php', setup: { summary: 'Install PHP 8.2+ CLI and ensure `php` is on PATH.', @@ -51,7 +51,7 @@ export const SERVER_CATALOG = [ id: 'zig', label: 'Zig', port: 8004, - detect: ['zig', 'version'], + detect: [['zig', 'version']], devScript: 'dev:zig', setup: { summary: 'Install Zig and ensure `zig` is on PATH.', @@ -60,10 +60,42 @@ export const SERVER_CATALOG = [ 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() { - const raw = process.env.AGUI_DISABLE_SERVERS ?? '' +function parseDisabledServers(env) { + const raw = env.AGUI_DISABLE_SERVERS ?? '' return new Set( raw .split(',') @@ -72,17 +104,19 @@ function parseDisabledServers() { ) } -function hasToolchain(detect) { - try { - execSync(detect.join(' '), { stdio: 'ignore' }) - return true - } catch { - return false - } +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() + const disabled = parseDisabledServers(env) return SERVER_CATALOG.map((server) => { const disabledByEnv = disabled.has(server.id) 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/python/README.md b/examples/ag-ui/servers/python/README.md new file mode 100644 index 000000000..0300ad7c6 --- /dev/null +++ b/examples/ag-ui/servers/python/README.md @@ -0,0 +1,28 @@ +# Python AG-UI server + +A dependency-free AG-UI server built with Python's standard library. It uses +`ThreadingHTTPServer` for HTTP and `urllib.request` to stream OpenAI or +Anthropic responses. + +## Prerequisites + +- Python 3.9+ +- An OpenAI or Anthropic API key + +## Run + +From `examples/ag-ui`: + +```bash +OPENAI_API_KEY=... pnpm dev:python +``` + +The server listens on `http://127.0.0.1:8006`. 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/python/__pycache__/server.cpython-314.pyc b/examples/ag-ui/servers/python/__pycache__/server.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4b1b88807d4861b4f46c3d0353664c1cd245cefe GIT binary patch literal 20595 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 literal 0 HcmV?d00001 diff --git a/examples/ag-ui/servers/python/server.py b/examples/ag-ui/servers/python/server.py new file mode 100644 index 000000000..cba8936cd --- /dev/null +++ b/examples/ag-ui/servers/python/server.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 + +import json +import os +import sys +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Callable, Iterable + +HOST = "127.0.0.1" +PORT = int(os.environ.get("PORT", "8006")) +DEFAULT_OPENAI_MODEL = "gpt-4o" +DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6" + + +def text_from_message(message: dict[str, Any]) -> 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/src/App.tsx b/examples/ag-ui/src/App.tsx index 2ac73c8f6..8fca0f93a 100644 --- a/examples/ag-ui/src/App.tsx +++ b/examples/ag-ui/src/App.tsx @@ -8,7 +8,7 @@ import { ChatMessages, } from '@tanstack/ai-react-ui' -type Backend = 'go' | 'rust' | 'php' | 'zig' +type Backend = 'go' | 'rust' | 'php' | 'zig' | 'bash' | 'python' type Provider = 'openai' | 'anthropic' type ServerSetup = { @@ -63,8 +63,15 @@ function SetupInstructions({ server }: { server: ServerInfo }) { AGUI_DISABLE_SERVERS - . Remove {server.id}{' '} - from that variable and restart pnpm dev:all. + . Remove{' '} + + {server.id} + {' '} + from that variable and restart{' '} + + pnpm dev:all + + .

) : null} diff --git a/examples/ag-ui/vite.config.ts b/examples/ag-ui/vite.config.ts index 52650552b..3b5ff6ad4 100644 --- a/examples/ag-ui/vite.config.ts +++ b/examples/ag-ui/vite.config.ts @@ -7,6 +7,8 @@ const backendProxies = [ { 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({ @@ -19,7 +21,8 @@ export default defineConfig({ { target: `http://127.0.0.1:${port}`, changeOrigin: true, - rewrite: (path) => path.replace(new RegExp(`^/api/${id}/?$`), '/') || '/', + rewrite: (path) => + path.replace(new RegExp(`^/api/${id}/?$`), '/') || '/', }, ]), ), From 87d492ab76653c894484b6d8db0a7713684eed35 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Sun, 19 Jul 2026 17:47:36 -0700 Subject: [PATCH 5/6] chore(examples): ignore Python bytecode Keep generated interpreter caches out of the AG-UI example sources. Co-authored-by: Cursor --- examples/ag-ui/.gitignore | 1 + .../python/__pycache__/server.cpython-314.pyc | Bin 20595 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100644 examples/ag-ui/servers/python/__pycache__/server.cpython-314.pyc diff --git a/examples/ag-ui/.gitignore b/examples/ag-ui/.gitignore index c03e558d6..25bdc3ee9 100644 --- a/examples/ag-ui/.gitignore +++ b/examples/ag-ui/.gitignore @@ -3,5 +3,6 @@ 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/servers/python/__pycache__/server.cpython-314.pyc b/examples/ag-ui/servers/python/__pycache__/server.cpython-314.pyc deleted file mode 100644 index 4b1b88807d4861b4f46c3d0353664c1cd245cefe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20595 zcmd6Pdu&_Rndc=hAD3_H{h}mVl4VnlB){T!olz{wmhH&uwW6enY?c;jo3Tu?my{oB zwiP;quGDR7W!KrTg=fO-x`>VOZakQF;@#~6&DK+N7V`&XDG_sHpx&7PivTl#JhJQ7 zDHi+t&gH$Ak|{ae*#dI}oqO(i-Sha)_deXmp^r-`PeaPU-i;|Ed-5q0dz#tP*k?IlO<4~JDWQ?-^L@4h_LTjABjq^YOgS4l z(Ufu>bFbl!83r2E&*{&BHF{c2iDuEV)u_B}#ds*BNk2Wc-$SBWJ^m$G!VScY^tOV@}MNLR9StyqP0HA~l3aBDcR2Jf}U ztXui~*L`9ga(t{t{jmnIe#grk7sh(}xMTHexUEVx4zo2hv;RQDXO!0;Fv^wBhmw&4 zgM$NNaD79NdAa@pW%DG~}tW2dO_Xhb>}kpjHTcf~Hs!oJYxXz0XfL>Br| z5s6acwoRM2cWlJpCfPJ_L>!b&J2r0IHf}kUNVRW`kC%*$hQ`8?_GCO3iy)_c+X$^) ze*HKT(lnbz5zz!U#2q*8_!Gj-+HhnUW+ksss$OIwDD(2`TWvx{A)+zqU;O?L$O#q6-q^dL8%O1F2R4Y3DE@ih^scZ z?^ARob#>p3vdn=${l)^l{r-qq57prt#-igf zsN)fh1i<Xg=!&=G)t19Fa~4+k`YZHxkuL<6e&eAC$Jw|BF2N^^L%cTIQkt!3 z8gQt6>dztVi_I&ou2v&qDj2_M-Kbf4e4@FE8!~ydqpz+Hjz9Vz zGdAKsVT^JCi)>DWq*PKir7k8Sh$9zLvUMaLOGRQSc_~!U&SE#X#lJ(;1avaN4RXC$ zJHU?yUw|JvYAmd<2Lu6z0Xl8Vf)0_QVHpGz^k1~420KrMKY{?DlR^l%z)hGNxkINE zC=h`#p5>#4054TxfKoN0{(wogN0U)H^s$kMGXedD(ut@bN zlYEF|(`Xbpk><&!D4Sl3M`HoAxT$1*+)sLVoLCF*ImReDR zp|^iPVuH&#%Pt+7I+V7~I{gzpcdaGUYp;~gTO00J8}3`UnubNQr4s8pFhS&wIDhI{2^-HJ6S|9{ISse!7Jc7kQRr*@-bLIfwRw-qHTEpwpRzdC06% zraWal3QT4adB*HvIfybBrB!AX`vb?FCHhyamS{jF7EF?||n*Rl4FV=WH(^w!A>lb*jXtDCeE zMqYPy>%3?E9nboYo#ppDm6yIg_4V|&Sx?iXDd+G^@1Aoss(osBcj#*FDB=h}NtFGGlxnexoVn=RSu?H@UJ+;8T} zeG9Fev-HyLsom4B&pKK%;jE)AYiiTaf=VT6+R9mAlUf~r=%+%5@TF%T7zX_);pw#r zQ(Wg2Okt145@V@gIFt%8Tp_v9%W62`5XzHnhzKXxi0wsmwfySHjV+neKWbQ@*B^KP zwCtxNx2k{A&r0>_DYAtW>}F0)DFlp7MaG!!g55C z?-2{w$w6u{%5KOa=R;CB5+0D^32DF1tnu%_z(sIQ#Z>_&$tJy>)|^~hJc@W&;;)-V9FL?@--%n{Eomv{H{cD;Cb zFnDC3r@yN&_`;Fyp2KpLn%mVsctAWd(6=vN;1tJVsp#2Ak0iwEhJIbZzG4Sx?8rft<7C zQr}eH^r^Ho>uj3nQOFrT?`XN>XnF4d5hvj;NL=K~(Rs(3JB~FuZ|S7@o|Qiw7kGO8)Kzl1`Eqi4{r8S9m?`6dg>#o`Syq-Ma2EG>!Y22{g_ zB%X`L?Q|9p5_}!^l+kE7LTN$q5P$rF$ZyY&eYYWK7&DIXK}{h-Y&V#E$XCQz575pm z3%W95fFzh@K{Tmsa>6oX87Ny;j8{=(yO(0kLqH}&7SU1&M+xhYRkSj^2>F_@rYO*# zs^O*gW)AD1k-Y(uf0hNTuvHtCFig@o3xmQiG3G23$K;2;&2fVQ)F1|k2;a>!{Y6IL zFB}0W0hR>|7Y2CK`0KetH#siK?dI#bsKL)|;Ivi1p7^*ZR8aM(M}IUowO+wa`<8W% z*J3()eA7=oCTzoN)E1&`&^FMdzUfcU54%o325qN}qL8pXXNwkSmj{Y{w?*$mP2szZ zPkh&b@3yKn^rw^CtigWaSJm}l>L%x7^H}O+`wr5d9~}gfM?9Q#7?Er_8-c<&4CSRKnqx6s^AhWGFfc`nFy+1Xe8F zeWkHH06Y*FQW(g^DJ~$eUL7_=8TS?ghIWZXtOD9k_NlZp- zt0Ed|iyUir=| zP++u65Ut+?exLoRh{!y+h(e^-8?s2wRhT-tEyuK7v`%r z-l^L7ak&pU>F3|PfTFUh^p3f*=3II0BM;}^HRIa3;N?r|@7Aw*`;{NQa?Lbb-#*<7 zDpB5$K6y1fU$$YcY(uWrfBEqJ8m@lrBVK9qn+N4wwQs&^!%WqN2b`g#UWU-g#}%SKM{i=G^P9ncuTtw`X3zxhd;>7HojWe$P|B zU^dz+KF7D!M*bOxAHCCBxNEcZ+is4%|N39NelwP>>zQ@;{<0@4eED;H73IH6zJ>Mw zR+qJRm*E3@pnEU(!Imy>cboa=ynvUVJB%p!xx2F4ZTWdi8RhR05Z^XY>1`*K-gZ}X zue055<|)6`Kqh~`c+#B(d-Ss2gBbTNuT#S}RkP4zRD<$9iFAtb6uO@w1sla;=Q@}!7}SeCVh z+WQpUb+{LJJU?}M7R-182s~w%w_f3|98C*v?)}i+v|y!dfpe5y-Z%Yhx@CIzhxWz= z8!{K{oW*|Mft13rXlsLSXsbO6YBEupKbrKzoa;}* zLbL%xP!1V!WaDk8Q>jF9S7&D^5$#aq&5n`y+0JvDI!QCxi8R#csVE4U?CKf=-It={ zA$lLTJ|7855y}79xlb|BwGYB*_t*>F=xB6=)#)T6t(&qOZD z9%WLRiWS8D9E^$5Io7W-4Cy?5iP?+juz(UTCB?2hDOS3YzKJ>vzc@gIL4uteOfIiY zy+&j>TiDbbUx=bbfz7^WtTuF7hFu-L=1DV^uDx15?`gf`X{FNAs={s|p`W^voOd+OIhx8kI)u-J)FJSkplh%tjDoA?(_hNQC?q!dTA8e#bf$~D5$Y^b`=A!39DPexeqEJj!; z`w7B=%sgR1>Pf-^#h60I7Z{}J#P<(LSVSJE9>hVkK%E9M&}mviR+vcjQiW(E$wDVW zq8%i{Bsz&eh@MV<$Xq}q-du%9Xb4y=*@1JIw>yzlzBo}SRZ(HWI;@d45EYP0B`hZa zc|vF{imdgomlb^d6@0yT{c`Q=Lc!OCVX`E!u>9+J0`$k|QCNoDfkPN_7t}t1+`(Xy z?}u0cOVuTB>(D)@_?-vwwJz6m0qCeUs1%+IkULY z0n*TU-tv$~=GtVWZ=(`B)}JBq1U}z*vuW1-td7rj&boJHg7iMNFYAAH&hae6&J}Oj-msMtn0=gXpLwbDrL9}@3##}|q9$`q3oA9L4;G%RsoPNqxkK+{()CWT zTbQLkLc@OPdBoB_inKSq=WO7M5dSIaZcqWjO-NfnP1^ z2rP&EYX$PIMUIW-ke97MKDNlQvmElZ70A;TIgq6Ab@I3s$ln$@PL@M{w*qQ<(Y!P4xIlWT&P^SgmWccs7`0*Idkh3fPr9uhHM|O-L2Muau#?ve z2Fx!;Hie{9iW?1_aTO~8K`PRube3Jg;K(Q(B*;lM7-YU>O%rUxnFozrT`ie(L2ox^te949yh}*Q#l{%E}|W?SX$vly7)3){@#GC9O{s3N=8Ocvh|Rd5J1PS zwY5!wd&g+}lqzb-RfWq@P{3BB5zSyP6C9W9Q}%hGdIkW$Gc)v~W0@m2H^b2Nk+A(q z4E@jBW134SHI`J|Qd}4Sxm@#6NJC%{)LoXokMt9JD_uhFB9)Lp#?j@516n#>=kYb*q_OV9e!;8KHGQpGH7Hqv?;0JApKm`RMNdUz zvZ1XXl=D^eE4z|#X9{W>YX^H(tcMIXWPTJ{bq7^aZHngeC!xfbE~7aN$l&lf=?4_i z3}w@ac=)1XKZY!=oDCc6l*TSs6rKz%n}HYcZt(tq2h%sEzL7pP>uH4tli-{eYG;Jn z^tL&niM_G&(vS^w&j~$RMz$t!W6O=?&8>fa@z3|p3A+jkw%y|AggsiuoKTB&!O<)K zNN9NSyb&5A7+$2?7%Sma(b#Fwe2kd7vj6%QI847om&tNO>@Zo(b@13U*QLAJLmvt% zuwX*Wj!zB=Lhb`NM$5zTzf}R30bAkm%U2~^<`RG{RtKcvsdWnOovipR2#-tpDdc+c zje4j&xM5&;Zrs`lBsL0DpfP@?Kl&zSJYK5UG?|(d*NNiiBVnTTi4YkKlTc`; zq#t2g(tk!&2qPB2!v3B?xpWzPAdjsi{AUR^93GPFEMKwgiZRI|ABYjPCHMcJ3~l^4d#aX9L4E! z<*5$@by&!&_rRIqWQVwreS$m64XC9B&)*vWKn21K0MK6%tRWV7nSnsU?*-ln;1QL8 zHpFZ!3&rdp#ct2x`iMrP2Q;;g-q{+OBk@biLNTukomo-wGJM+V)P{q`VV{~-@ua3A zT5a}A@G8xIu(%VoP5UapLJiHXfNMeexcFhBcPuO)R~Q5IafAA`B=E72QuVf`RoX%u zgSNVLYxOxg)Nd<0tou`}Fc+sfTlIM!C{h~2ZZ)g3(VsfqLHSxeUrjMJrfrZH?Sl}L z)E*nTL5sFSXyqeVP&3q~cz$IoY+rfG+DLT*1%>&fJ_6c7(L2bjZE^dG<0%nKkJ`mD zvAl3Rfcp(<--DVRkj?zUaJyDw9TW!5gO)-2Ap98VHs`3YHGi(y_=>MUC4I5b1%Jh{ zRl&!Ft(90U)^uv#NMCI3er}lfAQlSFUJ;L;VN&X`u!V=m3y>9g_h3}{xu~V|B-OPJ zLG=tvs2(o(VuxB6{q#{k2^;#^q~_{Rv3Ag=_k%h`_j{P+ITlbmkEp*m1kmWn^M^qK zOGORpSOay^U!r5#&>6ic&h3talZa4|q#sDI=`1cgeVW`nzQ( zt$%M{f8WsqJ$O4AjYX5EMdot^Ekr7w7+O$e{~TLcQc zVqf5Ig?_O=gw_-=eU4IH6rG^xuMs6l0zXMN{#f`UMfS9UO{1-n%ZltDV|-46Sp0Tn zPOWUt@r=%?Ip&;0MXcYW{p=GSbRS+gm-`T3vin_Y8Yw!Uw&|87nF z+qFNe&2+ze@STJ29(m`;&HmYzeX}*)lLw(D_SUA`;QZv-aL2Pj>pSb;GUwci&hM2s zyw(3ke`eQgdB>zJS5o&@!y645>ukw-#e^HUdUW0sxZ?@DUvs-H=PbRv?-6g@XR!Yh zNAdqIfw3XaSZ4iQbI#|{<)?KUt{r@@|9bzeU9->NPUe0nohB`LesfN!)&!26t8DsM z=HN}+t?fVS%{mV~;Egu>BZ2c)qtkh(f5z$0d26!G2T|a1!xRFavm;YS=A893aCm|B zrguySC+%8?_q>(a>SuD^y7VhBH>Vu{ewQ1-4-<&D!PaoMVbjgF*@iDo9hp3E*IA## zcf1v-h{QvqrKI$M!->z-;xqG3-;C3jE2+xXY`FI7t+uSUM<0Vyc(1%R-JdIO$k=k_ zb?Kp8c~hnrb{X`-ThWU*KiH3)^}l)0WyB@bE8X+HjWfQDbDO)fo}LGs(N7T8fs-* za~$O>>KM{cgzcFVzN8(ubmO(QIpJCD{Rt&R+Lt;1R1Jd(-y|})NUpytX}`##K=uUu z!|P2b z%?Xtob(?l zx=ayM@|sVMjwMfn<9U;^Xgg%{Nht)*hcP>OJ*{+`3YOR|3iu_qC;L$Yz*t&+rJRws zo?GU*n!VZT=WeC4-tI{=;I2TmTJg5;2foay*_sXo;`E}TdM#oD8PpMjxEjROFu`f>f)EKfU1gDG@H^4D#~?*tk5#XjV%Ff0K0>1b14QY zQ1P?_7c7mXVMHB2u5@@uaGX zAd2lpf=vpi8qejSxSDFoxn14_@5O{q;*Kdmbz#7{H6}@sq}-2CZbjJ1!dP%X{|@C( z5QRI)T&j82*=aU^5-}K6(9cx1=9xL6eaQ(_j~UYd_XoVP5Ddm}?1Mqs9t`4s;@Bvq z@vA>y8w-u*OFY5g$tZ45(2a#y9L02FHx(a=j|TJ3Ka6)*#Ekc7^zTzNPf?bl+Y}L? zNo*PwFc`!77##_QQn-?FVk{L&qSulFlk!MpbTk;mG5tPTF(s?Qy-WXwdZFkaso*rC z-~4Nuch#D#sr-=%He_Z&eq@2+kr^Q$m4bu8?_HSd9(kd?ULY0rBPS^{7nn-((Xdgk z0P3+oB;=7Dr+9&m`lD*V(62Q>VxKO~g!wR*;+A&BDpbINt#YyV9KkY)zGNMD7@&7W zG#O?O1(Z>y1NQRuc;@G}%-7uPS{!}l9M4x!&#YlMy! z$7WR`g>v|!EDr^T0h%u8UI<&U0Nsm8(c#o{hZ|LmhaW%WvwA;j&478lUbEWMPsBuG z_~|QuJmx3oHh4A5hFyaIC^}ta>%_-kn>+wPP$DwJHWoiQB*6quCQ$qwRuGrg!Q@GD z$)U)_6LDO2@57G|Nzzy%gd)E$T)ZH%QsTjPG-TWxl13Kd9!6fFq<24f_q-@%?Q3Z zpQ|S;iVzUa_6(udsq%y z;0|R0Btovr+FJ0tP1phDK05&_yIU&#no>*`p}Y984d|hQUW!^NB1loLH#23y8G8LD zMc<}qiXw&!Xo01Fpy)b9GZJWS7r^YN-o~v!oa#as4 z_@T3h+whYw5BK7?A0AfY%KgJuKRCySy$0M3c{pHzBk#k*266;z=`|YavZd=EaFlo` dnsBNYe4NQSIX=hNd~6cF-Fu(sOp0yq{{o+(u*?7e From 348caf2c0f92539ed1f71c2d56d613f37acc1100 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Sun, 19 Jul 2026 19:47:17 -0700 Subject: [PATCH 6/6] style(examples): format AG-UI server docs Apply the repository Markdown table formatting so the autofix check remains clean. Co-authored-by: Cursor --- examples/ag-ui/servers/go/README.md | 6 +++--- examples/ag-ui/servers/php/README.md | 10 +++++----- examples/ag-ui/servers/rust/README.md | 6 +++--- examples/ag-ui/servers/zig/README.md | 10 +++++----- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/examples/ag-ui/servers/go/README.md b/examples/ag-ui/servers/go/README.md index 03bb1ee73..b7371df0a 100644 --- a/examples/ag-ui/servers/go/README.md +++ b/examples/ag-ui/servers/go/README.md @@ -27,9 +27,9 @@ Supports simple `user` / `assistant` / `system` messages with string `content` o ## Environment -| Variable | Required when | -| --- | --- | -| `OPENAI_API_KEY` | `provider: openai` | +| 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/php/README.md b/examples/ag-ui/servers/php/README.md index 0d20e9f2e..72e5ce444 100644 --- a/examples/ag-ui/servers/php/README.md +++ b/examples/ag-ui/servers/php/README.md @@ -19,10 +19,10 @@ Requires PHP 8.2+ with the `curl` extension enabled. ## Endpoints -| Method | Path | Description | -| --- | --- | --- | -| POST | `/` | AG-UI chat (SSE) | -| GET | `/health` | Health check (`ok`) | -| OPTIONS | `/` | CORS preflight | +| 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/rust/README.md b/examples/ag-ui/servers/rust/README.md index 8a11969cc..93055d0c2 100644 --- a/examples/ag-ui/servers/rust/README.md +++ b/examples/ag-ui/servers/rust/README.md @@ -27,9 +27,9 @@ Supports simple `user` / `assistant` / `system` messages with string `content` o ## Environment -| Variable | Required when | -| --- | --- | -| `OPENAI_API_KEY` | `provider: openai` | +| 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/zig/README.md b/examples/ag-ui/servers/zig/README.md index 3ca5537ef..1bd95c8f5 100644 --- a/examples/ag-ui/servers/zig/README.md +++ b/examples/ag-ui/servers/zig/README.md @@ -19,10 +19,10 @@ Requires Zig with stdlib HTTP client support (tested with Zig 0.16). ## Endpoints -| Method | Path | Description | -| --- | --- | --- | -| POST | `/` | AG-UI chat (SSE) | -| GET | `/health` | Health check (`ok`) | -| OPTIONS | `/` | CORS preflight | +| 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.