feat(server): stream tts provider
This commit is contained in:
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Generated
+107
-28
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user