diff --git a/.env.example b/.env.example index 4007061b..e99fec67 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ -# Optional. Set to false to hide the demo's AI UI even when an Anthropic key is present. +# Optional. Set to false to hide the demo's AI UI even when an AI key is present. STUDIO_DEMO_AI_ENABLED=true + +# Either key enables the demo's AI flows. When both are present, OrcaRouter wins. ANTHROPIC_API_KEY=your_anthropic_api_key_here +ORCAROUTER_API_KEY=your_orcarouter_api_key_here diff --git a/FEATURES.md b/FEATURES.md index a6a8c714..e3726ca8 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -24,6 +24,12 @@ The local `ppg-dev` demo can be packaged into a Compute-ready artifact instead o The deploy builder precompiles the browser JS/CSS, injects those assets into the bundled server, copies Prisma Dev's PGlite runtime assets into the bundle with stable filenames, and bundles the Prisma Streams worker into `touch/` so the Compute artifact can boot and keep WAL-to-stream syncing alive outside the repo checkout. The same demo entrypoint can also run against external development infrastructure through `pnpm demo:ppg -- --database-url --streams-server-url `, or in streams-only mode through `pnpm demo:ppg -- --streams-server-url `. In those modes, Studio keeps serving the local shell and `/api/streams` proxy, but skips local Prisma Dev startup, local Streams startup, WAL wiring, and local seeding so you can point the demo at an already-running backend stack. +## OrcaRouter Demo AI Provider + +The `ppg-dev` demo can route all Studio AI flows (table filtering, SQL generation, SQL result visualization, and Query Insights recommendations) through OrcaRouter's OpenAI-compatible endpoint by setting `ORCAROUTER_API_KEY`. +OrcaRouter exposes a provider/model namespace across many models behind one endpoint, and when both `ANTHROPIC_API_KEY` and `ORCAROUTER_API_KEY` are present the demo prefers OrcaRouter. +The shared `llm` hook contract is unchanged, so the rest of the demo and the embeddable Studio surface are unaffected. + ## Streams-Only Studio Shell Studio can run without a database connection when a Streams server is configured, which makes it usable as a focused event-log and stream-search tool. diff --git a/README.md b/README.md index c7d35b84..2190ddf5 100644 --- a/README.md +++ b/README.md @@ -592,8 +592,8 @@ pnpm demo:ppg Then open [http://localhost:4310](http://localhost:4310). -To enable the demo's AI flows, copy `.env.example` to `.env` and set `ANTHROPIC_API_KEY`. -The demo reads that key server-side and calls Anthropic Haiku 4.5 directly over HTTP through one shared `llm` hook used by table filtering, SQL generation, SQL result visualization, and Query Insights recommendations. Set `STUDIO_DEMO_AI_ENABLED=false` to hide all AI affordances without removing the key. `STUDIO_DEMO_AI_FILTERING_ENABLED` is still accepted as a legacy alias. `.env` and `.env.local` are gitignored. +To enable the demo's AI flows, copy `.env.example` to `.env` and set `ANTHROPIC_API_KEY` or `ORCAROUTER_API_KEY` (when both are set, the demo prefers OrcaRouter). +The demo reads the key server-side and calls the chosen provider directly over HTTP through one shared `llm` hook used by table filtering, SQL generation, SQL result visualization, and Query Insights recommendations. Set `STUDIO_DEMO_AI_ENABLED=false` to hide all AI affordances without removing the key. `STUDIO_DEMO_AI_FILTERING_ENABLED` is still accepted as a legacy alias. `.env` and `.env.local` are gitignored. The demo: diff --git a/demo/ppg-dev/config.test.ts b/demo/ppg-dev/config.test.ts index a218e016..ff205add 100644 --- a/demo/ppg-dev/config.test.ts +++ b/demo/ppg-dev/config.test.ts @@ -81,6 +81,24 @@ describe("resolveDemoAiEnabled", () => { ).toBe(false); }); + it("returns false when no provider key is configured", () => { + expect( + resolveDemoAiEnabled({ + anthropicApiKey: "", + envValue: "true", + orcaRouterApiKey: "", + }), + ).toBe(false); + }); + + it("defaults to enabled when only the OrcaRouter key exists", () => { + expect( + resolveDemoAiEnabled({ + orcaRouterApiKey: "sk-orca-test", + }), + ).toBe(true); + }); + it("defaults to enabled when the Anthropic key exists", () => { expect( resolveDemoAiEnabled({ diff --git a/demo/ppg-dev/config.ts b/demo/ppg-dev/config.ts index f7a32298..83f0ee02 100644 --- a/demo/ppg-dev/config.ts +++ b/demo/ppg-dev/config.ts @@ -39,10 +39,15 @@ function parseOptionalBooleanEnv( } export function resolveDemoAiEnabled(args: { - anthropicApiKey: string; + anthropicApiKey?: string; envValue?: string; + orcaRouterApiKey?: string; }): boolean { - if (args.anthropicApiKey.trim().length === 0) { + const hasConfiguredProvider = + (args.anthropicApiKey?.trim().length ?? 0) > 0 || + (args.orcaRouterApiKey?.trim().length ?? 0) > 0; + + if (!hasConfiguredProvider) { return false; } diff --git a/demo/ppg-dev/orcarouter.test.ts b/demo/ppg-dev/orcarouter.test.ts new file mode 100644 index 00000000..91c8ac0a --- /dev/null +++ b/demo/ppg-dev/orcarouter.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + ORCAROUTER_DEMO_MODEL, + ORCAROUTER_MAX_TOKENS, + runOrcaRouterLlmRequest, +} from "./orcarouter"; + +type FetchLike = ( + ...args: Parameters +) => ReturnType; + +describe("runOrcaRouterLlmRequest", () => { + it("calls OrcaRouter's OpenAI-compatible endpoint and returns the first text choice", async () => { + const fetchImplementation = vi.fn(() => { + return Promise.resolve( + new Response( + JSON.stringify({ + choices: [ + { + finish_reason: "stop", + message: { + content: + '{"filters":[{"column":"email","operator":"ilike","value":"%abba%"}]}', + role: "assistant", + }, + }, + ], + }), + { + headers: { + "content-type": "application/json", + }, + status: 200, + }, + ), + ); + }); + + const responseText = await runOrcaRouterLlmRequest({ + apiKey: "test-key", + fetchImplementation, + request: { + prompt: "Filter rows where email contains abba", + task: "table-filter", + }, + }); + + expect(responseText).toContain('"column":"email"'); + expect(fetchImplementation).toHaveBeenCalledTimes(1); + expect(fetchImplementation).toHaveBeenCalledWith( + "https://api.orcarouter.ai/v1/chat/completions", + expect.any(Object), + ); + + const requestInit = fetchImplementation.mock.calls[0]?.[1]; + + expect(requestInit?.method).toBe("POST"); + expect(requestInit?.body).toBe( + JSON.stringify({ + max_tokens: ORCAROUTER_MAX_TOKENS, + messages: [ + { + content: "Filter rows where email contains abba", + role: "user", + }, + ], + model: ORCAROUTER_DEMO_MODEL, + }), + ); + + const headers = new Headers(requestInit?.headers); + + expect(headers.get("authorization")).toBe("Bearer test-key"); + expect(headers.get("content-type")).toBe("application/json"); + }); + + it("logs request metadata without leaking the API key or prompt", async () => { + const fetchImplementation = vi.fn(() => { + return Promise.resolve( + new Response( + JSON.stringify({ + choices: [ + { + finish_reason: "stop", + message: { + content: '{"filters":[]}', + role: "assistant", + }, + }, + ], + }), + { + headers: { + "content-type": "application/json", + }, + status: 200, + }, + ), + ); + }); + const consoleInfoSpy = vi + .spyOn(console, "info") + .mockImplementation(() => undefined); + + await runOrcaRouterLlmRequest({ + apiKey: "test-key", + fetchImplementation, + request: { + prompt: "Filter rows where email contains abba", + task: "table-filter", + }, + }); + + expect(consoleInfoSpy).toHaveBeenCalledWith("[demo][orcarouter] request", { + maxTokens: ORCAROUTER_MAX_TOKENS, + method: "POST", + model: ORCAROUTER_DEMO_MODEL, + promptLength: 37, + task: "table-filter", + url: "https://api.orcarouter.ai/v1/chat/completions", + }); + + consoleInfoSpy.mockRestore(); + }); + + it("surfaces OrcaRouter API errors", async () => { + const fetchImplementation = vi.fn(() => { + return Promise.resolve( + new Response( + JSON.stringify({ + error: { + message: "invalid api key", + }, + }), + { + headers: { + "content-type": "application/json", + }, + status: 401, + statusText: "Unauthorized", + }, + ), + ); + }); + + await expect( + runOrcaRouterLlmRequest({ + apiKey: "bad-key", + fetchImplementation, + request: { + prompt: "Generate a SQL query", + task: "sql-generation", + }, + }), + ).rejects.toThrow("invalid api key"); + }); + + it("surfaces an explicit error when OrcaRouter hits the output token limit", async () => { + const fetchImplementation = vi.fn(() => { + return Promise.resolve( + new Response( + JSON.stringify({ + choices: [ + { + finish_reason: "length", + message: { + content: "```json\n{", + role: "assistant", + }, + }, + ], + }), + { + headers: { + "content-type": "application/json", + }, + status: 200, + }, + ), + ); + }); + + await expect( + runOrcaRouterLlmRequest({ + apiKey: "test-key", + fetchImplementation, + request: { + prompt: "Generate a chart", + task: "sql-visualization", + }, + }), + ).rejects.toThrow( + "OrcaRouter stopped because it reached the configured output limit of 2048 tokens before finishing the response.", + ); + }); +}); diff --git a/demo/ppg-dev/orcarouter.ts b/demo/ppg-dev/orcarouter.ts new file mode 100644 index 00000000..8db0072e --- /dev/null +++ b/demo/ppg-dev/orcarouter.ts @@ -0,0 +1,93 @@ +import { + buildStudioLlmOutputLimitExceededMessage, + type StudioLlmRequest, +} from "../../data/llm"; + +type FetchLike = ( + ...args: Parameters +) => ReturnType; + +export const ORCAROUTER_DEMO_MODEL = "orcarouter/auto"; +const ORCAROUTER_API_URL = "https://api.orcarouter.ai/v1/chat/completions"; +export const ORCAROUTER_MAX_TOKENS = 2048; + +interface OrcaRouterChatCompletionResponse { + choices?: Array<{ + finish_reason?: string | null; + message?: { + content?: string | null; + role?: string; + }; + }>; + error?: { + message?: string; + }; +} + +export class OrcaRouterOutputLimitError extends Error { + constructor(message: string) { + super(message); + this.name = "OrcaRouterOutputLimitError"; + } +} + +export async function runOrcaRouterLlmRequest(args: { + apiKey: string; + fetchImplementation?: FetchLike; + request: StudioLlmRequest; +}): Promise { + const { apiKey, fetchImplementation = fetch, request } = args; + const httpRequest = { + body: JSON.stringify({ + max_tokens: ORCAROUTER_MAX_TOKENS, + messages: [ + { + content: request.prompt, + role: "user", + }, + ], + model: ORCAROUTER_DEMO_MODEL, + }), + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }, + method: "POST", + } satisfies RequestInit; + + console.info("[demo][orcarouter] request", { + maxTokens: ORCAROUTER_MAX_TOKENS, + method: httpRequest.method, + model: ORCAROUTER_DEMO_MODEL, + promptLength: request.prompt.length, + task: request.task, + url: ORCAROUTER_API_URL, + }); + + const response = await fetchImplementation(ORCAROUTER_API_URL, httpRequest); + const payload = (await response.json()) as OrcaRouterChatCompletionResponse; + + if (!response.ok) { + throw new Error( + payload.error?.message ?? + `OrcaRouter request failed (${response.status} ${response.statusText}).`, + ); + } + + if (payload.choices?.[0]?.finish_reason === "length") { + throw new OrcaRouterOutputLimitError( + buildStudioLlmOutputLimitExceededMessage({ + maxTokens: ORCAROUTER_MAX_TOKENS, + provider: "OrcaRouter", + }), + ); + } + + const content = payload.choices?.[0]?.message?.content; + + if (!content) { + throw new Error("OrcaRouter response did not include any text content."); + } + + return content; +} diff --git a/demo/ppg-dev/server.ts b/demo/ppg-dev/server.ts index ee4f2f6b..6e1dc397 100644 --- a/demo/ppg-dev/server.ts +++ b/demo/ppg-dev/server.ts @@ -16,6 +16,10 @@ import type { Query } from "../../data/query"; import pkg from "../../package.json" with { type: "json" }; import { AnthropicOutputLimitError, runAnthropicLlmRequest } from "./anthropic"; import { buildDemoConfig, resolveDemoAiEnabled } from "./config"; +import { + OrcaRouterOutputLimitError, + runOrcaRouterLlmRequest, +} from "./orcarouter"; import { createDemoQueryInsightsStore } from "./query-insights"; import { type DemoRuntime, startDemoRuntime } from "./runtime"; import { @@ -90,11 +94,13 @@ const isProduction = prebuiltAssets !== null; const APP_PORT = Number.parseInt(process.env.STUDIO_DEMO_PORT ?? "4310", 10); const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? ""; +const ORCAROUTER_API_KEY = process.env.ORCAROUTER_API_KEY ?? ""; const AI_ENABLED = resolveDemoAiEnabled({ anthropicApiKey: ANTHROPIC_API_KEY, envValue: process.env.STUDIO_DEMO_AI_ENABLED ?? process.env.STUDIO_DEMO_AI_FILTERING_ENABLED, + orcaRouterApiKey: ORCAROUTER_API_KEY, }); const BOOT_ID = crypto.randomUUID(); const STREAMS_PROXY_BASE_PATH = "/api/streams"; @@ -664,19 +670,32 @@ async function handleAiRequest(request: Request): Promise { } try { - const text = await runAnthropicLlmRequest({ - apiKey: ANTHROPIC_API_KEY, - request: { - prompt, - task, - }, - }); + // When an OrcaRouter key is configured, the demo routes Studio's shared + // llm hook through OrcaRouter's OpenAI-compatible endpoint; otherwise it + // falls back to the Anthropic Messages API. + const text = + ORCAROUTER_API_KEY.trim().length > 0 + ? await runOrcaRouterLlmRequest({ + apiKey: ORCAROUTER_API_KEY, + request: { + prompt, + task, + }, + }) + : await runAnthropicLlmRequest({ + apiKey: ANTHROPIC_API_KEY, + request: { + prompt, + task, + }, + }); return createAiSuccessResponse(text); } catch (error) { return createAiErrorResponse({ code: - error instanceof AnthropicOutputLimitError + error instanceof AnthropicOutputLimitError || + error instanceof OrcaRouterOutputLimitError ? "output-limit-exceeded" : "request-failed", message: error instanceof Error ? error.message : String(error),