feat: add authenticated voice pipeline API docs
CI / Verify and package (ubuntu-latest) (push) Canceled after 0s
CI / Verify and package (windows-latest) (push) Canceled after 0s

This commit is contained in:
snowspeeder
2026-08-04 19:09:33 -04:00
parent a2e4f1d576
commit 0793fa0ac4
14 changed files with 1874 additions and 9 deletions
+91
View File
@@ -0,0 +1,91 @@
# Voice Conversation API (v1)
`/api/v1/voice` is a server-side device API for native iOS and Echo-style clients. It is separate from the browser voice feature. Azure credentials remain on the PI WEB host.
## Security and provisioning
Create a device token on the PI WEB host:
```sh
pi-web voice-token create \
--project project-id --workspace workspace-id \
--model openai-codex/gpt-5.6-terra --thinking high
```
Each restriction flag may be repeated. Omitting a category grants `*` for that category. A restricted model or thinking scope requires the client to explicitly select an allowed value; it cannot inherit a server default. Project and workspace scopes are both enforced. Token records contain a SHA-256 token hash only and are persisted at mode `0600` in `~/.config/pi-web/voice-api.json`; the plaintext (`pwv1_...`) is printed only once.
```sh
pi-web voice-token list
pi-web voice-token revoke <token-id>
```
Use `Authorization: Bearer pwv1_...` on every HTTP and WebSocket connection. The API requires HTTPS, except loopback for local development/testing. A conversation is owned by the creating token: other valid device tokens receive `404` when they try to inspect, delete, or attach it. A client may select only a registered workspace id, never a filesystem path.
## HTTP
`GET /api/v1/voice/targets` returns only the caller's authorized registered workspaces plus effective scopes.
`POST /api/v1/voice/conversations`
```json
{
"workspaceId": "registered-workspace-id",
"model": "openai-codex/gpt-5.6-terra",
"thinking": "high",
"context": "You are helping with the home-automation project."
}
```
The response is `201` and includes the opaque conversation id, session id, selected workspace, and `input-ready` status. `GET` and `DELETE /api/v1/voice/conversations/:id` inspect and close the API conversation handle.
## WebSocket wire protocol
Connect to `wss://host/api/v1/voice/stream` with the Bearer header. The server first sends:
```json
{
"type":"hello",
"protocol":"pi-web.voice.v1",
"pcm":{"input":"s16le/16000/mono","output":"s16le/24000/mono"},
"binary":{"version":1,"headerBytes":8,"kind":"audio"}
}
```
Attach an owned conversation and wait for `input.ready` before sending a turn:
```json
{"type":"attach","conversationId":"..."}
```
### Text fallback
Send exactly one JSON input while `input-ready`:
```json
{"type":"input.text","text":"Turn on the kitchen lights."}
```
### Audio input
For speech input, send binary WebSocket frames containing raw signed little-endian 16-bit, 16 kHz, mono PCM (no WAV header). Frames must be nonempty, <=64 KiB, aligned to two bytes, and an utterance is limited to two minutes. Finish with:
```json
{"type":"input.end"}
```
Azure push-stream recognition emits `transcript.partial` and `transcript.final` JSON events. The final transcript is submitted to Pi. This Voice API never returns an Azure token or key to a device.
### Turn output and lifecycle
For either input form, the server emits `agent.working`, then `agent.accepted` after Pi accepted the prompt. It subscribes to the session event stream before submission and waits for Pi's native `agent.settled` event (not merely `agent.end`), with a two-minute turn timeout. `assistant.delta` is streamed while Pi answers; `assistant.final` contains only assistant text, never STT transcript.
Azure synthesis output is signed little-endian 16-bit, 24 kHz, mono PCM. Every binary server frame has this eight-byte header followed by PCM:
| Bytes | Meaning |
|---|---|
| 0 | protocol version: `1` |
| 1 | kind: `1` (audio) |
| 23 | reserved: `0` (big endian) |
| 47 | monotonically increasing frame sequence (uint32 big endian) |
The server then emits `{"type":"audio.end"}` and a fresh `input.ready`. Send `{"type":"close"}` or close the WebSocket to end; disconnecting closes STT/event resources and clears the handle's working state.
+87 -1
View File
@@ -24,6 +24,8 @@
"@codemirror/view": "^6.43.6",
"@fastify/compress": "^9.0.0",
"@fastify/static": "^9.3.0",
"@fastify/swagger": "^9.8.1",
"@fastify/swagger-ui": "^5.2.6",
"@fastify/websocket": "^11.3.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
@@ -3907,6 +3909,68 @@
"glob": "^13.0.0"
}
},
"node_modules/@fastify/swagger": {
"version": "9.8.1",
"resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.8.1.tgz",
"integrity": "sha512-VpHMnqZTY8iBZYJE8WWkbKPrXIYWy2rDfIf5qLr6DzZSpQYZ+KxQVcJFiq/AMlvNwI4gCBd66++iUlxXXGT0IQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"fastify-plugin": "^6.0.0",
"json-schema-resolver": "^3.0.0",
"openapi-types": "^12.1.3",
"rfdc": "^1.3.1",
"yaml": "^2.4.2"
}
},
"node_modules/@fastify/swagger-ui": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-5.2.6.tgz",
"integrity": "sha512-OMnms0O5s9wb6wis/K5nlrAMLsgUbr1GA8uphM41IasWe3AFdgxz6r/3bA9HTxlDNUYc2FGGKeqMp3ntxmSiNA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/static": "^9.1.2",
"fastify-plugin": "^5.0.0",
"openapi-types": "^12.1.3",
"rfdc": "^1.3.1",
"yaml": "^2.4.1"
}
},
"node_modules/@fastify/swagger-ui/node_modules/fastify-plugin": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/websocket": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.0.tgz",
@@ -7745,6 +7809,23 @@
"dequal": "^2.0.3"
}
},
"node_modules/json-schema-resolver": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz",
"integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==",
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"fast-uri": "^3.0.5",
"rfdc": "^1.1.4"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/Eomm/json-schema-resolver?sponsor=1"
}
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
@@ -8539,6 +8620,12 @@
}
}
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -10139,7 +10226,6 @@
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
+2
View File
@@ -70,6 +70,8 @@
"@codemirror/view": "^6.43.6",
"@fastify/compress": "^9.0.0",
"@fastify/static": "^9.3.0",
"@fastify/swagger": "^9.8.1",
"@fastify/swagger-ui": "^5.2.6",
"@fastify/websocket": "^11.3.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
+4
View File
@@ -26,8 +26,12 @@ describe("production build contents", () => {
try {
const fixtureDist = join(fixtureRoot, "dist", "server");
await mkdir(fixtureDist, { recursive: true });
// npm pack runs the package's prepare lifecycle even with --ignore-scripts
// on the npm version used in CI, so include its harmless fixture script.
await mkdir(join(fixtureRoot, "scripts"), { recursive: true });
await Promise.all([
copyFile(join(repoRoot, "package.json"), join(fixtureRoot, "package.json")),
copyFile(join(repoRoot, "scripts", "install-git-hooks.mjs"), join(fixtureRoot, "scripts", "install-git-hooks.mjs")),
writeFile(join(fixtureDist, "app.js"), "export {};\n", "utf8"),
writeFile(join(fixtureDist, "app.testSupport.js"), "export {};\n", "utf8"),
writeFile(join(fixtureDist, "app.testSupport.js.map"), "{}\n", "utf8"),
+48
View File
@@ -41,6 +41,7 @@ import {
nativeServicePrerequisiteShellCheck,
} from "./nativeServices/serviceProbe.js";
import { renderLaunchdPlist, renderSystemdUnit } from "./nativeServices/serviceRendering.js";
import { createVoiceToken, listVoiceTokens, revokeVoiceToken } from "./server/voiceApi.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
@@ -1060,6 +1061,9 @@ Usage:
pi-web start|stop|restart|status|logs
pi-web doctor
pi-web version
pi-web voice-token create [--model provider/model] [--thinking level] [--project project-id] [--workspace workspace-id]
pi-web voice-token list
pi-web voice-token revoke <token-id>
Recommended install:
npm install -g @jmfederico/pi-web --allow-scripts=node-pty
@@ -1078,11 +1082,55 @@ async function main(): Promise<void> {
else if (command === "logs") logs();
else if (command === "doctor") await doctor();
else if (command === "version") await printPiWebVersionReport();
else if (command === "voice-token") voiceToken(args);
else if (command === "--version" || command === "-v") console.log(packageVersion());
else if (command === "help" || command === "--help" || command === "-h") help();
else throw new Error(`Unknown command: ${command}`);
}
function voiceToken(args: string[]): void {
const [action, ...rest] = args;
if (action === "list") { console.log(JSON.stringify(listVoiceTokens(), null, 2)); return; }
if (action === "revoke") {
const id = rest[0];
if (id === undefined || !revokeVoiceToken(id)) throw new Error("Voice token was not found or is already revoked");
console.log(`Revoked voice token ${id}`);
return;
}
if (action === "create") {
const models: string[] = [];
const thinking: string[] = [];
const projects: string[] = [];
const workspaces: string[] = [];
for (let index = 0; index < rest.length; index += 1) {
const flag = rest[index];
const value = rest[index + 1];
if (
(flag !== "--model" &&
flag !== "--thinking" &&
flag !== "--project" &&
flag !== "--workspace") ||
value === undefined
)
throw new Error("Usage: pi-web voice-token create [--model provider/model] [--thinking level] [--project project-id] [--workspace workspace-id]");
if (flag === "--model") models.push(value);
else if (flag === "--thinking") thinking.push(value);
else if (flag === "--project") projects.push(value);
else workspaces.push(value);
index += 1;
}
const created = createVoiceToken({
...(models.length === 0 ? {} : { models }),
...(thinking.length === 0 ? {} : { thinking }),
...(projects.length === 0 ? {} : { projects }),
...(workspaces.length === 0 ? {} : { workspaces }),
});
console.log(`Voice token ${created.id} (shown once): ${created.token}`);
return;
}
throw new Error("Usage: pi-web voice-token create|list|revoke");
}
export function isCliEntrypoint(entrypoint: string | undefined = process.argv[1], modulePath: string = fileURLToPath(import.meta.url)): boolean {
if (entrypoint === undefined) return false;
if (entrypoint === modulePath) return true;
+10
View File
@@ -6,6 +6,8 @@ import type { ServerOptions as HttpsServerOptions } from "node:https";
import fastifyCompress from "@fastify/compress";
import fastifyStatic from "@fastify/static";
import fastifyWebsocket from "@fastify/websocket";
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUi from "@fastify/swagger-ui";
import { ProjectStore } from "./storage/projectStore.js";
import { ProjectService } from "./projects/projectService.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
@@ -22,6 +24,8 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { registerAzureSpeechRoutes } from "./azureSpeechRoutes.js";
import { voiceApiOpenApi } from "./voiceApiOpenApi.js";
import { registerVoiceApiRoutes } from "./voiceApi.js";
import { PiWebPluginService } from "./piWebPluginService.js";
import { createActiveProfilePiPackageService, type PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
@@ -168,6 +172,11 @@ export async function buildApp(deps: AppDependencies = {}) {
threshold: 1024,
});
await app.register(fastifyWebsocket);
await app.register(fastifySwagger, { mode: "static", specification: { document: voiceApiOpenApi } });
await app.register(fastifySwaggerUi, {
routePrefix: "/api/v1/voice/docs",
uiConfig: { docExpansion: "list", persistAuthorization: true },
});
const projects = deps.projects ?? new ProjectService(new ProjectStore());
const workspaces = deps.workspaces ?? new WorkspaceService();
@@ -223,6 +232,7 @@ export async function buildApp(deps: AppDependencies = {}) {
registerLocalMachineConfigRoutes(app, invalidatingConfigService);
registerAzureSpeechRoutes(app);
registerAzureSpeechRoutes(app, undefined, "/api/machines/local");
registerVoiceApiRoutes(app, { projects, workspaces, daemon: sessionDaemon });
registerMachineRoutes(app, machines);
registerMachinePluginProxyRoutes(app, machines);
+47 -8
View File
@@ -1,12 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import Fastify from "fastify";
import { registerAzureSpeechRoutes, type AzureSpeechService } from "./azureSpeechRoutes.js";
import {
registerAzureSpeechRoutes,
type AzureSpeechService,
} from "./azureSpeechRoutes.js";
function service(): AzureSpeechService {
return {
settings: vi.fn(() => ({ region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural", hasApiKey: true })),
update: vi.fn(() => ({ region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural", hasApiKey: true })),
token: vi.fn(async () => ({ token: "short-lived-token", region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural" })),
settings: vi.fn(() => ({
region: "eastus",
voice: "en-US-Andrew:DragonHDLatestNeural",
hasApiKey: true,
})),
update: vi.fn(() => ({
region: "eastus",
voice: "en-US-Andrew:DragonHDLatestNeural",
hasApiKey: true,
})),
token: vi.fn(() =>
Promise.resolve({
token: "short-lived-token",
region: "eastus",
voice: "en-US-Andrew:DragonHDLatestNeural",
})
),
};
}
@@ -18,12 +35,29 @@ describe("Azure Speech routes", () => {
const get = await app.inject({ method: "GET", url: "/api/azure-speech" });
expect(get.statusCode).toBe(200);
expect(get.json()).toEqual({ region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural", hasApiKey: true });
expect(get.json()).toEqual({
region: "eastus",
voice: "en-US-Andrew:DragonHDLatestNeural",
hasApiKey: true,
});
expect(get.body).not.toContain("apiKey");
const put = await app.inject({ method: "PUT", url: "/api/azure-speech", payload: { region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural", apiKey: "a".repeat(32) } });
const put = await app.inject({
method: "PUT",
url: "/api/azure-speech",
payload: {
region: "eastus",
voice: "en-US-Andrew:DragonHDLatestNeural",
apiKey: "a".repeat(32),
},
});
expect(put.statusCode).toBe(200);
expect(azure.update).toHaveBeenCalledWith({ region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural", apiKey: "a".repeat(32) });
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(azure.update).toHaveBeenCalledWith({
region: "eastus",
voice: "en-US-Andrew:DragonHDLatestNeural",
apiKey: "a".repeat(32),
});
expect(put.body).not.toContain("apiKey");
await app.close();
});
@@ -32,8 +66,13 @@ describe("Azure Speech routes", () => {
const app = Fastify();
const azure = service();
registerAzureSpeechRoutes(app, azure);
const response = await app.inject({ method: "PUT", url: "/api/azure-speech", payload: { region: "eastus!", voice: "voice" } });
const response = await app.inject({
method: "PUT",
url: "/api/azure-speech",
payload: { region: "eastus!", voice: "voice" },
});
expect(response.statusCode).toBe(400);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(azure.update).not.toHaveBeenCalled();
await app.close();
});
+9
View File
@@ -10,6 +10,15 @@ interface AzureSpeechSecret {
apiKey?: string;
}
/** Server-only Azure credentials. Never expose this shape through an HTTP route. */
export interface AzureSpeechServerSettings { region: string; voice: string; apiKey: string }
export function readAzureSpeechServerSettings(path = join(dirname(piWebConfigPath()), "azure-speech.json")): AzureSpeechServerSettings {
const secret = readSecret(path);
if (secret.apiKey === undefined || secret.region === "") throw new AzureSpeechConfigurationError("Azure Speech is not configured. Add a Speech key and region in Settings → Azure Speech.");
return { region: secret.region, voice: secret.voice, apiKey: secret.apiKey };
}
export interface AzureSpeechService {
settings(): AzureSpeechSettings;
update(input: AzureSpeechSettingsUpdate): AzureSpeechSettings;
@@ -65,6 +65,21 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("forwards Pi's native agent_settled event without inferring it from agent_end", async () => {
const { fake, service, events } = messagesService([]);
await service.status(sessionRef("session-1"));
fake.emit({ type: "agent_end" });
await new Promise<void>((resolve) => setImmediate(resolve));
expect(events.sessionEvents.map(({ event }) => event).filter((event) => event.type === "agent.settled")).toEqual([]);
fake.emit({ type: "agent_settled" });
expect(events.sessionEvents.map(({ event }) => event).filter((event) => event.type === "agent.settled")).toEqual([
{ type: "agent.settled" },
]);
await service.dispose();
});
it("annotates the join-time stream snapshot partial with the current thinking level", async () => {
const streamingMessage = {
role: "assistant",
+4
View File
@@ -3224,6 +3224,9 @@ export class PiSessionService implements SessionRouteService {
this.publishActivityForEvent(session, event);
const eventType = getString(event, "type");
if (eventType === "agent_end") this.abortRunScopedExtensionDialogs(session.sessionId);
// Pi itself emits agent_settled only after automatic retries, compaction,
// and queued continuations are exhausted. Preserve that native lifecycle
// event; never infer it from agent_end or mutable runtime flags.
if (eventType === "compaction_end") this.scheduleCompactionQueueDrain(session.sessionId);
if (eventType === "agent_start" || eventType === "agent_end") this.scheduleCompactionQueueDrain(session.sessionId);
this.publishStatus(session);
@@ -4209,6 +4212,7 @@ function toClientEvent(event: unknown, thinkingLevel?: string): SessionUiEvent {
}
if (eventType === "agent_start") return { type: "agent.start" };
if (eventType === "agent_end") return { type: "agent.end" };
if (eventType === "agent_settled") return { type: "agent.settled" };
if (eventType === "message_end") {
const message = getProperty(event, "message");
if (message === undefined) return { type: "message.end" };
+478
View File
@@ -0,0 +1,478 @@
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import fastifyWebsocket from "@fastify/websocket";
import Fastify from "fastify";
import WebSocket, { type RawData } from "ws";
import { afterEach, describe, expect, it } from "vitest";
import {
createVoiceToken,
encodeAudioFrame,
registerVoiceApiRoutes,
revokeVoiceToken,
} from "./voiceApi.js";
const temporary: string[] = [];
afterEach(() => {
for (const path of temporary.splice(0))
rmSync(path, { recursive: true, force: true });
});
function setup() {
const directory = mkdtempSync(join(tmpdir(), "pi-web-voice-api-"));
temporary.push(directory);
const configPath = join(directory, "voice-api.json");
const daemon = {
request: (method: string, path: string, body?: unknown) => {
if (method === "POST" && path === "/sessions")
return Promise.resolve(response(200, { id: "session-1" }));
if (method === "POST" && path.endsWith("/model"))
return Promise.resolve(response(200, {}));
if (method === "POST" && path.endsWith("/thinking-level"))
return Promise.resolve(response(200, {}));
if (method === "POST" && path.endsWith("/prompt"))
return Promise.resolve(response(200, { accepted: true, body }));
return Promise.resolve(response(404, { error: "not found" }));
},
connectWebSocket: () => {
throw new Error("not used");
},
};
const app = Fastify();
registerVoiceApiRoutes(app, {
configPath,
daemon,
projects: {
list: () =>
Promise.resolve([
{
id: "project-1",
name: "Home",
path: "/home/hope/home",
createdAt: new Date(0).toISOString(),
},
]),
},
workspaces: {
list: () =>
Promise.resolve([
{
id: "workspace-1",
projectId: "project-1",
path: "/home/hope/home",
label: "Home",
isMain: true,
isGitRepo: false,
isGitWorktree: false,
},
]),
},
});
return { app, configPath };
}
function response(statusCode: number, body: unknown) {
return { statusCode, headers: {}, body: JSON.stringify(body) };
}
function websocketConnectingState(): number {
return WebSocket.CONNECTING;
}
function rawDataToBuffer(data: RawData): Buffer {
if (Buffer.isBuffer(data)) return data;
if (data instanceof ArrayBuffer) return Buffer.from(data);
return Buffer.concat(data);
}
function responseId(body: string): string {
const parsed: unknown = JSON.parse(body);
if (
typeof parsed !== "object" ||
parsed === null ||
!("id" in parsed) ||
typeof parsed.id !== "string"
) {
throw new Error("conversation response is missing an id");
}
return parsed.id;
}
describe("voice API", () => {
it("frames synthesized PCM with a versioned kind and monotonic sequence header", () => {
const frame = encodeAudioFrame(42, Buffer.from([1, 2, 3, 4]));
expect(frame.subarray(0, 8)).toEqual(
Buffer.from([1, 1, 0, 0, 0, 0, 0, 42])
);
expect(frame.subarray(8)).toEqual(Buffer.from([1, 2, 3, 4]));
});
it("accepts PCM, emits recognition text, waits for agent.settled, then streams framed synthesis", async () => {
const directory = mkdtempSync(join(tmpdir(), "pi-web-voice-api-"));
temporary.push(directory);
const events = Object.assign(new EventEmitter(), {
close: () => undefined,
readyState: websocketConnectingState(),
});
const daemon = {
request: (method: string, path: string) => {
if (method === "POST" && path === "/sessions")
return Promise.resolve(response(200, { id: "session-1" }));
if (method === "POST" && path.endsWith("/prompt")) {
expect(events.listenerCount("message")).toBeGreaterThan(0);
queueMicrotask(() => {
events.emit(
"message",
Buffer.from(
JSON.stringify({
type: "assistant.delta",
text: "Settled reply",
})
)
);
events.emit(
"message",
Buffer.from(JSON.stringify({ type: "agent.settled" }))
);
});
return Promise.resolve(response(200, { accepted: true }));
}
return Promise.resolve(response(200, {}));
},
// EventEmitter supplies exactly the message/error/close surface the bridge consumes.
connectWebSocket: () => {
queueMicrotask(() => {
events.readyState = WebSocket.OPEN;
events.emit("open");
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return events as unknown as WebSocket;
},
};
const speech = {
recognize: (handlers: {
partial(text: string): void;
final(text: string): void;
error(error: Error): void;
}) => ({
write: () => {
handlers.partial("hello");
},
end: () => {
handlers.final("hello");
return Promise.resolve();
},
close: () => undefined,
}),
synthesize: (_text: string, onAudio: (pcm: Buffer) => void) => {
onAudio(Buffer.from([7, 8]));
return Promise.resolve();
},
};
const app = Fastify();
await app.register(fastifyWebsocket);
const configPath = join(directory, "voice-api.json");
registerVoiceApiRoutes(app, {
configPath,
daemon,
speech,
projects: {
list: () =>
Promise.resolve([
{ id: "p", name: "P", path: "/tmp", createdAt: "" },
]),
},
workspaces: {
list: () =>
Promise.resolve([
{
id: "w",
projectId: "p",
path: "/tmp",
label: "W",
isMain: true,
isGitRepo: false,
isGitWorktree: false,
},
]),
},
});
const token = createVoiceToken({}, configPath).token;
const created = await app.inject({
method: "POST",
url: "/api/v1/voice/conversations",
headers: { authorization: `Bearer ${token}` },
payload: { workspaceId: "w" },
});
const address = await app.listen({ port: 0, host: "127.0.0.1" });
const conversationId = responseId(created.body);
const received: (string | Buffer)[] = [];
await new Promise<void>((resolve, reject) => {
const client = new WebSocket(
address.replace("http", "ws") + "/api/v1/voice/stream",
{ headers: { authorization: `Bearer ${token}` } }
);
client.on("open", () => {
client.send(JSON.stringify({ type: "attach", conversationId }));
client.send(Buffer.from([0, 0]));
client.send(JSON.stringify({ type: "input.end" }));
});
client.on("message", (data, binary) => {
const message = rawDataToBuffer(data).toString();
received.push(binary ? rawDataToBuffer(data) : message);
if (
!binary &&
message.includes("input.ready") &&
received.some((item) => Buffer.isBuffer(item))
) {
client.close();
resolve();
}
});
client.on("error", (error) => {
reject(error);
});
});
expect(
received
.filter((item): item is string => typeof item === "string")
.join(" ")
).toContain("transcript.partial");
const jsonEvents = received
.filter((item): item is string => typeof item === "string")
.map((item): unknown => JSON.parse(item));
expect(jsonEvents).toContainEqual({
type: "assistant.final",
text: "Settled reply",
});
expect(received.find((item) => Buffer.isBuffer(item))).toEqual(
Buffer.from([1, 1, 0, 0, 0, 0, 0, 0, 7, 8])
);
await app.close();
});
it("requires a hashed bearer token and only returns registered workspaces", async () => {
const { app, configPath } = setup();
const created = createVoiceToken(
{ models: ["openai/gpt-5"], thinking: ["high"] },
configPath
);
const denied = await app.inject({
method: "GET",
url: "/api/v1/voice/targets",
});
expect(denied.statusCode).toBe(401);
const allowed = await app.inject({
method: "GET",
url: "/api/v1/voice/targets",
headers: { authorization: `Bearer ${created.token}` },
});
expect(allowed.json()).toMatchObject({
workspaces: [{ id: "workspace-1", path: "/home/hope/home" }],
scopes: { models: ["openai/gpt-5"], thinking: ["high"] },
});
expect(revokeVoiceToken(created.id, configPath)).toBe(true);
expect(
(
await app.inject({
method: "GET",
url: "/api/v1/voice/targets",
headers: { authorization: `Bearer ${created.token}` },
})
).statusCode
).toBe(401);
await app.close();
});
it("creates a scoped conversation and rejects arbitrary workspace/model choices", async () => {
const { app, configPath } = setup();
const token = createVoiceToken(
{ models: ["openai/gpt-5"], thinking: ["high"] },
configPath
).token;
const headers = { authorization: `Bearer ${token}` };
const missingRestrictedModel = await app.inject({
method: "POST",
url: "/api/v1/voice/conversations",
headers,
payload: { workspaceId: "workspace-1", thinking: "high" },
});
expect(missingRestrictedModel.statusCode).toBe(400);
const badWorkspace = await app.inject({
method: "POST",
url: "/api/v1/voice/conversations",
headers,
payload: { workspaceId: "/etc", model: "openai/gpt-5", thinking: "high" },
});
expect(badWorkspace.statusCode).toBe(400);
const badModel = await app.inject({
method: "POST",
url: "/api/v1/voice/conversations",
headers,
payload: {
workspaceId: "workspace-1",
model: "other/model",
thinking: "high",
},
});
expect(badModel.statusCode).toBe(400);
const created = await app.inject({
method: "POST",
url: "/api/v1/voice/conversations",
headers,
payload: {
workspaceId: "workspace-1",
model: "openai/gpt-5",
thinking: "high",
context: "Be concise",
},
});
expect(created.statusCode).toBe(201);
expect(created.json()).toMatchObject({
sessionId: "session-1",
workspaceId: "workspace-1",
status: "input-ready",
});
await app.close();
});
it("filters project/workspace scopes and isolates conversations by token owner", async () => {
const { app, configPath } = setup();
const owner = createVoiceToken(
{
projects: ["project-1"],
workspaces: ["workspace-1"],
models: ["openai/gpt-5"],
thinking: ["high"],
},
configPath
).token;
const other = createVoiceToken({}, configPath).token;
const headers = { authorization: `Bearer ${owner}` };
const targets = await app.inject({
method: "GET",
url: "/api/v1/voice/targets",
headers,
});
expect(targets.json()).toMatchObject({
scopes: {
projects: ["project-1"],
workspaces: ["workspace-1"],
},
workspaces: [{ id: "workspace-1" }],
});
const created = await app.inject({
method: "POST",
url: "/api/v1/voice/conversations",
headers,
payload: {
workspaceId: "workspace-1",
model: "openai/gpt-5",
thinking: "high",
},
});
const id = responseId(created.body);
expect(
(
await app.inject({
method: "GET",
url: `/api/v1/voice/conversations/${id}`,
headers: { authorization: `Bearer ${other}` },
})
).statusCode
).toBe(404);
expect(
(
await app.inject({
method: "DELETE",
url: `/api/v1/voice/conversations/${id}`,
headers: { authorization: `Bearer ${other}` },
})
).statusCode
).toBe(404);
expect(
(
await app.inject({
method: "GET",
url: `/api/v1/voice/conversations/${id}`,
headers,
})
).statusCode
).toBe(200);
await app.close();
});
it("rejects WebSocket attachment by a different valid device token", async () => {
const directory = mkdtempSync(join(tmpdir(), "pi-web-voice-api-"));
temporary.push(directory);
const configPath = join(directory, "voice-api.json");
const app = Fastify();
await app.register(fastifyWebsocket);
registerVoiceApiRoutes(app, {
configPath,
daemon: {
request: () => Promise.resolve(response(200, { id: "session-1" })),
connectWebSocket: () => {
throw new Error("no turn should start");
},
},
speech: {
recognize: () => ({
write: () => undefined,
end: () => Promise.resolve(),
close: () => undefined,
}),
synthesize: () => Promise.resolve(),
},
projects: {
list: () => Promise.resolve([{ id: "p", name: "P", path: "/tmp", createdAt: "" }]),
},
workspaces: {
list: () => Promise.resolve([{
id: "w",
projectId: "p",
path: "/tmp",
label: "W",
isMain: true,
isGitRepo: false,
isGitWorktree: false,
}]),
},
});
const owner = createVoiceToken({}, configPath).token;
const intruder = createVoiceToken({}, configPath).token;
const created = await app.inject({
method: "POST",
url: "/api/v1/voice/conversations",
headers: { authorization: `Bearer ${owner}` },
payload: { workspaceId: "w" },
});
const address = await app.listen({ port: 0, host: "127.0.0.1" });
const result = await new Promise<{ type?: string; error?: string }>((resolve, reject) => {
const client = new WebSocket(
address.replace("http", "ws") + "/api/v1/voice/stream",
{ headers: { authorization: `Bearer ${intruder}` } }
);
client.on("open", () => {
client.send(JSON.stringify({ type: "attach", conversationId: responseId(created.body) }));
});
client.on("message", (data, binary) => {
if (binary) return;
const payload: unknown = JSON.parse(rawDataToBuffer(data).toString());
if (
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof payload.error === "string"
) {
client.close();
resolve({ type: "error", error: payload.error });
}
});
client.on("error", reject);
});
expect(result).toEqual({ type: "error", error: "conversation not found" });
await app.close();
});
});
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
import type { OpenAPIV3 } from "openapi-types";
export const voiceApiOpenApi: OpenAPIV3.Document = {
openapi: "3.0.3",
info: {
title: "PI WEB Voice Pipeline API",
version: "1.0.0",
description: "Authenticated, server-side Azure Speech voice conversations for native and embedded clients. Audio is sent over the documented WebSocket protocol; Azure credentials never leave PI WEB.",
},
servers: [{ url: "/", description: "Current PI WEB HTTPS origin" }],
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "pwv1 device token" },
},
schemas: {
Error: { type: "object", required: ["error"], properties: { error: { type: "string" } } },
VoiceTarget: { type: "object", required: ["id", "projectId", "label", "path"], properties: { id: { type: "string" }, projectId: { type: "string" }, label: { type: "string" }, path: { type: "string", description: "Registered workspace directory" } } },
CreateConversation: { type: "object", required: ["workspaceId"], properties: { workspaceId: { type: "string" }, model: { type: "string", example: "openai-codex/gpt-5.6-terra" }, thinking: { type: "string", example: "high" }, context: { type: "string", maxLength: 12000, description: "Additional instructions appended to the turn" } } },
Conversation: { type: "object", required: ["id", "sessionId", "workspaceId", "cwd", "status"], properties: { id: { type: "string" }, sessionId: { type: "string" }, workspaceId: { type: "string" }, cwd: { type: "string" }, status: { type: "string", enum: ["input-ready", "working", "closed"] }, model: { type: "string" }, thinking: { type: "string" } } },
},
},
security: [{ bearerAuth: [] }],
paths: {
"/api/v1/voice/targets": {
get: { summary: "List authorized voice targets", responses: { "200": { description: "Registered workspaces permitted by this device token", content: { "application/json": { schema: { type: "object", required: ["workspaces", "scopes"], properties: { workspaces: { type: "array", items: { $ref: "#/components/schemas/VoiceTarget" } }, scopes: { type: "object" } } } } } }, "401": { description: "Missing or invalid token", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } } },
},
"/api/v1/voice/conversations": {
post: { summary: "Create a voice conversation", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/CreateConversation" } } } }, responses: { "201": { description: "Conversation created", content: { "application/json": { schema: { $ref: "#/components/schemas/Conversation" } } } }, "400": { description: "Invalid or unauthorized requested scope", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } } },
},
"/api/v1/voice/conversations/{id}": {
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
get: { summary: "Read a conversation", responses: { "200": { description: "Conversation", content: { "application/json": { schema: { $ref: "#/components/schemas/Conversation" } } } }, "404": { description: "Not found or not owned by this token" } } },
delete: { summary: "Close a conversation", responses: { "204": { description: "Closed" }, "404": { description: "Not found or not owned by this token" } } },
},
"/api/v1/voice/stream": {
get: { summary: "Voice audio WebSocket", description: "Upgrade to WebSocket using the bearer token. Send `attach`, then binary signed 16-bit little-endian PCM at 16 kHz mono, followed by `input.end`. Server emits transcript JSON, `assistant.final`, and raw 24 kHz mono PCM binary frames. See docs/voice-api.md for the binary frame layout.", responses: { "101": { description: "WebSocket upgraded" }, "401": { description: "Invalid token" } } },
},
},
};
+2
View File
@@ -1283,6 +1283,8 @@ type SessionUiEventBody =
| { type: "shell.end"; output?: string; exitCode?: number | null; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string; isError?: boolean }
| { type: "agent.start" }
| { type: "agent.end" }
/** Pi-native completion: no retry, compaction retry, or queued continuation remains. */
| { type: "agent.settled" }
| { type: "message.end"; message?: unknown }
| { type: "status.update"; status: SessionStatus }
| { type: "activity.update"; activity: SessionActivity }