fix(model-config): keep model selection valid and fix admin panel edge cases

- Fall back to the default server model when a saved one disappears
- Sync model config across tabs
- Validation uses the base path and sends the access code
- Model ids edited as drafts (no empty, duplicate or padded ids)
- Credential changes reset validation; stale validation results are dropped
- Admin: generateId over HTTP, env-locked group switches, discard and toggle fixes,
  clearing a secret field keeps the saved key, first provider not auto-default when .env sets AI_MODEL
- Model selector items use unique values
This commit is contained in:
dayuan.jiang
2026-10-03 17:45:41 +09:00
parent 87edf2e19d
commit 3193d20e00
8 changed files with 461 additions and 98 deletions
+14 -2
View File
@@ -35,6 +35,7 @@ import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import {
FIXED_CRED_PROVIDERS,
generateId,
PROVIDER_INFO,
type ProviderName,
SUGGESTED_MODELS,
@@ -225,6 +226,7 @@ function ProviderDetail({
</Button>
{suggestions.length > 0 && (
<Select
value=""
disabled={disabled}
onValueChange={(v) => addModel(v)}
>
@@ -390,12 +392,14 @@ function ProviderDetail({
export function ModelsSection({
providers,
envProviders,
envHasDefaultModel,
disabled,
password,
onChange,
}: {
providers: AdminProvider[]
envProviders: EnvProvider[]
envHasDefaultModel: boolean
disabled: boolean
password: string
onChange: (providers: AdminProvider[]) => void
@@ -409,10 +413,16 @@ export function ModelsSection({
const addProvider = (provider: ProviderName) => {
const newProvider: AdminProvider = {
id: crypto.randomUUID(),
// generateId works over plain HTTP; crypto.randomUUID needs HTTPS
id: generateId(),
provider,
models: [],
isDefault: providers.length === 0,
// Only the very first provider becomes the default, and only when
// the env config has no default that it would replace on save
isDefault:
providers.length === 0 &&
!envProviders.some((p) => p.isDefault) &&
!envHasDefaultModel,
}
onChange([...providers, newProvider])
setSelectedId(newProvider.id)
@@ -496,7 +506,9 @@ export function ModelsSection({
))}
</div>
<div className="border-t p-2">
{/* Always empty so picking the same type again still fires */}
<Select
value=""
disabled={disabled}
onValueChange={(v) => addProvider(v as ProviderName)}
>
+49 -14
View File
@@ -37,6 +37,19 @@ import { SettingField } from "./setting-field"
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
// For each toggleable group, whether any of its settings has a value (from
// the settings file or the environment)
function groupsWithValues(map: SettingsMap): Record<string, boolean> {
const result: Record<string, boolean> = {}
for (const group of SETTING_GROUPS) {
if (!group.toggleable) continue
result[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
(d) => map[d.key]?.source !== "default",
)
}
return result
}
export default function AdminPage() {
const dict = useDictionary()
// Localized group title/description, keyed by group id
@@ -62,6 +75,8 @@ export default function AdminPage() {
// Models section state
const [providers, setProviders] = useState<AdminProvider[]>([])
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
// Whether .env itself sets AI_MODEL (a default the panel would override)
const [envHasDefaultModel, setEnvHasDefaultModel] = useState(false)
const [savedProviders, setSavedProviders] = useState<string>("[]")
const providersDirty = JSON.stringify(providers) !== savedProviders
@@ -88,15 +103,13 @@ export default function AdminPage() {
const map: SettingsMap = {}
for (const s of data.settings) map[s.key] = s
setSettings(map)
// Seed each toggle once from whether the group has configured
// values; don't stomp a user's explicit toggle on later saves
// A group stays on while it still has values (e.g. from env vars
// that saving can't remove); a user's explicit "on" for a group
// with no values yet is kept across saves
setEnabledGroups((prev) => {
const next = { ...prev }
for (const group of SETTING_GROUPS) {
if (!group.toggleable || group.id in next) continue
next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
(d) => map[d.key]?.source !== "default",
)
const next = groupsWithValues(map)
for (const id of Object.keys(next)) {
next[id] = next[id] || !!prev[id]
}
return next
})
@@ -108,10 +121,12 @@ export default function AdminPage() {
(data: {
providers: AdminProvider[]
envProviders?: EnvProvider[]
envHasDefaultModel?: boolean
}) => {
setProviders(data.providers)
setSavedProviders(JSON.stringify(data.providers))
setEnvProviders(data.envProviders ?? [])
setEnvHasDefaultModel(!!data.envHasDefaultModel)
},
[],
)
@@ -181,8 +196,9 @@ export default function AdminPage() {
return () => observer.disconnect()
}, [authedPassword])
// value undefined drops the pending change (back to the saved value)
const handleChange = useCallback(
(key: string, value: string | null) => {
(key: string, value: string | null | undefined) => {
setSaveMessage(null)
setErrors((prev) => {
if (!(key in prev)) return prev
@@ -201,7 +217,7 @@ export default function AdminPage() {
value === "" &&
(!state || state.source !== "file") &&
!isSecretValue(state?.value)
if (isRevert || isNoop) {
if (value === undefined || isRevert || isNoop) {
const next = { ...prev }
delete next[key]
return next
@@ -225,9 +241,10 @@ export default function AdminPage() {
const next = { ...prev }
for (const key of keys) {
if (!enabled) {
// Stage deletion only for values currently set
if (settings[key]?.source !== "default")
next[key] = null
// Stage deletion of saved values; drop unsaved input
if (settings[key]?.source === "default")
delete next[key]
else next[key] = null
} else if (next[key] === null) {
delete next[key]
}
@@ -447,6 +464,7 @@ export default function AdminPage() {
<ModelsSection
providers={providers}
envProviders={envProviders}
envHasDefaultModel={envHasDefaultModel}
disabled={!writable || saving}
password={authedPassword}
onChange={(next) => {
@@ -462,6 +480,11 @@ export default function AdminPage() {
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
const groupOff =
group.toggleable && !enabledGroups[group.id]
// Values from env vars can't be removed here, so the
// group can't be turned off from the panel
const envLocked = defs.some(
(d) => settings[d.key]?.source === "env",
)
const fieldsDisabled = !writable || saving || !!groupOff
const gt = groupText(group.id)
const title = gt?.title ?? group.title
@@ -480,6 +503,11 @@ export default function AdminPage() {
</h2>
{group.toggleable && (
<label
title={
envLocked
? dict.admin.sourceEnvTitle
: undefined
}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
enabledGroups[group.id]
@@ -494,7 +522,11 @@ export default function AdminPage() {
checked={
!!enabledGroups[group.id]
}
disabled={!writable || saving}
disabled={
!writable ||
saving ||
envLocked
}
aria-label={formatMessage(
dict.admin.enableGroup,
{ group: title },
@@ -579,6 +611,9 @@ export default function AdminPage() {
setPending({})
setErrors({})
setProviders(JSON.parse(savedProviders))
setEnabledGroups(
groupsWithValues(settings),
)
}}
>
{dict.admin.discard}
+10 -5
View File
@@ -73,8 +73,10 @@ export function SecretInput({
}) {
const dict = useDictionary()
const [show, setShow] = useState(false)
// The stored marker as it was at mount, to revert to on empty
const [original] = useState(value)
// The stored marker to revert to on empty. Refreshed whenever the parent
// passes server state (a marker or nothing), e.g. after a save.
const [original, setOriginal] = useState(value)
if (typeof value !== "string" && value !== original) setOriginal(value)
const hadStored = isSecretValue(original)
const text = typeof value === "string" ? value : ""
const placeholder = isSecretValue(value)
@@ -146,7 +148,8 @@ export function SettingField({
pendingValue: string | null | undefined
error?: string
disabled: boolean
onChange: (value: string | null) => void
// undefined drops the pending change (back to the saved value)
onChange: (value: string | null | undefined) => void
}) {
const dict = useDictionary()
const isDirty = pendingValue !== undefined
@@ -226,16 +229,18 @@ export function SettingField({
case "secret":
control = (
<div className="w-full max-w-md">
{/* Clearing a saved secret reverts to it; the X button deletes */}
<SecretInput
id={inputId}
keepOnEmpty={source === "file"}
value={
isDirty
? (pendingValue ?? "")
: (secretState ?? currentValue)
: (secretState ?? undefined)
}
disabled={disabled}
onChange={(v) =>
onChange(typeof v === "string" ? v : "")
onChange(typeof v === "string" ? v : undefined)
}
/>
</div>
+99 -42
View File
@@ -56,6 +56,7 @@ import { useDictionary } from "@/hooks/use-dictionary"
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path"
import { formatMessage } from "@/lib/i18n/utils"
import { STORAGE_KEYS } from "@/lib/storage"
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
@@ -133,6 +134,14 @@ export function ModelConfigDialog({
modelId: string
message: string
} | null>(null)
// Model ID being typed; written to the config only when valid on blur
const [modelIdDraft, setModelIdDraft] = useState<{
id: string
value: string
} | null>(null)
// Bumped on every credential edit so a running test can tell that its
// results belong to the old credentials
const credentialsVersionRef = useRef(0)
const [dynamicSuggestedModels, setDynamicSuggestedModels] = useState<
Partial<Record<ProviderName, string[]>>
>({})
@@ -157,6 +166,11 @@ export function ModelConfigDialog({
(p) => p.id === selectedProviderId,
)
// Discard an unfinished model ID edit when the dialog closes
useEffect(() => {
if (!open) setModelIdDraft(null)
}, [open])
// Cleanup validation reset timeout on unmount
useEffect(() => {
return () => {
@@ -253,9 +267,9 @@ export function ModelConfigDialog({
field: keyof ProviderConfig,
value: string | boolean,
) => {
if (!selectedProviderId) return
updateProvider(selectedProviderId, { [field]: value })
// Reset validation when credentials change
if (!selectedProviderId || !selectedProvider) return
const updates: Partial<ProviderConfig> = { [field]: value }
// Reset validation of the provider and its models when credentials change
const credentialFields = [
"apiKey",
"baseUrl",
@@ -265,9 +279,17 @@ export function ModelConfigDialog({
"vertexApiKey",
]
if (credentialFields.includes(field)) {
credentialsVersionRef.current++
setValidationStatus("idle")
updateProvider(selectedProviderId, { validated: false })
setValidatingModelIndex(null)
updates.validated = false
updates.models = selectedProvider.models.map((m) => ({
...m,
validated: undefined,
validationError: undefined,
}))
}
updateProvider(selectedProviderId, updates)
}
// Handle adding a model to current provider
@@ -337,6 +359,7 @@ export function ModelConfigDialog({
let allValid = true
let errorCount = 0
const credentialsVersion = credentialsVersionRef.current
// Validate each model
for (let i = 0; i < selectedProvider.models.length; i++) {
@@ -346,26 +369,37 @@ export function ModelConfigDialog({
try {
// For EdgeOne, construct baseUrl from current origin
const baseUrl = isEdgeOne
? `${window.location.origin}/api/edgeai`
? `${window.location.origin}${getApiEndpoint("/api/edgeai")}`
: selectedProvider.baseUrl
const response = await fetch("/api/validate-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: selectedProvider.provider,
apiKey: selectedProvider.apiKey,
baseUrl,
modelId: model.modelId,
// AWS Bedrock credentials
awsAccessKeyId: selectedProvider.awsAccessKeyId,
awsSecretAccessKey: selectedProvider.awsSecretAccessKey,
awsRegion: selectedProvider.awsRegion,
// Vertex AI credentials (Express Mode)
vertexApiKey: selectedProvider.vertexApiKey,
}),
})
const data = await response.json()
const response = await fetch(
getApiEndpoint("/api/validate-model"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-access-code":
localStorage.getItem(STORAGE_KEYS.accessCode) ||
"",
},
body: JSON.stringify({
provider: selectedProvider.provider,
apiKey: selectedProvider.apiKey,
baseUrl,
modelId: model.modelId,
// AWS Bedrock credentials
awsAccessKeyId: selectedProvider.awsAccessKeyId,
awsSecretAccessKey:
selectedProvider.awsSecretAccessKey,
awsRegion: selectedProvider.awsRegion,
// Vertex AI credentials (Express Mode)
vertexApiKey: selectedProvider.vertexApiKey,
}),
},
)
const data = await response.json().catch(() => ({}))
// Credentials changed during the test: drop the results
if (credentialsVersionRef.current !== credentialsVersion) return
if (data.valid) {
updateModel(selectedProviderId, model.id, {
@@ -377,10 +411,15 @@ export function ModelConfigDialog({
errorCount++
updateModel(selectedProviderId, model.id, {
validated: false,
validationError: data.error || "Validation failed",
validationError:
data.error ||
(response.ok
? "Validation failed"
: `Request failed (${response.status})`),
})
}
} catch {
if (credentialsVersionRef.current !== credentialsVersion) return
allValid = false
errorCount++
updateModel(selectedProviderId, model.id, {
@@ -615,7 +654,9 @@ export function ModelConfigDialog({
{/* Add Provider */}
<div className="p-3 border-t border-border-subtle">
{/* Always empty so picking the same type again still fires */}
<Select
value=""
onValueChange={(v) =>
handleAddProvider(v as ProviderName)
}
@@ -837,6 +878,7 @@ export function ModelConfigDialog({
<Plus className="h-3.5 w-3.5" />
</Button>
<Select
value=""
onValueChange={(value) => {
if (value) {
handleAddModel(
@@ -989,7 +1031,10 @@ export function ModelConfigDialog({
</div>
<Input
value={
model.modelId
modelIdDraft?.id ===
model.id
? modelIdDraft.value
: model.modelId
}
title={
model.modelId
@@ -1007,24 +1052,14 @@ export function ModelConfigDialog({
null,
)
}
if (
selectedProviderId
) {
updateModel(
selectedProviderId,
model.id,
{
modelId:
e
.target
.value,
validated:
undefined,
validationError:
undefined,
},
)
}
setModelIdDraft(
{
id: model.id,
value: e
.target
.value,
},
)
}}
onKeyDown={(
e,
@@ -1041,6 +1076,10 @@ export function ModelConfigDialog({
) => {
const newModelId =
e.target.value.trim()
// Drop the draft; an invalid ID falls back to the saved one
setModelIdDraft(
null,
)
// Helper to show error with shake
const showError =
@@ -1135,6 +1174,24 @@ export function ModelConfigDialog({
setEditError(
null,
)
if (
selectedProviderId &&
newModelId !==
model.modelId
) {
updateModel(
selectedProviderId,
model.id,
{
modelId:
newModelId,
validated:
undefined,
validationError:
undefined,
},
)
}
}}
className="flex-1 min-w-0 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1"
/>
+12 -6
View File
@@ -264,9 +264,13 @@ export function ModelSelector({
(model) => (
<ModelSelectorItem
key={model.id}
value={
model.modelId
}
// Unique value so same-named models highlight
// separately; keywords keep search by name
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
onSelect={() =>
handleSelect(
model.id,
@@ -351,9 +355,11 @@ export function ModelSelector({
(model) => (
<ModelSelectorItem
key={model.id}
value={
model.modelId
}
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
onSelect={() =>
handleSelect(
model.id,
+57 -29
View File
@@ -101,6 +101,15 @@ function saveConfig(config: MultiModelConfig): void {
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
}
/**
* Server model to fall back to: the one marked default, else the first one
*/
function defaultServerModelId(
serverModels: FlattenedServerModel[],
): string | undefined {
return (serverModels.find((m) => m.isDefault) ?? serverModels[0])?.id
}
export interface UseModelConfigReturn {
// State
config: MultiModelConfig
@@ -144,6 +153,16 @@ export function useModelConfig(): UseModelConfigReturn {
setIsLoaded(true)
}, [])
// Pick up config changes saved by other tabs, so this tab neither shows a
// stale model nor overwrites their changes on its next save
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEYS.modelConfigs) setConfig(loadConfig())
}
window.addEventListener("storage", handleStorage)
return () => window.removeEventListener("storage", handleStorage)
}, [])
// Load server models on mount (if any)
useEffect(() => {
if (typeof window === "undefined") return
@@ -165,17 +184,18 @@ export function useModelConfig(): UseModelConfigReturn {
setServerModels(raw)
setServerLoaded(true)
// Auto-select default server model if no model is currently selected
// Auto-select the default server model if no model is selected,
// or if the saved server model is gone (renamed or removed)
setConfig((prev) => {
if (!prev.selectedModelId && raw.length > 0) {
const defaultModel = raw.find((m) => m.isDefault)
if (defaultModel) {
return { ...prev, selectedModelId: defaultModel.id }
}
// If no default marked, use first server model
return { ...prev, selectedModelId: raw[0].id }
}
return prev
const id = prev.selectedModelId
const isStale =
id?.startsWith("server:") &&
!raw.some((m) => m.id === id)
if (id && !isStale) return prev
const fallback = defaultServerModelId(raw)
return fallback === id
? prev
: { ...prev, selectedModelId: fallback }
})
})
.catch((error) => {
@@ -260,24 +280,31 @@ export function useModelConfig(): UseModelConfigReturn {
[],
)
const deleteProvider = useCallback((providerId: string) => {
setConfig((prev) => {
const provider = prev.providers.find((p) => p.id === providerId)
const modelIds = provider?.models.map((m) => m.id) || []
const deleteProvider = useCallback(
(providerId: string) => {
setConfig((prev) => {
const provider = prev.providers.find((p) => p.id === providerId)
const modelIds = provider?.models.map((m) => m.id) || []
// Clear selected model if it belongs to deleted provider
const newSelectedId =
prev.selectedModelId && modelIds.includes(prev.selectedModelId)
? undefined
: prev.selectedModelId
// Fall back to the default server model if the selected model
// belongs to the deleted provider
const newSelectedId =
prev.selectedModelId &&
modelIds.includes(prev.selectedModelId)
? defaultServerModelId(serverModels)
: prev.selectedModelId
return {
...prev,
providers: prev.providers.filter((p) => p.id !== providerId),
selectedModelId: newSelectedId,
}
})
}, [])
return {
...prev,
providers: prev.providers.filter(
(p) => p.id !== providerId,
),
selectedModelId: newSelectedId,
}
})
},
[serverModels],
)
const addModel = useCallback(
(providerId: string, modelId: string): ModelConfig => {
@@ -334,14 +361,15 @@ export function useModelConfig(): UseModelConfigReturn {
}
: p,
),
// Clear selected model if it was deleted
// Fall back to the default server model if the selected model
// was deleted
selectedModelId:
prev.selectedModelId === modelConfigId
? undefined
? defaultServerModelId(serverModels)
: prev.selectedModelId,
}))
},
[],
[serverModels],
)
const resetConfig = useCallback(() => {
+87
View File
@@ -0,0 +1,87 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
import type { ReactNode } from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
import { SecretInput, SettingField } from "@/app/[lang]/admin/setting-field"
import { DictionaryProvider } from "@/hooks/use-dictionary"
import type { SettingDef } from "@/lib/admin/settings-registry"
import type { Dictionary } from "@/lib/i18n/dictionaries"
import en from "@/lib/i18n/dictionaries/en.json"
const STORED = { isSet: true as const, hint: "…abcd" }
function withDict(node: ReactNode) {
return (
<DictionaryProvider dictionary={en as unknown as Dictionary}>
{node}
</DictionaryProvider>
)
}
function typeInto(label: string, text: string) {
fireEvent.change(screen.getByLabelText(label, { selector: "input" }), {
target: { value: text },
})
}
afterEach(cleanup)
describe("SecretInput", () => {
it("reverts to a key saved after mount instead of deleting it", () => {
const onChange = vi.fn()
const props = { id: "secret", keepOnEmpty: true, onChange }
// New provider: nothing stored at mount, then saved
const { rerender } = render(
withDict(
<>
<label htmlFor="secret">secret</label>
<SecretInput {...props} value={undefined} />
</>,
),
)
rerender(
withDict(
<>
<label htmlFor="secret">secret</label>
<SecretInput {...props} value={STORED} />
</>,
),
)
rerender(
withDict(
<>
<label htmlFor="secret">secret</label>
<SecretInput {...props} value="abc" />
</>,
),
)
typeInto("secret", "")
expect(onChange).toHaveBeenLastCalledWith(STORED)
})
})
describe("SettingField secret", () => {
const def: SettingDef = {
key: "LANGFUSE_SECRET_KEY",
group: "observability",
type: "secret",
label: "Langfuse Secret Key",
}
it("drops the pending change when a saved secret is typed over and cleared", () => {
const onChange = vi.fn()
const props = {
def,
state: { key: def.key, source: "file" as const, value: STORED },
disabled: false,
onChange,
}
const { rerender } = render(
withDict(<SettingField {...props} pendingValue={undefined} />),
)
rerender(withDict(<SettingField {...props} pendingValue="a" />))
typeInto(en.admin.settings.LANGFUSE_SECRET_KEY.label, "")
expect(onChange).toHaveBeenLastCalledWith(undefined)
})
})
+133
View File
@@ -0,0 +1,133 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { useModelConfig } from "@/hooks/use-model-config"
import type { FlattenedServerModel } from "@/lib/server-model-config"
import { STORAGE_KEYS } from "@/lib/storage"
import type { MultiModelConfig } from "@/lib/types/model-config"
const SERVER_MODELS: FlattenedServerModel[] = [
{
id: "server:openai-main:gpt-4o-mini",
modelId: "gpt-4o-mini",
provider: "openai",
providerLabel: "OpenAI Main",
isDefault: false,
},
{
id: "server:openai-main:gpt-4o",
modelId: "gpt-4o",
provider: "openai",
providerLabel: "OpenAI Main",
isDefault: true,
},
]
const USER_CONFIG: MultiModelConfig = {
version: 1,
providers: [
{
id: "p1",
provider: "openai",
apiKey: "sk-test",
models: [{ id: "m1", modelId: "gpt-4o" }],
},
],
}
function storeConfig(config: MultiModelConfig) {
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
}
async function renderLoaded() {
const hook = renderHook(() => useModelConfig())
await waitFor(() => expect(hook.result.current.isLoaded).toBe(true))
return hook
}
beforeEach(() => {
localStorage.clear()
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
json: async () => ({ models: SERVER_MODELS }),
})),
)
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe("useModelConfig server model selection", () => {
it("replaces a saved server model that no longer exists", async () => {
storeConfig({
...USER_CONFIG,
selectedModelId: "server:openai-production:gpt-4o",
})
const { result } = await renderLoaded()
expect(result.current.selectedModelId).toBe("server:openai-main:gpt-4o")
})
it("keeps a saved server model that still exists", async () => {
storeConfig({
...USER_CONFIG,
selectedModelId: "server:openai-main:gpt-4o-mini",
})
const { result } = await renderLoaded()
expect(result.current.selectedModelId).toBe(
"server:openai-main:gpt-4o-mini",
)
})
it("keeps a selected user model", async () => {
storeConfig({ ...USER_CONFIG, selectedModelId: "m1" })
const { result } = await renderLoaded()
expect(result.current.selectedModelId).toBe("m1")
})
it("falls back to the default server model when the selected model is deleted", async () => {
storeConfig({ ...USER_CONFIG, selectedModelId: "m1" })
const { result } = await renderLoaded()
act(() => result.current.deleteModel("p1", "m1"))
expect(result.current.selectedModelId).toBe("server:openai-main:gpt-4o")
})
it("falls back to the default server model when the selected provider is deleted", async () => {
storeConfig({ ...USER_CONFIG, selectedModelId: "m1" })
const { result } = await renderLoaded()
act(() => result.current.deleteProvider("p1"))
expect(result.current.selectedModelId).toBe("server:openai-main:gpt-4o")
})
})
describe("useModelConfig across tabs", () => {
it("reloads the config when another tab saves it", async () => {
storeConfig({ ...USER_CONFIG, selectedModelId: "m1" })
const { result } = await renderLoaded()
const fromOtherTab: MultiModelConfig = {
...USER_CONFIG,
providers: [
...USER_CONFIG.providers,
{
id: "p2",
provider: "anthropic",
apiKey: "sk-ant",
models: [{ id: "m2", modelId: "claude-sonnet-4-5" }],
},
],
selectedModelId: "m2",
}
act(() => {
storeConfig(fromOtherTab)
window.dispatchEvent(
new StorageEvent("storage", { key: STORAGE_KEYS.modelConfigs }),
)
})
expect(result.current.selectedModelId).toBe("m2")
expect(result.current.config.providers).toHaveLength(2)
})
})