Skip to content
Merged
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
26 changes: 17 additions & 9 deletions apps/petrinaut-website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ provides a fake optimizer for isolated UI development.
| -------------------------------- | ---------------- | ---------------- | ---------------------------------------------------------- |
| `OPENAI_API_KEY` | for chat to work | `api/chat.ts` | OpenAI key the function uses to call `streamText`. |
| `OPENAI_VOICE_API_KEY` | for voice | voice API | Dedicated OpenAI key used to create Realtime WebRTC calls. |
| `PETRINAUT_OPENAI_VOICE_ENABLED` | no | voice API | Set to `true` to enable voice outside production. |
| `PETRINAUT_OPENAI_VOICE_ENABLED` | no | voice API | Set to `true` to enable voice, including in production. |
| `PETRINAUT_AI_MODEL` | no | `api/chat.ts` | Overrides the default OpenAI model id. |
| `PETRINAUT_OPT_ORIGIN` | no | `vite.config.ts` | Overrides the local optimizer proxy target. |
| `VITE_BRUNCH_CHAT_ENDPOINT` | for Brunch | website | Base URL of the mounted Brunch Flue route. |
Expand All @@ -84,12 +84,20 @@ provides a fake optimizer for isolated UI development.

Local values live in `.env.local`; Vite's `loadEnv` (see [`vite.config.ts`](vite.config.ts)) copies them into `process.env` for both the dev server and the API functions. In production, set these in the Vercel project settings.

### Brunch Voice mode preview
### Brunch Voice mode

Voice mode is disabled by default and always unavailable when `VERCEL_ENV` is
`production`. To exercise the preview locally or in a Vercel preview, set a
real `VITE_BRUNCH_CHAT_ENDPOINT`, `PETRINAUT_OPENAI_VOICE_ENABLED=true`, and a
dedicated `OPENAI_VOICE_API_KEY`.
Voice mode is disabled by default. To enable it, configure a real
`VITE_BRUNCH_CHAT_ENDPOINT`, set `PETRINAUT_OPENAI_VOICE_ENABLED=true`, and
provide a dedicated `OPENAI_VOICE_API_KEY`.

Production Voice is temporarily unauthenticated. The same-origin check rejects
ordinary cross-site browser requests, but a non-browser caller can spoof its
`Origin` header and create billable Realtime sessions. Use a dedicated OpenAI
project with low usage thresholds and alerts, monitor it while Voice is
enabled, and set `PETRINAUT_OPENAI_VOICE_ENABLED=false` immediately if usage is
unexpected. Revoke or rotate the dedicated `OPENAI_VOICE_API_KEY` in OpenAI,
then update the deployment secret before re-enabling Voice. FE-1622 tracks
adding caller authentication.

Text and Voice mode use one assistant transcript and composer. When Voice mode
is available, the empty first-run prompt and empty composer show a waveform
Expand Down Expand Up @@ -174,9 +182,9 @@ correlation. Browser and server diagnostics report only operation, stage,
outcome, duration, request ID, andβ€”where applicableβ€”status or a sanitized error
code. Voice responses also expose privacy-safe `Server-Timing` metrics. These
diagnostics never record audio, SDP, transcript or prompt contents, canonical
speech text, credentials, or provider response bodies. This controlled-preview
evidence does not enable production: production remains unconditionally
disabled by the server policy.
speech text, credentials, or provider response bodies. Production Voice remains
behind the explicit server configuration, which is an operational switch rather
than caller authentication.

## Testing the API against the built output

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/** @vitest-environment jsdom */
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";

import { LocalStorageDemoApp } from "./local-storage-demo-app";

import type {
AgentConversationObservationSnapshot,
FlueClient,
} from "@flue/sdk";

vi.hoisted(() => {
window.matchMedia = (media) => ({
media,
matches: false,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent: () => true,
});
});

const brunchPreviewConfig = vi.hoisted(() => ({
chatEndpoint: "/agents/chat",
isBrunchConfigured: true,
}));

const flueClient = vi.hoisted(() => ({ current: null as unknown }));

vi.mock("./brunch-preview-config", () => ({
resolveBrunchPreviewConfig: () => brunchPreviewConfig,
}));

vi.mock("./brunch-principal", () => ({
getOrCreateBrunchPrincipal: () => "test-principal",
}));

vi.mock("@flue/sdk", async (importOriginal) => {
const original = await importOriginal<typeof import("@flue/sdk")>();
return {
...original,
createFlueClient: () => flueClient.current,
};
});

vi.mock("@hashintel/petrinaut/ui", async (importOriginal) => {
const original =
await importOriginal<typeof import("@hashintel/petrinaut/ui")>();
const inertWorker = () => ({
addEventListener() {},
postMessage() {},
removeEventListener() {},
terminate() {},
});

return {
...original,
Petrinaut: (props: Parameters<typeof original.Petrinaut>[0]) => (
<original.Petrinaut
{...props}
lspWorkerFactory={inertWorker}
monteCarloWorkerFactory={inertWorker}
simulationWorkerFactory={inertWorker}
/>
),
};
});

const stubStorage = () => {
const entries = new Map<string, string>();
vi.stubGlobal("localStorage", {
get length() {
return entries.size;
},
clear: () => entries.clear(),
getItem: (key: string) => entries.get(key) ?? null,
key: (index: number) => [...entries.keys()].at(index) ?? null,
removeItem: (key: string) => entries.delete(key),
setItem: (key: string, value: string) => entries.set(key, value),
} satisfies Storage);
localStorage.setItem(
"petrinaut:user-settings",
JSON.stringify({ showWalkthroughOnInit: false }),
);
};

afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

test("renders the microphone action when Brunch and server Voice are available", async () => {
stubStorage();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
vi.stubGlobal(
"ResizeObserver",
class {
public disconnect() {}
public observe() {}
public unobserve() {}
},
);
const snapshot: AgentConversationObservationSnapshot = {
conversation: {
conversationId: "production-voice",
messages: [],
settlements: [],
},
error: undefined,
offset: "0",
phase: "live",
};
flueClient.current = {
observe: () => ({
close: () => {},
getSnapshot: () => snapshot,
refresh: () => {},
subscribe: () => () => {},
}),
} as Pick<FlueClient, "observe"> as FlueClient;
const fetch = vi.fn<typeof globalThis.fetch>(async () =>
Response.json({ available: true, connectionTimeoutMs: 15_000 }),
);
vi.stubGlobal("fetch", fetch);

render(<LocalStorageDemoApp onSearchChange={() => {}} search={{}} />);

fireEvent.click(
await screen.findByRole("button", { name: "Show AI assistant" }),
);

await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(
"/api/voice/config",
expect.objectContaining({ cache: "no-store", method: "GET" }),
),
);
expect(
await screen.findByRole("button", { name: "Start voice mode" }),
).not.toBeNull();
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { describe, expect, test } from "vitest";
import { createOpenAIVoiceConfigHandler } from "./openai-voice-config";

describe("OpenAI voice config handler", () => {
test("returns only server-derived availability and the client timeout", async () => {
test("returns configured production availability without exposing server settings", async () => {
const handler = createOpenAIVoiceConfigHandler({
OPENAI_VOICE_API_KEY: "server-secret",
PETRINAUT_OPENAI_VOICE_ENABLED: "true",
VERCEL_ENV: "preview",
VERCEL_ENV: "production",
});

const response = await handler(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,45 @@ describe("OpenAI voice policy", () => {
).toEqual({ available: false, connectionTimeoutMs: 15_000 });
});

test("fails closed in production while authentication and quotas are unavailable", () => {
test("keeps the enabled flag closed without a non-whitespace key", () => {
expect(
getOpenAIVoiceAvailability({
PETRINAUT_OPENAI_VOICE_ENABLED: "true",
}),
).toEqual({ available: false, connectionTimeoutMs: 15_000 });
expect(
getOpenAIVoiceAvailability({
OPENAI_VOICE_API_KEY: " \t\n ",
PETRINAUT_OPENAI_VOICE_ENABLED: "true",
}),
).toEqual({ available: false, connectionTimeoutMs: 15_000 });
});

test("requires the enabled flag's exact lowercase value", () => {
expect(
getOpenAIVoiceAvailability({
OPENAI_VOICE_API_KEY: "server-secret",
PETRINAUT_OPENAI_VOICE_ENABLED: "TRUE",
}),
).toEqual({ available: false, connectionTimeoutMs: 15_000 });
});

test("enables explicitly configured voice in production", () => {
expect(
getOpenAIVoiceAvailability({
OPENAI_VOICE_API_KEY: "server-secret",
PETRINAUT_OPENAI_VOICE_ENABLED: "true",
VERCEL_ENV: "production",
}),
).toEqual({ available: false, connectionTimeoutMs: 15_000 });
).toEqual({ available: true, connectionTimeoutMs: 15_000 });

expect(
getOpenAIVoiceAvailability({
NODE_ENV: "production",
OPENAI_VOICE_API_KEY: "server-secret",
PETRINAUT_OPENAI_VOICE_ENABLED: "true",
}),
).toEqual({ available: false, connectionTimeoutMs: 15_000 });
).toEqual({ available: true, connectionTimeoutMs: 15_000 });

expect(
getOpenAIVoiceAvailability({
Expand Down
10 changes: 3 additions & 7 deletions apps/petrinaut-website/src/server/voice/openai-voice-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,11 @@ interface VoiceEnvironment {
readonly VERCEL_ENV?: string;
}

const isNonProductionRuntime = (environment: VoiceEnvironment): boolean =>
environment.VERCEL_ENV === "preview" ||
environment.VERCEL_ENV === "development" ||
(environment.VERCEL_ENV === undefined &&
environment.NODE_ENV !== "production");

/**
* This operational provider switch is not caller authentication.
*/
export const getOpenAIVoiceAvailability = (environment: VoiceEnvironment) => ({
available:
isNonProductionRuntime(environment) &&
environment.PETRINAUT_OPENAI_VOICE_ENABLED === "true" &&
Boolean(environment.OPENAI_VOICE_API_KEY?.trim()),
connectionTimeoutMs: OPENAI_REALTIME_CONNECTION_TIMEOUT_MS,
Expand Down
Loading