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",
|
||||
"@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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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: "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 }
|
||||
|
||||
Reference in New Issue
Block a user