fix(stage-ui): make provider capability checks non-throwing and reset chat model on provider switch (#2078)
This commit is contained in:
@@ -47,9 +47,9 @@ watch(activeProvider, async (provider, oldProvider) => {
|
||||
if (!provider)
|
||||
return
|
||||
|
||||
// Reset model when switching providers (but not on initial load)
|
||||
// The consciousness store clears the model selection on provider changes;
|
||||
// the page only tracks the selection and loads the new provider's catalog.
|
||||
if (oldProvider !== undefined && oldProvider !== provider) {
|
||||
activeModel.value = ''
|
||||
trackOfficialProviderSelection(provider, activeModel.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useProvidersStore } from '../providers'
|
||||
import { useConsciousnessStore } from './consciousness'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
locale: { value: 'en-US' },
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('consciousness store provider selection', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// supportsModelListing called providersStore.getProviderMetadata(...) with
|
||||
// `?.` as if it returned undefined for unknown ids, but the function throws.
|
||||
// With no provider selected yet (fresh install or reset state persists ''),
|
||||
// evaluating the computed surfaced a raw "Provider metadata for not found"
|
||||
// error to the user.
|
||||
//
|
||||
// We fixed this by adding a non-throwing findProviderMetadata lookup and
|
||||
// using it in the capability computeds of the consciousness, speech, and
|
||||
// hearing modules.
|
||||
//
|
||||
// https://github.com/moeru-ai/airi/issues/1761
|
||||
it('reports no model listing support instead of throwing when no provider is selected (Issue #1761)', () => {
|
||||
const store = useConsciousnessStore()
|
||||
|
||||
expect(store.activeProvider).toBe('')
|
||||
expect(() => store.supportsModelListing).not.toThrow()
|
||||
expect(store.supportsModelListing).toBe(false)
|
||||
})
|
||||
|
||||
it('reports no model listing support for a stale provider id that no longer exists (Issue #1761)', () => {
|
||||
const store = useConsciousnessStore()
|
||||
|
||||
store.activeProvider = 'provider-deleted-long-ago'
|
||||
|
||||
expect(() => store.supportsModelListing).not.toThrow()
|
||||
expect(store.supportsModelListing).toBe(false)
|
||||
})
|
||||
|
||||
it('findProviderMetadata returns metadata for known providers', () => {
|
||||
const providersStore = useProvidersStore()
|
||||
|
||||
const metadata = providersStore.findProviderMetadata('openai')
|
||||
|
||||
expect(metadata).toBeDefined()
|
||||
expect(metadata?.id).toBe('openai')
|
||||
})
|
||||
|
||||
it('findProviderMetadata returns undefined for empty and unknown ids', () => {
|
||||
const providersStore = useProvidersStore()
|
||||
|
||||
expect(providersStore.findProviderMetadata('')).toBeUndefined()
|
||||
expect(providersStore.findProviderMetadata('nope')).toBeUndefined()
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The model selection was only cleared on provider switches by a watcher in
|
||||
// the consciousness settings page. Provider changes made anywhere else
|
||||
// (onboarding, character cards, provider deletion) kept the previous
|
||||
// provider's model id, and the next chat request failed upstream with
|
||||
// 404 model_not_found (e.g. "Model gpt-oss-120b does not exist").
|
||||
//
|
||||
// We fixed this by moving the reset into the store itself, next to the
|
||||
// state it protects.
|
||||
//
|
||||
// https://github.com/moeru-ai/airi/issues/1761
|
||||
it('clears the model selection when the provider changes (Issue #1761)', () => {
|
||||
const store = useConsciousnessStore()
|
||||
|
||||
store.activeProvider = 'cerebras'
|
||||
store.activeModel = 'gpt-oss-120b'
|
||||
store.customModelName = 'custom-name'
|
||||
|
||||
store.activeProvider = 'openai'
|
||||
|
||||
expect(store.activeModel).toBe('')
|
||||
expect(store.customModelName).toBe('')
|
||||
})
|
||||
|
||||
it('clears the model synchronously so callers can set a new one right after', () => {
|
||||
const store = useConsciousnessStore()
|
||||
|
||||
store.activeProvider = 'cerebras'
|
||||
store.activeModel = 'gpt-oss-120b'
|
||||
|
||||
// The set-provider-then-set-model sequence used by auth provider sync and
|
||||
// character cards must keep the newly assigned model.
|
||||
store.activeProvider = 'official-provider'
|
||||
store.activeModel = 'auto'
|
||||
|
||||
expect(store.activeModel).toBe('auto')
|
||||
})
|
||||
|
||||
it('keeps the persisted model when the provider stays the same', () => {
|
||||
const store = useConsciousnessStore()
|
||||
|
||||
store.activeProvider = 'openai'
|
||||
store.activeModel = 'gpt-4o-mini'
|
||||
|
||||
store.activeProvider = 'openai'
|
||||
|
||||
expect(store.activeModel).toBe('gpt-4o-mini')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
|
||||
import { refManualReset } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { useProvidersStore } from '../providers'
|
||||
|
||||
@@ -17,7 +17,7 @@ export const useConsciousnessStore = defineStore('consciousness', () => {
|
||||
|
||||
// Computed properties
|
||||
const supportsModelListing = computed(() => {
|
||||
return providersStore.getProviderMetadata(activeProvider.value)?.capabilities.listModels !== undefined
|
||||
return providersStore.findProviderMetadata(activeProvider.value)?.capabilities.listModels !== undefined
|
||||
})
|
||||
|
||||
const providerModels = computed(() => {
|
||||
@@ -52,14 +52,34 @@ export const useConsciousnessStore = defineStore('consciousness', () => {
|
||||
modelSearchQuery.reset()
|
||||
}
|
||||
|
||||
// A model id belongs to the catalog of the provider it was picked from, so
|
||||
// clear the selection whenever the provider changes. This used to live only
|
||||
// in the consciousness settings page, so provider changes made elsewhere
|
||||
// (onboarding, character cards, provider deletion) kept the previous
|
||||
// provider's model and chat requests failed upstream with model_not_found.
|
||||
//
|
||||
// The watcher is synchronous on purpose: call sites assign the provider
|
||||
// first and a new model right after (e.g. use-auth-provider-sync), so a
|
||||
// deferred reset would wipe the model they just chose. Synchronous flush
|
||||
// makes "set provider, then set model" a safe, ordered operation.
|
||||
//
|
||||
// Issue #1761: https://github.com/moeru-ai/airi/issues/1761
|
||||
watch(activeProvider, (provider, oldProvider) => {
|
||||
if (provider === oldProvider)
|
||||
return
|
||||
|
||||
activeModel.value = ''
|
||||
activeCustomModelName.value = ''
|
||||
}, { flush: 'sync' })
|
||||
|
||||
async function loadModelsForProvider(provider: string) {
|
||||
if (provider && providersStore.getProviderMetadata(provider)?.capabilities.listModels !== undefined) {
|
||||
if (providersStore.findProviderMetadata(provider)?.capabilities.listModels !== undefined) {
|
||||
await providersStore.fetchModelsForProvider(provider)
|
||||
}
|
||||
}
|
||||
|
||||
async function getModelsForProvider(provider: string) {
|
||||
if (provider && providersStore.getProviderMetadata(provider)?.capabilities.listModels !== undefined) {
|
||||
if (providersStore.findProviderMetadata(provider)?.capabilities.listModels !== undefined) {
|
||||
return providersStore.getModelsForProvider(provider)
|
||||
}
|
||||
|
||||
|
||||
@@ -336,7 +336,7 @@ export const useHearingStore = defineStore('hearing-store', () => {
|
||||
|
||||
// Computed properties
|
||||
const supportsModelListing = computed(() => {
|
||||
return providersStore.getProviderMetadata(activeTranscriptionProvider.value)?.capabilities.listModels !== undefined
|
||||
return providersStore.findProviderMetadata(activeTranscriptionProvider.value)?.capabilities.listModels !== undefined
|
||||
})
|
||||
|
||||
const providerModels = computed(() => {
|
||||
@@ -352,13 +352,13 @@ export const useHearingStore = defineStore('hearing-store', () => {
|
||||
})
|
||||
|
||||
async function loadModelsForProvider(provider: string) {
|
||||
if (provider && providersStore.getProviderMetadata(provider)?.capabilities.listModels !== undefined) {
|
||||
if (providersStore.findProviderMetadata(provider)?.capabilities.listModels !== undefined) {
|
||||
await providersStore.fetchModelsForProvider(provider)
|
||||
}
|
||||
}
|
||||
|
||||
async function getModelsForProvider(provider: string) {
|
||||
if (provider && providersStore.getProviderMetadata(provider)?.capabilities.listModels !== undefined) {
|
||||
if (providersStore.findProviderMetadata(provider)?.capabilities.listModels !== undefined) {
|
||||
return providersStore.getModelsForProvider(provider)
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export const useSpeechStore = defineStore('speech', () => {
|
||||
|
||||
// Computed properties
|
||||
const supportsModelListing = computed(() => {
|
||||
return providersStore.getProviderMetadata(activeSpeechProvider.value)?.capabilities.listModels !== undefined
|
||||
return providersStore.findProviderMetadata(activeSpeechProvider.value)?.capabilities.listModels !== undefined
|
||||
})
|
||||
|
||||
const providerModels = computed(() => {
|
||||
|
||||
@@ -2769,6 +2769,21 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Non-throwing variant of getProviderMetadata for capability checks against
|
||||
// possibly-unset provider selections (fresh installs, reset state, deleted
|
||||
// providers persist '' or stale ids in localStorage). Callers that require
|
||||
// the provider to exist should keep using getProviderMetadata.
|
||||
//
|
||||
// Issue #1761: capability computeds used `getProviderMetadata(...)?.` as if
|
||||
// it returned undefined, but it throws — surfacing raw "Provider metadata
|
||||
// for not found" errors whenever no provider was selected yet.
|
||||
function findProviderMetadata(providerId: string) {
|
||||
if (!providerId || !providerMetadata[providerId])
|
||||
return undefined
|
||||
|
||||
return getProviderMetadata(providerId)
|
||||
}
|
||||
|
||||
// Get all providers metadata (for settings page).
|
||||
// Order: defined providers first (already sorted by order in registry), then legacy-only providers.
|
||||
const allProvidersMetadata = computed(() => {
|
||||
@@ -2958,6 +2973,7 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
providerRuntimeState,
|
||||
providerMetadata,
|
||||
getProviderMetadata,
|
||||
findProviderMetadata,
|
||||
getTranscriptionFeatures,
|
||||
allProvidersMetadata,
|
||||
initializeProvider,
|
||||
|
||||
Reference in New Issue
Block a user