From 774aed92bb6cea1665fed0276a91db61217cc875 Mon Sep 17 00:00:00 2001 From: Ayobami Adegoke Date: Fri, 17 Jul 2026 20:03:12 +0100 Subject: [PATCH] fix(stage-ui): make provider capability checks non-throwing and reset chat model on provider switch (#2078) --- .../pages/settings/modules/consciousness.vue | 4 +- .../src/stores/modules/consciousness.test.ts | 114 ++++++++++++++++++ .../src/stores/modules/consciousness.ts | 28 ++++- .../stage-ui/src/stores/modules/hearing.ts | 6 +- .../stage-ui/src/stores/modules/speech.ts | 2 +- packages/stage-ui/src/stores/providers.ts | 16 +++ 6 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 packages/stage-ui/src/stores/modules/consciousness.test.ts diff --git a/packages/stage-pages/src/pages/settings/modules/consciousness.vue b/packages/stage-pages/src/pages/settings/modules/consciousness.vue index 0ee48dc10..4088c5155 100644 --- a/packages/stage-pages/src/pages/settings/modules/consciousness.vue +++ b/packages/stage-pages/src/pages/settings/modules/consciousness.vue @@ -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) } diff --git a/packages/stage-ui/src/stores/modules/consciousness.test.ts b/packages/stage-ui/src/stores/modules/consciousness.test.ts new file mode 100644 index 000000000..698317eff --- /dev/null +++ b/packages/stage-ui/src/stores/modules/consciousness.test.ts @@ -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') + }) +}) diff --git a/packages/stage-ui/src/stores/modules/consciousness.ts b/packages/stage-ui/src/stores/modules/consciousness.ts index 11cc4fb6e..a5fcd15b1 100644 --- a/packages/stage-ui/src/stores/modules/consciousness.ts +++ b/packages/stage-ui/src/stores/modules/consciousness.ts @@ -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) } diff --git a/packages/stage-ui/src/stores/modules/hearing.ts b/packages/stage-ui/src/stores/modules/hearing.ts index 77281a071..ef4277451 100644 --- a/packages/stage-ui/src/stores/modules/hearing.ts +++ b/packages/stage-ui/src/stores/modules/hearing.ts @@ -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) } diff --git a/packages/stage-ui/src/stores/modules/speech.ts b/packages/stage-ui/src/stores/modules/speech.ts index bb2020dd6..36cf66841 100644 --- a/packages/stage-ui/src/stores/modules/speech.ts +++ b/packages/stage-ui/src/stores/modules/speech.ts @@ -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(() => { diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index c7e31846a..2704efcd1 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -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,