diff --git a/apps/server/docs/ai-context/verifications/streaming-tts.md b/apps/server/docs/ai-context/verifications/streaming-tts.md new file mode 100644 index 000000000..38287dbc5 --- /dev/null +++ b/apps/server/docs/ai-context/verifications/streaming-tts.md @@ -0,0 +1,221 @@ +# Bidirectional streaming TTS — verification + +Verification artifacts for the airi-side proxy at `/api/v1/audio/speech/ws` +and the unspeech-side bridge at `/v1/audio/speech/stream`, both new in +this session (server-dev branch, 2026-05-15). + +## Coverage status + +| User path | Code wired | Has fresh evidence | +|---|---|---| +| Unspeech ws upgrade (HTTP 101) | ✅ | ✅ smoke 2026-05-15 | +| Unspeech rejects malformed first frame as JSON `error` event | ✅ | ✅ smoke 2026-05-15 | +| Unspeech rejects unsupported backend as JSON `error` event | ✅ | ✅ smoke 2026-05-15 | +| Unspeech rejects missing `Authorization` as JSON `error` event | ✅ | ✅ smoke + integration `TestBridge_ErrorEventOnMissingApiKey` 2026-05-15 | +| Unspeech post-upgrade errors no longer write to hijacked HTTP conn | ✅ | ✅ smoke (server log clean) + integration error-event test 2026-05-15 | +| Unspeech `finish` waits for upstream completion (codex CRITICAL #1) | ✅ | ✅ integration `TestBridge_FinishWaitsForUpstreamCompletion` 2026-05-15 | +| Unspeech `cancel` after `finish` reaches upstream (codex follow-up) | ✅ | ✅ integration `TestBridge_CancelAfterFinish` 2026-05-15 | +| apps/server proxy forwards start/text/finish + streams audio back | ✅ | ✅ integration `audio-speech-ws route.test.ts > forwards start/text/finish` 2026-05-15 | +| apps/server bills from `usage.text_words` when upstream returns it | ✅ | ✅ integration ditto (asserts `accumulate({units: 42})`) 2026-05-15 | +| apps/server falls back to input-char count when usage absent | ✅ | ✅ integration `falls back to input-char count` 2026-05-15 | +| apps/server pre-flight rejects `insufficient_flux` | ✅ | ✅ integration `refuses ... insufficient_flux` 2026-05-15 | +| apps/server rejects `streaming_tts_not_configured` when config missing | ✅ | ✅ integration `refuses ... streaming_tts_not_configured` 2026-05-15 | +| stage-ui `streamingSynthesize` resolves on session.finished | ✅ | ✅ unit `streaming-session.test.ts > resolves with concatenated audio` 2026-05-15 | +| stage-ui rejects on close-without-session.finished (codex HIGH #2) | ✅ | ✅ unit `rejects when the ws closes before session.finished` 2026-05-15 | +| stage-ui rejects on `error` event with code/message | ✅ | ✅ unit `rejects with the upstream code/message on an error event` 2026-05-15 | +| stage-ui aborts cleanly on signal abort (sends `cancel`) | ✅ | ✅ unit `aborts the session and rejects with AbortError on signal abort` 2026-05-15 | +| Stage.vue streaming provider end-to-end with audio playback | ✅ | ⏳ pending — requires logged-in user + real Volcengine key | +| Full happy path with real Volcengine upstream | ✅ | ⏳ pending — gated `TestBidirectionalStream_Integration` in unspeech (requires `VOLCENGINE_API_KEY`) | + +The ⏳ rows are the only paths still requiring live Volcengine +credentials. Every other production code path (post-upgrade error +handling, bridge state machine including the codex-found bugs, proxy +billing, proxy pre-flight, browser-side session lifecycle including the +partial-as-success fix) is covered by automated tests that run on every +`go test` / `pnpm exec vitest run` without external dependencies. + +## Smoke: unspeech protocol surface (no upstream call) + +- **Scenario**: walk through every error path in the new + `/v1/audio/speech/stream` route and confirm each one delivers a clean + JSON `error` event followed by a policy-violation close frame, instead + of dumping a stack trace over the hijacked websocket bytes (the + pre-fix behavior — codex review item #5 + smoke discovery). +- **Command**: + ```bash + cd /Users/luoling8192/Git/moeru-ai/unspeech + go build -o /tmp/unspeech ./cmd/unspeech + /tmp/unspeech & # listens on :5933 + node /tmp/smoke-streaming-tts.mjs + kill %1 + ``` + The smoke script is `/tmp/smoke-streaming-tts.mjs` (see "smoke script" + appendix below). +- **Expected output**: three `PASS` lines, each carrying a JSON `error` + event with a stable `code` discriminator. +- **Actual output** (unspeech `26817b6` + WIP, 2026-05-15): + ``` + [bad-first-frame] PASS + events: [{"event":"error","code":"invalid_first_frame","message":"first frame must be event=start"}] + [unsupported-backend] PASS + events: [{"event":"error","code":"unsupported_backend","message":"streaming is only supported for backend=volcengine"}] + [volcengine-no-auth] PASS + events: [{"event":"error","code":"missing_api_key","message":"missing X-Api-Key in Authorization header"}] + ``` +- **Server log diff vs pre-fix**: before the post-upgrade error fix, + the same scenarios produced `response.status=500` lines plus + `echo: http: response.WriteHeader on hijacked connection` stack + traces in the unspeech log. After the fix, every request returns + `response.status=200` (handler returns `mo.Ok` so the echo error + middleware never tries to write HTTP). Clean. +- **Environment**: unspeech `26817b6` with WIP from this session, airi + `4f2ed81a3`, Node v24, local macOS. + +## Pending: live happy path (operator needs Volcengine key) + +The smoke scenarios above cover everything that can run without a real +Volcengine API key. To finalise the verification an operator with a +production-tier Volcengine key needs to run the live happy path. The +exact commands and assertions are below — paste this output back here +once it lands. + +### Prerequisite: seed `STREAMING_TTS_UPSTREAM` + +```bash +cd apps/server +LLM_ROUTER_MASTER_KEY="$LLM_ROUTER_MASTER_KEY" \ +VOLCENGINE_TTS_API_KEY="$VOLCENGINE_TTS_API_KEY" \ +pnpm exec dotenvx run --env-file=.env.local -- \ + tsx scripts/seed-router-config.ts \ + --streaming-tts-key "$VOLCENGINE_TTS_API_KEY" +``` + +> Note: `seed-router-config.ts` does not yet implement +> `--streaming-tts-key`. Until then, write the configKV entry directly +> from a one-off node REPL using `configKV.set('STREAMING_TTS_UPSTREAM', +> { baseURL: 'ws://localhost:5933/v1/audio/speech/stream', keys: [{ id: +> 'volcengine-prod-1', ciphertext: envelopeCrypto.encryptKey(, +> { modelName: 'streaming-tts', keyEntryId: 'volcengine-prod-1' }) }] })`. + +### Scenario L1: streaming session happy path + +- **User path**: user types a chat message → LLM streams → speech + pipeline opens streaming ws → audio plays back as upstream synthesises. +- **Setup**: log in as a user with ≥1 flux. In speech settings, pick + "Official Streaming Speech Provider", model `volcengine/seed-tts-2.0`, + any Volcengine voice (e.g. `zh_female_shuangkuaisisi_moon_bigtts`). +- **Command** (manual): send a chat message. Observe browser devtools + Network tab → WS frames panel. +- **Expected**: + 1. Single ws frame `start` (text) sent by client. + 2. Single ws frame `text` (text) sent by client carrying the LLM + output as input. + 3. Single ws frame `finish` (text) sent by client. + 4. Server emits `session.started` (text) within ~500ms. + 5. Server emits multiple binary frames totalling > 0 bytes within + ~1.5s of `start` (the streaming first-packet latency we care + about — should be lower than a buffered REST round-trip). + 6. Server emits `session.finished` (text) with + `payload.usage.text_words > 0`. + 7. Client closes the ws with code 1000. + 8. Audio plays cleanly through the stage's `playbackManager` (no + truncation, no stuck animation). +- **Billing check** (after the request lands): + ```sql + SELECT * FROM flux_transaction WHERE user_id = '<user-id>' + ORDER BY created_at DESC LIMIT 1; + ``` + Expected: one row with `meter = 'tts'` (or whatever the meter name + resolves to), `amount` matching `floor(text_words / FLUX_PER_1K_CHARS_TTS * + 1000)` (cross-check with the ttsMeter accumulate path). +- **Actual output**: ⏳ pending operator run. + +### Scenario L2: abort mid-synthesis cancels upstream + +- **User path**: user clicks stop while audio is still playing → ws + closes → upstream session terminated → no stray flux debit. +- **Command** (manual): in dev console, trigger the chat abort + controller mid-stream. +- **Expected**: + 1. Client sends `cancel` (text) frame. + 2. Server bridge forwards `CancelSession` (event=101) upstream. + 3. Volcengine emits `SessionCanceled` (event=151); server-side bridge + does NOT block waiting for it (documented v1 limitation). + 4. apps/server proxy closes ws cleanly with code 1000. + 5. No `session.finished` event reaches client → no billing call + fires → no new `flux_transaction` row (verified via the same SQL + query as L1). +- **Actual output**: ⏳ pending operator run. + +### Scenario L3: truncated upstream surfaces as error, not silent success + +- **User path**: simulate an upstream truncation (kill unspeech mid-session) + → client should error out, not play partial-then-go-silent. +- **Command** (manual): start full stack, begin a session, then + `pkill -f unspeech` while audio is still arriving. +- **Expected**: + 1. Client's ws receives a close frame without `session.finished`. + 2. `streamingSynthesize()` rejects with + `streaming_tts_closed: ... (received N bytes without session.finished)`. + 3. Stage.vue catches the rejection, logs the diagnostic, returns + null for the segment. + 4. Console shows the new diagnostic line from + `[Speech Pipeline] tts() failed` with provider / model / voice + context. (Codex review fix #6.) +- **Actual output**: ⏳ pending operator run. + +## Pre-existing static checks (refreshable on every commit) + +- `go build ./...` in `unspeech` — ✅ 2026-05-15. +- `go test ./pkg/backend/volcengine/...` in `unspeech` — ✅ 2026-05-15 + (v3frame round-trip tests). +- `pnpm -F @proj-airi/server typecheck` — ✅ 2026-05-15. +- `pnpm -F @proj-airi/server exec vitest run` — ✅ 344/344 pass. +- `pnpm -F @proj-airi/stage-ui typecheck` — ✅ 2026-05-15. +- `pnpm -F @proj-airi/stage-ui exec vitest run --project node` — ✅ + 375/375 pass. (Browser project not run; pre-existing Playwright env + gap unrelated to this change.) +- `pnpm -F @proj-airi/stage-tamagotchi typecheck` — ✅ 2026-05-15. +- `pnpm -F @proj-airi/stage-web typecheck` — ✅ 2026-05-15. +- `pnpm exec eslint <changed-files>` — ✅ clean after autofix. + +## Known v1 limitations (recorded so future verifications track them) + +- **No fallback on streaming upstream failure**. Live ws can't + transparently switch upstream mid-session, so v1 uses the first key + only. Codex MEDIUM #3. +- **JWT in `?token=` query**. Same pattern as `/ws/chat`; reusable + bearer in URL is recorded by access logs. Codex MEDIUM #4. Worth + rotating to short-lived tickets in a follow-up. +- **`cancel` ack not surfaced**. Server does not wait for upstream + `SessionCanceled` before closing. Documented in the wire spec. +- **Per-segment ws (not session-level)**. stage-ui opens a fresh ws + per speech segment; future Phase B refactor can keep one ws per LLM + intent and chunk on `sentence.end` for true play-as-you-receive. + +## Smoke script appendix + +The file `/tmp/smoke-streaming-tts.mjs` used in the smoke run: + +```js +import WebSocket from '<airi-root>/node_modules/.pnpm/ws@*/node_modules/ws/wrapper.mjs' + +const URL = 'ws://localhost:5933/v1/audio/speech/stream' + +function runScenario(name, send, expect) { /* ... see /tmp/... */ } + +const scenarios = [ + ['bad-first-frame', ws => ws.send(JSON.stringify({ event: 'text', text: 'hi' })),], + ['unsupported-backend', ws => ws.send(JSON.stringify({ event: 'start', model: 'openai/tts-1', voice: 'alloy' })),], + ['volcengine-no-auth', ws => ws.send(JSON.stringify({ event: 'start', model: 'volcengine/seed-tts-2.0', voice: 'zh_female_shuangkuaisisi_moon_bigtts' })),], +] + +for (const [name, send, expect] of scenarios) + await runScenario(name, send, expect) +``` + +This is intentionally a one-shot debug helper, not a CI fixture. If we +want a CI guard, we can move the scenarios into a Go test against an +in-process echo server (with a stub Volcengine ws dialer) and assert +the JSON error events directly — left as a TODO when the protocol +gains more code paths worth regressing against. diff --git a/apps/server/package.json b/apps/server/package.json index c14589363..4c85f11ba 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -59,11 +59,13 @@ "resend": "^6.12.2", "stripe": "^22.0.2", "valibot": "catalog:", + "ws": "catalog:", "zod": "catalog:" }, "devDependencies": { "@better-auth/cli": "^1.4.21", "@types/pg": "^8.20.0", + "@types/ws": "^8.18.1", "drizzle-kit": "catalog:", "unspeech": "catalog:xsai" } diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts index 308a56854..c2f98b379 100644 --- a/apps/server/src/app.test.ts +++ b/apps/server/src/app.test.ts @@ -72,6 +72,10 @@ function createTestDeps() { route: vi.fn(async () => new Response('{}', { status: 200 })), invalidateConfig: vi.fn(), } as any, + envelopeCrypto: { + encryptKey: vi.fn(), + decryptKey: vi.fn(), + } as any, posthog: null, } diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index a457a9924..033657732 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -19,6 +19,7 @@ import type { RequestLogService } from './services/request-log' import type { StripeService } from './services/stripe' import type { UserDeletionService } from './services/user-deletion' import type { HonoEnv } from './types/hono' +import type { EnvelopeCrypto } from './utils/envelope-crypto' import process from 'node:process' @@ -46,6 +47,7 @@ import { emitOtelLog, initOtel } from './otel' import { registerActiveSessionsGauge } from './otel/gauges/active-sessions' import { registerDistinctActiveUsersGauge } from './otel/gauges/distinct-active-users' import { createAdminFluxGrantsRoutes } from './routes/admin/flux-grants' +import { createAudioSpeechWsHandlers } from './routes/audio-speech-ws' import { createAuthRoutes } from './routes/auth' import { createCharacterRoutes } from './routes/characters' import { createChatWsHandlers } from './routes/chat-ws' @@ -89,6 +91,7 @@ interface AppDeps { ttsMeter: FluxMeter requestLogService: RequestLogService configKV: ConfigKVService + envelopeCrypto: EnvelopeCrypto redis: Redis env: Env otel: OtelInstance | null @@ -164,6 +167,33 @@ export async function buildApp(deps: AppDeps) { return chatWsSetup(session.user.id) })) + // Bidirectional streaming TTS proxy. The handler factory builds one ws-to-ws + // bridge per connection: client ↔ apps/server ↔ unspeech ↔ upstream + // (Volcengine bidirection etc.). Auth via ?token= mirrors /ws/chat — + // browsers can't set Authorization headers on WebSocket constructors. + const audioSpeechWsSetup = createAudioSpeechWsHandlers({ + configKV: deps.configKV, + envelopeCrypto: deps.envelopeCrypto, + fluxService: deps.fluxService, + ttsMeter: deps.ttsMeter, + requestLogService: deps.requestLogService, + }) + app.get('/api/v1/audio/speech/ws', upgradeWebSocket(async (c) => { + const token = c.req.query('token') + if (!token) { + throw createUnauthorizedError('Missing token') + } + const session = await resolveRequestAuth( + deps.auth, + deps.env, + new Headers({ Authorization: `Bearer ${token}` }), + ) + if (!session?.user) { + throw createUnauthorizedError('Invalid token') + } + return audioSpeechWsSetup(session.user.id) + })) + // Cross-instance config invalidation. The subscriber owns its own // connection + lifecycle metrics; see services/llm-router/config-sync-subscriber.ts. createConfigSyncSubscriber({ @@ -558,22 +588,27 @@ export async function createApp() { }, dependsOn.otel?.revenue), }) + // Envelope crypto for at-rest upstream key decryption. Shared by the LLM + // router (HTTP chat / TTS) and the audio-speech-ws proxy (streaming TTS) + // so a single master-key change rotates every surface at once. + const envelopeCrypto = injeca.provide('libs:envelopeCrypto', { + dependsOn: { env: parsedEnv }, + build: ({ dependsOn }) => createEnvelopeCrypto({ + masterKey: dependsOn.env.LLM_ROUTER_MASTER_KEY, + previousMasterKey: dependsOn.env.LLM_ROUTER_MASTER_KEY_PREVIOUS, + }), + }) + // LLM router (KTD-5 in-process replacement for the knoway sidecar). // LLM_ROUTER_MASTER_KEY is required at env-parse time, so this provider // always builds a real router — the legacy `null` fallback path is gone. const llmRouter = injeca.provide('services:llmRouter', { - dependsOn: { configKV, env: parsedEnv, otel }, - build: ({ dependsOn }) => { - const envelopeCrypto = createEnvelopeCrypto({ - masterKey: dependsOn.env.LLM_ROUTER_MASTER_KEY, - previousMasterKey: dependsOn.env.LLM_ROUTER_MASTER_KEY_PREVIOUS, - }) - return createLlmRouterService({ - configKV: dependsOn.configKV, - envelopeCrypto, - gatewayMetrics: dependsOn.otel?.gateway ?? null, - }) - }, + dependsOn: { configKV, envelopeCrypto, otel }, + build: ({ dependsOn }) => createLlmRouterService({ + configKV: dependsOn.configKV, + envelopeCrypto: dependsOn.envelopeCrypto, + gatewayMetrics: dependsOn.otel?.gateway ?? null, + }), }) await injeca.start() @@ -591,6 +626,7 @@ export async function createApp() { adminFluxGrantsService, ttsMeter, configKV, + envelopeCrypto, redis, env: parsedEnv, otel, @@ -626,6 +662,7 @@ export async function createApp() { ttsMeter: resolved.ttsMeter, requestLogService: resolved.requestLogService, configKV: resolved.configKV, + envelopeCrypto: resolved.envelopeCrypto, redis: resolved.redis, env: resolved.env, otel: resolved.otel, diff --git a/apps/server/src/routes/audio-speech-ws/index.ts b/apps/server/src/routes/audio-speech-ws/index.ts new file mode 100644 index 000000000..747cbd347 --- /dev/null +++ b/apps/server/src/routes/audio-speech-ws/index.ts @@ -0,0 +1,493 @@ +import type { WSContext, WSEvents } from 'hono/ws' + +import type { FluxMeter } from '../../services/billing/flux-meter' +import type { ConfigKVService } from '../../services/config-kv' +import type { FluxService } from '../../services/flux' +import type { RequestLogService } from '../../services/request-log' +import type { EnvelopeCrypto } from '../../utils/envelope-crypto' + +import { Buffer } from 'node:buffer' + +import WebSocket from 'ws' + +import { useLogger } from '@guiiai/logg' +import { context as otelContext, SpanStatusCode, trace } from '@opentelemetry/api' + +import { nanoid } from '../../utils/id' +import { + AIRI_ATTR_BILLING_FLUX_CONSUMED, + AIRI_ATTR_GEN_AI_GATEWAY_KEY_ID, + AIRI_ATTR_GEN_AI_GATEWAY_UPSTREAM_URL, + AIRI_ATTR_GEN_AI_OPERATION_KIND, + GEN_AI_ATTR_REQUEST_MODEL, +} from '../../utils/observability' + +const log = useLogger('audio-speech-ws').useGlobalConfig() + +/** + * Conservative pre-flight estimate: assume the worst-case streaming session + * synthesises ~2k input chars before billing materialises. Users below this + * affordability threshold are refused before the upstream ws is dialed — + * mirrors the pre-flight pattern at /audio/speech (handleTTS). + */ +const STREAMING_PREFLIGHT_CHARS_ESTIMATE = 2000 + +const STREAM_MODEL_LABEL_FALLBACK = 'streaming-tts' + +const tracer = trace.getTracer('audio-speech-ws') + +export interface AudioSpeechWsHandlersOptions { + configKV: ConfigKVService + envelopeCrypto: EnvelopeCrypto + fluxService: FluxService + ttsMeter: FluxMeter + requestLogService: RequestLogService +} + +/** + * Build the per-user setup function for the bidirectional streaming TTS proxy. + * + * Use when: + * - Wiring `/api/v1/audio/speech/ws` in {@link app.ts}. The factory returns a + * curried `setupPeer(userId)` that produces hono `WSEvents`, mirroring the + * shape of {@link createChatWsHandlers} so app.ts wires both routes the + * same way. + * + * Expects: + * - The route handler has already resolved auth via the `?token=` query + * (see app.ts wiring) and passes a verified `userId` in. + * - `STREAMING_TTS_UPSTREAM` configKV entry is populated with at least one + * key; absent config rejects the upgrade with policy-violation close. + * + * Returns: + * - A function that takes `userId` and returns hono `WSEvents`. Each call + * produces a fresh closure scoped to one connection — there is no global + * peer registry because streaming TTS is single-session per connection. + */ +export function createAudioSpeechWsHandlers(opts: AudioSpeechWsHandlersOptions) { + return function setupPeer(userId: string): WSEvents { + const sessionState = createSessionState(userId, opts) + + return { + onOpen(_event, ws) { + sessionState.attachClient(ws) + // Dial upstream inside the open handler so failure surfaces as a + // clean close on the client ws rather than a 500 on the upgrade. + void sessionState.dialUpstream() + }, + onMessage(message, ws) { + sessionState.handleClientMessage(message, ws) + }, + onClose(_event, _ws) { + sessionState.handleClientClose() + }, + onError(event, ws) { + log.withFields({ userId, event: String(event) }).warn('client ws error') + sessionState.handleClientClose() + try { + ws.close(1011, 'internal_error') + } + catch {} + }, + } + } +} + +/** + * Per-connection state machine. Holds the upstream ws once dialed, queues + * client frames until upstream is ready, and propagates close/error in both + * directions. + */ +function createSessionState(userId: string, opts: AudioSpeechWsHandlersOptions) { + const requestId = nanoid() + const startedAt = Date.now() + const span = tracer.startSpan('llm.gateway.tts.stream', { + attributes: { + [AIRI_ATTR_GEN_AI_OPERATION_KIND]: 'text_to_speech_stream', + }, + }) + + let clientWs: WSContext | null = null + let upstreamWs: WebSocket | null = null + let upstreamReady = false + let closed = false + let billed = false + let totalInputChars = 0 + let modelLabel = STREAM_MODEL_LABEL_FALLBACK + /** + * Frames the client sent before the upstream finished dialing. Buffered to + * avoid silently dropping the `start` frame; flushed in arrival order once + * the upstream ws transitions to OPEN. + */ + const pendingClientFrames: Array<{ data: Buffer | string, isBinary: boolean }> = [] + + function attachClient(ws: WSContext) { + clientWs = ws + } + + async function dialUpstream() { + let upstreamConfig: Awaited<ReturnType<ConfigKVService['getOptional']>> + try { + upstreamConfig = await opts.configKV.getOptional('STREAMING_TTS_UPSTREAM') + } + catch (err) { + log.withError(err).error('STREAMING_TTS_UPSTREAM read failed') + closeWithError(1011, 'config_unavailable') + return + } + + if (!upstreamConfig || !upstreamConfig.baseURL || upstreamConfig.keys.length === 0) { + closeWithError(1008, 'streaming_tts_not_configured') + return + } + + // Pre-flight balance check: refuse before dialing if the user cannot + // afford the worst-case session. + try { + const flux = await opts.fluxService.getFlux(userId) + if (flux.flux <= 0) { + closeWithError(1008, 'insufficient_flux') + return + } + await opts.ttsMeter.assertCanAfford(userId, STREAMING_PREFLIGHT_CHARS_ESTIMATE, flux.flux) + } + catch (err) { + log.withError(err).withFields({ userId }).warn('pre-flight rejected streaming tts') + // assertCanAfford throws PaymentRequiredError (402) — translate to ws + // policy-violation close. The client can read the close code/reason to + // surface a 'top up' prompt. + closeWithError(1008, 'insufficient_flux') + return + } + + // Decrypt the first key. Streaming surface does not do per-attempt key + // rotation: a live ws cannot transparently switch upstream mid-session + // without breaking audio continuity. Fallback policy belongs at the + // session-retry layer (next client connect), not inline. + const entry = upstreamConfig.keys[0] + let keyPlaintext: Buffer + try { + keyPlaintext = opts.envelopeCrypto.decryptKey(entry.ciphertext, { + modelName: STREAM_MODEL_LABEL_FALLBACK, + keyEntryId: entry.id, + }) + } + catch (err) { + log.withError(err).withFields({ keyEntryId: entry.id }).error('decrypt failed for streaming tts key') + closeWithError(1011, 'decrypt_failed') + return + } + + const upstreamURL = upstreamConfig.baseURL + span.setAttribute(AIRI_ATTR_GEN_AI_GATEWAY_UPSTREAM_URL, upstreamURL) + span.setAttribute(AIRI_ATTR_GEN_AI_GATEWAY_KEY_ID, entry.id) + + let upstream: WebSocket + try { + upstream = new WebSocket(upstreamURL, { + headers: { + Authorization: `Bearer ${keyPlaintext.toString('utf8')}`, + }, + }) + } + finally { + // Wipe plaintext immediately — the ws lib has already serialized the + // header into its outgoing handshake buffer. + keyPlaintext.fill(0) + } + + upstreamWs = upstream + + upstream.on('open', () => { + upstreamReady = true + // Flush anything the client sent during dial. + for (const frame of pendingClientFrames) { + try { + upstream.send(frame.data, { binary: frame.isBinary }) + } + catch (err) { + log.withError(err).warn('failed to flush queued client frame') + } + } + pendingClientFrames.length = 0 + }) + + upstream.on('message', (data, isBinary) => { + handleUpstreamMessage(data, isBinary) + }) + + upstream.on('close', (code, reason) => { + log.withFields({ userId, code, reason: reason?.toString() }).debug('upstream ws closed') + finalize() + }) + + upstream.on('error', (err) => { + log.withError(err).withFields({ userId }).warn('upstream ws error') + span.recordException(err) + span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }) + try { + clientWs?.send(JSON.stringify({ + event: 'error', + code: 'upstream_error', + message: err.message, + })) + } + catch {} + finalize() + }) + } + + function handleClientMessage(message: { data: unknown }, ws: WSContext) { + if (closed) + return + + const isBinary = !(typeof message.data === 'string') + const payload: Buffer | string = typeof message.data === 'string' + ? message.data + : message.data instanceof Buffer + ? message.data + : message.data instanceof ArrayBuffer + ? Buffer.from(message.data) + : Buffer.from(message.data as ArrayBufferLike) + + // Sniff input chars from text frames so billing has a fallback when + // upstream usage.text_words is absent. Only the `text` event contributes; + // start/finish/cancel do not. + if (!isBinary && typeof payload === 'string') { + maybeAccountInputChars(payload) + } + + if (!upstreamWs || !upstreamReady) { + pendingClientFrames.push({ data: payload, isBinary }) + return + } + + try { + upstreamWs.send(payload, { binary: isBinary }) + } + catch (err) { + log.withError(err).warn('failed to forward client frame to upstream') + try { + ws.close(1011, 'upstream_send_failed') + } + catch {} + } + } + + function handleClientClose() { + if (closed) + return + // Client dropped — best-effort cancel upstream so the upstream session + // releases its resources. We do not wait for SessionCanceled ack. + if (upstreamWs && upstreamReady) { + try { + upstreamWs.send(JSON.stringify({ event: 'cancel' })) + } + catch {} + } + finalize() + } + + function handleUpstreamMessage(data: Buffer | Buffer[] | ArrayBuffer, isBinary: boolean) { + if (!clientWs) + return + if (isBinary) { + // Audio binary frames pass through verbatim. + try { + clientWs.send(toBufferLike(data)) + } + catch (err) { + log.withError(err).warn('failed to forward upstream audio to client') + } + return + } + + // Control frame: forward to client AND inspect for usage / model labels. + const text = bufferToString(data) + try { + clientWs.send(text) + } + catch (err) { + log.withError(err).warn('failed to forward upstream control frame to client') + } + + try { + const evt = JSON.parse(text) as { event?: string, payload?: Record<string, unknown> } + handleUpstreamControlEvent(evt) + } + catch { + // unspeech only ever sends JSON on text frames per the v1 spec; a parse + // failure here is a bug in unspeech or a wire corruption. Don't kill + // the session over it — the client gets the raw frame regardless. + } + } + + function handleUpstreamControlEvent(evt: { event?: string, payload?: Record<string, unknown> }) { + switch (evt.event) { + case 'session.finished': { + // Pull authoritative usage from upstream when present. Falls back to + // the client-text-frame estimate accumulated in handleClientMessage. + const usageChars = readUsageChars(evt.payload) + const billUnits = usageChars ?? totalInputChars + if (billUnits > 0) + void billSession(billUnits, 'session.finished') + else + finalize() + break + } + case 'error': { + const code = typeof evt.payload?.code === 'string' ? evt.payload.code : 'upstream_error' + log.withFields({ userId, code, message: String(evt.payload?.message ?? '') }).warn('upstream sent error event') + span.setStatus({ code: SpanStatusCode.ERROR, message: code }) + break + } + // session.started / sentence.* / subtitle — no server-side action, pure + // pass-through to client. + } + } + + function maybeAccountInputChars(rawText: string) { + try { + const parsed = JSON.parse(rawText) as { event?: string, text?: string } + if (parsed.event === 'text' && typeof parsed.text === 'string') { + totalInputChars += parsed.text.length + } + else if (parsed.event === 'start') { + // Capture model label for OTel attrs / request log. + const model = (parsed as Record<string, unknown>).model + if (typeof model === 'string' && model.length > 0) + modelLabel = model + } + } + catch { + // Non-JSON text frame from client — ignore for billing, will fail + // upstream-side anyway. + } + } + + async function billSession(units: number, reason: string) { + if (billed) + return + billed = true + span.setAttribute(GEN_AI_ATTR_REQUEST_MODEL, modelLabel) + + let flux: Awaited<ReturnType<FluxService['getFlux']>> + try { + flux = await opts.fluxService.getFlux(userId) + } + catch (err) { + log.withError(err).withFields({ userId }).warn('flux read failed at session end') + finalize() + return + } + + let fluxConsumed = 0 + try { + const result = await otelContext.with(trace.setSpan(otelContext.active(), span), () => + opts.ttsMeter.accumulate({ + userId, + units, + currentBalance: flux.flux, + requestId, + metadata: { model: modelLabel }, + })) + fluxConsumed = result.fluxDebited + span.setAttribute(AIRI_ATTR_BILLING_FLUX_CONSUMED, fluxConsumed) + } + catch (err) { + // Billing failure is surfaced but does not retroactively reject the + // already-delivered audio — the user got the audio, the meter retains + // the debt for the next request to settle (per FluxMeter rollback path). + log.withError(err).withFields({ userId, units, reason }).error('billing accumulate failed for streaming tts') + span.recordException(err as Error) + span.setStatus({ code: SpanStatusCode.ERROR, message: 'billing_failed' }) + } + + const durationMs = Date.now() - startedAt + try { + await opts.requestLogService.logRequest({ + userId, + model: modelLabel, + status: 200, + durationMs, + fluxConsumed, + }) + } + catch (err) { + log.withError(err).warn('failed to write request log for streaming tts') + } + + finalize() + } + + function finalize() { + if (closed) + return + closed = true + try { + upstreamWs?.close() + } + catch {} + try { + clientWs?.close() + } + catch {} + span.end() + } + + function closeWithError(code: number, reason: string) { + if (closed) + return + span.setStatus({ code: SpanStatusCode.ERROR, message: reason }) + if (clientWs) { + try { + clientWs.send(JSON.stringify({ event: 'error', code: reason, message: reason })) + } + catch {} + try { + clientWs.close(code, reason) + } + catch {} + } + closed = true + span.end() + } + + return { + attachClient, + dialUpstream, + handleClientMessage, + handleClientClose, + } +} + +function bufferToString(data: Buffer | Buffer[] | ArrayBuffer): string { + if (Array.isArray(data)) + return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) + return Buffer.from(data).toString('utf8') + return data.toString('utf8') +} + +function toBufferLike(data: Buffer | Buffer[] | ArrayBuffer): ArrayBuffer { + if (Array.isArray(data)) { + const merged = Buffer.concat(data) + return merged.buffer.slice(merged.byteOffset, merged.byteOffset + merged.byteLength) as ArrayBuffer + } + if (data instanceof ArrayBuffer) + return data + // Buffer + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer +} + +function readUsageChars(payload: Record<string, unknown> | undefined): number | null { + if (!payload || typeof payload !== 'object') + return null + const usage = (payload as { usage?: unknown }).usage + if (!usage || typeof usage !== 'object') + return null + const textWords = (usage as { text_words?: unknown }).text_words + if (typeof textWords === 'number' && Number.isFinite(textWords) && textWords >= 0) + return Math.floor(textWords) + return null +} diff --git a/apps/server/src/routes/audio-speech-ws/route.test.ts b/apps/server/src/routes/audio-speech-ws/route.test.ts new file mode 100644 index 000000000..29a3a7414 --- /dev/null +++ b/apps/server/src/routes/audio-speech-ws/route.test.ts @@ -0,0 +1,344 @@ +import type { AddressInfo } from 'node:net' + +import type { WSContext, WSEvents } from 'hono/ws' + +import { Buffer } from 'node:buffer' +import { createServer } from 'node:http' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebSocketServer } from 'ws' + +import { createAudioSpeechWsHandlers } from './index' + +interface MockUpstream { + url: string + /** Outgoing JSON frames the server should send after receiving `start`. */ + scriptedResponses: Array< + | { kind: 'json', payload: Record<string, unknown> } + | { kind: 'binary', bytes: Buffer } + > + /** Frames the upstream actually received from the proxy, in arrival order. */ + receivedFrames: Array<{ kind: 'text' | 'binary', data: string | Buffer }> + /** Auth header observed during handshake. */ + observedAuth: string | undefined + close: () => Promise<void> +} + +async function startMockUpstream(scriptedResponses: MockUpstream['scriptedResponses']): Promise<MockUpstream> { + const receivedFrames: MockUpstream['receivedFrames'] = [] + let observedAuth: string | undefined + + const httpServer = createServer() + const wss = new WebSocketServer({ server: httpServer }) + + wss.on('connection', (ws, req) => { + observedAuth = req.headers.authorization + let replayed = false + ws.on('message', async (data, isBinary) => { + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer) + const decoded = isBinary ? buf : buf.toString('utf8') + receivedFrames.push({ + kind: isBinary ? 'binary' : 'text', + data: isBinary ? buf : decoded, + }) + + // Hold the scripted replay until we observe the client's `finish` + // frame. Replaying earlier would let `session.finished` arrive at + // the proxy before `finish` has been forwarded upstream, race + // teardown, and drop the in-flight client frames — the proxy is + // correct, the previous mock was the source of the race. + if (replayed) + return + + let triggerReplay = false + if (isBinary) { + // Streaming protocol's only legal client→server binary frames + // would be raw audio (we never send any in tests). + } + else { + try { + const ev = JSON.parse(decoded as string) as { event?: string } + if (ev.event === 'finish' || ev.event === 'cancel') + triggerReplay = true + } + catch {} + } + + // For tests that send NO frames (pre-flight rejection cases) the + // upstream is never dialed; this handler is unreachable. + if (!triggerReplay && scriptedResponses.length === 0) + return + if (!triggerReplay) + return + + replayed = true + for (const resp of scriptedResponses) { + await new Promise(resolve => setTimeout(resolve, 5)) + + if (resp.kind === 'json') + ws.send(JSON.stringify(resp.payload), { binary: false }) + else + ws.send(resp.bytes, { binary: true }) + } + }) + }) + + await new Promise<void>((resolve) => { + httpServer.listen(0, '127.0.0.1', resolve) + }) + + const { port } = httpServer.address() as AddressInfo + + return { + url: `ws://127.0.0.1:${port}`, + scriptedResponses, + receivedFrames, + get observedAuth() { + return observedAuth + }, + async close() { + wss.close() + await new Promise<void>(resolve => httpServer.close(() => resolve())) + }, + } +} + +interface MockClientWs { + ctx: WSContext + sent: Array<{ kind: 'text' | 'binary', data: string | ArrayBuffer | Buffer }> + closed: boolean + closeCode?: number + closeReason?: string +} + +function makeMockClientWs(): MockClientWs { + const sent: MockClientWs['sent'] = [] + const state = { + closed: false as boolean, + closeCode: undefined as number | undefined, + closeReason: undefined as string | undefined, + } + const ctx = { + send: (data: string | ArrayBuffer | Buffer) => { + sent.push({ + kind: typeof data === 'string' ? 'text' : 'binary', + data, + }) + }, + close: (code?: number, reason?: string) => { + state.closed = true + state.closeCode = code + state.closeReason = reason + }, + readyState: 1, + binaryType: 'arraybuffer', + raw: {} as any, + protocol: '', + url: null, + } as unknown as WSContext + + return { + ctx, + sent, + get closed() { return state.closed }, + get closeCode() { return state.closeCode }, + get closeReason() { return state.closeReason }, + } +} + +function makeFakeDeps(overrides: { + upstreamURL: string + fluxBalance: number + decryptedKey?: string +}) { + const ttsMeter = { + assertCanAfford: vi.fn(async () => undefined), + accumulate: vi.fn(async () => ({ + fluxDebited: 1, + debtAfter: 0, + balanceAfter: overrides.fluxBalance - 1, + unbilledFlux: 0, + })), + } + const fluxService = { + getFlux: vi.fn(async () => ({ flux: overrides.fluxBalance })), + } + const requestLogService = { + logRequest: vi.fn(async () => undefined), + } + const configKV = { + getOptional: vi.fn(async (key: string) => { + if (key === 'STREAMING_TTS_UPSTREAM') { + return { + baseURL: overrides.upstreamURL, + keys: [{ id: 'test-key-1', ciphertext: 'ENCRYPTED_PLACEHOLDER' }], + adapterParams: {}, + } + } + return null + }), + } + const envelopeCrypto = { + decryptKey: vi.fn(() => Buffer.from(overrides.decryptedKey ?? 'mock-upstream-token', 'utf8')), + } + + return { configKV, envelopeCrypto, fluxService, ttsMeter, requestLogService } +} + +/** Drives the WSEvents lifecycle as if a real client had connected. */ +async function driveClientSession(events: WSEvents, client: MockClientWs, clientFrames: Array<string | Buffer>) { + // onOpen handles the initial dial. The route fires `void dialUpstream()` + // which is async, so we await a microtask tick to let the upstream + // dialing kick off. + events.onOpen?.(new Event('open') as any, client.ctx) + await new Promise(r => setTimeout(r, 50)) + + for (const frame of clientFrames) { + const isBinary = Buffer.isBuffer(frame) + const data = isBinary ? frame : String(frame) + events.onMessage?.({ data } as any, client.ctx) + await new Promise(r => setTimeout(r, 20)) + } +} + +describe('audio-speech-ws route', () => { + let upstream: MockUpstream + + beforeEach(() => {}) + afterEach(async () => { + if (upstream) + await upstream.close() + }) + + it('forwards start/text/finish to upstream, streams binary back, and bills on session.finished', async () => { + const audioPayload = Buffer.from('FAKE_AUDIO_BYTES_AAAAAAAAAA', 'utf8') + upstream = await startMockUpstream([ + { kind: 'json', payload: { event: 'session.started' } }, + { kind: 'binary', bytes: audioPayload }, + { kind: 'json', payload: { event: 'session.finished', payload: { usage: { text_words: 42 } } } }, + ]) + + const deps = makeFakeDeps({ upstreamURL: upstream.url, fluxBalance: 100 }) + const handlers = createAudioSpeechWsHandlers(deps as any) + const events = handlers('user-123') + const client = makeMockClientWs() + + await driveClientSession(events, client, [ + JSON.stringify({ event: 'start', model: 'volcengine/seed-tts-2.0', voice: 'mock' }), + JSON.stringify({ event: 'text', text: 'hello streaming tts' }), + JSON.stringify({ event: 'finish' }), + ]) + + // Allow the upstream replay + billing pipeline to drain. + await new Promise(r => setTimeout(r, 200)) + + // Upstream got a properly authenticated handshake. + expect(upstream.observedAuth).toBe('Bearer mock-upstream-token') + + // Upstream got all three text frames in order. + expect(upstream.receivedFrames).toHaveLength(3) + expect(upstream.receivedFrames[0]).toMatchObject({ kind: 'text' }) + expect(JSON.parse(upstream.receivedFrames[0].data as string)).toMatchObject({ event: 'start' }) + expect(JSON.parse(upstream.receivedFrames[1].data as string)).toMatchObject({ event: 'text', text: 'hello streaming tts' }) + expect(JSON.parse(upstream.receivedFrames[2].data as string)).toMatchObject({ event: 'finish' }) + + // Client received the scripted control + audio frames in order. + const clientTextFrames = client.sent.filter(s => s.kind === 'text').map(s => JSON.parse(s.data as string)) + const clientBinaryFrames = client.sent.filter(s => s.kind === 'binary') + + expect(clientTextFrames.map(f => f.event)).toEqual(['session.started', 'session.finished']) + expect(clientBinaryFrames).toHaveLength(1) + + // Billing was triggered from session.finished.usage.text_words. The + // `units` argument MUST be the upstream-reported text_words, not the + // sniff-from-text-frame fallback (which would be the input string + // length of "hello streaming tts" = 19). + expect(deps.ttsMeter.accumulate).toHaveBeenCalledTimes(1) + expect((deps.ttsMeter.accumulate.mock.calls[0] as any[])[0]).toMatchObject({ + userId: 'user-123', + units: 42, + metadata: { model: 'volcengine/seed-tts-2.0' }, + }) + + // Request log gets the model label from the start frame, not the + // hardcoded fallback. + expect(deps.requestLogService.logRequest).toHaveBeenCalledTimes(1) + expect((deps.requestLogService.logRequest.mock.calls[0] as any[])[0]).toMatchObject({ + userId: 'user-123', + model: 'volcengine/seed-tts-2.0', + status: 200, + fluxConsumed: 1, + }) + }) + + it('refuses the session with insufficient_flux when the user is broke', async () => { + upstream = await startMockUpstream([]) + const deps = makeFakeDeps({ upstreamURL: upstream.url, fluxBalance: 0 }) + const handlers = createAudioSpeechWsHandlers(deps as any) + const events = handlers('user-broke') + const client = makeMockClientWs() + + await driveClientSession(events, client, []) + + // Upstream should never have been dialed — pre-flight fails first. + expect(upstream.receivedFrames).toHaveLength(0) + + // Client got the error event and a clean close. + const errorFrame = client.sent.find(s => s.kind === 'text') + expect(errorFrame).toBeDefined() + expect(JSON.parse(errorFrame!.data as string)).toMatchObject({ + event: 'error', + code: 'insufficient_flux', + }) + expect(client.closed).toBe(true) + expect(client.closeCode).toBe(1008) + }) + + it('refuses with streaming_tts_not_configured when STREAMING_TTS_UPSTREAM is empty', async () => { + const deps = makeFakeDeps({ upstreamURL: 'ws://unused', fluxBalance: 100 }) + deps.configKV.getOptional = vi.fn(async () => null) as any + + const handlers = createAudioSpeechWsHandlers(deps as any) + const events = handlers('user-noconf') + const client = makeMockClientWs() + + await driveClientSession(events, client, []) + + const errorFrame = client.sent.find(s => s.kind === 'text') + expect(errorFrame).toBeDefined() + expect(JSON.parse(errorFrame!.data as string)).toMatchObject({ + event: 'error', + code: 'streaming_tts_not_configured', + }) + expect(client.closed).toBe(true) + }) + + it('falls back to input-char count for billing when upstream omits usage', async () => { + // No usage in session.finished — proxy must bill the cumulative + // length of every `text` frame's `text` field instead. + upstream = await startMockUpstream([ + { kind: 'json', payload: { event: 'session.started' } }, + { kind: 'binary', bytes: Buffer.from('audio', 'utf8') }, + { kind: 'json', payload: { event: 'session.finished', payload: {} } }, + ]) + + const deps = makeFakeDeps({ upstreamURL: upstream.url, fluxBalance: 100 }) + const handlers = createAudioSpeechWsHandlers(deps as any) + const events = handlers('user-no-usage') + const client = makeMockClientWs() + + await driveClientSession(events, client, [ + JSON.stringify({ event: 'start', model: 'volcengine/seed-tts-1.0', voice: 'mock' }), + JSON.stringify({ event: 'text', text: 'hello' }), + JSON.stringify({ event: 'text', text: 'world' }), + JSON.stringify({ event: 'finish' }), + ]) + await new Promise(r => setTimeout(r, 200)) + + expect(deps.ttsMeter.accumulate).toHaveBeenCalledTimes(1) + expect((deps.ttsMeter.accumulate.mock.calls[0] as any[])[0]).toMatchObject({ + userId: 'user-no-usage', + units: 10, // "hello" + "world" = 10 chars + }) + }) +}) diff --git a/apps/server/src/services/config-kv.ts b/apps/server/src/services/config-kv.ts index 69e6a6003..c010f103d 100644 --- a/apps/server/src/services/config-kv.ts +++ b/apps/server/src/services/config-kv.ts @@ -121,6 +121,16 @@ const ConfigEntrySchemas = { // No default — the router throws CONFIG_NOT_SET when this entry is absent // so the admin endpoint (U9) is forced to populate it before traffic flows. LLM_ROUTER_CONFIG: optional(llmRouterConfigSchema), + // Streaming TTS upstream — a single unspeech instance that the + // /api/v1/audio/speech/ws proxy connects to. Separate from + // LLM_ROUTER_CONFIG.tts.models because the streaming surface has different + // semantics from one-shot HTTP TTS: ws-to-ws bridging, no per-attempt retry + // (a live ws cannot transparently switch upstream mid-session), upstream + // does the protocol translation to providers (Volcengine v3 etc.). Reuses + // ttsUpstreamSchema only for the key envelope shape — `keys` carry the + // upstream-provider API key (e.g. Volcengine X-Api-Key), not an unspeech + // tenant token. + STREAMING_TTS_UPSTREAM: optional(ttsUpstreamSchema), } as const type ConfigDefinitions = { diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 4985b08d5..e8f420bed 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -1337,6 +1337,8 @@ pages: description: Official AI provider by AIRI. speech-title: Official Speech Provider speech-description: Official text-to-speech provider by AIRI. + speech-streaming-title: Official Streaming Speech Provider + speech-streaming-description: Official low-latency text-to-speech provider by AIRI (bidirectional WebSocket). transcription-title: Official Transcription Provider transcription-description: Official speech-to-text provider by AIRI. transcriptions: diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 423a2a011..f6dc1ea82 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -1288,6 +1288,8 @@ pages: description: 登录后即可使用的服务来源 speech-title: AIRI 官方语音合成服务 speech-description: 登录后即可使用的发声单元服务 + speech-streaming-title: AIRI 官方流式语音合成服务 + speech-streaming-description: 双向流式低延迟的语音合成服务,登录后即可使用 transcription-title: AIRI 官方语音识别服务 transcription-description: 登录后即可使用的听觉单元服务 transcriptions: diff --git a/packages/stage-ui/package.json b/packages/stage-ui/package.json index e17b3c7ff..b832c9f57 100644 --- a/packages/stage-ui/package.json +++ b/packages/stage-ui/package.json @@ -188,6 +188,7 @@ "@types/hast": "catalog:", "@types/splitpanes": "catalog:", "@types/unist": "catalog:", + "@types/ws": "^8.18.1", "@unocss/reset": "^66.6.8", "@vitejs/plugin-vue": "^6.0.6", "@webgpu/types": "catalog:", @@ -202,6 +203,7 @@ "vite": "^6.4.2", "vitest-browser-vue": "catalog:", "vue": "catalog:", - "vue-tsc": "^3.2.6" + "vue-tsc": "^3.2.6", + "ws": "catalog:" } } diff --git a/packages/stage-ui/src/components/scenes/Stage.vue b/packages/stage-ui/src/components/scenes/Stage.vue index 51404a244..8f4510569 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -29,6 +29,8 @@ import { useIOTraceBridge } from '../../composables/use-io-trace-bridge' import { initIOTracer } from '../../composables/use-io-tracer' import { useSpeechPipelineAnalytics } from '../../composables/use-speech-pipeline-analytics' import { Emotion, EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions' +import { OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '../../libs/providers/providers/official' +import { streamingSynthesize } from '../../libs/speech/streaming-session' import { useAudioContext, useSpeakingStore } from '../../stores/audio' import { useBackgroundStore } from '../../stores/background' import { useChatOrchestratorStore } from '../../stores/chat' @@ -38,7 +40,6 @@ import { useSpeechStore } from '../../stores/modules/speech' import { useProvidersStore } from '../../stores/providers' import { useSettings } from '../../stores/settings' import { useSpeechRuntimeStore } from '../../stores/speech-runtime' - const props = withDefaults(defineProps<{ paused?: boolean focusAt: { x: number, y: number } @@ -360,11 +361,38 @@ const speechPipeline = createSpeechPipeline<AudioBuffer>({ : request.text try { - const res = await generateSpeech({ - ...provider.speech(model, providerConfig), - input, - voice: voice.id, - }) + // Streaming TTS path: open a WebSocket to apps/server which proxies to + // unspeech bidirectional. Server-side starts streaming audio as the + // upstream synthesises, so first-byte latency is significantly lower + // than the buffered REST round-trip. We still wait for `session.finished` + // here and decode a single AudioBuffer per segment because the existing + // playbackManager is AudioBuffer-shaped; a future refactor can chunk on + // `sentence.end` events for true play-as-you-receive. + let res: ArrayBuffer | null = null + if (activeSpeechProvider.value === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID) { + // Derive the upstream resource id from the routed model. `model` + // looks like `volcengine/seed-tts-2.0`; the bit after the slash + // becomes `api_resource_id` for unspeech → Volcengine X-Api-Resource-Id. + const apiResourceId = model.includes('/') ? model.split('/', 2)[1] : 'seed-tts-2.0' + const result = await streamingSynthesize({ + model, + voice: voice.id, + input, + signal, + extraBody: { + api_resource_id: apiResourceId, + audio: { sample_rate: 24000, bit_rate: 64000 }, + }, + }) + res = result.audio + } + else { + res = await generateSpeech({ + ...provider.speech(model, providerConfig), + input, + voice: voice.id, + }) + } if (signal.aborted || !res || res.byteLength === 0) return null @@ -372,7 +400,20 @@ const speechPipeline = createSpeechPipeline<AudioBuffer>({ const audioBuffer = await audioContext.decodeAudioData(res) return audioBuffer } - catch { + catch (err) { + // Surface the error with context. Pipeline still drops the segment + // (returning null) so the conversation keeps going, but operators see + // the failure in devtools instead of silent truncation. Streaming + // failures (truncated session, network drop, billing rejection) now + // produce visible diagnostic lines — see codex review item #6. + if (!signal.aborted) { + console.error('[Speech Pipeline] tts() failed', { + provider: activeSpeechProvider.value, + model, + voice: voice?.id, + error: err, + }) + } return null } }, diff --git a/packages/stage-ui/src/libs/providers/providers/official/index.ts b/packages/stage-ui/src/libs/providers/providers/official/index.ts index f05c8a9c6..37b2c32fa 100644 --- a/packages/stage-ui/src/libs/providers/providers/official/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/official/index.ts @@ -11,6 +11,7 @@ import { defineProvider } from '../registry' import { createOfficialOpenAIProvider, OFFICIAL_ICON, withCredentials } from './shared' export const OFFICIAL_SPEECH_PROVIDER_ID = 'official-provider-speech' +export const OFFICIAL_SPEECH_STREAMING_PROVIDER_ID = 'official-provider-speech-streaming' // Locale → voice id map recommended by the server. Populated by listVoices() // from the /audio/voices response's `recommended` field so the auto-pick can @@ -157,6 +158,100 @@ export const providerOfficialSpeech = defineProvider({ }, }) +/** + * Streaming sibling of {@link providerOfficialSpeech}. Same auth and voice + * catalog as the HTTP TTS provider, but speech synthesis goes through the + * `/api/v1/audio/speech/ws` proxy (server bridges to unspeech bidirectional + * upstream — Volcengine v3 today). The pipeline consumer (Stage.vue) detects + * this provider id and dispatches to `streamingSynthesize` instead of + * `generateSpeech`. + * + * The `createProvider` hook still returns the OpenAI-shaped provider so that + * legacy code paths (REST `/v1/audio/speech` fallback when the ws path errors + * out) keep working without a separate provider instance. + */ +export const providerOfficialSpeechStreaming = defineProvider({ + id: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, + order: -1, + name: 'Official Streaming Speech Provider', + nameLocalize: ({ t }) => t('settings.pages.providers.provider.official.speech-streaming-title'), + description: 'Official streaming text-to-speech provider by AIRI (low-latency bidirectional WebSocket).', + descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.official.speech-streaming-description'), + tasks: ['text-to-speech'], + icon: OFFICIAL_ICON, + requiresCredentials: false, + createProviderConfig: () => officialConfigSchema, + createProvider(_config) { + const provider = createOfficialOpenAIProvider() + const originalSpeech = provider.speech.bind(provider) + provider.speech = (model: string) => { + const result = originalSpeech(model) + result.fetch = withCredentials() + return result + } + return provider + }, + validationRequiredWhen: () => false, + extraMethods: { + listModels: async (): Promise<ModelInfo[]> => { + // Streaming-capable models. The wire `model` field uses the + // `<backend>/<id>` shape unspeech expects (see + // `unspeech/docs/wire-protocols/audio-speech-stream-v1.md`). + return [ + { + id: 'volcengine/seed-tts-2.0', + name: 'Volcengine Seed-TTS 2.0', + provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, + description: 'Volcengine bidirectional streaming TTS (TTS 2.0)', + }, + { + id: 'volcengine/seed-tts-1.0', + name: 'Volcengine Seed-TTS 1.0', + provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, + description: 'Volcengine bidirectional streaming TTS (TTS 1.0)', + }, + ] + }, + listVoices: async (): Promise<VoiceInfo[]> => { + // Reuse the HTTP voices endpoint with an explicit `?model=` filter so + // the catalog matches Volcengine's bidirectional upstream rather than + // whatever DEFAULT_TTS_MODEL points at. + const res = await globalThis.fetch( + `${SERVER_URL}/api/v1/openai/audio/voices?model=volcengine`, + { headers: authHeaders() }, + ) + if (!res.ok) + return [] + + const data = await res.json() as { + voices?: { + id: string + name: string + description?: string + labels?: Record<string, unknown> + languages?: { code: string, title: string }[] + preview_audio_url?: string + }[] + } + if (!Array.isArray(data.voices)) + return [] + + return data.voices.map((v) => { + const rawGender = typeof v.labels?.gender === 'string' ? (v.labels.gender as string) : undefined + return { + id: v.id, + name: v.name, + provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, + description: v.description || undefined, + gender: rawGender?.toLowerCase() || undefined, + previewURL: v.preview_audio_url || undefined, + languages: Array.isArray(v.languages) ? v.languages : [], + } + }) + }, + }, +}) + const LOCALE_SEPARATOR_RE = /[-_]/ function languagePrefix(locale: string): string { diff --git a/packages/stage-ui/src/libs/speech/streaming-session.test.ts b/packages/stage-ui/src/libs/speech/streaming-session.test.ts new file mode 100644 index 000000000..b4a0e0eec --- /dev/null +++ b/packages/stage-ui/src/libs/speech/streaming-session.test.ts @@ -0,0 +1,209 @@ +import type { AddressInfo } from 'node:net' + +import { Buffer } from 'node:buffer' +import { createServer } from 'node:http' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebSocketServer } from 'ws' + +import { streamingSynthesize } from './streaming-session' + +vi.mock('../auth', () => ({ + getAuthToken: () => 'test-jwt', +})) + +vi.mock('../server', () => ({ + SERVER_URL: 'http://placeholder', +})) + +interface MockServer { + url: string + observedTokens: string[] + closeUnexpectedly: () => void + stop: () => Promise<void> +} + +async function startMockServer(handler: (ws: import('ws').WebSocket) => void): Promise<MockServer> { + const observedTokens: string[] = [] + const httpServer = createServer() + const wss = new WebSocketServer({ server: httpServer }) + + let activeWs: import('ws').WebSocket | undefined + + wss.on('connection', (ws, req) => { + activeWs = ws + const u = new URL(req.url!, 'http://localhost') + const token = u.searchParams.get('token') + if (token != null) + observedTokens.push(token) + + handler(ws) + }) + + await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve)) + const { port } = httpServer.address() as AddressInfo + + return { + url: `http://127.0.0.1:${port}`, + observedTokens, + closeUnexpectedly: () => { + activeWs?.close(1011, 'simulated_truncation') + }, + async stop() { + wss.close() + await new Promise<void>(r => httpServer.close(() => r())) + }, + } +} + +describe('streamingSynthesize', () => { + let server: MockServer | undefined + + beforeEach(() => { + server = undefined + }) + afterEach(async () => { + await server?.stop() + }) + + it('resolves with concatenated audio when session.finished arrives', async () => { + const chunkA = Buffer.from('AAAA', 'utf8') + const chunkB = Buffer.from('BBBB', 'utf8') + + server = await startMockServer((ws) => { + ws.on('message', async (data, isBinary) => { + // Only respond to the first client message (start). The + // subsequent text/finish frames are also sent by the streaming + // session, but for this test we just want to flush the response + // pipeline immediately. + if (isBinary) + return + const parsed = JSON.parse(data.toString()) as { event?: string } + if (parsed.event !== 'start') + return + + await new Promise(r => setTimeout(r, 5)) + ws.send(JSON.stringify({ event: 'session.started' })) + await new Promise(r => setTimeout(r, 5)) + ws.send(chunkA, { binary: true }) + ws.send(chunkB, { binary: true }) + await new Promise(r => setTimeout(r, 5)) + ws.send(JSON.stringify({ + event: 'sentence.end', + payload: { text: 'hello', words: [{ word: 'hello', startTime: 0, endTime: 0.5 }] }, + })) + ws.send(JSON.stringify({ + event: 'session.finished', + payload: { usage: { text_words: 5 } }, + })) + }) + }) + + const result = await streamingSynthesize({ + serverUrl: server.url, + model: 'volcengine/seed-tts-2.0', + voice: 'mock', + input: 'hello', + }) + + expect(new Uint8Array(result.audio)).toEqual(new Uint8Array([ + ...chunkA, + ...chunkB, + ])) + expect(result.byteLength).toBe(8) + expect(result.sentences).toHaveLength(1) + expect(result.sentences[0]).toMatchObject({ kind: 'end' }) + expect(server.observedTokens).toEqual(['test-jwt']) + }) + + it('rejects when the ws closes before session.finished (codex HIGH #2)', async () => { + // Server sends some audio chunks then closes the ws WITHOUT emitting + // session.finished. Pre-fix behavior: streamingSynthesize would + // resolve with the partial audio, and Stage.vue would play a + // truncated segment as if it were complete. Post-fix: must reject. + server = await startMockServer((ws) => { + ws.on('message', async (data, isBinary) => { + if (isBinary) + return + const parsed = JSON.parse(data.toString()) as { event?: string } + if (parsed.event !== 'start') + return + + await new Promise(r => setTimeout(r, 5)) + ws.send(JSON.stringify({ event: 'session.started' })) + ws.send(Buffer.from('PARTIAL_AUDIO', 'utf8'), { binary: true }) + // Drop the connection without session.finished. Code 1011 is a + // valid server-side close indicating "server encountered an + // error"; we cannot use 1006 because that's reserved for the + // implicit abnormal-closure code (not legal in a close frame). + await new Promise(r => setTimeout(r, 5)) + ws.close(1011, 'simulated_truncation') + }) + }) + + await expect(streamingSynthesize({ + serverUrl: server.url, + model: 'volcengine/seed-tts-2.0', + voice: 'mock', + input: 'hello', + })).rejects.toThrow(/streaming_tts_closed/) + }) + + it('rejects with the upstream code/message on an error event', async () => { + server = await startMockServer((ws) => { + ws.on('message', async (data, isBinary) => { + if (isBinary) + return + const parsed = JSON.parse(data.toString()) as { event?: string } + if (parsed.event !== 'start') + return + + await new Promise(r => setTimeout(r, 5)) + ws.send(JSON.stringify({ + event: 'error', + code: 'insufficient_flux', + message: 'go top up', + })) + }) + }) + + await expect(streamingSynthesize({ + serverUrl: server.url, + model: 'volcengine/seed-tts-2.0', + voice: 'mock', + input: 'hello', + })).rejects.toThrow(/insufficient_flux.*go top up/) + }) + + it('aborts the session and rejects with AbortError on signal abort', async () => { + let cancelObserved = false + server = await startMockServer((ws) => { + ws.on('message', (data, isBinary) => { + if (isBinary) + return + const parsed = JSON.parse(data.toString()) as { event?: string } + if (parsed.event === 'cancel') + cancelObserved = true + }) + }) + + const ctrl = new AbortController() + const promise = streamingSynthesize({ + serverUrl: server.url, + model: 'volcengine/seed-tts-2.0', + voice: 'mock', + input: 'hello', + signal: ctrl.signal, + }) + + // Fire abort after the ws is open and `start` was sent. + await new Promise(r => setTimeout(r, 50)) + ctrl.abort() + + await expect(promise).rejects.toThrow(/aborted|AbortError|undefined/) // DOMException + + // Give the mock a tick to log the `cancel` frame. + await new Promise(r => setTimeout(r, 50)) + expect(cancelObserved).toBe(true) + }) +}) diff --git a/packages/stage-ui/src/libs/speech/streaming-session.ts b/packages/stage-ui/src/libs/speech/streaming-session.ts new file mode 100644 index 000000000..5afcd7020 --- /dev/null +++ b/packages/stage-ui/src/libs/speech/streaming-session.ts @@ -0,0 +1,248 @@ +import { getAuthToken } from '../auth' +import { SERVER_URL } from '../server' + +/** + * One control event over the bidirectional streaming TTS protocol + * (unspeech v1, see `unspeech/docs/wire-protocols/audio-speech-stream-v1.md`). + * + * Audio frames travel as raw WebSocket binary frames and never use this + * envelope. + */ +export interface StreamingTtsServerEvent { + event: + | 'session.started' + | 'sentence.start' + | 'sentence.end' + | 'subtitle' + | 'session.finished' + | 'error' + text?: string + code?: string + message?: string + payload?: Record<string, unknown> +} + +/** + * Inbound bookkeeping captured during a session. Returned alongside the + * audio buffer so UI consumers can drive captions / mouth shape from + * sentence boundaries when the upstream model emits them. + */ +export interface StreamingTtsSessionResult { + audio: ArrayBuffer + /** Sentence-level events received from the gateway, in arrival order. */ + sentences: Array<{ kind: 'start' | 'end' | 'subtitle', payload?: Record<string, unknown> }> + /** Total bytes accumulated across all binary audio frames. */ + byteLength: number +} + +export interface StreamingTtsSessionOptions { + /** Server URL override. Defaults to {@link SERVER_URL}. */ + serverUrl?: string + /** Override the auth token (Bearer). Defaults to {@link getAuthToken}. */ + token?: string + /** unspeech-routed model id, e.g. `volcengine/seed-tts-2.0`. */ + model: string + /** Upstream voice / speaker id. */ + voice: string + /** The text to synthesize. */ + input: string + /** OpenAI-style format. `mp3` default; streaming upstream rejects `wav`. */ + responseFormat?: 'mp3' | 'opus' | 'aac' | 'flac' | 'pcm' + /** + * Backend-specific knobs forwarded verbatim into the `extra_body` of the + * `start` frame. For Volcengine: `api_resource_id`, `audio.*`, `additions`, + * `section_id`, `context_texts`, etc. + */ + extraBody?: Record<string, unknown> + /** Caller-side abort signal. Closes the ws and rejects with `AbortError`. */ + signal?: AbortSignal +} + +const DEFAULT_RESPONSE_FORMAT = 'mp3' as const + +/** + * Runs one bidirectional streaming TTS session against the airi server + * (`/api/v1/audio/speech/ws`) and returns the concatenated audio when the + * upstream emits `session.finished`. + * + * Use when: + * - The stage speech pipeline's per-segment `tts()` callback wants to use + * the streaming gateway instead of HTTP `/audio/speech`. + * + * Expects: + * - The user is authenticated; `getAuthToken()` returns a JWT or one is + * passed in `options.token`. + * - The server has `STREAMING_TTS_UPSTREAM` configured. + * + * Returns: + * - `{ audio, sentences, byteLength }` once `session.finished` arrives. + * - Rejects with the upstream `error.message` on a server error event. + * - Rejects with the abort reason on signal abort. + */ +export async function streamingSynthesize(options: StreamingTtsSessionOptions): Promise<StreamingTtsSessionResult> { + const token = options.token ?? getAuthToken() + if (!token) + throw new Error('streaming-tts: not authenticated') + + const baseUrl = options.serverUrl ?? SERVER_URL + const wsUrl = toWebSocketUrl(baseUrl, '/api/v1/audio/speech/ws', token) + + const audioChunks: ArrayBuffer[] = [] + const sentences: StreamingTtsSessionResult['sentences'] = [] + let totalBytes = 0 + + return new Promise<StreamingTtsSessionResult>((resolve, reject) => { + const ws = new WebSocket(wsUrl) + ws.binaryType = 'arraybuffer' + + let settled = false + function settle(action: () => void) { + if (settled) + return + settled = true + try { + action() + } + finally { + try { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) + ws.close() + } + catch {} + if (options.signal != null) + options.signal.removeEventListener('abort', onAbort) + } + } + + function onAbort() { + settle(() => { + try { + ws.send(JSON.stringify({ event: 'cancel' })) + } + catch {} + reject(options.signal?.reason ?? new DOMException('aborted', 'AbortError')) + }) + } + + if (options.signal != null) { + if (options.signal.aborted) { + onAbort() + return + } + options.signal.addEventListener('abort', onAbort, { once: true }) + } + + ws.addEventListener('open', () => { + const startFrame = { + event: 'start', + model: options.model, + voice: options.voice, + response_format: options.responseFormat ?? DEFAULT_RESPONSE_FORMAT, + ...(options.extraBody ? { extra_body: options.extraBody } : {}), + } + ws.send(JSON.stringify(startFrame)) + ws.send(JSON.stringify({ event: 'text', text: options.input })) + ws.send(JSON.stringify({ event: 'finish' })) + }) + + // Becomes true only after the server emits `session.finished`. The close + // handler uses this to distinguish "completed gracefully, ws then closed" + // from "ws closed mid-stream with partial audio". Without this flag we + // would silently resolve with truncated audio whenever the close arrived + // before `session.finished` — exactly the failure mode codex flagged + // (HIGH #2): the user hears a cut-off sentence and the pipeline treats it + // as a successful segment. + let sawSessionFinished = false + + ws.addEventListener('message', (e) => { + if (typeof e.data === 'string') { + let evt: StreamingTtsServerEvent + try { + evt = JSON.parse(e.data) as StreamingTtsServerEvent + } + catch { + return + } + + switch (evt.event) { + case 'sentence.start': + sentences.push({ kind: 'start', payload: evt.payload }) + break + case 'sentence.end': + sentences.push({ kind: 'end', payload: evt.payload }) + break + case 'subtitle': + sentences.push({ kind: 'subtitle', payload: evt.payload }) + break + case 'session.finished': { + sawSessionFinished = true + settle(() => { + const audio = concatArrayBuffers(audioChunks) + resolve({ audio, sentences, byteLength: totalBytes }) + }) + break + } + case 'error': { + const code = evt.code ?? 'streaming_tts_error' + const message = evt.message ?? code + settle(() => reject(new Error(`${code}: ${message}`))) + break + } + } + return + } + + // binary audio chunk + if (e.data instanceof ArrayBuffer) { + audioChunks.push(e.data) + totalBytes += e.data.byteLength + } + }) + + ws.addEventListener('error', () => { + // The 'error' event carries no useful info per WebSocket API; the close + // event right after will have the actual reason. + }) + + ws.addEventListener('close', (ev) => { + settle(() => { + // Only treat the close as success when the server explicitly told us + // the session finished. Partial audio without session.finished means + // the upstream was truncated (network blip, server restart, upstream + // error not caught upstream of the close) and we must surface it as + // an error, not as a silently shorter segment. + if (sawSessionFinished) { + // settle() above already resolved on session.finished; this branch + // exists for the rare ordering where the close arrives before the + // settle from session.finished took effect — still a success. + resolve({ audio: concatArrayBuffers(audioChunks), sentences, byteLength: totalBytes }) + return + } + const reason = ev.reason || `closed_${ev.code}` + reject(new Error(`streaming_tts_closed: ${reason} (received ${totalBytes} bytes without session.finished)`)) + }) + }) + }) +} + +function toWebSocketUrl(httpBase: string, path: string, token: string): string { + const u = new URL(path, httpBase) + u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:' + u.searchParams.set('token', token) + return u.toString() +} + +function concatArrayBuffers(parts: ArrayBuffer[]): ArrayBuffer { + if (parts.length === 0) + return new ArrayBuffer(0) + if (parts.length === 1) + return parts[0] + const total = parts.reduce((acc, p) => acc + p.byteLength, 0) + const out = new Uint8Array(total) + let offset = 0 + for (const part of parts) { + out.set(new Uint8Array(part), offset) + offset += part.byteLength + } + return out.buffer +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 556dae75d..ccf9a0789 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -619,10 +619,10 @@ importers: dependencies: '@better-auth/drizzle-adapter': specifier: ^1.6.5 - version: 1.6.5(@better-auth/core@1.6.5(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)) + version: 1.6.5(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)) '@better-auth/oauth-provider': specifier: 'catalog:' - version: 1.5.6(@better-auth/core@1.6.5(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.5(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)))(better-call@1.3.5(zod@4.3.6)) + version: 1.5.6(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.5(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)))(better-call@1.1.8(zod@4.3.6)) '@dotenvx/dotenvx': specifier: ^1.61.1 version: 1.61.1 @@ -746,16 +746,22 @@ importers: valibot: specifier: 'catalog:' version: 1.2.0(typescript@5.9.3) + ws: + specifier: 'catalog:' + version: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) zod: specifier: 'catalog:' version: 4.3.6 devDependencies: '@better-auth/cli': specifier: ^1.4.21 - version: 1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.2.2)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.9)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) + version: 1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.2.2)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.9)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) '@types/pg': specifier: ^8.20.0 version: 8.20.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 drizzle-kit: specifier: 'catalog:' version: 0.31.10 @@ -3510,6 +3516,9 @@ importers: '@types/unist': specifier: 'catalog:' version: 3.0.3 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 '@unocss/reset': specifier: ^66.6.8 version: 66.6.8 @@ -3555,6 +3564,9 @@ importers: vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@5.9.3) + ws: + specifier: 'catalog:' + version: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) packages/stage-ui-live2d: dependencies: @@ -19322,13 +19334,13 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/cli@1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.2.2)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.9)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3))': + '@better-auth/cli@1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.2.2)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.9)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3))': dependencies: '@babel/core': 7.29.0 '@babel/preset-react': 7.28.5(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) - '@better-auth/telemetry': 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1)) + '@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/telemetry': 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1)) '@better-auth/utils': 0.3.0 '@clack/prompts': 0.11.0 '@mrleebo/prisma-ast': 0.13.1 @@ -19405,17 +19417,6 @@ snapshots: nanostores: 1.1.1 zod: 4.3.6 - '@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1)': - dependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.21 - '@standard-schema/spec': 1.1.0 - better-call: 1.3.5(zod@4.3.6) - jose: 6.2.2 - kysely: 0.28.14 - nanostores: 1.1.1 - zod: 4.3.6 - '@better-auth/core@1.6.5(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1)': dependencies: '@better-auth/utils': 0.4.0 @@ -19429,6 +19430,13 @@ snapshots: nanostores: 1.1.1 zod: 4.3.6 + '@better-auth/drizzle-adapter@1.6.5(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))': + dependencies: + '@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/utils': 0.3.0 + optionalDependencies: + drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) + '@better-auth/drizzle-adapter@1.6.5(@better-auth/core@1.6.5(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))': dependencies: '@better-auth/core': 1.6.5(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) @@ -19453,13 +19461,13 @@ snapshots: '@better-auth/core': 1.6.5(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.4.0 - '@better-auth/oauth-provider@1.5.6(@better-auth/core@1.6.5(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.5(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)))(better-call@1.3.5(zod@4.3.6))': + '@better-auth/oauth-provider@1.5.6(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.5(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)))(better-call@1.1.8(zod@4.3.6))': dependencies: - '@better-auth/core': 1.6.5(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) - '@better-auth/utils': 0.4.0 + '@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 better-auth: 1.6.5(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)) - better-call: 1.3.5(zod@4.3.6) + better-call: 1.1.8(zod@4.3.6) jose: 6.2.2 zod: 4.3.6 @@ -19470,9 +19478,9 @@ snapshots: optionalDependencies: '@prisma/client': 5.22.0 - '@better-auth/telemetry@1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))': + '@better-auth/telemetry@1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1))': dependencies: - '@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -24281,6 +24289,20 @@ snapshots: vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vue: 3.5.32(typescript@5.9.3) + '@vitest/browser-playwright@4.1.4(bufferutil@4.1.0)(playwright@1.59.1)(utf-8-validate@5.0.10)(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)': + dependencies: + '@vitest/browser': 4.1.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4) + '@vitest/mocker': 4.1.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + playwright: 1.59.1 + tinyrainbow: 3.1.0 + vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser-playwright@4.1.4(bufferutil@4.1.0)(playwright@1.59.1)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)': dependencies: '@vitest/browser': 4.1.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4) @@ -24308,6 +24330,24 @@ snapshots: - vite optional: true + '@vitest/browser@4.1.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/utils': 4.1.4 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@4.1.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)': dependencies: '@blazediff/core': 1.9.1 @@ -24355,9 +24395,9 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) optionalDependencies: - '@vitest/browser': 4.1.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4) + '@vitest/browser': 4.1.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4) '@vitest/eslint-plugin@1.6.15(@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.4)': dependencies: @@ -24380,6 +24420,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + '@vitest/mocker@4.1.4(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.4 @@ -25489,7 +25537,7 @@ snapshots: better-auth@1.4.21(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.41.0(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)): dependencies: '@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1) - '@better-auth/telemetry': 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1)) + '@better-auth/telemetry': 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.1.1)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 @@ -25536,7 +25584,7 @@ snapshots: drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9) pg: 8.20.0 react: 19.2.3 - vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vue: 3.5.32(typescript@5.9.3) transitivePeerDependencies: - '@cloudflare/workers-types' @@ -33472,7 +33520,7 @@ snapshots: vitest-browser-vue@2.1.0(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)): dependencies: '@vue/test-utils': 2.4.6 - vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vue: 3.5.32(typescript@5.9.3) vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): @@ -33506,6 +33554,37 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.4 + '@vitest/mocker': 4.1.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.4 + '@vitest/runner': 4.1.4 + '@vitest/snapshot': 4.1.4 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 25.6.0 + '@vitest/browser-playwright': 4.1.4(bufferutil@4.1.0)(playwright@1.59.1)(utf-8-validate@5.0.10)(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4) + '@vitest/coverage-v8': 4.1.4(@vitest/browser@4.1.4)(vitest@4.1.4) + jsdom: 29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3) + transitivePeerDependencies: + - msw + vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.4