Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <postgres-url> --streams-server-url <streams-url>`, or in streams-only mode through `pnpm demo:ppg -- --streams-server-url <streams-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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
18 changes: 18 additions & 0 deletions demo/ppg-dev/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
9 changes: 7 additions & 2 deletions demo/ppg-dev/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
197 changes: 197 additions & 0 deletions demo/ppg-dev/orcarouter.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>
) => ReturnType<typeof fetch>;

describe("runOrcaRouterLlmRequest", () => {
it("calls OrcaRouter's OpenAI-compatible endpoint and returns the first text choice", async () => {
const fetchImplementation = vi.fn<FetchLike>(() => {
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<FetchLike>(() => {
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<FetchLike>(() => {
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<FetchLike>(() => {
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.",
);
});
});
93 changes: 93 additions & 0 deletions demo/ppg-dev/orcarouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
buildStudioLlmOutputLimitExceededMessage,
type StudioLlmRequest,
} from "../../data/llm";

type FetchLike = (
...args: Parameters<typeof fetch>
) => ReturnType<typeof fetch>;

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<string> {
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;
}
Loading