Archived
feat: add authenticated voice pipeline API docs
This commit is contained in:
@@ -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) |
|
||||||
|
| 2–3 | reserved: `0` (big endian) |
|
||||||
|
| 4–7 | 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.
|
||||||
Generated
+87
-1
@@ -24,6 +24,8 @@
|
|||||||
"@codemirror/view": "^6.43.6",
|
"@codemirror/view": "^6.43.6",
|
||||||
"@fastify/compress": "^9.0.0",
|
"@fastify/compress": "^9.0.0",
|
||||||
"@fastify/static": "^9.3.0",
|
"@fastify/static": "^9.3.0",
|
||||||
|
"@fastify/swagger": "^9.8.1",
|
||||||
|
"@fastify/swagger-ui": "^5.2.6",
|
||||||
"@fastify/websocket": "^11.3.0",
|
"@fastify/websocket": "^11.3.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
@@ -3907,6 +3909,68 @@
|
|||||||
"glob": "^13.0.0"
|
"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": {
|
"node_modules/@fastify/websocket": {
|
||||||
"version": "11.3.0",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.0.tgz",
|
||||||
@@ -7745,6 +7809,23 @@
|
|||||||
"dequal": "^2.0.3"
|
"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": {
|
"node_modules/json-schema-to-ts": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -10139,7 +10226,6 @@
|
|||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
|
|||||||
@@ -70,6 +70,8 @@
|
|||||||
"@codemirror/view": "^6.43.6",
|
"@codemirror/view": "^6.43.6",
|
||||||
"@fastify/compress": "^9.0.0",
|
"@fastify/compress": "^9.0.0",
|
||||||
"@fastify/static": "^9.3.0",
|
"@fastify/static": "^9.3.0",
|
||||||
|
"@fastify/swagger": "^9.8.1",
|
||||||
|
"@fastify/swagger-ui": "^5.2.6",
|
||||||
"@fastify/websocket": "^11.3.0",
|
"@fastify/websocket": "^11.3.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
|||||||
@@ -26,8 +26,12 @@ describe("production build contents", () => {
|
|||||||
try {
|
try {
|
||||||
const fixtureDist = join(fixtureRoot, "dist", "server");
|
const fixtureDist = join(fixtureRoot, "dist", "server");
|
||||||
await mkdir(fixtureDist, { recursive: true });
|
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([
|
await Promise.all([
|
||||||
copyFile(join(repoRoot, "package.json"), join(fixtureRoot, "package.json")),
|
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.js"), "export {};\n", "utf8"),
|
||||||
writeFile(join(fixtureDist, "app.testSupport.js"), "export {};\n", "utf8"),
|
writeFile(join(fixtureDist, "app.testSupport.js"), "export {};\n", "utf8"),
|
||||||
writeFile(join(fixtureDist, "app.testSupport.js.map"), "{}\n", "utf8"),
|
writeFile(join(fixtureDist, "app.testSupport.js.map"), "{}\n", "utf8"),
|
||||||
|
|||||||
+48
@@ -41,6 +41,7 @@ import {
|
|||||||
nativeServicePrerequisiteShellCheck,
|
nativeServicePrerequisiteShellCheck,
|
||||||
} from "./nativeServices/serviceProbe.js";
|
} from "./nativeServices/serviceProbe.js";
|
||||||
import { renderLaunchdPlist, renderSystemdUnit } from "./nativeServices/serviceRendering.js";
|
import { renderLaunchdPlist, renderSystemdUnit } from "./nativeServices/serviceRendering.js";
|
||||||
|
import { createVoiceToken, listVoiceTokens, revokeVoiceToken } from "./server/voiceApi.js";
|
||||||
|
|
||||||
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
|
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
|
||||||
|
|
||||||
@@ -1060,6 +1061,9 @@ Usage:
|
|||||||
pi-web start|stop|restart|status|logs
|
pi-web start|stop|restart|status|logs
|
||||||
pi-web doctor
|
pi-web doctor
|
||||||
pi-web version
|
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:
|
Recommended install:
|
||||||
npm install -g @jmfederico/pi-web --allow-scripts=node-pty
|
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 === "logs") logs();
|
||||||
else if (command === "doctor") await doctor();
|
else if (command === "doctor") await doctor();
|
||||||
else if (command === "version") await printPiWebVersionReport();
|
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 === "--version" || command === "-v") console.log(packageVersion());
|
||||||
else if (command === "help" || command === "--help" || command === "-h") help();
|
else if (command === "help" || command === "--help" || command === "-h") help();
|
||||||
else throw new Error(`Unknown command: ${command}`);
|
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 {
|
export function isCliEntrypoint(entrypoint: string | undefined = process.argv[1], modulePath: string = fileURLToPath(import.meta.url)): boolean {
|
||||||
if (entrypoint === undefined) return false;
|
if (entrypoint === undefined) return false;
|
||||||
if (entrypoint === modulePath) return true;
|
if (entrypoint === modulePath) return true;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { ServerOptions as HttpsServerOptions } from "node:https";
|
|||||||
import fastifyCompress from "@fastify/compress";
|
import fastifyCompress from "@fastify/compress";
|
||||||
import fastifyStatic from "@fastify/static";
|
import fastifyStatic from "@fastify/static";
|
||||||
import fastifyWebsocket from "@fastify/websocket";
|
import fastifyWebsocket from "@fastify/websocket";
|
||||||
|
import fastifySwagger from "@fastify/swagger";
|
||||||
|
import fastifySwaggerUi from "@fastify/swagger-ui";
|
||||||
import { ProjectStore } from "./storage/projectStore.js";
|
import { ProjectStore } from "./storage/projectStore.js";
|
||||||
import { ProjectService } from "./projects/projectService.js";
|
import { ProjectService } from "./projects/projectService.js";
|
||||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||||
@@ -22,6 +24,8 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
|||||||
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
|
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
|
||||||
import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||||
import { registerAzureSpeechRoutes } from "./azureSpeechRoutes.js";
|
import { registerAzureSpeechRoutes } from "./azureSpeechRoutes.js";
|
||||||
|
import { voiceApiOpenApi } from "./voiceApiOpenApi.js";
|
||||||
|
import { registerVoiceApiRoutes } from "./voiceApi.js";
|
||||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||||
import { createActiveProfilePiPackageService, type PiPackageService } from "./piPackageService.js";
|
import { createActiveProfilePiPackageService, type PiPackageService } from "./piPackageService.js";
|
||||||
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
|
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
|
||||||
@@ -168,6 +172,11 @@ export async function buildApp(deps: AppDependencies = {}) {
|
|||||||
threshold: 1024,
|
threshold: 1024,
|
||||||
});
|
});
|
||||||
await app.register(fastifyWebsocket);
|
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 projects = deps.projects ?? new ProjectService(new ProjectStore());
|
||||||
const workspaces = deps.workspaces ?? new WorkspaceService();
|
const workspaces = deps.workspaces ?? new WorkspaceService();
|
||||||
@@ -223,6 +232,7 @@ export async function buildApp(deps: AppDependencies = {}) {
|
|||||||
registerLocalMachineConfigRoutes(app, invalidatingConfigService);
|
registerLocalMachineConfigRoutes(app, invalidatingConfigService);
|
||||||
registerAzureSpeechRoutes(app);
|
registerAzureSpeechRoutes(app);
|
||||||
registerAzureSpeechRoutes(app, undefined, "/api/machines/local");
|
registerAzureSpeechRoutes(app, undefined, "/api/machines/local");
|
||||||
|
registerVoiceApiRoutes(app, { projects, workspaces, daemon: sessionDaemon });
|
||||||
|
|
||||||
registerMachineRoutes(app, machines);
|
registerMachineRoutes(app, machines);
|
||||||
registerMachinePluginProxyRoutes(app, machines);
|
registerMachinePluginProxyRoutes(app, machines);
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import { registerAzureSpeechRoutes, type AzureSpeechService } from "./azureSpeechRoutes.js";
|
import {
|
||||||
|
registerAzureSpeechRoutes,
|
||||||
|
type AzureSpeechService,
|
||||||
|
} from "./azureSpeechRoutes.js";
|
||||||
|
|
||||||
function service(): AzureSpeechService {
|
function service(): AzureSpeechService {
|
||||||
return {
|
return {
|
||||||
settings: vi.fn(() => ({ region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural", hasApiKey: true })),
|
settings: vi.fn(() => ({
|
||||||
update: vi.fn(() => ({ region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural", hasApiKey: true })),
|
region: "eastus",
|
||||||
token: vi.fn(async () => ({ token: "short-lived-token", region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural" })),
|
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" });
|
const get = await app.inject({ method: "GET", url: "/api/azure-speech" });
|
||||||
expect(get.statusCode).toBe(200);
|
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");
|
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(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");
|
expect(put.body).not.toContain("apiKey");
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
@@ -32,8 +66,13 @@ describe("Azure Speech routes", () => {
|
|||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
const azure = service();
|
const azure = service();
|
||||||
registerAzureSpeechRoutes(app, azure);
|
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);
|
expect(response.statusCode).toBe(400);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||||
expect(azure.update).not.toHaveBeenCalled();
|
expect(azure.update).not.toHaveBeenCalled();
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ interface AzureSpeechSecret {
|
|||||||
apiKey?: string;
|
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 {
|
export interface AzureSpeechService {
|
||||||
settings(): AzureSpeechSettings;
|
settings(): AzureSpeechSettings;
|
||||||
update(input: AzureSpeechSettingsUpdate): AzureSpeechSettings;
|
update(input: AzureSpeechSettingsUpdate): AzureSpeechSettings;
|
||||||
|
|||||||
@@ -65,6 +65,21 @@ describe("PiSessionService", () => {
|
|||||||
await service.dispose();
|
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 () => {
|
it("annotates the join-time stream snapshot partial with the current thinking level", async () => {
|
||||||
const streamingMessage = {
|
const streamingMessage = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
|
|||||||
@@ -3224,6 +3224,9 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
this.publishActivityForEvent(session, event);
|
this.publishActivityForEvent(session, event);
|
||||||
const eventType = getString(event, "type");
|
const eventType = getString(event, "type");
|
||||||
if (eventType === "agent_end") this.abortRunScopedExtensionDialogs(session.sessionId);
|
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 === "compaction_end") this.scheduleCompactionQueueDrain(session.sessionId);
|
||||||
if (eventType === "agent_start" || eventType === "agent_end") this.scheduleCompactionQueueDrain(session.sessionId);
|
if (eventType === "agent_start" || eventType === "agent_end") this.scheduleCompactionQueueDrain(session.sessionId);
|
||||||
this.publishStatus(session);
|
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_start") return { type: "agent.start" };
|
||||||
if (eventType === "agent_end") return { type: "agent.end" };
|
if (eventType === "agent_end") return { type: "agent.end" };
|
||||||
|
if (eventType === "agent_settled") return { type: "agent.settled" };
|
||||||
if (eventType === "message_end") {
|
if (eventType === "message_end") {
|
||||||
const message = getProperty(event, "message");
|
const message = getProperty(event, "message");
|
||||||
if (message === undefined) return { type: "message.end" };
|
if (message === undefined) return { type: "message.end" };
|
||||||
|
|||||||
@@ -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
@@ -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" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1283,6 +1283,8 @@ type SessionUiEventBody =
|
|||||||
| { type: "shell.end"; output?: string; exitCode?: number | null; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string; isError?: boolean }
|
| { type: "shell.end"; output?: string; exitCode?: number | null; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string; isError?: boolean }
|
||||||
| { type: "agent.start" }
|
| { type: "agent.start" }
|
||||||
| { type: "agent.end" }
|
| { type: "agent.end" }
|
||||||
|
/** Pi-native completion: no retry, compaction retry, or queued continuation remains. */
|
||||||
|
| { type: "agent.settled" }
|
||||||
| { type: "message.end"; message?: unknown }
|
| { type: "message.end"; message?: unknown }
|
||||||
| { type: "status.update"; status: SessionStatus }
|
| { type: "status.update"; status: SessionStatus }
|
||||||
| { type: "activity.update"; activity: SessionActivity }
|
| { type: "activity.update"; activity: SessionActivity }
|
||||||
|
|||||||
Reference in New Issue
Block a user