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 })