/ tests / chat-page-settings-invariants.test.ts
chat-page-settings-invariants.test.ts
  1  import { describe, expect, it } from 'vitest'
  2  
  3  import {
  4    listModelsForProviderPreset,
  5    mergeProviderCatalogWithProviders,
  6    mergeSettingsFormWithProviderEdits,
  7    normalizeSettingsFormProviderModels,
  8    reconcileProvidersWithSnapshotCatalog,
  9    validateSettingsFormProviderModels,
 10  } from '@/features/chat/components/chat-page/settings'
 11  import type { ProviderCatalogItem } from '@/lib/shared/chat'
 12  import type { ProviderWithSettings } from '@/lib/shared/providers'
 13  import type { SettingsFormState } from '@/features/chat/components/chat-page/types'
 14  
 15  const CATALOG: ProviderCatalogItem[] = [
 16    {
 17      id: 'openai',
 18      label: 'OpenAI',
 19      available: true,
 20      baseUrl: 'https://api.openai.com/v1',
 21      description: null,
 22      defaultChatModel: 'gpt-5-mini',
 23      models: ['gpt-5-mini', 'gpt-4.1-mini', 'gpt-5-mini'],
 24      modelCapabilities: {},
 25    },
 26    {
 27      id: 'example',
 28      label: 'Example',
 29      available: true,
 30      baseUrl: 'https://example.com/v1',
 31      description: null,
 32      defaultChatModel: 'minimax-m2',
 33      models: ['minimax-m2'],
 34      modelCapabilities: {},
 35    },
 36  ]
 37  
 38  function makeForm(overrides: Partial<SettingsFormState> = {}): SettingsFormState {
 39    const base: SettingsFormState = {
 40      sessionChatModelOverride: '',
 41      sessionThinkingLevelOverride: '',
 42      sessionReasoningLevelOverride: '',
 43      sessionProviderPreset: 'inherit',
 44      sessionInheritedProviderPreset: 'openai',
 45      sessionProviderBaseUrl: '',
 46      sessionProviderEndpointMode: 'auto',
 47      sessionProviderApiKey: '',
 48      defaultsChatModel: '',
 49      defaultsEmbeddingModel: '',
 50      defaultsTranscriptionModel: '',
 51      defaultsEmbeddingProviderId: '',
 52      defaultsTranscriptionProviderId: '',
 53      defaultsProviderModelById: {},
 54      defaultsProviderModelOpenAi: '',
 55      defaultsProviderModelRouter: '',
 56      defaultsProviderModelCustom: '',
 57      defaultsThinkingLevel: '',
 58      defaultsReasoningLevel: '',
 59      defaultsProviderPreset: '',
 60      defaultsOpenAiBaseUrl: '',
 61      defaultsOpenAiApiKey: '',
 62      defaultsRouterBaseUrl: '',
 63      defaultsRouterApiKey: '',
 64      defaultsProviderBaseUrl: '',
 65      defaultsProviderEndpointMode: '',
 66      defaultsProviderApiKey: '',
 67      defaultsToolCallLogRetentionDays: '30',
 68    }
 69  
 70    return {
 71      ...base,
 72      ...overrides,
 73      defaultsProviderModelById: {
 74        ...base.defaultsProviderModelById,
 75        ...(overrides.defaultsProviderModelById ?? {}),
 76      },
 77    }
 78  }
 79  
 80  describe('chat page provider/model invariants', () => {
 81    it('builds provider model lists with dedupe and defaults included', () => {
 82      expect(listModelsForProviderPreset(CATALOG, 'openai')).toEqual([
 83        'gpt-5-mini',
 84        'gpt-4.1-mini',
 85      ])
 86    })
 87  
 88    it('sorts models with :free suffix to the top of the list', () => {
 89      const catalogWithFree: ProviderCatalogItem[] = [
 90        {
 91          ...CATALOG[0],
 92          defaultChatModel: 'gpt-5-mini',
 93          models: ['gpt-5-mini', 'a-model', 'b-model:free', 'c-model:free', 'd-model'],
 94        },
 95      ]
 96      expect(listModelsForProviderPreset(catalogWithFree, 'openai')).toEqual([
 97        'b-model:free',
 98        'c-model:free',
 99        'gpt-5-mini',
100        'a-model',
101        'd-model',
102      ])
103    })
104  
105    it('preserves explicit models when catalog and form momentarily disagree', () => {
106      const normalized = normalizeSettingsFormProviderModels(
107        makeForm({
108          sessionProviderPreset: 'example',
109          sessionChatModelOverride: 'gpt-5-mini',
110          defaultsProviderPreset: 'openai',
111          defaultsChatModel: 'minimax-m2',
112          defaultsProviderModelById: {
113            openai: 'minimax-m2',
114          },
115        }),
116        CATALOG,
117      )
118  
119      expect(normalized.sessionChatModelOverride).toBe('gpt-5-mini')
120      expect(normalized.defaultsChatModel).toBe('minimax-m2')
121      expect(normalized.defaultsProviderModelById.openai).toBe('minimax-m2')
122    })
123  
124    it('uses inherited provider models when session provider mode is inherit', () => {
125      const normalized = normalizeSettingsFormProviderModels(
126        makeForm({
127          sessionProviderPreset: 'inherit',
128          sessionInheritedProviderPreset: 'example',
129          sessionChatModelOverride: 'gpt-5-mini',
130        }),
131        CATALOG,
132      )
133  
134      expect(normalized.sessionChatModelOverride).toBe('gpt-5-mini')
135    })
136  
137    it('rejects selecting an explicit model for a provider with no configured models', () => {
138      const error = validateSettingsFormProviderModels(
139        makeForm({
140          sessionProviderPreset: 'new-provider',
141          sessionChatModelOverride: 'gpt-5-mini',
142        }),
143        CATALOG,
144      )
145  
146      expect(error).toContain('has no configured models')
147    })
148  
149    it('maps non-catalog providers into merged provider catalog', () => {
150      const mergedCatalog = mergeProviderCatalogWithProviders(CATALOG, [
151        {
152          id: 'openrouter',
153          name: 'OpenRouter',
154          type: 'openai',
155          isCustom: true,
156          settings: {
157            apiHost: 'https://openrouter.ai/api/v1',
158            models: [{ modelId: 'openrouter/auto', type: 'chat' }],
159          },
160        } as ProviderWithSettings,
161      ])
162      const openRouter = mergedCatalog.find((profile) => profile.id === 'openrouter')
163  
164      expect(openRouter?.available).toBe(true)
165      expect(openRouter?.baseUrl).toBe('https://openrouter.ai/api/v1')
166      expect(openRouter?.label).toBe('OpenRouter')
167      expect(openRouter?.models).toEqual(['openrouter/auto'])
168    })
169  
170    it('merges selected default provider settings into defaults form fields', () => {
171      const mergedForm = mergeSettingsFormWithProviderEdits(
172        makeForm({
173          defaultsProviderPreset: 'example',
174        }),
175        [
176          {
177            id: 'openai',
178            name: 'OpenAI',
179            type: 'openai',
180            isCustom: false,
181            settings: { apiHost: 'https://api.openai.com/v1', apiKey: 'openai-key' },
182          },
183          {
184            id: 'example',
185            name: 'Example',
186            type: 'openai',
187            isCustom: true,
188            settings: {
189              apiHost: 'https://example.com/v1',
190              apiKey: 'example-key',
191            },
192          },
193        ] as ProviderWithSettings[],
194      )
195  
196      expect(mergedForm.defaultsOpenAiBaseUrl).toBe('https://api.openai.com/v1')
197      expect(mergedForm.defaultsProviderBaseUrl).toBe('https://example.com/v1')
198      expect(mergedForm.defaultsProviderApiKey).toBe('example-key')
199    })
200  
201    it('reconciles snapshot providers without duplicating existing drafts', () => {
202      const reconciled = reconcileProvidersWithSnapshotCatalog({
203        previousProviders: [
204          {
205            id: 'openai',
206            name: 'OpenAI',
207            type: 'openai',
208            isCustom: false,
209            settings: { apiHost: 'https://api.openai.com/v1' },
210          },
211          {
212            id: 'draft-provider',
213            name: 'Draft',
214            type: 'openai',
215            isCustom: true,
216            settings: { apiHost: 'https://draft.example.com/v1' },
217          },
218        ] as ProviderWithSettings[],
219        providerCatalog: CATALOG,
220      })
221  
222      const ids = reconciled.map((provider) => provider.id)
223      expect(ids).toContain('openai')
224      expect(ids).toContain('example')
225      expect(ids).toContain('draft-provider')
226    })
227  })