feat(server): server-controlled default streaming TTS model
The streaming speech provider settings page was seeding its model picker with a hardcoded `volcengine/seed-tts-2.0`, which contradicts the just-landed "streaming model catalog comes from the server" contract. Now the default also comes from configKV. - `UNSPEECH_UPSTREAM.streaming` gains `defaultModel?: string`. - `GET /api/v1/audio/models/streaming` response gains `default: string | null` reading that field. - admin slice apply: `streaming.defaultModel` survives key/URL rotation alongside `streaming.models`. - `providerOfficialSpeechStreaming.listModels` stashes the value in a module-scope ref, exposed via `getDefaultStreamingModel()`. - Streaming speech settings page reads the helper instead of the hardcoded id; falls back to the first server-returned model if the operator hasn't curated a default. `handleGenerateSpeech` no longer has a `seed-tts-2.0` backstop — a missing backend prefix in the selected model id now throws instead of silently defaulting.
This commit is contained in:
@@ -91,6 +91,12 @@ const UnspeechSliceSchema = object({
|
||||
),
|
||||
plaintextKey: pipe(string(), nonEmpty('streaming.plaintextKey is required'), maxLength(MAX_KEY_LENGTH)),
|
||||
keyEntryId: optional(pipe(string(), nonEmpty(), maxLength(200), NO_PIPE)),
|
||||
models: optional(array(object({
|
||||
id: pipe(string(), nonEmpty('streaming.models[].id is required'), maxLength(200)),
|
||||
name: optional(pipe(string(), nonEmpty(), maxLength(200))),
|
||||
description: optional(pipe(string(), nonEmpty(), maxLength(500))),
|
||||
}))),
|
||||
defaultModel: optional(pipe(string(), nonEmpty('streaming.defaultModel must not be empty'), maxLength(200))),
|
||||
})),
|
||||
})
|
||||
|
||||
|
||||
@@ -290,7 +290,6 @@ export function createV1Routes(
|
||||
logger.withError(err).warn('Failed to close stream writer')
|
||||
}
|
||||
|
||||
// Extract usage from final SSE data lines
|
||||
let usage: UsageInfo = {}
|
||||
try {
|
||||
const lines = tailBuffer.split('\n').filter(l => l.startsWith('data: ') && !l.includes('[DONE]'))
|
||||
@@ -702,14 +701,9 @@ export function createV1Routes(
|
||||
}
|
||||
|
||||
async function handleListTTSModels(_c: Context<HonoEnv>) {
|
||||
// Surface the concrete TTS models the operator has configured plus the
|
||||
// `auto` alias. Clients need real model ids to pass `?model=<id>` to
|
||||
// `/audio/voices`, otherwise the voice catalog endpoint can never resolve
|
||||
// anything beyond the DEFAULT_TTS_MODEL catalog — which is the bug that
|
||||
// hid the Azure voices from the UI.
|
||||
//
|
||||
// `auto` is kept on top as an explicit "use the operator default" knob
|
||||
// for clients that don't care which concrete model handles them.
|
||||
// Surface the concrete TTS models the operator has configured. The UI
|
||||
// should select an explicit model id so voice catalog requests stay
|
||||
// model-scoped instead of hiding behind DEFAULT_TTS_MODEL.
|
||||
const config = await configKV.getOrThrow('LLM_ROUTER_CONFIG')
|
||||
// `LLM_ROUTER_CONFIG` is `optional()` at the schema, so its inferred type
|
||||
// tolerates `undefined`. `getOrThrow` already throws on missing entries,
|
||||
@@ -717,29 +711,32 @@ export function createV1Routes(
|
||||
// a TS narrowing aid.
|
||||
const modelIds = Object.keys(config?.tts?.models ?? {}).sort()
|
||||
return Response.json({
|
||||
models: [
|
||||
{ id: 'auto', name: 'Auto' },
|
||||
...modelIds.map(id => ({ id, name: id })),
|
||||
],
|
||||
models: modelIds.map(id => ({ id, name: id })),
|
||||
})
|
||||
}
|
||||
|
||||
async function handleListStreamingTTSModels(_c: Context<HonoEnv>) {
|
||||
const unspeech = await configKV.getOptional('UNSPEECH_UPSTREAM')
|
||||
const models = unspeech?.streaming?.models ?? []
|
||||
// `available` is the operator-controlled visibility switch the client gates
|
||||
// the streaming provider on. It tracks whether `UNSPEECH_UPSTREAM.streaming`
|
||||
// is configured at all — not whether `models[]` happens to be empty — so an
|
||||
// operator who has wired the upstream but not yet curated models still
|
||||
// surfaces the provider rather than silently hiding it.
|
||||
return Response.json({
|
||||
available: !!unspeech?.streaming?.baseURL,
|
||||
models: models.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name ?? m.id,
|
||||
description: m.description,
|
||||
})),
|
||||
default: unspeech?.streaming?.defaultModel ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const chatGuard = configGuard(configKV, ['FLUX_PER_REQUEST'], 'Service is not available yet')
|
||||
const ttsGuard = configGuard(configKV, ['FLUX_PER_1K_CHARS_TTS'], 'TTS service is not available yet')
|
||||
|
||||
// 60 requests per minute per user for LLM completions
|
||||
const completionsRateLimit = rateLimiter({ max: 60, windowSec: 60, metrics: rateLimitMetrics, routeLabel: 'openai.completions' })
|
||||
|
||||
// OpenAI-compatible surface (mounted at /api/v1/openai). Only routes that
|
||||
|
||||
@@ -11,8 +11,6 @@ import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import { createV1Routes } from '.'
|
||||
import { ApiError } from '../../../utils/error'
|
||||
|
||||
// --- Mock helpers ---
|
||||
|
||||
function createMockFluxService(flux = 100): FluxService {
|
||||
return {
|
||||
getFlux: vi.fn(async () => ({ userId: 'user-1', flux })),
|
||||
@@ -165,8 +163,6 @@ function createTestApp(
|
||||
|
||||
const testUser = { id: 'user-1', name: 'Test User', email: 'test@example.com' }
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('v1CompletionsRoutes', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
@@ -319,12 +315,10 @@ describe('v1CompletionsRoutes', () => {
|
||||
const data = await res.json() as { id: string }
|
||||
expect(data.id).toBe('chatcmpl-1')
|
||||
|
||||
// Verify flux was debited via billingService
|
||||
expect(billingService.consumeFluxForLLM).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'user-1', amount: 1 }),
|
||||
)
|
||||
|
||||
// Verify upstream was called with correct URL and resolved model
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'http://mock-gateway/chat/completions',
|
||||
expect.objectContaining({
|
||||
@@ -406,13 +400,11 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
// Post-billing: no charge on failed requests
|
||||
expect(billingService.consumeFluxForLLM).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 503 when config keys are missing', async () => {
|
||||
const configKV = createMockConfigKV()
|
||||
// Override getOptional to return null for required keys
|
||||
configKV.getOptional = vi.fn(async () => null)
|
||||
|
||||
const app = createTestApp(createMockFluxService(), configKV)
|
||||
@@ -748,7 +740,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
})
|
||||
|
||||
describe('gET /api/v1/audio/models', () => {
|
||||
it('exposes auto alias plus every configured tts model id', async () => {
|
||||
it('exposes every configured tts model id', async () => {
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV({
|
||||
@@ -771,14 +763,13 @@ describe('v1CompletionsRoutes', () => {
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json() as { models: { id: string, name: string }[] }
|
||||
expect(data.models[0]).toEqual({ id: 'auto', name: 'Auto' })
|
||||
expect(data.models.slice(1).map(m => m.id)).toEqual([
|
||||
expect(data.models.map(m => m.id)).toEqual([
|
||||
'alibaba/cosyvoice-v2',
|
||||
'microsoft/v1',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns only the auto alias when no tts models are configured', async () => {
|
||||
it('returns an empty list when no tts models are configured', async () => {
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV({
|
||||
@@ -793,7 +784,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json() as { models: { id: string, name: string }[] }
|
||||
expect(data.models).toEqual([{ id: 'auto', name: 'Auto' }])
|
||||
expect(data.models).toEqual([])
|
||||
})
|
||||
|
||||
it('should return 401 when unauthenticated', async () => {
|
||||
@@ -805,7 +796,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
})
|
||||
|
||||
describe('gET /api/v1/audio/models/streaming', () => {
|
||||
it('returns the operator-configured streaming model catalog', async () => {
|
||||
it('returns the operator-configured streaming model catalog + default', async () => {
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV({
|
||||
@@ -818,6 +809,7 @@ describe('v1CompletionsRoutes', () => {
|
||||
{ id: 'volcengine/seed-tts-2.0', name: 'Volcengine Seed-TTS 2.0', description: 'TTS 2.0' },
|
||||
{ id: 'volcengine/seed-tts-1.0' },
|
||||
],
|
||||
defaultModel: 'volcengine/seed-tts-2.0',
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -829,11 +821,37 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json() as { models: { id: string, name: string, description?: string }[] }
|
||||
const data = await res.json() as { available: boolean, models: { id: string, name: string, description?: string }[], default: string | null }
|
||||
expect(data.available).toBe(true)
|
||||
expect(data.models).toEqual([
|
||||
{ id: 'volcengine/seed-tts-2.0', name: 'Volcengine Seed-TTS 2.0', description: 'TTS 2.0' },
|
||||
{ id: 'volcengine/seed-tts-1.0', name: 'volcengine/seed-tts-1.0' },
|
||||
])
|
||||
expect(data.default).toBe('volcengine/seed-tts-2.0')
|
||||
})
|
||||
|
||||
it('returns default: null when operator has not set a streaming default', async () => {
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV({
|
||||
UNSPEECH_UPSTREAM: {
|
||||
restBaseURL: 'http://unspeech.local:5933',
|
||||
streaming: {
|
||||
baseURL: 'wss://unspeech.local',
|
||||
keys: [{ id: 'k1', ciphertext: 'enc' }],
|
||||
models: [{ id: 'volcengine/seed-tts-2.0', name: 'Vol' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await app.fetch(
|
||||
new Request('http://localhost/api/v1/audio/models/streaming', { method: 'GET' }),
|
||||
{ user: testUser } as any,
|
||||
)
|
||||
|
||||
const data = await res.json() as { default: string | null }
|
||||
expect(data.default).toBeNull()
|
||||
})
|
||||
|
||||
it('returns an empty list when UNSPEECH_UPSTREAM is unset', async () => {
|
||||
@@ -845,11 +863,12 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json() as { models: unknown[] }
|
||||
const data = await res.json() as { available: boolean, models: unknown[] }
|
||||
expect(data.available).toBe(false)
|
||||
expect(data.models).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty list when streaming subtree has no models', async () => {
|
||||
it('reports available: true with empty models when streaming subtree has no models', async () => {
|
||||
const app = createTestApp(
|
||||
createMockFluxService(),
|
||||
createMockConfigKV({
|
||||
@@ -869,7 +888,8 @@ describe('v1CompletionsRoutes', () => {
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json() as { models: unknown[] }
|
||||
const data = await res.json() as { available: boolean, models: unknown[] }
|
||||
expect(data.available).toBe(true)
|
||||
expect(data.models).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -21,8 +21,6 @@ describe('configKVService', () => {
|
||||
service = createConfigKVService(redis as any)
|
||||
})
|
||||
|
||||
// --- get ---
|
||||
|
||||
it('get should throw 503 when key is not set', async () => {
|
||||
await expect(service.getOrThrow('FLUX_PER_1K_CHARS_TTS'))
|
||||
.rejects
|
||||
@@ -43,8 +41,6 @@ describe('configKVService', () => {
|
||||
expect(redis.get).toHaveBeenCalledWith(configRedisKey('FLUX_PER_REQUEST'))
|
||||
})
|
||||
|
||||
// --- getOptional ---
|
||||
|
||||
it('getOptional should return schema default when key has one', async () => {
|
||||
const value = await service.getOptional('FLUX_PER_REQUEST')
|
||||
expect(value).toBe(5)
|
||||
@@ -62,7 +58,35 @@ describe('configKVService', () => {
|
||||
expect(value).toBe(200)
|
||||
})
|
||||
|
||||
// --- set ---
|
||||
it('getOptional should throw CONFIG_INVALID when Redis contains malformed JSON', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// If an operator edits config:LLM_ROUTER_CONFIG directly with invalid JSON,
|
||||
// JSON.parse used to throw SyntaxError through the request handler and log
|
||||
// it as an unhandled 500.
|
||||
//
|
||||
// We fixed this by translating stored config parse/validation failures into
|
||||
// a stable API error at the configKV boundary.
|
||||
redis._store.set(configRedisKey('LLM_ROUTER_CONFIG'), '{"llm":{}')
|
||||
|
||||
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
|
||||
.rejects
|
||||
.toMatchObject({
|
||||
statusCode: 503,
|
||||
errorCode: 'CONFIG_INVALID',
|
||||
})
|
||||
})
|
||||
|
||||
it('getOptional should throw CONFIG_INVALID when Redis contains schema-invalid JSON', async () => {
|
||||
redis._store.set(configRedisKey('FLUX_PER_REQUEST'), JSON.stringify('5'))
|
||||
|
||||
await expect(service.getOptional('FLUX_PER_REQUEST'))
|
||||
.rejects
|
||||
.toMatchObject({
|
||||
statusCode: 503,
|
||||
errorCode: 'CONFIG_INVALID',
|
||||
})
|
||||
})
|
||||
|
||||
it('set should write value to Redis with prefix', async () => {
|
||||
await service.set('FLUX_PER_REQUEST', 10)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type Redis from 'ioredis'
|
||||
import type { InferOutput } from 'valibot'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { any, array, boolean, check, nonEmpty, number, object, optional, parse, picklist, pipe, record, regex, string } from 'valibot'
|
||||
|
||||
import { createServiceUnavailableError } from '../../utils/error'
|
||||
@@ -71,6 +72,7 @@ export const streamingTtsUpstreamSchema = object({
|
||||
})),
|
||||
[],
|
||||
),
|
||||
defaultModel: optional(string()),
|
||||
})
|
||||
|
||||
export const unspeechUpstreamSchema = object({
|
||||
@@ -158,7 +160,19 @@ type ConfigDefinitions = {
|
||||
type ConfigKey = keyof ConfigDefinitions
|
||||
|
||||
function parseValue<K extends ConfigKey>(key: K, raw: string): ConfigDefinitions[K] {
|
||||
return parse(ConfigEntrySchemas[key], JSON.parse(raw)) as ConfigDefinitions[K]
|
||||
try {
|
||||
return parse(ConfigEntrySchemas[key], JSON.parse(raw)) as ConfigDefinitions[K]
|
||||
}
|
||||
catch (error) {
|
||||
throw createServiceUnavailableError(
|
||||
'Service configuration is invalid',
|
||||
'CONFIG_INVALID',
|
||||
{
|
||||
key,
|
||||
message: errorMessageFrom(error) ?? 'Unknown config parse error',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function serializeValue<K extends ConfigKey>(key: K, value: ConfigDefinitions[K]): string {
|
||||
|
||||
@@ -228,7 +228,7 @@ export const azureAdapter: TtsAdapter = {
|
||||
if (!ctx.keyPlaintext)
|
||||
throw createServiceUnavailableError('azure tts key not configured', 'AZURE_TTS_NOT_CONFIGURED')
|
||||
|
||||
const url = `${ctx.unspeechBaseURL.replace(/\/+$/, '')}/api/voices?backend=microsoft®ion=${encodeURIComponent(ctx.region)}`
|
||||
const url = `${ctx.unspeechBaseURL.replace(/\/+$/, '')}/api/voices?provider=microsoft®ion=${encodeURIComponent(ctx.region)}`
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
|
||||
@@ -109,7 +109,7 @@ export const dashscopeCosyvoiceAdapter: TtsAdapter = {
|
||||
// (unspeech/pkg/backend/alibaba/voices.go `//go:embed voices.json`),
|
||||
// so this call is in-memory on unspeech's side and only crosses a TCP
|
||||
// hop. No upstream credential is required.
|
||||
const url = `${ctx.unspeechBaseURL.replace(/\/+$/, '')}/api/voices?backend=alibaba`
|
||||
const url = `${ctx.unspeechBaseURL.replace(/\/+$/, '')}/api/voices?provider=alibaba`
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('getAdapter', () => {
|
||||
})
|
||||
|
||||
describe('dashscopeCosyvoiceAdapter.getVoiceCatalog', () => {
|
||||
it('calls unspeech with backend=alibaba (no Bearer)', async () => {
|
||||
it('calls unspeech with provider=alibaba (no Bearer)', async () => {
|
||||
const adapter = getAdapter('dashscope-cosyvoice')
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
|
||||
voices: [{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }],
|
||||
@@ -75,7 +75,7 @@ describe('dashscopeCosyvoiceAdapter.getVoiceCatalog', () => {
|
||||
|
||||
expect(voices).toEqual([{ id: 'longxiaochun_v2', name: 'Longxiaochun v2' }])
|
||||
const [calledUrl, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?backend=alibaba')
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=alibaba')
|
||||
const headers = (init.headers ?? {}) as Record<string, string>
|
||||
expect(headers.Authorization).toBeUndefined()
|
||||
})
|
||||
@@ -92,7 +92,7 @@ describe('dashscopeCosyvoiceAdapter.getVoiceCatalog', () => {
|
||||
})
|
||||
|
||||
describe('volcengineAdapter.getVoiceCatalog', () => {
|
||||
it('calls unspeech with backend=volcengine and forwards adapterParams.model as ?model=', async () => {
|
||||
it('calls unspeech with provider=volcengine and forwards adapterParams.model as ?model=', async () => {
|
||||
const adapter = getAdapter('volcengine')
|
||||
const fetchImpl = vi.fn(async () => new Response(JSON.stringify({
|
||||
voices: [{ id: 'zh_female_x', name: 'X' }],
|
||||
@@ -106,7 +106,7 @@ describe('volcengineAdapter.getVoiceCatalog', () => {
|
||||
|
||||
expect(voices).toEqual([{ id: 'zh_female_x', name: 'X' }])
|
||||
const [calledUrl] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?backend=volcengine&model=seed-tts-2.0')
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=volcengine&model=seed-tts-2.0')
|
||||
})
|
||||
|
||||
it('omits ?model= when adapterParams.model is not set', async () => {
|
||||
@@ -118,7 +118,7 @@ describe('volcengineAdapter.getVoiceCatalog', () => {
|
||||
fetchImpl,
|
||||
})
|
||||
const [calledUrl] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?backend=volcengine')
|
||||
expect(calledUrl).toBe('http://unspeech.local/api/voices?provider=volcengine')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('azureAdapter.getVoiceCatalog', () => {
|
||||
expect(voices).toEqual([{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' }])
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
const [calledUrl, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]
|
||||
expect(calledUrl).toBe('http://unspeech.local:5933/api/voices?backend=microsoft®ion=eastasia')
|
||||
expect(calledUrl).toBe('http://unspeech.local:5933/api/voices?provider=microsoft®ion=eastasia')
|
||||
const headers = init.headers as Record<string, string>
|
||||
expect(headers.Authorization).toBe('Bearer subscription-key-XYZ')
|
||||
})
|
||||
|
||||
@@ -120,7 +120,7 @@ export const volcengineAdapter: TtsAdapter = {
|
||||
// further by `compatible_models` — adapterParams.model is the operator-
|
||||
// configured resource id (e.g. `seed-tts-2.0`).
|
||||
const url = new URL(`${ctx.unspeechBaseURL.replace(/\/+$/, '')}/api/voices`)
|
||||
url.searchParams.set('backend', 'volcengine')
|
||||
url.searchParams.set('provider', 'volcengine')
|
||||
const apiResourceId = typeof ctx.adapterParams?.model === 'string'
|
||||
? ctx.adapterParams.model
|
||||
: undefined
|
||||
|
||||
@@ -96,6 +96,10 @@ export interface UnspeechSliceInput {
|
||||
plaintextKey: string
|
||||
/** @default 'volcengine-prod-1' */
|
||||
keyEntryId?: string
|
||||
/** Operator-curated streaming models exposed to the frontend picker. */
|
||||
models?: Array<{ id: string, name?: string, description?: string }>
|
||||
/** Server-curated default streaming model id. */
|
||||
defaultModel?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +266,8 @@ export function buildUnspeechSlice(input: UnspeechSliceInput, envelope: Envelope
|
||||
baseURL: input.streaming.upstreamURL,
|
||||
keys: [{ id: keyEntryId, ciphertext }],
|
||||
adapterParams: {},
|
||||
models: [],
|
||||
models: input.streaming.models ?? [],
|
||||
defaultModel: input.streaming.defaultModel,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -451,18 +456,22 @@ export function createAdminRouterConfigService(deps: AdminRouterConfigDeps) {
|
||||
nextRouterConfig = buildNextRouterConfig(input.mode, existing, llmTtsSlices)
|
||||
}
|
||||
|
||||
// Step 3: build the next UNSPEECH_UPSTREAM. Streaming `models` carry the
|
||||
// operator-curated catalog and must survive key/URL rotation, so we read
|
||||
// existing and graft them onto the new value when the slice's streaming
|
||||
// Step 3: build the next UNSPEECH_UPSTREAM. Streaming `models` +
|
||||
// `defaultModel` are operator-curated and must survive key/URL rotation,
|
||||
// so we graft them from the existing entry when the slice's streaming
|
||||
// subtree is set (otherwise there's nothing to merge into).
|
||||
let nextUnspeech: UnspeechUpstream | undefined
|
||||
if (unspeechSlice) {
|
||||
const existing = await deps.configKV.getOptional('UNSPEECH_UPSTREAM')
|
||||
const newValue = unspeechSlice.value
|
||||
if (newValue.streaming && existing?.streaming?.models?.length) {
|
||||
if (newValue.streaming && existing?.streaming) {
|
||||
nextUnspeech = {
|
||||
...newValue,
|
||||
streaming: { ...newValue.streaming, models: existing.streaming.models },
|
||||
streaming: {
|
||||
...newValue.streaming,
|
||||
models: existing.streaming.models?.length ? existing.streaming.models : newValue.streaming.models,
|
||||
defaultModel: existing.streaming.defaultModel ?? newValue.streaming.defaultModel,
|
||||
},
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -356,11 +356,23 @@ describe('createAdminRouterConfigService', () => {
|
||||
streaming: {
|
||||
upstreamURL: 'wss://unspeech.example/v1/audio/speech/stream',
|
||||
plaintextKey: 'volc',
|
||||
models: [
|
||||
{ id: 'volcengine/seed-tts-2.0', name: 'Seed-TTS 2.0', description: 'Low-latency streaming TTS' },
|
||||
],
|
||||
defaultModel: 'volcengine/seed-tts-2.0',
|
||||
},
|
||||
}],
|
||||
})
|
||||
|
||||
expect(kv.store.has('UNSPEECH_UPSTREAM')).toBe(true)
|
||||
expect(kv.store.get('UNSPEECH_UPSTREAM')).toMatchObject({
|
||||
streaming: {
|
||||
models: [
|
||||
{ id: 'volcengine/seed-tts-2.0', name: 'Seed-TTS 2.0', description: 'Low-latency streaming TTS' },
|
||||
],
|
||||
defaultModel: 'volcengine/seed-tts-2.0',
|
||||
},
|
||||
})
|
||||
expect(kv.store.has('LLM_ROUTER_CONFIG')).toBe(false)
|
||||
expect(result.invalidatedKeys).toEqual(['UNSPEECH_UPSTREAM'])
|
||||
expect(captured.map(p => JSON.parse(p.payload).key)).toEqual(['UNSPEECH_UPSTREAM'])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Buffer } from 'node:buffer'
|
||||
|
||||
import type Redis from 'ioredis'
|
||||
import type { Voice } from 'unspeech'
|
||||
|
||||
import type { GatewayMetrics } from '../../../otel'
|
||||
import type { EnvelopeCrypto } from '../../../utils/envelope-crypto'
|
||||
@@ -176,6 +177,7 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
|
||||
const logger = useLogger('llm-router').useGlobalConfig()
|
||||
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
||||
const configLoader = createConfigLoader({ configKV: options.configKV, ttlMs: options.configCacheTtlMs })
|
||||
const ttsVoiceCatalogLoads = new Map<string, Promise<Voice[]>>()
|
||||
|
||||
/**
|
||||
* Run one upstream's key list in order, returning either:
|
||||
@@ -652,44 +654,55 @@ export function createLlmRouterService(options: CreateLlmRouterServiceOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
const unspeechBaseURL = (await options.configKV.getOrThrow('UNSPEECH_UPSTREAM')).restBaseURL
|
||||
const existingLoad = ttsVoiceCatalogLoads.get(cacheKey)
|
||||
if (existingLoad != null)
|
||||
return existingLoad
|
||||
|
||||
// Live providers (Azure) need the decrypted Azure subscription key + region;
|
||||
// static-catalog providers (alibaba, volcengine) ignore both. The router
|
||||
// decrypts unconditionally so the adapter doesn't have to know which
|
||||
// category it's in — adapters that don't need creds just won't read them.
|
||||
const region = typeof upstream.adapterParams?.region === 'string'
|
||||
? upstream.adapterParams.region
|
||||
: undefined
|
||||
const load = (async () => {
|
||||
const unspeechBaseURL = (await options.configKV.getOrThrow('UNSPEECH_UPSTREAM')).restBaseURL
|
||||
|
||||
const keyEntry = upstream.keys[0]
|
||||
const plaintext = slice.model.provider === 'azure'
|
||||
? options.envelopeCrypto.decryptKey(keyEntry.ciphertext, { modelName, keyEntryId: keyEntry.id })
|
||||
: undefined
|
||||
// Live providers (Azure) need the decrypted Azure subscription key + region;
|
||||
// static-catalog providers (alibaba, volcengine) ignore both. The router
|
||||
// decrypts unconditionally so the adapter doesn't have to know which
|
||||
// category it's in — adapters that don't need creds just won't read them.
|
||||
const region = typeof upstream.adapterParams?.region === 'string'
|
||||
? upstream.adapterParams.region
|
||||
: undefined
|
||||
|
||||
try {
|
||||
const voices = await adapter.getVoiceCatalog({
|
||||
keyPlaintext: plaintext,
|
||||
region,
|
||||
adapterParams: upstream.adapterParams ?? {},
|
||||
unspeechBaseURL,
|
||||
fetchImpl,
|
||||
})
|
||||
const keyEntry = upstream.keys[0]
|
||||
const plaintext = slice.model.provider === 'azure'
|
||||
? options.envelopeCrypto.decryptKey(keyEntry.ciphertext, { modelName, keyEntryId: keyEntry.id })
|
||||
: undefined
|
||||
|
||||
// Cache only on success — failure responses must NOT be persisted or
|
||||
// the next admin reconfigure would have to wait out the TTL even after
|
||||
// fixing credentials.
|
||||
const ttl = options.ttsVoiceCacheTtlSeconds ?? ttsVoicesCacheTtl(slice.model.provider)
|
||||
await options.redis.set(cacheKey, JSON.stringify(voices), 'EX', ttl)
|
||||
.catch((err) => {
|
||||
logger.withError(err).withFields({ cacheKey }).warn('failed to write tts voices cache')
|
||||
try {
|
||||
const voices = await adapter.getVoiceCatalog({
|
||||
keyPlaintext: plaintext,
|
||||
region,
|
||||
adapterParams: upstream.adapterParams ?? {},
|
||||
unspeechBaseURL,
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
return voices
|
||||
}
|
||||
finally {
|
||||
plaintext?.fill(0)
|
||||
}
|
||||
// Cache only on success — failure responses must NOT be persisted or
|
||||
// the next admin reconfigure would have to wait out the TTL even after
|
||||
// fixing credentials.
|
||||
const ttl = options.ttsVoiceCacheTtlSeconds ?? ttsVoicesCacheTtl(slice.model.provider)
|
||||
await options.redis.set(cacheKey, JSON.stringify(voices), 'EX', ttl)
|
||||
.catch((err) => {
|
||||
logger.withError(err).withFields({ cacheKey }).warn('failed to write tts voices cache')
|
||||
})
|
||||
|
||||
return voices
|
||||
}
|
||||
finally {
|
||||
plaintext?.fill(0)
|
||||
}
|
||||
})().finally(() => {
|
||||
ttsVoiceCatalogLoads.delete(cacheKey)
|
||||
})
|
||||
|
||||
ttsVoiceCatalogLoads.set(cacheKey, load)
|
||||
return load
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -754,5 +754,48 @@ describe('createLlmRouterService', () => {
|
||||
// adapter's `Error & { status }` was read as undefined.
|
||||
expect(fallbackCalls[0][1]).toMatchObject({ reason: '401' })
|
||||
})
|
||||
|
||||
it('listTtsVoices deduplicates concurrent cold-cache upstream fetches per model', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Azure voice catalogs are cached after a successful fetch, but concurrent
|
||||
// cold-cache requests used to miss Redis together and each hit unspeech's
|
||||
// microsoft voices endpoint. That can amplify one settings-page open into
|
||||
// several Azure voices/list calls and trigger upstream 429.
|
||||
//
|
||||
// We fixed this by sharing the in-flight catalog load for the same
|
||||
// provider/model cache key. Failures are still returned to every caller and
|
||||
// are not cached.
|
||||
const { config, crypto } = makeTtsConfig({
|
||||
upstreams: [{ baseURL: 'https://az.example', keyIds: ['kA1'], adapterParams: { region: 'eastasia' } }],
|
||||
})
|
||||
|
||||
let resolveFetch!: () => void
|
||||
const fetchImpl = vi.fn(() => new Promise<Response>((resolve) => {
|
||||
resolveFetch = () => resolve(happyResponse({
|
||||
voices: [{ id: 'en-US-AvaMultilingualNeural', name: 'Ava' }],
|
||||
}))
|
||||
}))
|
||||
|
||||
const router = createLlmRouterService({
|
||||
configKV: makeConfigKV(config),
|
||||
envelopeCrypto: crypto,
|
||||
gatewayMetrics: null,
|
||||
fetchImpl,
|
||||
redis: makeRedisStub(),
|
||||
})
|
||||
|
||||
const first = router.listTtsVoices('tts-test')
|
||||
const second = router.listTtsVoices('tts-test')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
resolveFetch()
|
||||
|
||||
const [firstVoices, secondVoices] = await Promise.all([first, second])
|
||||
expect(firstVoices.map(voice => voice.id)).toEqual(['en-US-AvaMultilingualNeural'])
|
||||
expect(secondVoices.map(voice => voice.id)).toEqual(['en-US-AvaMultilingualNeural'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -87,13 +87,13 @@ function syncOpenAICompatibleSettings() {
|
||||
|
||||
onMounted(async () => {
|
||||
await providersStore.loadModelsForConfiguredProviders()
|
||||
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
|
||||
speechStore.ensureActiveSpeechModel()
|
||||
await speechStore.loadVoicesForProvider(activeSpeechProvider.value, activeSpeechModel.value || undefined)
|
||||
syncOpenAICompatibleSettings()
|
||||
})
|
||||
|
||||
watch(activeSpeechProvider, async (newProvider, oldProvider) => {
|
||||
await providersStore.loadModelsForConfiguredProviders()
|
||||
await speechStore.loadVoicesForProvider(newProvider)
|
||||
|
||||
// Reset model and voice when switching providers (but not on initial load)
|
||||
if (oldProvider !== undefined && oldProvider !== newProvider) {
|
||||
@@ -102,12 +102,18 @@ watch(activeSpeechProvider, async (newProvider, oldProvider) => {
|
||||
activeSpeechVoice.value = undefined
|
||||
}
|
||||
|
||||
// Re-seed the streaming default model after the reset above so its voices
|
||||
// load model-scoped (the server only returns recommended voices for an
|
||||
// explicit ?model=). No-op for other providers / when a model is selected.
|
||||
speechStore.ensureActiveSpeechModel()
|
||||
await speechStore.loadVoicesForProvider(newProvider, activeSpeechModel.value || undefined)
|
||||
|
||||
syncOpenAICompatibleSettings()
|
||||
})
|
||||
|
||||
watch(activeSpeechModel, async () => {
|
||||
if (activeSpeechProvider.value) {
|
||||
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
|
||||
await speechStore.loadVoicesForProvider(activeSpeechProvider.value, activeSpeechModel.value || undefined)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+25
-12
@@ -4,7 +4,7 @@ import {
|
||||
ProviderSettingsLayout,
|
||||
SpeechPlayground,
|
||||
} from '@proj-airi/stage-ui/components'
|
||||
import { streamingSynthesize } from '@proj-airi/stage-ui/libs'
|
||||
import { getDefaultStreamingModel, streamingSynthesize } from '@proj-airi/stage-ui/libs'
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
@@ -23,18 +23,19 @@ const { isAuthenticated, credits, needsLogin } = storeToRefs(authStore)
|
||||
|
||||
const providerId = 'official-provider-speech-streaming'
|
||||
const providerMetadata = computed(() => providersStore.getProviderMetadata(providerId))
|
||||
const defaultModel = 'volcengine/seed-tts-2.0'
|
||||
|
||||
const providerConfig = computed(() => providersStore.getProviderConfig(providerId))
|
||||
|
||||
// Model picker. Pulled from the provider's `extraMethods.listModels` —
|
||||
// today that's two hard-coded Volcengine variants, but the picker stays
|
||||
// generic so adding ICL / other backends later doesn't need UI changes.
|
||||
// Model picker. The catalog and the default model id both come from the
|
||||
// server's `/api/v1/audio/models/streaming` response (operator-controlled
|
||||
// via `UNSPEECH_UPSTREAM.streaming`); no client-side hardcoded defaults so
|
||||
// adding ICL / other backends doesn't need a UI release.
|
||||
const providerModels = computed(() => providersStore.getModelsForProvider(providerId))
|
||||
const modelsLoading = computed(() => providersStore.isLoadingModels[providerId] || false)
|
||||
const serverDefaultModel = ref<string | null>(null)
|
||||
const model = computed({
|
||||
get(): string {
|
||||
return (providerConfig.value?.model as string | undefined) ?? defaultModel
|
||||
return (providerConfig.value?.model as string | undefined) ?? serverDefaultModel.value ?? ''
|
||||
},
|
||||
set(val: string) {
|
||||
providerConfig.value.model = val
|
||||
@@ -57,8 +58,13 @@ async function loadVoices() {
|
||||
|
||||
onMounted(async () => {
|
||||
await providersStore.fetchModelsForProvider(providerId)
|
||||
if (!providerConfig.value.model)
|
||||
providerConfig.value.model = defaultModel
|
||||
// `getDefaultStreamingModel()` is populated by the provider's listModels()
|
||||
// (just ran via fetchModelsForProvider). If the operator hasn't curated a
|
||||
// default server-side, fall back to the first model the server returned
|
||||
// so the picker always has something selected.
|
||||
serverDefaultModel.value = getDefaultStreamingModel() ?? providerModels.value[0]?.id ?? null
|
||||
if (!providerConfig.value.model && serverDefaultModel.value)
|
||||
providerConfig.value.model = serverDefaultModel.value
|
||||
await loadVoices()
|
||||
})
|
||||
|
||||
@@ -76,11 +82,18 @@ watch(model, async () => {
|
||||
// per-preview because there's no LLM token stream here — we just send
|
||||
// one `text` frame containing the static preview prompt.
|
||||
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean): Promise<ArrayBuffer> {
|
||||
const requestedModel = model.value || defaultModel
|
||||
const requestedModel = model.value
|
||||
if (!requestedModel)
|
||||
throw new Error('No streaming TTS model selected and server returned no default')
|
||||
// `model` looks like `volcengine/seed-tts-2.0`. The trailing path is
|
||||
// forwarded as Volcengine's `api_resource_id` so the upstream knows
|
||||
// which model variant to use; matches the wiring in `Stage.vue`.
|
||||
const apiResourceId = requestedModel.includes('/') ? requestedModel.split('/', 2)[1] : 'seed-tts-2.0'
|
||||
// forwarded as Volcengine's `api_resource_id` so the upstream knows which
|
||||
// model variant to use; matches the wiring in `Stage.vue`. We require the
|
||||
// `<backend>/<resource>` shape and refuse anything else — silently picking
|
||||
// a fallback resource id hides config drift.
|
||||
const slashIndex = requestedModel.indexOf('/')
|
||||
if (slashIndex < 0)
|
||||
throw new Error(`Streaming model id missing backend prefix: ${requestedModel}`)
|
||||
const apiResourceId = requestedModel.slice(slashIndex + 1)
|
||||
const result = await streamingSynthesize({
|
||||
model: requestedModel,
|
||||
voice: voiceId,
|
||||
|
||||
@@ -32,7 +32,7 @@ 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 { getDefinedProvider } from '../../libs/providers/providers'
|
||||
import { getDefaultStreamingModel, getDefinedProvider } from '../../libs/providers/providers'
|
||||
import { createStageTtsSession } from '../../libs/speech/tts-session'
|
||||
import { useAudioContext, useSpeakingStore } from '../../stores/audio'
|
||||
import { useBackgroundStore } from '../../stores/background'
|
||||
@@ -575,8 +575,17 @@ function buildStreamingSnapshot(): StreamingSessionSnapshot | null {
|
||||
const voiceId = activeSpeechVoice.value?.id
|
||||
if (!voiceId)
|
||||
return null
|
||||
const sessionModel = (activeSpeechModel.value as string | undefined) || 'volcengine/seed-tts-2.0'
|
||||
const apiResourceId = sessionModel.includes('/') ? sessionModel.split('/', 2)[1] : 'seed-tts-2.0'
|
||||
// Resolve the concrete streaming model id. The active speech model is only
|
||||
// valid here when it carries the `<backend>/<api_resource_id>` shape the ws
|
||||
// upstream expects — the HTTP TTS `auto` alias (and an empty selection after
|
||||
// a provider switch) must NOT reach the bridge, so fall back to the
|
||||
// server-curated default instead of a hardcoded id. Returns null (segmenter
|
||||
// fallback) when neither resolves, rather than guessing a resource id.
|
||||
const activeModel = activeSpeechModel.value as string | undefined
|
||||
const sessionModel = activeModel?.includes('/') ? activeModel : getDefaultStreamingModel()
|
||||
if (!sessionModel?.includes('/'))
|
||||
return null
|
||||
const apiResourceId = sessionModel.split('/', 2)[1]
|
||||
// TTS 2.0 / ICL 2.0 ship subtitles asynchronously relative to audio
|
||||
// (per the wire spec), so chunk-on-sentence-end would drop frames.
|
||||
// Buffer the entire session and decode at session.finished instead.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { initializeAuth } from '../libs/auth'
|
||||
import { getStreamingTtsAvailable } from '../libs/providers'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useConsciousnessStore } from '../stores/modules/consciousness'
|
||||
import { useHearingStore } from '../stores/modules/hearing'
|
||||
@@ -16,6 +17,14 @@ const AUTH_ACTIVATED_PROVIDERS: Array<{ id: string, module: 'consciousness' | 's
|
||||
{ id: 'official-provider-speech', module: 'speech' },
|
||||
]
|
||||
|
||||
// The streaming TTS provider is NOT in the static list above because its
|
||||
// visibility is operator-controlled: `UNSPEECH_UPSTREAM.streaming` may be
|
||||
// unconfigured server-side. It's bootstrapped separately (see
|
||||
// `syncStreamingSpeechProvider`) — probed on sign-in, then force-configured
|
||||
// only when the server reports it available, mirroring how the HTTP TTS
|
||||
// provider uses `forceProviderConfigured` but gating it on a server signal.
|
||||
const STREAMING_SPEECH_PROVIDER_ID = 'official-provider-speech-streaming'
|
||||
|
||||
/**
|
||||
* Glue layer: uses auth lifecycle hooks to activate/deactivate
|
||||
* official providers. Providers themselves know nothing about auth.
|
||||
@@ -60,7 +69,7 @@ export function useAuthProviderSync() {
|
||||
case 'speech':
|
||||
if (!speechStore.activeSpeechProvider || speechStore.activeSpeechProvider === 'speech-noop') {
|
||||
speechStore.activeSpeechProvider = id
|
||||
speechStore.activeSpeechModel = 'auto'
|
||||
speechStore.activeSpeechModel = ''
|
||||
}
|
||||
break
|
||||
case 'hearing':
|
||||
@@ -85,8 +94,54 @@ export function useAuthProviderSync() {
|
||||
catch (err) {
|
||||
console.error('error loading models for official providers', err)
|
||||
}
|
||||
|
||||
await syncStreamingSpeechProvider()
|
||||
})
|
||||
|
||||
// Bootstrap the streaming TTS provider from the server's availability signal.
|
||||
// Probing populates `getStreamingTtsAvailable()` (and the default model /
|
||||
// voices) via the provider's listModels(). The availability override drives
|
||||
// the provider's presence in the available/configured lists (and thus the
|
||||
// settings card + picker); force-configure makes it selectable. It is never
|
||||
// set as the active speech provider — the HTTP TTS provider stays default.
|
||||
async function syncStreamingSpeechProvider() {
|
||||
if (providersStore.getProviderMetadata(STREAMING_SPEECH_PROVIDER_ID) == null)
|
||||
return
|
||||
|
||||
await providersStore.fetchModelsForProvider(STREAMING_SPEECH_PROVIDER_ID)
|
||||
|
||||
const available = getStreamingTtsAvailable()
|
||||
providersStore.setProviderAvailabilityOverride(STREAMING_SPEECH_PROVIDER_ID, available)
|
||||
|
||||
if (available) {
|
||||
providersStore.forceProviderConfigured(STREAMING_SPEECH_PROVIDER_ID)
|
||||
// The speech-module watcher skips voice loading for streaming until it's
|
||||
// confirmed configured (avoids a pre-probe request on reload), so when
|
||||
// streaming is the persisted active provider, load its voices now that
|
||||
// it's confirmed available.
|
||||
if (speechStore.activeSpeechProvider === STREAMING_SPEECH_PROVIDER_ID) {
|
||||
speechStore.ensureStreamingDefaultModel()
|
||||
await speechStore.loadVoicesForProvider(STREAMING_SPEECH_PROVIDER_ID, speechStore.activeSpeechModel || undefined)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
providersStore.setProviderUnconfigured(STREAMING_SPEECH_PROVIDER_ID)
|
||||
// `setProviderUnconfigured` blanks `validatedCredentialHash`, which makes
|
||||
// the speech-module reset watcher skip its own clear. So when the server
|
||||
// now reports streaming unavailable on an authenticated reload (no logout
|
||||
// event fires), clear a stale active streaming selection here.
|
||||
clearActiveStreamingSelection()
|
||||
}
|
||||
|
||||
function clearActiveStreamingSelection() {
|
||||
if (speechStore.activeSpeechProvider !== STREAMING_SPEECH_PROVIDER_ID)
|
||||
return
|
||||
speechStore.activeSpeechProvider = ''
|
||||
speechStore.activeSpeechModel = ''
|
||||
speechStore.activeSpeechVoiceId = ''
|
||||
}
|
||||
|
||||
authStore.onLogout(() => {
|
||||
hasSynced = false
|
||||
|
||||
@@ -94,6 +149,15 @@ export function useAuthProviderSync() {
|
||||
providersStore.setProviderUnconfigured(id)
|
||||
}
|
||||
|
||||
// Streaming TTS is bootstrapped outside AUTH_ACTIVATED_PROVIDERS, so reset
|
||||
// it explicitly. `setProviderUnconfigured` blanks `validatedCredentialHash`,
|
||||
// which makes the speech-module watcher skip its own reset (it guards
|
||||
// against racing initial validation), so clear the active selection here
|
||||
// too when streaming was the active provider.
|
||||
clearActiveStreamingSelection()
|
||||
providersStore.setProviderUnconfigured(STREAMING_SPEECH_PROVIDER_ID)
|
||||
providersStore.setProviderAvailabilityOverride(STREAMING_SPEECH_PROVIDER_ID, false)
|
||||
|
||||
// Reset active provider/model if they belong to an auth-activated provider
|
||||
for (const { id, module } of AUTH_ACTIVATED_PROVIDERS) {
|
||||
switch (module) {
|
||||
|
||||
@@ -34,6 +34,11 @@ import './cloudflare-workers-ai'
|
||||
import './azure-ai-foundry'
|
||||
import './official'
|
||||
|
||||
export {
|
||||
getDefaultStreamingModel,
|
||||
getStreamingTtsAvailable,
|
||||
} from './official'
|
||||
|
||||
export {
|
||||
getDefinedProvider,
|
||||
listProviders,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Ref, WatchSource } from 'vue'
|
||||
|
||||
import type { ModelInfo, VoiceInfo } from '../../../../stores/providers'
|
||||
|
||||
import { watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { getAuthToken } from '../../../../libs/auth'
|
||||
@@ -13,11 +13,34 @@ import { createOfficialAudioProvider, createOfficialOpenAIProvider, OFFICIAL_ICO
|
||||
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
|
||||
// prefer a curated default per locale. Falls back to language + first-voice
|
||||
// matching when the server returns no recommendations.
|
||||
let recommendedVoicesByLocale: Record<string, string> = {}
|
||||
// Locale → voice id map recommended by the server, keyed by provider id.
|
||||
// Populated by each speech provider's listVoices() from the response's
|
||||
// `recommended` field so the auto-pick can prefer a curated default per
|
||||
// locale. Keyed per provider because the HTTP and streaming providers have
|
||||
// independent catalogs and recommendation buckets. Falls back to language +
|
||||
// first-voice matching when the server returns no recommendations.
|
||||
const recommendedVoicesByProvider: Record<string, Record<string, string>> = {}
|
||||
|
||||
// Server-curated default streaming model id, populated by the streaming
|
||||
// provider's listModels(). Pages that need to seed an initial model selection
|
||||
// read this via getDefaultStreamingModel() instead of hardcoding an id.
|
||||
let defaultStreamingModelId: string | null = null
|
||||
|
||||
export function getDefaultStreamingModel(): string | null {
|
||||
return defaultStreamingModelId
|
||||
}
|
||||
|
||||
// Operator-controlled visibility switch for the streaming provider. The server
|
||||
// reports it via `/api/v1/audio/models/streaming` (`available`), and the
|
||||
// auth-activation glue gates `forceProviderConfigured` on this so the provider
|
||||
// only surfaces when `UNSPEECH_UPSTREAM.streaming` is configured server-side.
|
||||
// Reactive so the providers store re-derives configured speech providers when
|
||||
// the probe resolves after sign-in.
|
||||
const streamingTtsAvailable = ref(false)
|
||||
|
||||
export function getStreamingTtsAvailable(): boolean {
|
||||
return streamingTtsAvailable.value
|
||||
}
|
||||
|
||||
const officialConfigSchema = z.object({})
|
||||
|
||||
@@ -107,9 +130,8 @@ export const providerOfficialSpeech = defineProvider({
|
||||
listVoices: async (_config, _provider, model): Promise<VoiceInfo[]> => {
|
||||
// Voice catalogs are model-scoped on the server side. Pass the active
|
||||
// model through so Azure / cosyvoice / future provider voices route to
|
||||
// the right adapter. `auto` defers to the server's DEFAULT_TTS_MODEL,
|
||||
// but it MUST be sent explicitly — an absent `model` is treated as a
|
||||
// client bug and returns 400.
|
||||
// the right adapter. If model discovery has not completed yet, keep the
|
||||
// legacy `auto` request as a startup fallback.
|
||||
const target = model && model.length > 0 ? model : 'auto'
|
||||
const url = new URL(`${SERVER_URL}/api/v1/audio/voices`)
|
||||
url.searchParams.set('model', target)
|
||||
@@ -137,7 +159,7 @@ export const providerOfficialSpeech = defineProvider({
|
||||
// Refresh the server-side recommendation map. Done here rather than
|
||||
// threading it through the return value because the auto-pick watcher
|
||||
// lives in this module and reads the same singleton.
|
||||
recommendedVoicesByLocale = (data.recommended && typeof data.recommended === 'object') ? data.recommended : {}
|
||||
recommendedVoicesByProvider[OFFICIAL_SPEECH_PROVIDER_ID] = (data.recommended && typeof data.recommended === 'object') ? data.recommended : {}
|
||||
|
||||
if (!Array.isArray(data.voices))
|
||||
throw new Error('audio voices upstream returned malformed body')
|
||||
@@ -155,10 +177,9 @@ export const providerOfficialSpeech = defineProvider({
|
||||
// NOTICE: deliberately dropping `compatible_models`. The official
|
||||
// provider resolves voices through the server's /audio/voices?model=
|
||||
// endpoint, which already returns only voices valid for the active
|
||||
// model (or the DEFAULT_TTS_MODEL when the client's selection is the
|
||||
// 'auto' alias). Re-applying the client-side filter on top would
|
||||
// zero out the list because upstream compatibility ids never match
|
||||
// 'auto'. See packages/stage-pages/.../speech.vue filter predicate.
|
||||
// model. Re-applying the client-side filter on top can zero out the
|
||||
// list when upstream compatibility ids differ from AIRI's router ids.
|
||||
// See packages/stage-pages/.../speech.vue filter predicate.
|
||||
languages: Array.isArray(v.languages) ? v.languages : [],
|
||||
}
|
||||
})
|
||||
@@ -214,17 +235,26 @@ export const providerOfficialSpeechStreaming = defineProvider({
|
||||
extraMethods: {
|
||||
listModels: async (): Promise<ModelInfo[]> => {
|
||||
// Streaming TTS catalog is operator-controlled via configKV
|
||||
// (`STREAMING_TTS_MODELS`). The wire `model` field uses the
|
||||
// `<backend>/<api_resource_id>` shape unspeech expects (see
|
||||
// `unspeech/docs/wire-protocols/audio-speech-stream-v1.md`); the server
|
||||
// returns whatever the operator put there, no client-side defaults.
|
||||
// (`UNSPEECH_UPSTREAM.streaming`). Wire shape uses `<backend>/<api_resource_id>`
|
||||
// (see `unspeech/docs/wire-protocols/audio-speech-stream-v1.md`); the
|
||||
// server returns whatever the operator put there, no client-side
|
||||
// defaults. `default` (when set) seeds initial model selection via
|
||||
// {@link getDefaultStreamingModel}.
|
||||
// Reset the operator-driven signals up front so a failed/aborted probe
|
||||
// leaves the provider hidden rather than stuck on a stale "available".
|
||||
streamingTtsAvailable.value = false
|
||||
defaultStreamingModelId = null
|
||||
|
||||
const res = await globalThis.fetch(`${SERVER_URL}/api/v1/audio/models/streaming`, { headers: authHeaders() })
|
||||
if (!res.ok)
|
||||
throw new Error(`streaming models upstream ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 256))
|
||||
|
||||
const data = await res.json() as { models?: { id: string, name?: string, description?: string }[] }
|
||||
const data = await res.json() as { available?: boolean, models: { id: string, name?: string, description?: string }[], default?: string | null }
|
||||
if (!Array.isArray(data.models))
|
||||
throw new Error('streaming models upstream returned malformed body')
|
||||
throw new Error('streaming models upstream missing models[]')
|
||||
|
||||
streamingTtsAvailable.value = data.available === true
|
||||
defaultStreamingModelId = typeof data.default === 'string' && data.default.length > 0 ? data.default : null
|
||||
|
||||
return data.models.map(m => ({
|
||||
id: m.id,
|
||||
@@ -235,8 +265,8 @@ export const providerOfficialSpeechStreaming = defineProvider({
|
||||
},
|
||||
listVoices: async (_config, _provider, model): Promise<VoiceInfo[]> => {
|
||||
// Streaming voices live behind a dedicated endpoint
|
||||
// (`/audio/voices/streaming`) because they come from a separate
|
||||
// configKV entry (`STREAMING_TTS_UPSTREAM`) than the HTTP TTS
|
||||
// (`/audio/voices/streaming`) because they come from the
|
||||
// `UNSPEECH_UPSTREAM.streaming` configKV subtree rather than the HTTP TTS
|
||||
// `?model=...` lookup. The server proxies to unspeech's
|
||||
// `/api/voices?provider=volcengine`, which ships an embed-time
|
||||
// catalogue without requiring credentials.
|
||||
@@ -264,7 +294,14 @@ export const providerOfficialSpeechStreaming = defineProvider({
|
||||
languages?: { code: string, title: string }[]
|
||||
preview_audio_url?: string
|
||||
}[]
|
||||
recommended?: Record<string, string>
|
||||
}
|
||||
|
||||
// Mirror the HTTP provider: stash the server's per-locale recommendations
|
||||
// so setupOfficialSpeechAutoPick can seed a curated default voice when
|
||||
// the streaming provider becomes active.
|
||||
recommendedVoicesByProvider[OFFICIAL_SPEECH_STREAMING_PROVIDER_ID] = (data.recommended && typeof data.recommended === 'object') ? data.recommended : {}
|
||||
|
||||
if (!Array.isArray(data.voices))
|
||||
throw new Error('streaming voices upstream returned malformed body')
|
||||
|
||||
@@ -318,10 +355,13 @@ function lookupRecommendedVoiceId(locale: string, map: Record<string, string>):
|
||||
return undefined
|
||||
}
|
||||
|
||||
// NOTICE: Only the official speech provider auto-configures a default voice
|
||||
// after login. Third-party providers leave voice selection to the user. The
|
||||
// target locale is derived from the UI locale on each run — we don't persist
|
||||
// it, since that was the root of the cross-provider filter drift bug.
|
||||
const AUTO_PICK_PROVIDER_IDS = new Set([OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID])
|
||||
|
||||
// NOTICE: Only the official speech providers (HTTP + streaming) auto-configure
|
||||
// a default voice after login. Third-party providers leave voice selection to
|
||||
// the user. The target locale is derived from the UI locale on each run — we
|
||||
// don't persist it, since that was the root of the cross-provider filter
|
||||
// drift bug.
|
||||
export function setupOfficialSpeechAutoPick(ctx: {
|
||||
activeSpeechProvider: Ref<string>
|
||||
activeSpeechVoiceId: Ref<string>
|
||||
@@ -329,12 +369,12 @@ export function setupOfficialSpeechAutoPick(ctx: {
|
||||
uiLocale: WatchSource<string> | Ref<string>
|
||||
}) {
|
||||
watch([ctx.availableVoices, ctx.activeSpeechProvider], ([voices, provider]) => {
|
||||
if (provider !== OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
if (!AUTO_PICK_PROVIDER_IDS.has(provider))
|
||||
return
|
||||
if (ctx.activeSpeechVoiceId.value)
|
||||
return
|
||||
|
||||
const providerVoices = voices[OFFICIAL_SPEECH_PROVIDER_ID]
|
||||
const providerVoices = voices[provider]
|
||||
if (!providerVoices?.length)
|
||||
return
|
||||
|
||||
@@ -356,7 +396,7 @@ export function setupOfficialSpeechAutoPick(ctx: {
|
||||
// 3) any English voice (en-US, then en-*) — broadest comprehensible
|
||||
// fallback when the user's locale has no coverage at all
|
||||
// 4) alphabetical first voice, as a last resort
|
||||
const recommendedId = lookupRecommendedVoiceId(targetLocale, recommendedVoicesByLocale)
|
||||
const recommendedId = lookupRecommendedVoiceId(targetLocale, recommendedVoicesByProvider[provider] ?? {})
|
||||
const speaksLocale = (v: VoiceInfo, code: string) => (v.languages || []).some(l => l.code === code)
|
||||
const match = (recommendedId && providerVoices.find(v => v.id === recommendedId))
|
||||
|| providerVoices.find(v => speaksLocale(v, targetLocale))
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { toSignedPercent } from './speech'
|
||||
import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '../../libs/providers/providers/official'
|
||||
import { useProvidersStore } from '../providers'
|
||||
import { toSignedPercent, useSpeechStore } from './speech'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
locale: { value: 'en-US' },
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('speech store helpers', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('formats positive percentages with a plus sign', () => {
|
||||
expect(toSignedPercent(25)).toBe('+25%')
|
||||
})
|
||||
@@ -15,4 +29,53 @@ describe('speech store helpers', () => {
|
||||
it('formats zero as 0%', () => {
|
||||
expect(toSignedPercent(0)).toBe('0%')
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* await speechStore.loadVoicesForProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, 'volcengine/seed-tts-2.0')
|
||||
*/
|
||||
it('does not load streaming voices before server availability is confirmed', async () => {
|
||||
const providersStore = useProvidersStore()
|
||||
const speechStore = useSpeechStore()
|
||||
const listVoices = vi.fn(async () => [])
|
||||
const metadata = providersStore.providerMetadata[OFFICIAL_SPEECH_STREAMING_PROVIDER_ID]
|
||||
metadata.capabilities.listVoices = listVoices
|
||||
providersStore.providerRuntimeState[OFFICIAL_SPEECH_STREAMING_PROVIDER_ID].isConfigured = false
|
||||
|
||||
const voices = await speechStore.loadVoicesForProvider(
|
||||
OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,
|
||||
'volcengine/seed-tts-2.0',
|
||||
)
|
||||
|
||||
expect(voices).toEqual([])
|
||||
expect(listVoices).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* speechStore.ensureActiveSpeechModel()
|
||||
*/
|
||||
it('resets stale streaming model when the regular official speech provider is active', () => {
|
||||
const providersStore = useProvidersStore()
|
||||
const speechStore = useSpeechStore()
|
||||
speechStore.activeSpeechProvider = OFFICIAL_SPEECH_PROVIDER_ID
|
||||
speechStore.activeSpeechModel = 'volcengine/seed-tts-2.0'
|
||||
speechStore.activeSpeechVoiceId = 'zh_female_x'
|
||||
speechStore.activeSpeechVoice = {
|
||||
id: 'zh_female_x',
|
||||
name: 'X',
|
||||
provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,
|
||||
languages: [],
|
||||
}
|
||||
providersStore.providerRuntimeState[OFFICIAL_SPEECH_PROVIDER_ID].models = [
|
||||
{ id: 'microsoft/v1', name: 'microsoft/v1', provider: OFFICIAL_SPEECH_PROVIDER_ID },
|
||||
{ id: 'alibaba/cosyvoice-v2', name: 'alibaba/cosyvoice-v2', provider: OFFICIAL_SPEECH_PROVIDER_ID },
|
||||
]
|
||||
|
||||
speechStore.ensureActiveSpeechModel()
|
||||
|
||||
expect(speechStore.activeSpeechModel).toBe('microsoft/v1')
|
||||
expect(speechStore.activeSpeechVoiceId).toBe('')
|
||||
expect(speechStore.activeSpeechVoice).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SpeechProviderWithExtraOptions } from '@xsai-ext/providers/utils'
|
||||
|
||||
import type { VoiceInfo } from '../providers'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
|
||||
import { refManualReset } from '@vueuse/core'
|
||||
import { generateSpeech } from '@xsai/generate-speech'
|
||||
@@ -11,7 +12,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { toXml } from 'xast-util-to-xml'
|
||||
import { x } from 'xastscript'
|
||||
|
||||
import { setupOfficialSpeechAutoPick } from '../../libs/providers/providers/official'
|
||||
import { getDefaultStreamingModel, OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID, setupOfficialSpeechAutoPick } from '../../libs/providers/providers/official'
|
||||
import { useProvidersStore } from '../providers'
|
||||
|
||||
export function toSignedPercent(value: number): string {
|
||||
@@ -88,6 +89,13 @@ export const useSpeechStore = defineStore('speech', () => {
|
||||
return []
|
||||
}
|
||||
|
||||
// Streaming provider visibility is server-driven and only confirmed after
|
||||
// the auth probe force-configures it. Keep the gate at the public loader so
|
||||
// pages cannot bypass it and issue `/voices/streaming` while unavailable.
|
||||
if (provider === OFFICIAL_SPEECH_STREAMING_PROVIDER_ID && !providersStore.configuredProviders[provider]) {
|
||||
return []
|
||||
}
|
||||
|
||||
isLoadingSpeechProviderVoices.value = true
|
||||
speechProviderError.value = null
|
||||
|
||||
@@ -102,7 +110,7 @@ export const useSpeechStore = defineStore('speech', () => {
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`Error fetching voices for ${provider}:`, error)
|
||||
speechProviderError.value = error instanceof Error ? error.message : 'Unknown error'
|
||||
speechProviderError.value = errorMessageFrom(error) ?? 'Unknown error'
|
||||
return []
|
||||
}
|
||||
finally {
|
||||
@@ -115,12 +123,64 @@ export const useSpeechStore = defineStore('speech', () => {
|
||||
return availableVoices.value[provider] || []
|
||||
}
|
||||
|
||||
function clearVoiceSelection() {
|
||||
activeSpeechVoiceId.value = ''
|
||||
activeSpeechVoice.value = undefined
|
||||
}
|
||||
|
||||
// Streaming TTS voices are model-scoped: the server only returns recommended
|
||||
// voices for an explicit `?model=`. Ensure the active model is a valid
|
||||
// streaming model id so voice loading gets the right recommendations (parity
|
||||
// with the HTTP provider's auto-pick). Reseeds the server-curated default
|
||||
// both when no model is selected AND when `activeSpeechModel` still holds a
|
||||
// stale id from a previously-active provider (the global model ref is shared
|
||||
// across providers, and the per-surface reset may not have run yet). No-op
|
||||
// for non-streaming providers.
|
||||
function ensureStreamingDefaultModel() {
|
||||
if (activeSpeechProvider.value !== OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)
|
||||
return
|
||||
const streamingModels = providersStore.getModelsForProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)
|
||||
const hasValidSelection = !!activeSpeechModel.value && streamingModels.some(m => m.id === activeSpeechModel.value)
|
||||
if (hasValidSelection)
|
||||
return
|
||||
// Replace an empty/stale (non-streaming) selection with the server default.
|
||||
// When no default can be resolved yet (catalog not loaded), clear it to ''
|
||||
// so callers pass `undefined` (server returns the full streaming catalog)
|
||||
// rather than forwarding a stale non-streaming model id as `?model=`.
|
||||
const nextModel = getDefaultStreamingModel() ?? streamingModels[0]?.id ?? ''
|
||||
if (activeSpeechModel.value === nextModel)
|
||||
return
|
||||
activeSpeechModel.value = nextModel
|
||||
// The previously-selected voice belonged to the stale/empty model context,
|
||||
// so drop it; auto-pick re-picks a recommended voice for the new model.
|
||||
clearVoiceSelection()
|
||||
}
|
||||
|
||||
function ensureActiveSpeechModel() {
|
||||
ensureStreamingDefaultModel()
|
||||
|
||||
if (activeSpeechProvider.value !== OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
return
|
||||
|
||||
const models = providersStore.getModelsForProvider(OFFICIAL_SPEECH_PROVIDER_ID)
|
||||
if (!models.length)
|
||||
return
|
||||
|
||||
const hasValidSelection = !!activeSpeechModel.value && models.some(m => m.id === activeSpeechModel.value)
|
||||
if (hasValidSelection)
|
||||
return
|
||||
|
||||
activeSpeechModel.value = models[0]?.id ?? ''
|
||||
clearVoiceSelection()
|
||||
}
|
||||
|
||||
// Watch for provider changes and load voices
|
||||
watch(activeSpeechProvider, async (newProvider) => {
|
||||
if (newProvider) {
|
||||
await loadVoicesForProvider(newProvider)
|
||||
// Don't reset voice settings when changing providers to allow for persistence
|
||||
}
|
||||
if (!newProvider)
|
||||
return
|
||||
ensureActiveSpeechModel()
|
||||
await loadVoicesForProvider(newProvider, activeSpeechModel.value || undefined)
|
||||
// Don't reset voice settings when changing providers to allow for persistence
|
||||
}, {
|
||||
// REVIEW: should we always load voices on init? What will happen when network is not available?
|
||||
immediate: true,
|
||||
@@ -159,7 +219,8 @@ export const useSpeechStore = defineStore('speech', () => {
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadVoicesForProvider(activeSpeechProvider.value).then(() => {
|
||||
ensureActiveSpeechModel()
|
||||
loadVoicesForProvider(activeSpeechProvider.value, activeSpeechModel.value || undefined).then(() => {
|
||||
if (activeSpeechVoiceId.value) {
|
||||
activeSpeechVoice.value = availableVoices.value[activeSpeechProvider.value]?.find(voice => voice.id === activeSpeechVoiceId.value)
|
||||
}
|
||||
@@ -173,6 +234,10 @@ export const useSpeechStore = defineStore('speech', () => {
|
||||
uiLocale: locale,
|
||||
})
|
||||
|
||||
watch(providerModels, () => {
|
||||
ensureActiveSpeechModel()
|
||||
})
|
||||
|
||||
watch([activeSpeechVoiceId, availableVoices], ([voiceId, voices]) => {
|
||||
if (voiceId) {
|
||||
// For OpenAI Compatible, create a custom voice object (no voices available from API)
|
||||
@@ -334,6 +399,8 @@ export const useSpeechStore = defineStore('speech', () => {
|
||||
speech,
|
||||
loadVoicesForProvider,
|
||||
getVoicesForProvider,
|
||||
ensureStreamingDefaultModel,
|
||||
ensureActiveSpeechModel,
|
||||
generateSSML,
|
||||
resetState,
|
||||
}
|
||||
|
||||
@@ -2275,6 +2275,19 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
const providerValidationInFlight = new Map<string, Promise<boolean>>()
|
||||
const providerRevalidationLoops = new Map<string, { resume: () => void }>()
|
||||
|
||||
// Server-driven availability overrides for providers whose visibility can
|
||||
// only be decided at runtime from the backend (e.g. the streaming TTS
|
||||
// provider, which exists only when `UNSPEECH_UPSTREAM.streaming` is
|
||||
// configured server-side). A `false` entry hides the provider from the
|
||||
// available lists regardless of its static `isAvailableBy`; an absent entry
|
||||
// means no override. Written by the auth-sync glue after it probes the
|
||||
// server. Reactive so the available/configured provider lists re-derive.
|
||||
const providerAvailabilityOverrides = ref<Record<string, boolean>>({})
|
||||
|
||||
function setProviderAvailabilityOverride(providerId: string, available: boolean) {
|
||||
providerAvailabilityOverrides.value = { ...providerAvailabilityOverrides.value, [providerId]: available }
|
||||
}
|
||||
|
||||
const configuredProviders = computed(() => {
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const [key, state] of Object.entries(providerRuntimeState.value)) {
|
||||
@@ -2686,9 +2699,17 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
}
|
||||
|
||||
const availableProvidersMetadata = computedAsync<ProviderMetadata[]>(async () => {
|
||||
// Spread-read the overrides synchronously so this re-runs when a
|
||||
// server-driven availability flips: computedAsync uses watchEffect, which
|
||||
// only tracks reactive reads before the first `await` — the per-provider
|
||||
// `isAvailableBy()` below runs after one, so reads inside it aren't tracked.
|
||||
const overrides = { ...providerAvailabilityOverrides.value }
|
||||
const providers: ProviderMetadata[] = []
|
||||
|
||||
for (const provider of allProvidersMetadata.value) {
|
||||
if (overrides[provider.id] === false)
|
||||
continue
|
||||
|
||||
const p = getProviderMetadata(provider.id)
|
||||
const isAvailableBy = p.isAvailableBy || (() => true)
|
||||
|
||||
@@ -2786,6 +2807,7 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
resetProviderSettings,
|
||||
forceProviderConfigured,
|
||||
setProviderUnconfigured,
|
||||
setProviderAvailabilityOverride,
|
||||
availableProvidersMetadata,
|
||||
allChatProvidersMetadata,
|
||||
allAudioSpeechProvidersMetadata,
|
||||
|
||||
Reference in New Issue
Block a user