feat(server): stream tts provider

This commit is contained in:
RainbowBird
2026-05-18 23:33:24 +08:00
parent 1a9db35b30
commit bf1aba757c
15 changed files with 1837 additions and 48 deletions
@@ -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(<plaintext>,
> { 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.
+2
View File
@@ -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"
}
+4
View File
@@ -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,
}
+49 -12
View File
@@ -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,
@@ -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
}
@@ -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
})
})
})
+10
View File
@@ -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 = {
@@ -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:
@@ -1288,6 +1288,8 @@ pages:
description: 登录后即可使用的服务来源
speech-title: AIRI 官方语音合成服务
speech-description: 登录后即可使用的发声单元服务
speech-streaming-title: AIRI 官方流式语音合成服务
speech-streaming-description: 双向流式低延迟的语音合成服务,登录后即可使用
transcription-title: AIRI 官方语音识别服务
transcription-description: 登录后即可使用的听觉单元服务
transcriptions:
+3 -1
View File
@@ -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:"
}
}
@@ -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
}
},
@@ -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 {
@@ -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)
})
})
@@ -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
}
+107 -28
View File
@@ -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