fix(stage-pages): initialize streaming speech provider config (#2440)
This commit is contained in:
@@ -54,7 +54,6 @@ import './voicevox'
|
||||
|
||||
export {
|
||||
getDefaultStreamingModel,
|
||||
getStreamingTtsAvailable,
|
||||
OFFICIAL_TRANSCRIPTION_PROVIDER_ID,
|
||||
} from './official'
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Ref, WatchSource } from 'vue'
|
||||
|
||||
import type { ModelInfo, VoiceInfo } from '../../types'
|
||||
import type { ModelInfo, ProviderModelCatalog, VoiceInfo } from '../../types'
|
||||
|
||||
import { ref, watch } from 'vue'
|
||||
import { watch } from 'vue'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { getAuthToken } from '../../../../libs/auth'
|
||||
@@ -42,18 +42,6 @@ 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({})
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
@@ -64,6 +52,36 @@ function authHeaders(): Record<string, string> {
|
||||
return headers
|
||||
}
|
||||
|
||||
async function listStreamingModelCatalog(): Promise<ProviderModelCatalog> {
|
||||
// Streaming TTS catalog is operator-controlled via configKV
|
||||
// (`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.
|
||||
// Reset the default up front so a failed probe cannot retain stale data.
|
||||
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 { available?: boolean, models: { id: string, name?: string, description?: string }[], default?: string | null }
|
||||
if (!Array.isArray(data.models))
|
||||
throw new Error('streaming models upstream missing models[]')
|
||||
|
||||
defaultStreamingModelId = typeof data.default === 'string' && data.default.length > 0 ? data.default : null
|
||||
|
||||
return {
|
||||
available: data.available === true,
|
||||
defaultModel: defaultStreamingModelId,
|
||||
models: data.models.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name ?? m.id,
|
||||
provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,
|
||||
description: m.description,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export const providerOfficialChat = defineProvider({
|
||||
id: OFFICIAL_CHAT_PROVIDER_ID,
|
||||
order: -1,
|
||||
@@ -257,36 +275,8 @@ export const providerOfficialSpeechStreaming = defineProvider({
|
||||
},
|
||||
validationRequiredWhen: () => false,
|
||||
extraMethods: {
|
||||
listModels: async (): Promise<ModelInfo[]> => {
|
||||
// Streaming TTS catalog is operator-controlled via configKV
|
||||
// (`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 { available?: boolean, models: { id: string, name?: string, description?: string }[], default?: string | null }
|
||||
if (!Array.isArray(data.models))
|
||||
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,
|
||||
name: m.name ?? m.id,
|
||||
provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,
|
||||
description: m.description,
|
||||
}))
|
||||
},
|
||||
listModelCatalog: listStreamingModelCatalog,
|
||||
listModels: async () => (await listStreamingModelCatalog()).models,
|
||||
listVoices: async (_config, _provider, model): Promise<VoiceInfo[]> => {
|
||||
// Streaming voices live behind a dedicated endpoint
|
||||
// (`/audio/voices/streaming`) because they come from the
|
||||
|
||||
@@ -74,7 +74,18 @@ export interface ProviderConfigContext<TConfig> {
|
||||
t: ComposerTranslation
|
||||
}
|
||||
|
||||
/** Serializable model discovery result returned across renderer boundaries. */
|
||||
export interface ProviderModelCatalog {
|
||||
/** Models discovered for this provider. */
|
||||
models: ModelInfo[]
|
||||
/** Whether the server exposes this catalog. Absent when discovery did not return an authoritative state. */
|
||||
available?: boolean
|
||||
/** Server-selected model id, or null when the server has no default. */
|
||||
defaultModel?: string | null
|
||||
}
|
||||
|
||||
export interface ProviderExtraMethods<TConfig> {
|
||||
listModelCatalog?: (config: TConfig, provider: ProviderInstance, contextOptions?: { t: (input: string) => string }) => Promise<ProviderModelCatalog>
|
||||
listModels?: (config: TConfig, provider: ProviderInstance, contextOptions?: { t: (input: string) => string }) => Promise<ModelInfo[]>
|
||||
/**
|
||||
* Returns the voice catalogue. `model` lets providers whose voices vary by
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { LeadershipMode, SyncedPiniaRuntime } from 'pinia-plugin-synced'
|
||||
import type { App } from 'vue'
|
||||
|
||||
import en from '@proj-airi/i18n/locales/en'
|
||||
|
||||
import { createPinia, disposePinia } from 'pinia'
|
||||
import { createSyncedPiniaPlugin } from 'pinia-plugin-synced'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '../../libs/providers/providers/official'
|
||||
import { useProviderConfigStore } from './config'
|
||||
import { useProviderStore } from './provider'
|
||||
|
||||
const syncedContexts: Array<{
|
||||
app: App
|
||||
pinia: ReturnType<typeof createPinia>
|
||||
runtime: SyncedPiniaRuntime
|
||||
}> = []
|
||||
|
||||
function createSyncedContext(namespace: string, leadership: LeadershipMode) {
|
||||
const pinia = createPinia()
|
||||
const runtime = createSyncedPiniaPlugin({
|
||||
callTimeout: 1000,
|
||||
leadership,
|
||||
namespace,
|
||||
})
|
||||
pinia.use(runtime.plugin)
|
||||
let providerStore: ReturnType<typeof useProviderStore> | undefined
|
||||
let providerConfigStore: ReturnType<typeof useProviderConfigStore> | undefined
|
||||
const app = createApp({
|
||||
setup() {
|
||||
providerStore = useProviderStore()
|
||||
providerConfigStore = useProviderConfigStore()
|
||||
return () => null
|
||||
},
|
||||
})
|
||||
app
|
||||
.use(createI18n({ legacy: false, locale: 'en', messages: { en } }))
|
||||
.use(pinia)
|
||||
.mount(document.createElement('div'))
|
||||
if (!providerStore || !providerConfigStore)
|
||||
throw new Error('Provider stores did not initialize')
|
||||
|
||||
syncedContexts.push({ app, pinia, runtime })
|
||||
return { pinia, providerConfigStore, providerStore, runtime }
|
||||
}
|
||||
|
||||
describe('provider model catalog synchronization', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const context of syncedContexts.splice(0)) {
|
||||
context.app.unmount()
|
||||
context.runtime.dispose()
|
||||
disposePinia(context.pinia)
|
||||
}
|
||||
vi.unstubAllGlobals()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/pull/2440#discussion_r3912226716
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The streaming provider stored server availability and its default model
|
||||
// in module-local variables. A follower-only settings window routed model
|
||||
// discovery to the leader, then read its own unchanged local variables.
|
||||
//
|
||||
// Before: the leader returned only the models and kept the other catalog
|
||||
// fields in its renderer.
|
||||
//
|
||||
// We fixed this by returning one serializable model catalog from the action.
|
||||
// The follower receives the models, availability, and default model together.
|
||||
it('returns streaming catalog metadata to a follower-only renderer', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async () => Response.json({
|
||||
available: true,
|
||||
default: 'volcengine/seed-tts-2.0',
|
||||
models: [
|
||||
{ id: 'volcengine/seed-tts-2.0', name: 'Seed TTS 2.0' },
|
||||
],
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const namespace = `provider-model-catalog:${crypto.randomUUID()}`
|
||||
const leaderContext = createSyncedContext(namespace, 'leader-only')
|
||||
await vi.waitFor(() => expect(leaderContext.runtime.isLeader()).toBe(true))
|
||||
|
||||
const followerContext = createSyncedContext(namespace, 'follower-only')
|
||||
await vi.waitFor(() => expect(followerContext.runtime.getLeaderId()).toBe(leaderContext.runtime.participantId))
|
||||
|
||||
await followerContext.providerStore.initializeProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)
|
||||
const catalog = await followerContext.providerStore.fetchModelsForProvider(OFFICIAL_SPEECH_STREAMING_PROVIDER_ID)
|
||||
|
||||
expect(catalog).toEqual({
|
||||
available: true,
|
||||
defaultModel: 'volcengine/seed-tts-2.0',
|
||||
models: [
|
||||
expect.objectContaining({
|
||||
id: 'volcengine/seed-tts-2.0',
|
||||
name: 'Seed TTS 2.0',
|
||||
provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -530,23 +530,33 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
const definition = getProviderDefinition(providerId)
|
||||
const provider = await definition.createProvider(config)
|
||||
try {
|
||||
if (definition.extraMethods?.listModelCatalog) {
|
||||
const catalog = await definition.extraMethods.listModelCatalog(config, provider, { t })
|
||||
return {
|
||||
...catalog,
|
||||
models: normalizeProviderModels(providerId, catalog.models),
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.extraMethods?.listModels) {
|
||||
const models = await definition.extraMethods.listModels(config, provider, { t })
|
||||
return normalizeProviderModels(providerId, models)
|
||||
return { models: normalizeProviderModels(providerId, models) }
|
||||
}
|
||||
|
||||
if (isModelProvider(provider))
|
||||
return normalizeProviderModels(providerId, await listModels(provider.model()))
|
||||
return { models: normalizeProviderModels(providerId, await listModels(provider.model())) }
|
||||
|
||||
const baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''
|
||||
const apiKey = typeof config.apiKey === 'string' ? config.apiKey.trim() : ''
|
||||
if (!baseUrl)
|
||||
return []
|
||||
return { models: [] }
|
||||
|
||||
return normalizeProviderModels(providerId, await listModels({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
}))
|
||||
return {
|
||||
models: normalizeProviderModels(providerId, await listModels({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
finally {
|
||||
await disposeTemporaryProvider(provider)
|
||||
@@ -603,11 +613,11 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
async function fetchModelsForProvider(providerId: string) {
|
||||
const definition = findProviderDefinition(providerId)
|
||||
if (!definition)
|
||||
return []
|
||||
return { models: [] }
|
||||
|
||||
const config = providerCredentials.value[providerId]
|
||||
if (!config && definition.requiresCredentials !== false)
|
||||
return []
|
||||
return { models: [] }
|
||||
|
||||
initializeProviderRuntimeState(providerId)
|
||||
providerRuntimeState.value = {
|
||||
@@ -620,8 +630,8 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const models = await listProviderModels(providerId, config || {})
|
||||
const normalizedModels = uniqBy(models.filter(model => !!model.id), m => m.id)
|
||||
const catalog = await listProviderModels(providerId, config || {})
|
||||
const normalizedModels = uniqBy(catalog.models.filter(model => !!model.id), m => m.id)
|
||||
.map(model => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
@@ -646,11 +656,15 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
modelError: null,
|
||||
},
|
||||
}
|
||||
// Synced action results pass through structuredClone. Return the local
|
||||
// array because reading the same array from state returns a Vue proxy.
|
||||
return normalizedModels
|
||||
// Synced action results pass through structuredClone. Return local
|
||||
// catalog values because reading models back from state returns a Vue
|
||||
// proxy and provider-specific metadata is not part of synced state.
|
||||
return {
|
||||
...catalog,
|
||||
models: normalizedModels,
|
||||
}
|
||||
}
|
||||
return []
|
||||
return { models: [] }
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`Error fetching models for ${providerId}:`, error)
|
||||
@@ -665,7 +679,7 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
},
|
||||
}
|
||||
}
|
||||
return []
|
||||
return { models: [] }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user