fix(stage-ui): make provider capability checks non-throwing and reset chat model on provider switch (#2078)

This commit is contained in:
Ayobami Adegoke
2026-07-18 03:03:12 +08:00
committed by GitHub
parent 337b814ea3
commit 774aed92bb
6 changed files with 160 additions and 10 deletions
@@ -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(() => {
+16
View File
@@ -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,