fix(stage-pages): initialize streaming speech provider config (#2440)

This commit is contained in:
leafyy
2026-09-02 19:00:05 +08:00
committed by GitHub
parent aafb39c435
commit 13bbad95dc
8 changed files with 478 additions and 80 deletions
@@ -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: '<div />' } }],
})
}
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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<void>((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<typeof fetch>(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()
}
+3 -2
View File
@@ -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 }),
@@ -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<string | null>(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() {
<ComboboxSelect
v-model="model"
:options="modelOptions"
:disabled="modelsLoading"
:disabled="modelsLoading || !providerConfig"
placeholder="Choose a model..."
/>
</div>
@@ -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: [] }
}
}