diff --git a/docs/.env.example b/docs/.env.example new file mode 100644 index 000000000..cdd0ee808 --- /dev/null +++ b/docs/.env.example @@ -0,0 +1,9 @@ +# Hosted workloads are available as soon as their dedicated key is non-empty. +# Use a separately named OpenUI Cloud key for every workload so usage is attributable by key. +THESYS_API_KEY_DOCS_CHAT_OSS= +THESYS_API_KEY_DOCS_CHAT_CLOUD= +THESYS_API_KEY_DOCS_COMPARE_MARKDOWN= +THESYS_API_KEY_DOCS_COMPARE_OSS= +THESYS_API_KEY_DOCS_COMPARE_CLOUD= +THESYS_API_KEY_DOCS_PLAYGROUND= +THESYS_API_KEY_DOCS_GITHUB= diff --git a/docs/README.md b/docs/README.md index 332d32d37..eae38dab1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ pnpm --filter @openuidev/docs dev pnpm --filter @openuidev/docs build ``` -### Chat and comparison demo configuration +### Hosted demo configuration `/chat` remains the standalone OpenUI OSS and Cloud chat and starts in **OpenUI OSS** mode. Its selected mode is not stored across reloads. @@ -25,25 +25,34 @@ selected mode is not stored across reloads. `/compare` compares two visible response modes at a time and defaults to **Rendered Markdown vs OpenUI Cloud**. Use its page-level switcher to show **Markdown vs OSS** or **OSS vs Cloud**. The selected pair is stored in the `pair` query parameter. All three comparison providers remain -mounted and receive each shared prompt; switching the visible pair resets the demo. Markdown and -OSS generation use the existing server-side `OPENROUTER_API_KEY`. +mounted and receive each shared prompt; switching the visible pair resets the demo. -OpenUI Cloud requires the following server-side variables. If either is missing, Cloud requests -show the unavailable state at runtime: +All hosted demo generation uses OpenUI Cloud as the LLM provider. The OSS, Markdown, playground, +and GitHub workloads use Cloud's OpenAI-compatible Chat Completions endpoint while the managed +Cloud surfaces use its Responses endpoint. Each workload has a dedicated server-side key for +independent usage attribution: ```bash -OPENUI_CLOUD_DEMO_ENABLED=true -THESYS_API_KEY=your-cloud-key +THESYS_API_KEY_DOCS_CHAT_OSS=your-chat-oss-key +THESYS_API_KEY_DOCS_CHAT_CLOUD=your-chat-cloud-key +THESYS_API_KEY_DOCS_COMPARE_MARKDOWN=your-compare-markdown-key +THESYS_API_KEY_DOCS_COMPARE_OSS=your-compare-oss-key +THESYS_API_KEY_DOCS_COMPARE_CLOUD=your-compare-cloud-key +THESYS_API_KEY_DOCS_PLAYGROUND=your-playground-key +THESYS_API_KEY_DOCS_GITHUB=your-github-key ``` -Do not expose `THESYS_API_KEY` through a `NEXT_PUBLIC_*` variable. The browser generates an -anonymous user ID, retains it in `sessionStorage`, and sends it with Cloud requests. Active -comparison threads are not restored after a refresh. +There is deliberately no shared-key fallback: if a workload's key is missing, that workload shows +the unavailable state instead of charging another demo's key. Do not expose any +`THESYS_API_KEY_DOCS_*` value through a `NEXT_PUBLIC_*` variable. The browser generates an anonymous +user ID, retains it in `sessionStorage`, and sends it with managed Cloud requests. Active comparison +threads are not restored after a refresh. -The Cloud feature flag is intentionally fail-closed. Keep it disabled on public deployments until -a shared, cross-instance session-and-IP rate limiter, Cloud organization budgets/token scopes, and -an approved conversation retention/deletion process are in place. Same-origin validation is an -additional browser safeguard, not a substitute for those cost controls. +Hosted demos are always enabled; there is no global feature flag. Before deploying them publicly, +configure a shared, cross-instance session-and-IP rate limiter, Cloud organization budgets/token +scopes, and an approved conversation retention/deletion process. The managed Responses and +frontend-token routes also enforce same-origin validation as an additional browser safeguard, not +a substitute for those cost controls. ## Project structure @@ -78,12 +87,15 @@ docs/ │ ├── blog/ # Blog pages │ ├── chat/ # Standalone OpenUI OSS and Cloud chat │ ├── compare/ # Pairwise Markdown, OSS, and Cloud comparison -│ ├── demo/ # Demo route -│ ├── playground/ # Interactive playground +│ ├── demo/ # Individual hosted demos +│ ├── demos/ # OpenUI-vs-JSON playground │ │ │ ├── api/ # API routes +│ │ ├── _lib/ # Shared hosted-demo handlers │ │ ├── search/route.ts # Search endpoint -│ │ ├── chat/route.ts # Chat API +│ │ ├── chat/route.ts # Standalone OSS chat API +│ │ ├── compare/ # Markdown and OSS comparison APIs +│ │ ├── openui-cloud/ # Managed chat/comparison + token APIs │ │ ├── demo/github/stream/route.ts # GitHub demo stream │ │ └── playground/stream/route.ts # Playground stream │ ├── og/docs/[...slug]/route.tsx # OG image generation diff --git a/docs/app/api/_lib/chat-completions-handler.ts b/docs/app/api/_lib/chat-completions-handler.ts new file mode 100644 index 000000000..a7a4545fb --- /dev/null +++ b/docs/app/api/_lib/chat-completions-handler.ts @@ -0,0 +1,493 @@ +import { + createDemoCreditsExhaustedResponse, + isDemoCreditsExhaustedError, +} from "@/lib/demo-credits"; +import { readOpenuiCloudConfig, type DocsDemoWorkload } from "@/lib/openui-cloud/config"; +import { unavailableResponse } from "@/lib/openui-cloud/errors"; +import { readFileSync } from "fs"; +import { NextRequest } from "next/server"; +import OpenAI from "openai"; +import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs"; +import { join } from "path"; + +const openUiSystemPrompt = readFileSync( + join(process.cwd(), "generated/chat-system-prompt.txt"), + "utf-8", +); + +const markdownSystemPrompt = `You are a helpful assistant. Respond using clear, well-structured GitHub-Flavored Markdown. + +Use headings, lists, tables, links, block quotes, and fenced code blocks when they make the response easier to understand. + +Return only Markdown content. Do not emit OpenUI Lang, component syntax, JSON UI descriptions, or instructions for a renderer.`; + +type ResponseMode = "markdown" | "openui"; +const TOOL_NAMES = ["get_weather", "get_stock_price", "search_web"] as const; +type ToolName = (typeof TOOL_NAMES)[number]; +const TOOL_NAME_SET = new Set(TOOL_NAMES); + +interface ChatRequestBody { + messages: unknown[]; + toolNames?: ToolName[]; +} + +function invalidRequest(message: string) { + return Response.json({ error: { message } }, { status: 400 }); +} + +function readHttpStatus(error: unknown): number | undefined { + if (!error || typeof error !== "object" || !("status" in error)) return undefined; + const status = (error as { status?: unknown }).status; + return typeof status === "number" && status >= 400 && status <= 599 ? status : undefined; +} + +function upstreamErrorResponse(error: unknown): Response { + const status = readHttpStatus(error); + if (isDemoCreditsExhaustedError(error, status)) { + return createDemoCreditsExhaustedResponse(); + } + + const message = error instanceof Error ? error.message : "OpenUI Cloud request failed"; + return Response.json({ error: { message } }, { status: status ?? 502 }); +} + +function parseRequestBody(body: unknown): ChatRequestBody | Response { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return invalidRequest("Request body must be a JSON object"); + } + + const { messages, toolNames } = body as Record; + + if (!Array.isArray(messages)) { + return invalidRequest("messages must be an array"); + } + + if ( + toolNames !== undefined && + (!Array.isArray(toolNames) || + !toolNames.every((toolName) => typeof toolName === "string" && TOOL_NAME_SET.has(toolName))) + ) { + return invalidRequest(`toolNames must contain only: ${TOOL_NAMES.join(", ")}`); + } + + return { + messages, + toolNames: toolNames as ToolName[] | undefined, + }; +} + +// ── Tool implementations ── + +function getWeather({ location }: { location: string }): Promise { + return new Promise((resolve) => { + setTimeout(() => { + const knownTemps: Record = { + tokyo: 22, + "san francisco": 18, + london: 14, + "new york": 25, + paris: 19, + sydney: 27, + mumbai: 33, + berlin: 16, + }; + const conditions = ["Sunny", "Partly Cloudy", "Cloudy", "Light Rain", "Clear Skies"]; + const temp = knownTemps[location.toLowerCase()] ?? Math.floor(Math.random() * 30 + 5); + const condition = conditions[Math.floor(Math.random() * conditions.length)]; + resolve( + JSON.stringify({ + location, + temperature_celsius: temp, + temperature_fahrenheit: Math.round(temp * 1.8 + 32), + condition, + humidity_percent: Math.floor(Math.random() * 40 + 40), + wind_speed_kmh: Math.floor(Math.random() * 25 + 5), + forecast: [ + { day: "Tomorrow", high: temp + 2, low: temp - 4, condition: "Partly Cloudy" }, + { day: "Day After", high: temp + 1, low: temp - 3, condition: "Sunny" }, + ], + }), + ); + }, 800); + }); +} + +function getStockPrice({ symbol }: { symbol: string }): Promise { + return new Promise((resolve) => { + setTimeout(() => { + const s = symbol.toUpperCase(); + const knownPrices: Record = { + AAPL: 189.84, + GOOGL: 141.8, + TSLA: 248.42, + MSFT: 378.91, + AMZN: 178.25, + NVDA: 875.28, + META: 485.58, + }; + const price = knownPrices[s] ?? Math.floor(Math.random() * 500 + 20); + const change = parseFloat((Math.random() * 8 - 4).toFixed(2)); + resolve( + JSON.stringify({ + symbol: s, + price: parseFloat((price + change).toFixed(2)), + change, + change_percent: parseFloat(((change / price) * 100).toFixed(2)), + volume: `${(Math.random() * 50 + 10).toFixed(1)}M`, + day_high: parseFloat((price + Math.abs(change) + 1.5).toFixed(2)), + day_low: parseFloat((price - Math.abs(change) - 1.2).toFixed(2)), + }), + ); + }, 600); + }); +} + +function searchWeb({ query }: { query: string }): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve( + JSON.stringify({ + query, + results: [ + { + title: `Top result for "${query}"`, + snippet: `Comprehensive overview of ${query} with the latest information.`, + }, + { + title: `${query} - Latest News`, + snippet: `Recent developments and updates related to ${query}.`, + }, + { + title: `Understanding ${query}`, + snippet: `An in-depth guide explaining everything about ${query}.`, + }, + ], + }), + ); + }, 1000); + }); +} + +// ── Tool definitions ── + +const tools: any[] = [ + { + type: "function", + function: { + name: "get_weather", + description: "Get current weather for a location.", + parameters: { + type: "object", + properties: { location: { type: "string", description: "City name" } }, + required: ["location"], + }, + function: getWeather, + parse: JSON.parse, + }, + }, + { + type: "function", + function: { + name: "get_stock_price", + description: "Get stock price for a ticker symbol.", + parameters: { + type: "object", + properties: { symbol: { type: "string", description: "Ticker symbol, e.g. AAPL" } }, + required: ["symbol"], + }, + function: getStockPrice, + parse: JSON.parse, + }, + }, + { + type: "function", + function: { + name: "search_web", + description: "Search the web for information.", + parameters: { + type: "object", + properties: { query: { type: "string", description: "Search query" } }, + required: ["query"], + }, + function: searchWeb, + parse: JSON.parse, + }, + }, +]; + +// ── SSE helpers ── + +function sseToolCallStart( + encoder: TextEncoder, + tc: { id: string; function: { name: string } }, + index: number, +) { + return encoder.encode( + `data: ${JSON.stringify({ + id: `chatcmpl-tc-${tc.id}`, + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index, + id: tc.id, + type: "function", + function: { name: tc.function.name, arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + })}\n\n`, + ); +} + +function sseToolCallArgs( + encoder: TextEncoder, + tc: { id: string; function: { arguments: string } }, + result: string, + index: number, +) { + let enrichedArgs: string; + try { + enrichedArgs = JSON.stringify({ + _request: JSON.parse(tc.function.arguments), + _response: JSON.parse(result), + }); + } catch { + enrichedArgs = tc.function.arguments; + } + return encoder.encode( + `data: ${JSON.stringify({ + id: `chatcmpl-tc-${tc.id}-args`, + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { tool_calls: [{ index, function: { arguments: enrichedArgs } }] }, + finish_reason: null, + }, + ], + })}\n\n`, + ); +} + +// ── Route handler ── + +export async function handleChatCompletions( + req: NextRequest, + demo: Extract, + responseMode: ResponseMode, +) { + let body: unknown; + try { + body = await req.json(); + } catch { + return invalidRequest("Request body must be valid JSON"); + } + + const parsedBody = parseRequestBody(body); + if (parsedBody instanceof Response) { + return parsedBody; + } + + const { messages, toolNames } = parsedBody; + const selectedTools = + toolNames === undefined + ? tools + : tools.filter((tool) => toolNames.includes(tool.function.name as ToolName)); + + const config = readOpenuiCloudConfig(demo); + if (!config) return unavailableResponse(); + + const client = new OpenAI({ + apiKey: config.apiKey, + baseURL: config.embedBaseUrl, + }); + const MODEL = "openai/gpt-5.4"; + + const cleanMessages = (messages as any[]) + .filter( + (m) => + m.role !== "tool" && + (responseMode === "openui" || (m.role !== "system" && m.role !== "developer")), + ) + .map((m) => { + if (m.role === "assistant" && m.tool_calls?.length) { + const { tool_calls: _tc, ...rest } = m; + return rest; + } + return m; + }); + + const chatMessages: ChatCompletionMessageParam[] = [ + { + role: "system" as const, + content: responseMode === "markdown" ? markdownSystemPrompt : openUiSystemPrompt, + }, + ...cleanMessages, + ]; + + const encoder = new TextEncoder(); + let controllerClosed = false; + let activeRunner: { abort: () => void } | undefined; + let connectionEstablished = false; + let resolveUpstreamConnection: () => void; + let rejectUpstreamConnection: (error: unknown) => void; + const upstreamConnection = new Promise((resolve, reject) => { + resolveUpstreamConnection = resolve; + rejectUpstreamConnection = reject; + }); + + const readable = new ReadableStream({ + start(controller) { + const enqueue = (data: Uint8Array) => { + if (controllerClosed) return; + try { + controller.enqueue(data); + } catch { + /* already closed */ + } + }; + const close = () => { + if (controllerClosed) return; + controllerClosed = true; + try { + controller.close(); + } catch { + /* already closed */ + } + }; + + const pendingCalls: Array<{ id: string; name: string; arguments: string }> = []; + let callIdx = 0; + let resultIdx = 0; + + const runner: any = + selectedTools.length === 0 + ? client.chat.completions.stream( + { + model: MODEL, + messages: chatMessages, + }, + { signal: req.signal }, + ) + : (client.chat.completions as any).runTools( + { + model: MODEL, + messages: chatMessages, + tools: selectedTools, + stream: true, + }, + { signal: req.signal }, + ); + activeRunner = runner; + runner.on("connect", () => { + connectionEstablished = true; + resolveUpstreamConnection(); + }); + + const handleAbort = () => { + runner.abort(); + close(); + }; + req.signal.addEventListener("abort", handleAbort, { once: true }); + + const finish = () => { + req.signal.removeEventListener("abort", handleAbort); + activeRunner = undefined; + close(); + }; + + if (selectedTools.length > 0) { + runner.on("functionToolCall", (fc: any) => { + const id = `tc-${callIdx}`; + pendingCalls.push({ id, name: fc.name, arguments: fc.arguments }); + enqueue(sseToolCallStart(encoder, { id, function: { name: fc.name } }, callIdx)); + callIdx++; + }); + + runner.on("functionToolCallResult", (result: string) => { + const tc = pendingCalls[resultIdx]; + if (tc) { + enqueue( + sseToolCallArgs( + encoder, + { id: tc.id, function: { arguments: tc.arguments } }, + result, + resultIdx, + ), + ); + } + resultIdx++; + }); + } + + runner.on("chunk", (chunk: any) => { + // Keep credit handling to non-2xx responses. Provider-specific mid-stream + // chunks are intentionally ignored because they are harder to maintain + // across OpenUI Cloud/OpenAI streaming shape changes. + const choice = chunk.choices?.[0]; + const delta = choice?.delta; + if (!delta) return; + if (delta.content) { + enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + if (choice?.finish_reason === "stop") { + enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + }); + + runner.on("end", () => { + if (controllerClosed) return; + + enqueue(encoder.encode("data: [DONE]\n\n")); + finish(); + }); + + runner.on("error", (err: any) => { + if (controllerClosed) return; + + if (!connectionEstablished) { + rejectUpstreamConnection(err); + finish(); + return; + } + + const msg = err instanceof Error ? err.message : "Stream error"; + console.error("Chat route error:", msg); + enqueue(encoder.encode(`data: ${JSON.stringify({ error: msg })}\n\n`)); + finish(); + }); + + runner.on("abort", (error: unknown) => { + if (!connectionEstablished) { + rejectUpstreamConnection(error); + } + finish(); + }); + }, + cancel() { + activeRunner?.abort(); + activeRunner = undefined; + }, + }); + + try { + await upstreamConnection; + } catch (error) { + return upstreamErrorResponse(error); + } + + return new Response(readable, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/docs/app/api/chat/route.ts b/docs/app/api/chat/route.ts index e49212f6e..fb974c7c1 100644 --- a/docs/app/api/chat/route.ts +++ b/docs/app/api/chat/route.ts @@ -1,450 +1,6 @@ -import { readFileSync } from "fs"; -import { NextRequest } from "next/server"; -import OpenAI from "openai"; -import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs"; -import { join } from "path"; +import type { NextRequest } from "next/server"; +import { handleChatCompletions } from "../_lib/chat-completions-handler"; -const openUiSystemPrompt = readFileSync( - join(process.cwd(), "generated/chat-system-prompt.txt"), - "utf-8", -); - -const markdownSystemPrompt = `You are a helpful assistant. Respond using clear, well-structured GitHub-Flavored Markdown. - -Use headings, lists, tables, links, block quotes, and fenced code blocks when they make the response easier to understand. - -Return only Markdown content. Do not emit OpenUI Lang, component syntax, JSON UI descriptions, or instructions for a renderer.`; - -type ResponseMode = "markdown" | "openui"; -const TOOL_NAMES = ["get_weather", "get_stock_price", "search_web"] as const; -type ToolName = (typeof TOOL_NAMES)[number]; -const TOOL_NAME_SET = new Set(TOOL_NAMES); - -interface ChatRequestBody { - messages: unknown[]; - responseMode?: ResponseMode; - toolNames?: ToolName[]; -} - -function invalidRequest(message: string) { - return Response.json({ error: { message } }, { status: 400 }); -} - -function parseRequestBody(body: unknown): ChatRequestBody | Response { - if (!body || typeof body !== "object" || Array.isArray(body)) { - return invalidRequest("Request body must be a JSON object"); - } - - const { messages, responseMode, toolNames } = body as Record; - - if (!Array.isArray(messages)) { - return invalidRequest("messages must be an array"); - } - - if (responseMode !== undefined && responseMode !== "markdown" && responseMode !== "openui") { - return invalidRequest('responseMode must be either "markdown" or "openui"'); - } - - if ( - toolNames !== undefined && - (!Array.isArray(toolNames) || - !toolNames.every((toolName) => typeof toolName === "string" && TOOL_NAME_SET.has(toolName))) - ) { - return invalidRequest(`toolNames must contain only: ${TOOL_NAMES.join(", ")}`); - } - - return { - messages, - responseMode: responseMode as ResponseMode | undefined, - toolNames: toolNames as ToolName[] | undefined, - }; -} - -// ── Tool implementations ── - -function getWeather({ location }: { location: string }): Promise { - return new Promise((resolve) => { - setTimeout(() => { - const knownTemps: Record = { - tokyo: 22, - "san francisco": 18, - london: 14, - "new york": 25, - paris: 19, - sydney: 27, - mumbai: 33, - berlin: 16, - }; - const conditions = ["Sunny", "Partly Cloudy", "Cloudy", "Light Rain", "Clear Skies"]; - const temp = knownTemps[location.toLowerCase()] ?? Math.floor(Math.random() * 30 + 5); - const condition = conditions[Math.floor(Math.random() * conditions.length)]; - resolve( - JSON.stringify({ - location, - temperature_celsius: temp, - temperature_fahrenheit: Math.round(temp * 1.8 + 32), - condition, - humidity_percent: Math.floor(Math.random() * 40 + 40), - wind_speed_kmh: Math.floor(Math.random() * 25 + 5), - forecast: [ - { day: "Tomorrow", high: temp + 2, low: temp - 4, condition: "Partly Cloudy" }, - { day: "Day After", high: temp + 1, low: temp - 3, condition: "Sunny" }, - ], - }), - ); - }, 800); - }); -} - -function getStockPrice({ symbol }: { symbol: string }): Promise { - return new Promise((resolve) => { - setTimeout(() => { - const s = symbol.toUpperCase(); - const knownPrices: Record = { - AAPL: 189.84, - GOOGL: 141.8, - TSLA: 248.42, - MSFT: 378.91, - AMZN: 178.25, - NVDA: 875.28, - META: 485.58, - }; - const price = knownPrices[s] ?? Math.floor(Math.random() * 500 + 20); - const change = parseFloat((Math.random() * 8 - 4).toFixed(2)); - resolve( - JSON.stringify({ - symbol: s, - price: parseFloat((price + change).toFixed(2)), - change, - change_percent: parseFloat(((change / price) * 100).toFixed(2)), - volume: `${(Math.random() * 50 + 10).toFixed(1)}M`, - day_high: parseFloat((price + Math.abs(change) + 1.5).toFixed(2)), - day_low: parseFloat((price - Math.abs(change) - 1.2).toFixed(2)), - }), - ); - }, 600); - }); -} - -function searchWeb({ query }: { query: string }): Promise { - return new Promise((resolve) => { - setTimeout(() => { - resolve( - JSON.stringify({ - query, - results: [ - { - title: `Top result for "${query}"`, - snippet: `Comprehensive overview of ${query} with the latest information.`, - }, - { - title: `${query} - Latest News`, - snippet: `Recent developments and updates related to ${query}.`, - }, - { - title: `Understanding ${query}`, - snippet: `An in-depth guide explaining everything about ${query}.`, - }, - ], - }), - ); - }, 1000); - }); -} - -// ── Tool definitions ── - -const tools: any[] = [ - { - type: "function", - function: { - name: "get_weather", - description: "Get current weather for a location.", - parameters: { - type: "object", - properties: { location: { type: "string", description: "City name" } }, - required: ["location"], - }, - function: getWeather, - parse: JSON.parse, - }, - }, - { - type: "function", - function: { - name: "get_stock_price", - description: "Get stock price for a ticker symbol.", - parameters: { - type: "object", - properties: { symbol: { type: "string", description: "Ticker symbol, e.g. AAPL" } }, - required: ["symbol"], - }, - function: getStockPrice, - parse: JSON.parse, - }, - }, - { - type: "function", - function: { - name: "search_web", - description: "Search the web for information.", - parameters: { - type: "object", - properties: { query: { type: "string", description: "Search query" } }, - required: ["query"], - }, - function: searchWeb, - parse: JSON.parse, - }, - }, -]; - -// ── SSE helpers ── - -function sseToolCallStart( - encoder: TextEncoder, - tc: { id: string; function: { name: string } }, - index: number, -) { - return encoder.encode( - `data: ${JSON.stringify({ - id: `chatcmpl-tc-${tc.id}`, - object: "chat.completion.chunk", - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index, - id: tc.id, - type: "function", - function: { name: tc.function.name, arguments: "" }, - }, - ], - }, - finish_reason: null, - }, - ], - })}\n\n`, - ); -} - -function sseToolCallArgs( - encoder: TextEncoder, - tc: { id: string; function: { arguments: string } }, - result: string, - index: number, -) { - let enrichedArgs: string; - try { - enrichedArgs = JSON.stringify({ - _request: JSON.parse(tc.function.arguments), - _response: JSON.parse(result), - }); - } catch { - enrichedArgs = tc.function.arguments; - } - return encoder.encode( - `data: ${JSON.stringify({ - id: `chatcmpl-tc-${tc.id}-args`, - object: "chat.completion.chunk", - choices: [ - { - index: 0, - delta: { tool_calls: [{ index, function: { arguments: enrichedArgs } }] }, - finish_reason: null, - }, - ], - })}\n\n`, - ); -} - -// ── Route handler ── - -export async function POST(req: NextRequest) { - let body: unknown; - try { - body = await req.json(); - } catch { - return invalidRequest("Request body must be valid JSON"); - } - - const parsedBody = parseRequestBody(body); - if (parsedBody instanceof Response) { - return parsedBody; - } - - const { messages, responseMode = "openui", toolNames } = parsedBody; - const selectedTools = - toolNames === undefined - ? tools - : tools.filter((tool) => toolNames.includes(tool.function.name as ToolName)); - - const apiKey = process.env.OPENROUTER_API_KEY; - if (!apiKey) { - return Response.json( - { error: { message: "OPENROUTER_API_KEY not configured" } }, - { status: 500 }, - ); - } - - const client = new OpenAI({ - apiKey, - baseURL: "https://openrouter.ai/api/v1", - }); - const MODEL = "openai/gpt-5.4"; - - const cleanMessages = (messages as any[]) - .filter( - (m) => - m.role !== "tool" && - (responseMode === "openui" || (m.role !== "system" && m.role !== "developer")), - ) - .map((m) => { - if (m.role === "assistant" && m.tool_calls?.length) { - const { tool_calls: _tc, ...rest } = m; - return rest; - } - return m; - }); - - const chatMessages: ChatCompletionMessageParam[] = [ - { - role: "system" as const, - content: responseMode === "markdown" ? markdownSystemPrompt : openUiSystemPrompt, - }, - ...cleanMessages, - ]; - - const encoder = new TextEncoder(); - let controllerClosed = false; - let activeRunner: { abort: () => void } | undefined; - - const readable = new ReadableStream({ - start(controller) { - const enqueue = (data: Uint8Array) => { - if (controllerClosed) return; - try { - controller.enqueue(data); - } catch { - /* already closed */ - } - }; - const close = () => { - if (controllerClosed) return; - controllerClosed = true; - try { - controller.close(); - } catch { - /* already closed */ - } - }; - - const pendingCalls: Array<{ id: string; name: string; arguments: string }> = []; - let callIdx = 0; - let resultIdx = 0; - - const runner: any = - selectedTools.length === 0 - ? client.chat.completions.stream( - { - model: MODEL, - messages: chatMessages, - }, - { signal: req.signal }, - ) - : (client.chat.completions as any).runTools( - { - model: MODEL, - messages: chatMessages, - tools: selectedTools, - stream: true, - }, - { signal: req.signal }, - ); - activeRunner = runner; - - const handleAbort = () => { - runner.abort(); - close(); - }; - req.signal.addEventListener("abort", handleAbort, { once: true }); - - const finish = () => { - req.signal.removeEventListener("abort", handleAbort); - activeRunner = undefined; - close(); - }; - - if (selectedTools.length > 0) { - runner.on("functionToolCall", (fc: any) => { - const id = `tc-${callIdx}`; - pendingCalls.push({ id, name: fc.name, arguments: fc.arguments }); - enqueue(sseToolCallStart(encoder, { id, function: { name: fc.name } }, callIdx)); - callIdx++; - }); - - runner.on("functionToolCallResult", (result: string) => { - const tc = pendingCalls[resultIdx]; - if (tc) { - enqueue( - sseToolCallArgs( - encoder, - { id: tc.id, function: { arguments: tc.arguments } }, - result, - resultIdx, - ), - ); - } - resultIdx++; - }); - } - - runner.on("chunk", (chunk: any) => { - // Keep credit handling to non-2xx responses. Provider-specific mid-stream - // chunks are intentionally ignored because they are harder to maintain - // across OpenRouter/OpenAI streaming shape changes. - const choice = chunk.choices?.[0]; - const delta = choice?.delta; - if (!delta) return; - if (delta.content) { - enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); - } - if (choice?.finish_reason === "stop") { - enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); - } - }); - - runner.on("end", () => { - if (controllerClosed) return; - - enqueue(encoder.encode("data: [DONE]\n\n")); - finish(); - }); - - runner.on("error", (err: any) => { - if (controllerClosed) return; - - const msg = err instanceof Error ? err.message : "Stream error"; - console.error("Chat route error:", msg); - enqueue(encoder.encode(`data: ${JSON.stringify({ error: msg })}\n\n`)); - finish(); - }); - - runner.on("abort", finish); - }, - cancel() { - activeRunner?.abort(); - activeRunner = undefined; - }, - }); - - return new Response(readable, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - }, - }); +export function POST(request: NextRequest) { + return handleChatCompletions(request, "chat-oss", "openui"); } diff --git a/docs/app/api/compare/markdown/route.ts b/docs/app/api/compare/markdown/route.ts new file mode 100644 index 000000000..b488f9550 --- /dev/null +++ b/docs/app/api/compare/markdown/route.ts @@ -0,0 +1,6 @@ +import type { NextRequest } from "next/server"; +import { handleChatCompletions } from "../../_lib/chat-completions-handler"; + +export function POST(request: NextRequest) { + return handleChatCompletions(request, "compare-markdown", "markdown"); +} diff --git a/docs/app/api/compare/oss/route.ts b/docs/app/api/compare/oss/route.ts new file mode 100644 index 000000000..972316ef4 --- /dev/null +++ b/docs/app/api/compare/oss/route.ts @@ -0,0 +1,6 @@ +import type { NextRequest } from "next/server"; +import { handleChatCompletions } from "../../_lib/chat-completions-handler"; + +export function POST(request: NextRequest) { + return handleChatCompletions(request, "compare-oss", "openui"); +} diff --git a/docs/app/api/demo/github/stream/route.ts b/docs/app/api/demo/github/stream/route.ts index e5f96bd52..540b6c13e 100644 --- a/docs/app/api/demo/github/stream/route.ts +++ b/docs/app/api/demo/github/stream/route.ts @@ -2,7 +2,8 @@ import { createDemoCreditsExhaustedResponse, isDemoCreditsExhaustedError, } from "@/lib/demo-credits"; -import { BASE_URL } from "@/lib/source"; +import { readOpenuiCloudConfig } from "@/lib/openui-cloud/config"; +import { unavailableResponse } from "@/lib/openui-cloud/errors"; import { generatePrompt, type PromptSpec } from "@openuidev/lang-core"; import { readFileSync } from "fs"; import { type NextRequest } from "next/server"; @@ -60,21 +61,14 @@ export async function POST(req: NextRequest) { } chatMessages.push({ role: "user", content: prompt }); - const apiKey = process.env.OPENROUTER_API_KEY; - if (!apiKey) { - return Response.json( - { error: { message: "OPENROUTER_API_KEY not configured" } }, - { status: 500 }, - ); - } + const config = readOpenuiCloudConfig("github"); + if (!config) return unavailableResponse(); - const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + const res = await fetch(`${config.embedBaseUrl}/chat/completions`, { method: "POST", headers: { - Authorization: `Bearer ${apiKey}`, + Authorization: `Bearer ${config.apiKey}`, "Content-Type": "application/json", - "HTTP-Referer": `${BASE_URL}/demo/github`, - "X-Title": "OpenUI GitHub Demo", }, body: JSON.stringify({ model: GITHUB_DEMO_MODEL, @@ -93,7 +87,7 @@ export async function POST(req: NextRequest) { return Response.json( { error: (err as { error?: { message?: string } }).error ?? { - message: `OpenRouter error ${res.status}`, + message: `OpenUI Cloud error ${res.status}`, }, }, { status: res.status }, @@ -102,7 +96,7 @@ export async function POST(req: NextRequest) { // Keep credit handling to provider 4xx responses. Provider-specific mid-stream // error chunks are intentionally passed through because they are harder to - // maintain across OpenRouter/OpenAI streaming shape changes. + // maintain across OpenUI Cloud/OpenAI streaming shape changes. return new Response(res.body, { headers: { "Content-Type": "text/event-stream", diff --git a/docs/app/api/openui-cloud/_lib/frontend-token-handler.ts b/docs/app/api/openui-cloud/_lib/frontend-token-handler.ts new file mode 100644 index 000000000..1effbec6e --- /dev/null +++ b/docs/app/api/openui-cloud/_lib/frontend-token-handler.ts @@ -0,0 +1,29 @@ +import { mintFrontendToken } from "@/lib/openui-cloud/cloud-api"; +import { readOpenuiCloudConfig, type CloudDemoWorkload } from "@/lib/openui-cloud/config"; +import { unavailableResponse } from "@/lib/openui-cloud/errors"; +import { hasAllowedOrigin, hasValidEmptyBody } from "@/lib/openui-cloud/request"; +import { readCloudUserId } from "@/lib/openui-cloud/user-id"; + +export async function handleFrontendToken( + request: Request, + demo: CloudDemoWorkload, +): Promise { + const config = readOpenuiCloudConfig(demo); + if (!config) return unavailableResponse(); + if (!hasAllowedOrigin(request)) return unavailableResponse(403); + if (!(await hasValidEmptyBody(request))) return unavailableResponse(415); + + const userId = readCloudUserId(request); + if (!userId) return unavailableResponse(401); + + try { + const { token, expiresAt } = await mintFrontendToken(config, userId, request.signal); + + return Response.json( + { token, expires_at: expiresAt }, + { headers: { "Cache-Control": "no-store" } }, + ); + } catch { + return unavailableResponse(503); + } +} diff --git a/docs/app/api/openui-cloud/_lib/responses-handler.ts b/docs/app/api/openui-cloud/_lib/responses-handler.ts new file mode 100644 index 000000000..b43aed8cd --- /dev/null +++ b/docs/app/api/openui-cloud/_lib/responses-handler.ts @@ -0,0 +1,130 @@ +import { readOpenuiCloudConfig, type CloudDemoWorkload } from "@/lib/openui-cloud/config"; +import { unavailableResponse } from "@/lib/openui-cloud/errors"; +import { resolveRequestedModel } from "@/lib/openui-cloud/models"; +import { hasAllowedOrigin, hasJsonContentType, readLimitedJson } from "@/lib/openui-cloud/request"; +import { artifactTool, createResponsesInstructions } from "@openuidev/thesys-server"; +import OpenAI from "openai"; +import type { ResponseInputItem } from "openai/resources/responses/responses"; + +const MAX_INPUT_ITEMS = 16; +const MAX_THREAD_ID_LENGTH = 256; + +interface CloudChatRequest { + workload: CloudDemoWorkload; + threadId: string; + input: ResponseInputItem[]; + model: string; +} + +export async function handleCloudResponses(request: Request): Promise { + if (!hasAllowedOrigin(request)) return unavailableResponse(403); + if (!hasJsonContentType(request)) return unavailableResponse(415); + + let body: CloudChatRequest; + try { + const payload = await readLimitedJson(request); + const parsed = parseCloudChatRequest(payload); + if (!parsed) return unavailableResponse(400); + body = parsed; + } catch { + return unavailableResponse(400); + } + + const config = readOpenuiCloudConfig(body.workload); + if (!config) return unavailableResponse(); + + const client = new OpenAI({ + baseURL: config.embedBaseUrl, + apiKey: config.apiKey, + }); + + const stream = (await client.responses.create( + { + model: body.model, + conversation: body.threadId, + input: body.input, + stream: true, + store: true, + tools: [ + artifactTool({ artifacts: ["slides", "report"] }), + { type: "web_search" }, + { type: "image_search" }, + ], + instructions: createResponsesInstructions(), + // The Cloud Responses endpoint extends the stock OpenAI tool union. + } as any, + { signal: request.signal }, + )) as unknown as AsyncIterable>; + + return createSseResponse(stream, request.signal); +} + +function parseCloudChatRequest(value: unknown): CloudChatRequest | null { + if (!isRecord(value)) return null; + + const workload = value.workload; + if (workload !== "chat-cloud" && workload !== "compare-cloud") return null; + + const threadId = value.threadId; + if ( + typeof threadId !== "string" || + threadId.length === 0 || + threadId.length > MAX_THREAD_ID_LENGTH || + !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(threadId) + ) { + return null; + } + + const input = value.input; + if ( + !Array.isArray(input) || + input.length === 0 || + input.length > MAX_INPUT_ITEMS || + !input.every(isRecord) + ) { + return null; + } + + const model = resolveRequestedModel(value.model); + if (!model) return null; + + return { workload, threadId, input: input as unknown as ResponseInputItem[], model }; +} + +function createSseResponse( + stream: AsyncIterable>, + requestSignal: AbortSignal, +): Response { + const encoder = new TextEncoder(); + let cancelled = false; + let iterator: AsyncIterator> | undefined; + + const body = new ReadableStream({ + async start(controller) { + iterator = stream[Symbol.asyncIterator](); + while (!cancelled) { + const next = await iterator.next(); + if (next.done || cancelled) break; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(next.value)}\n\n`)); + } + if (!cancelled && !requestSignal.aborted) controller.close(); + }, + async cancel() { + cancelled = true; + await iterator?.return?.(); + }, + }); + + return new Response(body, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/docs/app/api/openui-cloud/chat/route.ts b/docs/app/api/openui-cloud/chat/route.ts index b24d53836..dffe60a4d 100644 --- a/docs/app/api/openui-cloud/chat/route.ts +++ b/docs/app/api/openui-cloud/chat/route.ts @@ -1,125 +1,5 @@ -import { readOpenuiCloudConfig } from "@/lib/openui-cloud/config"; -import { unavailableResponse } from "@/lib/openui-cloud/errors"; -import { resolveRequestedModel } from "@/lib/openui-cloud/models"; -import { hasAllowedOrigin, hasJsonContentType, readLimitedJson } from "@/lib/openui-cloud/request"; -import { artifactTool, createResponsesInstructions } from "@openuidev/thesys-server"; -import OpenAI from "openai"; -import type { ResponseInputItem } from "openai/resources/responses/responses"; +import { handleCloudResponses } from "../_lib/responses-handler"; -const MAX_INPUT_ITEMS = 16; -const MAX_THREAD_ID_LENGTH = 256; - -interface CloudChatRequest { - threadId: string; - input: ResponseInputItem[]; - model: string; -} - -export async function POST(request: Request): Promise { - const config = readOpenuiCloudConfig(); - if (!config) return unavailableResponse(); - if (!hasAllowedOrigin(request)) return unavailableResponse(403); - if (!hasJsonContentType(request)) return unavailableResponse(415); - - let body: CloudChatRequest; - try { - const payload = await readLimitedJson(request); - const parsed = parseCloudChatRequest(payload); - if (!parsed) return unavailableResponse(400); - body = parsed; - } catch { - return unavailableResponse(400); - } - - const client = new OpenAI({ - baseURL: `${config.apiOrigin}/v1/embed`, - apiKey: config.apiKey, - }); - - const stream = (await client.responses.create( - { - model: body.model, - conversation: body.threadId, - input: body.input, - stream: true, - store: true, - tools: [ - artifactTool({ artifacts: ["slides", "report"] }), - { type: "web_search" }, - { type: "image_search" }, - ], - instructions: createResponsesInstructions(), - // The Cloud Responses endpoint extends the stock OpenAI tool union. - } as any, - { signal: request.signal }, - )) as unknown as AsyncIterable>; - - return createSseResponse(stream, request.signal); -} - -function parseCloudChatRequest(value: unknown): CloudChatRequest | null { - if (!isRecord(value)) return null; - - const threadId = value.threadId; - if ( - typeof threadId !== "string" || - threadId.length === 0 || - threadId.length > MAX_THREAD_ID_LENGTH || - !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(threadId) - ) { - return null; - } - - const input = value.input; - if ( - !Array.isArray(input) || - input.length === 0 || - input.length > MAX_INPUT_ITEMS || - !input.every(isRecord) - ) { - return null; - } - - const model = resolveRequestedModel(value.model); - if (!model) return null; - - return { threadId, input: input as unknown as ResponseInputItem[], model }; -} - -function createSseResponse( - stream: AsyncIterable>, - requestSignal: AbortSignal, -): Response { - const encoder = new TextEncoder(); - let cancelled = false; - let iterator: AsyncIterator> | undefined; - - const body = new ReadableStream({ - async start(controller) { - iterator = stream[Symbol.asyncIterator](); - while (!cancelled) { - const next = await iterator.next(); - if (next.done || cancelled) break; - controller.enqueue(encoder.encode(`data: ${JSON.stringify(next.value)}\n\n`)); - } - if (!cancelled && !requestSignal.aborted) controller.close(); - }, - async cancel() { - cancelled = true; - await iterator?.return?.(); - }, - }); - - return new Response(body, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - "X-Accel-Buffering": "no", - }, - }); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +export function POST(request: Request) { + return handleCloudResponses(request); } diff --git a/docs/app/api/openui-cloud/compare/frontend-token/route.ts b/docs/app/api/openui-cloud/compare/frontend-token/route.ts new file mode 100644 index 000000000..d4cd15e05 --- /dev/null +++ b/docs/app/api/openui-cloud/compare/frontend-token/route.ts @@ -0,0 +1,5 @@ +import { handleFrontendToken } from "../../_lib/frontend-token-handler"; + +export function POST(request: Request) { + return handleFrontendToken(request, "compare-cloud"); +} diff --git a/docs/app/api/openui-cloud/frontend-token/route.ts b/docs/app/api/openui-cloud/frontend-token/route.ts index 13aa7d089..111789cdc 100644 --- a/docs/app/api/openui-cloud/frontend-token/route.ts +++ b/docs/app/api/openui-cloud/frontend-token/route.ts @@ -1,26 +1,5 @@ -import { mintFrontendToken } from "@/lib/openui-cloud/cloud-api"; -import { readOpenuiCloudConfig } from "@/lib/openui-cloud/config"; -import { unavailableResponse } from "@/lib/openui-cloud/errors"; -import { hasAllowedOrigin, hasValidEmptyBody } from "@/lib/openui-cloud/request"; -import { readCloudUserId } from "@/lib/openui-cloud/user-id"; +import { handleFrontendToken } from "../_lib/frontend-token-handler"; -export async function POST(request: Request): Promise { - const config = readOpenuiCloudConfig(); - if (!config) return unavailableResponse(); - if (!hasAllowedOrigin(request)) return unavailableResponse(403); - if (!(await hasValidEmptyBody(request))) return unavailableResponse(415); - - const userId = readCloudUserId(request); - if (!userId) return unavailableResponse(401); - - try { - const { token, expiresAt } = await mintFrontendToken(config, userId, request.signal); - - return Response.json( - { token, expires_at: expiresAt }, - { headers: { "Cache-Control": "no-store" } }, - ); - } catch { - return unavailableResponse(503); - } +export function POST(request: Request) { + return handleFrontendToken(request, "chat-cloud"); } diff --git a/docs/app/api/playground/stream/route.ts b/docs/app/api/playground/stream/route.ts index a5e931a44..e37950437 100644 --- a/docs/app/api/playground/stream/route.ts +++ b/docs/app/api/playground/stream/route.ts @@ -2,7 +2,8 @@ import { createDemoCreditsExhaustedResponse, isDemoCreditsExhaustedError, } from "@/lib/demo-credits"; -import { BASE_URL } from "@/lib/source"; +import { readOpenuiCloudConfig } from "@/lib/openui-cloud/config"; +import { unavailableResponse } from "@/lib/openui-cloud/errors"; import { readFileSync } from "fs"; import { type NextRequest } from "next/server"; import { join } from "path"; @@ -17,15 +18,16 @@ const conversationLog: Array<{ role: string; content: string }> = []; export async function POST(req: NextRequest) { const { model, prompt } = await req.json(); + const config = readOpenuiCloudConfig("playground"); + if (!config) return unavailableResponse(); + conversationLog.push({ role: "user", content: prompt }); - const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + const res = await fetch(`${config.embedBaseUrl}/chat/completions`, { method: "POST", headers: { - Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, + Authorization: `Bearer ${config.apiKey}`, "Content-Type": "application/json", - "HTTP-Referer": `${BASE_URL}/demos`, - "X-Title": "OpenUI Playground", }, body: JSON.stringify({ model, @@ -47,7 +49,7 @@ export async function POST(req: NextRequest) { return Response.json( { error: (err as { error?: { message?: string } }).error ?? { - message: `OpenRouter error ${res.status}`, + message: `OpenUI Cloud error ${res.status}`, }, }, { status: res.status }, @@ -56,7 +58,7 @@ export async function POST(req: NextRequest) { // Keep credit handling to provider 4xx responses. Provider-specific mid-stream // error chunks are intentionally passed through because they are harder to - // maintain across OpenRouter/OpenAI streaming shape changes. + // maintain across OpenUI Cloud/OpenAI streaming shape changes. const [streamForClient, streamForLog] = res.body!.tee(); const reader = streamForLog.getReader(); diff --git a/docs/app/compare/_components/agent-surfaces/cloud-agent-surface.tsx b/docs/app/compare/_components/agent-surfaces/cloud-agent-surface.tsx index fd65d8217..7e102142f 100644 --- a/docs/app/compare/_components/agent-surfaces/cloud-agent-surface.tsx +++ b/docs/app/compare/_components/agent-surfaces/cloud-agent-surface.tsx @@ -15,10 +15,10 @@ interface CloudAgentSurfaceProps { export function CloudAgentSurface({ registry }: CloudAgentSurfaceProps) { const [userId] = useState(getOrCreateCloudUserId); - const [llm] = useState(createCloudChatLLM); + const [llm] = useState(() => createCloudChatLLM("compare-cloud")); const cloudFetch = useMemo(() => { return async (input, init) => { - if (typeof input !== "string" || input !== "/api/openui-cloud/frontend-token") { + if (typeof input !== "string" || input !== "/api/openui-cloud/compare/frontend-token") { return fetch(input, init); } @@ -28,7 +28,7 @@ export function CloudAgentSurface({ registry }: CloudAgentSurfaceProps) { }; }, [userId]); const cloudStorage = useOpenuiCloudStorage({ - token: "/api/openui-cloud/frontend-token", + token: "/api/openui-cloud/compare/frontend-token", fetch: cloudFetch, }); diff --git a/docs/app/compare/_components/agent-surfaces/use-comparison-chat-llm.ts b/docs/app/compare/_components/agent-surfaces/use-comparison-chat-llm.ts index 0272e39bb..2ad46c6c8 100644 --- a/docs/app/compare/_components/agent-surfaces/use-comparison-chat-llm.ts +++ b/docs/app/compare/_components/agent-surfaces/use-comparison-chat-llm.ts @@ -14,15 +14,16 @@ export function useComparisonChatLLM( responseMode: ComparisonResponseMode, onCreditsExhausted: () => void, ): ChatLLM { + const endpoint = responseMode === "markdown" ? "/api/compare/markdown" : "/api/compare/oss"; + return useMemo( () => ({ send: async ({ messages, signal }) => { - const response = await fetch("/api/chat", { + const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: openAIMessageFormat.toApi(messages), - responseMode, toolNames: [], }), signal, @@ -36,7 +37,7 @@ export function useComparisonChatLLM( }, streamProtocol: openAIAdapter(), }), - [onCreditsExhausted, responseMode], + [endpoint, onCreditsExhausted], ); } diff --git a/docs/lib/openui-cloud/chat-llm.ts b/docs/lib/openui-cloud/chat-llm.ts index 490b24f0c..7d410a53c 100644 --- a/docs/lib/openui-cloud/chat-llm.ts +++ b/docs/lib/openui-cloud/chat-llm.ts @@ -1,3 +1,4 @@ +import type { CloudDemoWorkload } from "@/lib/openui-cloud/config"; import { DEFAULT_MODEL } from "@/lib/openui-cloud/models"; import { openAIConversationMessageFormat, @@ -9,7 +10,7 @@ interface CloudChatLLM extends ChatLLM { setSelectedModel: (model: string) => void; } -export function createCloudChatLLM(): CloudChatLLM { +export function createCloudChatLLM(workload: CloudDemoWorkload = "chat-cloud"): CloudChatLLM { let selectedModel = DEFAULT_MODEL; return { @@ -21,6 +22,7 @@ export function createCloudChatLLM(): CloudChatLLM { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ + workload, threadId, input: openAIConversationMessageFormat.toApi(messages.slice(-1)), model: selectedModel, diff --git a/docs/lib/openui-cloud/config.ts b/docs/lib/openui-cloud/config.ts index b5dec15cf..9beb37a4e 100644 --- a/docs/lib/openui-cloud/config.ts +++ b/docs/lib/openui-cloud/config.ts @@ -1,19 +1,45 @@ const CLOUD_API_ORIGIN = "https://api.thesys.dev"; +export type DocsDemoWorkload = + | "chat-oss" + | "chat-cloud" + | "compare-markdown" + | "compare-oss" + | "compare-cloud" + | "playground" + | "github"; + +export type CloudDemoWorkload = Extract; + +const DEMO_API_KEY_ENV = { + "chat-oss": "THESYS_API_KEY_DOCS_CHAT_OSS", + "chat-cloud": "THESYS_API_KEY_DOCS_CHAT_CLOUD", + "compare-markdown": "THESYS_API_KEY_DOCS_COMPARE_MARKDOWN", + "compare-oss": "THESYS_API_KEY_DOCS_COMPARE_OSS", + "compare-cloud": "THESYS_API_KEY_DOCS_COMPARE_CLOUD", + playground: "THESYS_API_KEY_DOCS_PLAYGROUND", + github: "THESYS_API_KEY_DOCS_GITHUB", +} as const satisfies Record; + export interface OpenuiCloudConfig { apiKey: string; apiOrigin: string; + embedBaseUrl: string; } -/** Read Cloud configuration at request time so OSS-only builds do not require Cloud secrets. */ -export function readOpenuiCloudConfig(): OpenuiCloudConfig | null { - if (process.env.OPENUI_CLOUD_DEMO_ENABLED !== "true") return null; - - const apiKey = process.env.THESYS_API_KEY?.trim(); +/** + * Read a hosted demo's Cloud configuration at request time. + * + * Each workload has a dedicated key so Cloud usage can be attributed + * independently. + */ +export function readOpenuiCloudConfig(demo: DocsDemoWorkload): OpenuiCloudConfig | null { + const apiKey = process.env[DEMO_API_KEY_ENV[demo]]?.trim(); if (!apiKey) return null; return { apiKey, apiOrigin: CLOUD_API_ORIGIN, + embedBaseUrl: `${CLOUD_API_ORIGIN}/v1/embed`, }; } diff --git a/docs/lib/openui-cloud/request.ts b/docs/lib/openui-cloud/request.ts index 9bc48bde2..00ddcc57d 100644 --- a/docs/lib/openui-cloud/request.ts +++ b/docs/lib/openui-cloud/request.ts @@ -20,18 +20,7 @@ export function hasAllowedOrigin(request: Request): boolean { if (normalizedOrigin !== origin) return false; - const allowedOrigins = new Set([new URL(request.url).origin]); - for (const configuredOrigin of (process.env.OPENUI_CLOUD_ALLOWED_ORIGINS ?? "").split(",")) { - const candidate = configuredOrigin.trim(); - if (!candidate) continue; - try { - allowedOrigins.add(new URL(candidate).origin); - } catch { - // A malformed allowlist entry is ignored rather than broadening access. - } - } - - return allowedOrigins.has(normalizedOrigin); + return normalizedOrigin === new URL(request.url).origin; } export function hasJsonContentType(request: Request): boolean {