From 13bbad95dc9c8eef87d1e131e928bd5ccfbbe13b Mon Sep 17 00:00:00 2001 From: leafyy Date: Wed, 2 Sep 2026 19:00:05 +0800 Subject: [PATCH] fix(stage-pages): initialize streaming speech provider config (#2440) --- ...-provider-speech-streaming.browser.test.ts | 234 ++++++++++++++++++ apps/stage-web/vite.config.ts | 5 +- .../official-provider-speech-streaming.vue | 74 ++++-- .../src/libs/providers/providers/index.ts | 1 - .../providers/providers/official/index.ts | 78 +++--- packages/stage-ui/src/libs/providers/types.ts | 11 + .../provider-model-catalog.browser.test.ts | 109 ++++++++ .../stage-ui/src/stores/providers/provider.ts | 46 ++-- 8 files changed, 478 insertions(+), 80 deletions(-) create mode 100644 apps/stage-web/src/pages/official-provider-speech-streaming.browser.test.ts create mode 100644 packages/stage-ui/src/stores/providers/provider-model-catalog.browser.test.ts diff --git a/apps/stage-web/src/pages/official-provider-speech-streaming.browser.test.ts b/apps/stage-web/src/pages/official-provider-speech-streaming.browser.test.ts new file mode 100644 index 000000000..4f59e646c --- /dev/null +++ b/apps/stage-web/src/pages/official-provider-speech-streaming.browser.test.ts @@ -0,0 +1,234 @@ +import type { Session, User } from 'better-auth' + +import en from '@proj-airi/i18n/locales/en' +import OfficialProviderSpeechStreamingPage from '@proj-airi/stage-pages/pages/settings/providers/speech/official-provider-speech-streaming.vue' + +import { errorMessageFrom } from '@moeru/std' +import { useAuthStore } from '@proj-airi/stage-ui/stores/auth' +import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config' +import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-vue' +import { createI18n } from 'vue-i18n' +import { createMemoryHistory, createRouter, routerKey } from 'vue-router' + +import 'virtual:uno.css' + +const providerId = 'official-provider-speech-streaming' + +const user: User = { + id: 'user-1', + name: 'AIRI User', + email: 'user@example.com', + emailVerified: true, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} + +const session: Session = { + id: 'session-1', + token: 'server-session-token', + userId: user.id, + expiresAt: new Date('2026-12-01T00:00:00.000Z'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { template: '
' } }], + }) +} + +function createTestI18n() { + return createI18n({ + legacy: false, + locale: 'en', + messages: { en }, + }) +} + +function responseFor(input: RequestInfo | URL) { + const url = typeof input === 'string' + ? input + : input instanceof Request + ? input.url + : input.toString() + + if (url.endsWith('/api/v1/flux')) { + return new Response(JSON.stringify({ userId: user.id, flux: 42 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + + if (url.includes('/api/v1/audio/models/streaming')) { + return new Response(JSON.stringify({ + available: true, + models: [{ id: 'volcengine/seed-tts-2.0', name: 'Seed TTS 2.0' }], + default: 'volcengine/seed-tts-2.0', + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + + if (url.includes('/api/v1/audio/voices/streaming')) { + return new Response(JSON.stringify({ voices: [], recommended: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + + throw new Error(`Unexpected request: ${url}`) +} + +async function renderPage(pinia = createPinia()) { + setActivePinia(pinia) + const errors: unknown[] = [] + const router = createTestRouter() + + await render(OfficialProviderSpeechStreamingPage, { + global: { + config: { + errorHandler: error => errors.push(error), + }, + directives: { motion: {} }, + plugins: [pinia, createTestI18n()], + provide: { [routerKey as symbol]: router }, + }, + }) + + return { errors, pinia } +} + +describe('official streaming speech provider settings', () => { + beforeEach(() => { + localStorage.clear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + localStorage.clear() + }) + + // https://airi.moeru.ai/settings/providers/speech/official-provider-speech-streaming + it('does not request the protected catalog before authentication', async () => { + const fetchMock = vi.fn(async input => responseFor(input)) + vi.stubGlobal('fetch', fetchMock) + + const { errors } = await renderPage() + + await expect.poll(() => errors.map(error => errorMessageFrom(error))).toEqual([]) + expect(fetchMock).not.toHaveBeenCalled() + }) + + // ROOT CAUSE: + // + // The provider store stopped creating every provider configuration at startup. + // This page still read config.model before it initialized the streaming provider. + // A direct page load therefore failed after the model catalog request completed. + // + // Before: fetch the catalog, then write providerConfig.value.model. + // + // We fixed this by waiting for authentication and initializing the provider. + // The page now applies server availability before it loads model voices. + it('initializes provider configuration before it applies the server default model', async () => { + const fetchMock = vi.fn(async input => responseFor(input)) + vi.stubGlobal('fetch', fetchMock) + const pinia = createPinia() + + const { errors } = await renderPage(pinia) + useAuthStore(pinia).$patch({ user, session }) + const providerConfigStore = useProviderConfigStore(pinia) + + await expect.poll(() => providerConfigStore.getProviderConfig(providerId)?.model).toBe('volcengine/seed-tts-2.0') + await expect.poll(() => providerConfigStore.providers[providerId]?.status).toBe('configured') + expect(errors.map(error => errorMessageFrom(error))).toEqual([]) + expect(fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/models/streaming'))).toBe(true) + expect(fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/voices/streaming'))).toBe(true) + }) + + // https://github.com/moeru-ai/airi/pull/2440#discussion_r3912226728 + // ROOT CAUSE: + // + // The Electron settings renderer routes forceProviderConfigured to its + // leader. The page enabled its voice watcher without awaiting that action, + // so the public voice loader still saw an unconfigured provider and stopped. + // The later configuration snapshot did not change any watcher dependency. + // + // Before: start voice loading while forceProviderConfigured is pending. + // + // We fixed this by awaiting the configuration action before publishing the + // local availability state that enables model-specific voice loading. + it('waits for provider configuration before it loads streaming voices', async () => { + const fetchMock = vi.fn(async input => responseFor(input)) + vi.stubGlobal('fetch', fetchMock) + const pinia = createPinia() + + const { errors } = await renderPage(pinia) + const providerStore = useProviderStore(pinia) + const forceProviderConfigured = providerStore.forceProviderConfigured + let finishConfiguration: (() => void) | undefined + const configurationPending = new Promise((resolve) => { + finishConfiguration = resolve + }) + vi.spyOn(providerStore, 'forceProviderConfigured').mockImplementation(async (requestedProviderId) => { + await configurationPending + forceProviderConfigured(requestedProviderId) + }) + + useAuthStore(pinia).$patch({ user, session }) + await expect.poll(() => fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/models/streaming'))).toBe(true) + expect(fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/voices/streaming'))).toBe(false) + + finishConfiguration?.() + await expect.poll(() => fetchMock.mock.calls.some(([input]) => responseUrl(input).includes('/api/v1/audio/voices/streaming'))).toBe(true) + expect(errors.map(error => errorMessageFrom(error))).toEqual([]) + }) + + // https://github.com/moeru-ai/airi/pull/2440#discussion_r3912777731 + // ROOT CAUSE: + // + // Model discovery returns no availability field when its request fails. + // The page treated that unknown state as an authoritative unavailable state, + // which hid the provider and marked its existing configuration as unconfigured. + // + // Before: catalog.available === true converted a discovery failure to false. + // + // We fixed this by changing provider state only when discovery returns an + // explicit availability value. + it('preserves configured provider state when catalog discovery fails', async () => { + const fetchMock = vi.fn(async (input) => { + if (responseUrl(input).includes('/api/v1/audio/models/streaming')) + return new Response('upstream unavailable', { status: 502 }) + return responseFor(input) + }) + vi.stubGlobal('fetch', fetchMock) + const pinia = createPinia() + + const { errors } = await renderPage(pinia) + const providerStore = useProviderStore(pinia) + const providerConfigStore = useProviderConfigStore(pinia) + providerConfigStore.ensureProvider(providerId, providerId, { model: 'volcengine/seed-tts-2.0' }) + providerConfigStore.setProviderStatus(providerId, 'configured') + providerConfigStore.markProviderAdded(providerId) + const setProviderUnconfigured = vi.spyOn(providerStore, 'setProviderUnconfigured') + const setProviderAvailabilityOverride = vi.spyOn(providerStore, 'setProviderAvailabilityOverride') + + useAuthStore(pinia).$patch({ user, session }) + await expect.poll(() => providerStore.modelLoadError[providerId]).toContain('streaming models upstream 502') + + expect(setProviderAvailabilityOverride).not.toHaveBeenCalledWith(providerId, false) + expect(setProviderUnconfigured).not.toHaveBeenCalledWith(providerId) + expect(providerConfigStore.providers[providerId]?.status).toBe('configured') + expect(errors.map(error => errorMessageFrom(error))).toEqual([]) + }) +}) + +function responseUrl(input: RequestInfo | URL): string { + if (typeof input === 'string') + return input + if (input instanceof Request) + return input.url + return input.toString() +} diff --git a/apps/stage-web/vite.config.ts b/apps/stage-web/vite.config.ts index c9070c4cd..679631120 100644 --- a/apps/stage-web/vite.config.ts +++ b/apps/stage-web/vite.config.ts @@ -234,8 +234,9 @@ export default defineConfig({ fullInstall: true, }), - // https://github.com/webfansplz/vite-plugin-vue-devtools - VueDevTools(), + // Browser tests mount short-lived apps. A delayed DevTools setup can run + // after the test app is gone, so only load this plugin for the real app. + ...(env.VITEST ? [] : [VueDevTools()]), DownloadLive2DSDK(), Download('https://dist.ayaka.moe/live2d-models/hiyori_free_zh.zip', 'hiyori_free_zh.zip', 'live2d/models', { parentDir: stageUIAssetsRoot, cacheDir: sharedCacheDir }), diff --git a/packages/stage-pages/src/pages/settings/providers/speech/official-provider-speech-streaming.vue b/packages/stage-pages/src/pages/settings/providers/speech/official-provider-speech-streaming.vue index 46139288a..01f6def5e 100644 --- a/packages/stage-pages/src/pages/settings/providers/speech/official-provider-speech-streaming.vue +++ b/packages/stage-pages/src/pages/settings/providers/speech/official-provider-speech-streaming.vue @@ -5,7 +5,7 @@ import { ProviderSettingsLayout, SpeechPlayground, } from '@proj-airi/stage-ui/components' -import { getDefaultStreamingModel, selectProviderMetadata, streamingSynthesize } from '@proj-airi/stage-ui/libs' +import { selectProviderMetadata, 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 { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config' @@ -13,7 +13,7 @@ import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider' import { Callout, ComboboxSelect } from '@proj-airi/ui' import { computedAsync } from '@vueuse/core' import { storeToRefs } from 'pinia' -import { computed, onMounted, ref, watch } from 'vue' +import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' @@ -42,12 +42,15 @@ const providerConfig = computed(() => providerStore.getProviderConfig(providerId const providerModels = computed(() => providersStore.getModelsForProvider(providerId)) const modelsLoading = computed(() => providersStore.isLoadingModels[providerId] || false) const serverDefaultModel = ref(null) +const streamingAvailable = ref(false) const model = computed({ get(): string { return (providerConfig.value?.model as string | undefined) ?? serverDefaultModel.value ?? '' }, set(val: string) { - providerConfig.value.model = val + const config = providerConfig.value + if (config) + config.model = val }, }) const modelOptions = computed(() => providerModels.value.map(m => ({ label: m.name, value: m.id }))) @@ -65,24 +68,61 @@ async function loadVoices() { } } -onMounted(async () => { - await providersStore.fetchModelsForProvider(providerId) - // `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() -}) +watch(isAuthenticated, async (authenticated, _, onCleanup) => { + let active = true + onCleanup(() => active = false) + streamingAvailable.value = false + serverDefaultModel.value = null + if (!authenticated) + return + + await providersStore.initializeProvider(providerId) + if (!active) + return + + const catalog = await providersStore.fetchModelsForProvider(providerId) + if (!active) + return + + // An absent value means that discovery failed before the server returned an + // authoritative state. Keep the last configured state and availability + // override so a transient request failure cannot hide the provider. + if (catalog.available === undefined) + return + + const available = catalog.available + await providersStore.setProviderAvailabilityOverride(providerId, available) + if (!active) + return + + if (!available) { + await providersStore.setProviderUnconfigured(providerId) + return + } + + await providersStore.forceProviderConfigured(providerId) + if (!active) + return + + streamingAvailable.value = true + + // If the operator did not curate a default server-side, fall back to the + // first model in the same catalog response. Do not read synchronized model + // state here because its follower snapshot can arrive after the action. + serverDefaultModel.value = catalog.defaultModel ?? catalog.models[0]?.id ?? null + const config = providerConfig.value + if (config && !config.model && serverDefaultModel.value) + config.model = serverDefaultModel.value +}, { immediate: true }) // Volcengine TTS 1.0 and 2.0 ship different voice catalogues (mars/moon/ICL // vs uranus/saturn; see unspeech voices.go). Re-fetch on model change so the // list switches accordingly. -watch(model, async () => { +watch([isAuthenticated, streamingAvailable, model], async ([authenticated, available, selectedModel]) => { + if (!authenticated || !available || !selectedModel) + return await loadVoices() -}) +}, { immediate: true }) // Synthesize via the streaming session helper. The page uses the SAME // transport the runtime pipeline uses (ws → API proxy → unspeech @@ -184,7 +224,7 @@ function handleLogin() {
diff --git a/packages/stage-ui/src/libs/providers/providers/index.ts b/packages/stage-ui/src/libs/providers/providers/index.ts index 4ac560c84..77012d537 100644 --- a/packages/stage-ui/src/libs/providers/providers/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/index.ts @@ -54,7 +54,6 @@ import './voicevox' export { getDefaultStreamingModel, - getStreamingTtsAvailable, OFFICIAL_TRANSCRIPTION_PROVIDER_ID, } from './official' diff --git a/packages/stage-ui/src/libs/providers/providers/official/index.ts b/packages/stage-ui/src/libs/providers/providers/official/index.ts index 007c8aa6d..d63b514b8 100644 --- a/packages/stage-ui/src/libs/providers/providers/official/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/official/index.ts @@ -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 { @@ -64,6 +52,36 @@ function authHeaders(): Record { return headers } +async function listStreamingModelCatalog(): Promise { + // Streaming TTS catalog is operator-controlled via configKV + // (`UNSPEECH_UPSTREAM.streaming`). Wire shape uses `/` + // (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 => { - // Streaming TTS catalog is operator-controlled via configKV - // (`UNSPEECH_UPSTREAM.streaming`). Wire shape uses `/` - // (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 => { // Streaming voices live behind a dedicated endpoint // (`/audio/voices/streaming`) because they come from the diff --git a/packages/stage-ui/src/libs/providers/types.ts b/packages/stage-ui/src/libs/providers/types.ts index 7f667a0a2..614ec7993 100644 --- a/packages/stage-ui/src/libs/providers/types.ts +++ b/packages/stage-ui/src/libs/providers/types.ts @@ -74,7 +74,18 @@ export interface ProviderConfigContext { 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 { + listModelCatalog?: (config: TConfig, provider: ProviderInstance, contextOptions?: { t: (input: string) => string }) => Promise listModels?: (config: TConfig, provider: ProviderInstance, contextOptions?: { t: (input: string) => string }) => Promise /** * Returns the voice catalogue. `model` lets providers whose voices vary by diff --git a/packages/stage-ui/src/stores/providers/provider-model-catalog.browser.test.ts b/packages/stage-ui/src/stores/providers/provider-model-catalog.browser.test.ts new file mode 100644 index 000000000..d8b21a9b3 --- /dev/null +++ b/packages/stage-ui/src/stores/providers/provider-model-catalog.browser.test.ts @@ -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 + 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 | undefined + let providerConfigStore: ReturnType | 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(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, + }), + ], + }) + }) +}) diff --git a/packages/stage-ui/src/stores/providers/provider.ts b/packages/stage-ui/src/stores/providers/provider.ts index b7f99389a..75d4a1609 100644 --- a/packages/stage-ui/src/stores/providers/provider.ts +++ b/packages/stage-ui/src/stores/providers/provider.ts @@ -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: [] } } }