From cd2084630e3d20c37f3b19bc50eaef9da5642d0c Mon Sep 17 00:00:00 2001 From: lizhelang Date: Wed, 1 Apr 2026 19:58:06 +0800 Subject: [PATCH 1/3] fix: keep custom-openai models visible across setup and models page --- .../__tests__/openclaw-model-config.test.ts | 45 ++++++++ electron/main/openclaw-model-config.ts | 60 ++++++++++- src/pages/ModelCenter.tsx | 102 ++++++++++++++++++ src/pages/__tests__/model-center.test.tsx | 30 ++++++ src/pages/__tests__/models-page-state.test.ts | 45 ++++++++ src/shared/model-catalog-state.ts | 71 +++++++++++- 6 files changed, 347 insertions(+), 6 deletions(-) diff --git a/electron/main/__tests__/openclaw-model-config.test.ts b/electron/main/__tests__/openclaw-model-config.test.ts index 8c6360c..f71c29d 100644 --- a/electron/main/__tests__/openclaw-model-config.test.ts +++ b/electron/main/__tests__/openclaw-model-config.test.ts @@ -23,6 +23,7 @@ function failedWithStdout(stdout: string, code = 1): CliCommandResult { beforeEach(() => { resetOpenClawLegacyEnvWarningsForTests() + vi.unstubAllGlobals() }) describe('applyModelConfigAction', () => { @@ -600,4 +601,48 @@ describe('scanLocalModels', () => { expect(result.ok).toBe(false) expect(result.errorCode).toBe('parse_error') }) + + it('falls back to direct /models discovery for custom-openai when the CLI reports no models found', async () => { + const runCommand = vi.fn(async () => ok('No models found.')) + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ + data: [ + { id: 'gpt-5' }, + { id: 'gpt-4.1', display_name: 'GPT-4.1' }, + ], + }), + })) + vi.stubGlobal('fetch', fetchMock) + + const result = await scanLocalModels( + { + provider: 'custom-openai', + baseUrl: 'http://127.0.0.1:1234/v1', + apiKey: 'sk-test', + }, + { runCommand } + ) + + expect(runCommand).toHaveBeenCalledWith( + ['models', 'list', '--all', '--local', '--json', '--provider', 'custom-openai'], + expect.any(Number) + ) + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:1234/v1/models', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Accept: 'application/json', + Authorization: 'Bearer sk-test', + }), + }) + ) + expect(result.ok).toBe(true) + expect((result.data as any).models).toEqual([ + { key: 'custom-openai/gpt-5', name: 'gpt-5' }, + { key: 'custom-openai/gpt-4.1', name: 'gpt-4.1' }, + ]) + expect((result.data as any).count).toBe(2) + }) }) diff --git a/electron/main/openclaw-model-config.ts b/electron/main/openclaw-model-config.ts index 6c7098f..a07025f 100644 --- a/electron/main/openclaw-model-config.ts +++ b/electron/main/openclaw-model-config.ts @@ -318,6 +318,10 @@ export interface ValidateProviderCredentialResult { const NO_MODELS_FOUND_OUTPUT_REGEX = /(?:^|\n)\s*(?:error:\s*)?no(?:\s+\w+){0,3}\s+models?\s+found\.?\s*(?:\n|$)/i +function isCustomOpenAiProvider(provider: string): boolean { + return String(provider || '').trim() === 'custom-openai' +} + function readLocalScanEntries(payload: unknown): Array> { if (Array.isArray(payload)) { return payload.filter((entry): entry is Record => Boolean(entry && typeof entry === 'object')) @@ -361,6 +365,36 @@ function normalizeLocalScanPayload(payload: unknown, provider: string): { count: } } +async function tryDiscoverCustomOpenAiModelsViaHttp( + input: LocalModelScanInput +): Promise<{ count: number; models: Array<{ key: string; name: string }> } | null> { + if (!isCustomOpenAiProvider(input.provider)) return null + + const normalizedBaseUrl = String(input.baseUrl || '').trim().replace(/\/+$/, '') + if (!normalizedBaseUrl) return null + + const headers: Record = { + Accept: 'application/json', + } + const normalizedApiKey = String(input.apiKey || '').trim() + if (normalizedApiKey) { + headers.Authorization = `Bearer ${normalizedApiKey}` + } + + try { + const response = await fetch(`${normalizedBaseUrl}/models`, { + method: 'GET', + headers, + }) + if (!response.ok) return null + + const payload = await response.json() + return normalizeLocalScanPayload(payload, input.provider) + } catch { + return null + } +} + const LOCAL_PROVIDER_ENV_MAP: Record = { ollama: { hostKey: 'OLLAMA_HOST', apiKeyKey: 'OLLAMA_API_KEY' }, vllm: { hostKey: 'VLLM_BASE_URL', apiKeyKey: 'VLLM_API_KEY' }, @@ -576,9 +610,20 @@ export async function scanLocalModels( const parsed = parseJsonResult>('scan-models', command, result) if (parsed.ok) { + const normalizedPayload = normalizeLocalScanPayload(parsed.data, provider) + if (normalizedPayload.count === 0) { + const httpFallback = await tryDiscoverCustomOpenAiModelsViaHttp(input) + if (httpFallback) { + return { + ...parsed, + data: httpFallback, + } + } + } + return { ...parsed, - data: normalizeLocalScanPayload(parsed.data, provider), + data: normalizedPayload, } } @@ -589,6 +634,19 @@ export async function scanLocalModels( // Some local provider bridges return a plain-text success message when no models are loaded. // Treat it as an empty model list so the UI can guide the user to pull/load models. if (parsed.errorCode === 'parse_error' && NO_MODELS_FOUND_OUTPUT_REGEX.test(normalizedStdout)) { + const httpFallback = await tryDiscoverCustomOpenAiModelsViaHttp(input) + if (httpFallback) { + return { + ok: true, + action: 'scan-models', + command, + stdout: result.stdout, + stderr: result.stderr, + code: result.code, + data: httpFallback, + } + } + return { ok: true, action: 'scan-models', diff --git a/src/pages/ModelCenter.tsx b/src/pages/ModelCenter.tsx index adeb03b..1571ecb 100644 --- a/src/pages/ModelCenter.tsx +++ b/src/pages/ModelCenter.tsx @@ -67,6 +67,82 @@ export function buildLocalProviderEnvUpdatesForSubmit(params: { return {} } +function cloneConfigValue(config: Record | null | undefined): Record { + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return {} + } + return JSON.parse(JSON.stringify(config)) as Record +} + +function ensureObjectRecord(parent: Record, key: string): Record { + const current = parent[key] + if (current && typeof current === 'object' && !Array.isArray(current)) { + return current as Record + } + parent[key] = {} + return parent[key] as Record +} + +function stripProviderPrefix(modelKey: string, providerId: string): string { + const normalizedModelKey = String(modelKey || '').trim() + const normalizedProviderId = String(providerId || '').trim() + if (!normalizedModelKey) return '' + const providerPrefix = `${normalizedProviderId}/` + if (normalizedProviderId && normalizedModelKey.startsWith(providerPrefix)) { + return normalizedModelKey.slice(providerPrefix.length).trim() + } + if (!normalizedModelKey.includes('/')) return normalizedModelKey + return normalizedModelKey.split('/').slice(1).join('/').trim() +} + +export function buildNextConfigWithLocalProviderSnapshot(params: { + currentConfig: Record | null | undefined + providerId: string + baseUrl: string + selectedModelKey: string + discoveredModels?: Array<{ key: string; name: string }> | null +}): Record { + const providerId = String(params.providerId || '').trim() + if (!providerId) { + return cloneConfigValue(params.currentConfig) + } + + const nextConfig = cloneConfigValue(params.currentConfig) + const modelsSection = ensureObjectRecord(nextConfig, 'models') + const providersSection = ensureObjectRecord(modelsSection, 'providers') + const currentProviderConfig = + providersSection[providerId] && typeof providersSection[providerId] === 'object' && !Array.isArray(providersSection[providerId]) + ? { ...(providersSection[providerId] as Record) } + : {} + + const persistedModels = new Map() + for (const entry of params.discoveredModels || []) { + const modelId = stripProviderPrefix(entry?.key, providerId) + if (!modelId || persistedModels.has(modelId)) continue + persistedModels.set(modelId, { + id: modelId, + name: String(entry?.name || modelId).trim() || modelId, + }) + } + + const selectedModelId = stripProviderPrefix(params.selectedModelKey, providerId) + if (selectedModelId && !persistedModels.has(selectedModelId)) { + persistedModels.set(selectedModelId, { + id: selectedModelId, + name: selectedModelId, + }) + } + + const normalizedBaseUrl = String(params.baseUrl || '').trim() + providersSection[providerId] = { + ...currentProviderConfig, + ...(normalizedBaseUrl ? { baseUrl: normalizedBaseUrl } : {}), + ...(persistedModels.size > 0 ? { models: Array.from(persistedModels.values()) } : {}), + } + + return nextConfig +} + interface LocalConnectionTestResult { ok: boolean reachable: boolean @@ -2190,6 +2266,32 @@ export default function ModelCenter({ return } + setStatusText('正在写入本地 Provider 配置...') + const currentConfig = await window.api.readConfig() + const nextConfig = buildNextConfigWithLocalProviderSnapshot({ + currentConfig, + providerId: selectedProviderId, + baseUrl: localBaseUrl, + selectedModelKey: selectedLocalModel, + discoveredModels: scanResult?.models || [], + }) + const writeResult = await window.api.applyConfigPatchGuarded({ + beforeConfig: currentConfig, + afterConfig: nextConfig, + reason: 'unknown', + }) + if (!writeResult.ok) { + setPhase('ready') + setStatusText('') + setError( + toUserFacingCliFailureMessage({ + stderr: writeResult.message, + fallback: '写入本地 Provider 配置失败', + }) + ) + return + } + setStatusText('正在应用默认模型...') const applyResult = await applyDefaultModelWithGatewayReload({ model: selectedLocalModel, diff --git a/src/pages/__tests__/model-center.test.tsx b/src/pages/__tests__/model-center.test.tsx index 04fbb75..747f95e 100644 --- a/src/pages/__tests__/model-center.test.tsx +++ b/src/pages/__tests__/model-center.test.tsx @@ -5,6 +5,7 @@ import { appendRetryRefreshHint, type OpenClawCapabilities, buildLocalProviderEnvUpdatesForSubmit, + buildNextConfigWithLocalProviderSnapshot, buildCapabilitiesLoadingDisplay, refreshModelCapabilitiesData, buildSkipSetupContext, @@ -254,6 +255,35 @@ describe('buildProviderOptions', () => { }) }) +describe('buildNextConfigWithLocalProviderSnapshot', () => { + it('persists local custom-openai provider details and scanned models into models.providers', () => { + expect( + buildNextConfigWithLocalProviderSnapshot({ + currentConfig: null, + providerId: 'custom-openai', + baseUrl: 'http://192.168.31.139:12995/v1', + selectedModelKey: 'custom-openai/gpt-4', + discoveredModels: [ + { key: 'custom-openai/gpt-4', name: 'gpt-4' }, + { key: 'custom-openai/gpt-4.1', name: 'gpt-4.1' }, + ], + }) + ).toEqual({ + models: { + providers: { + 'custom-openai': { + baseUrl: 'http://192.168.31.139:12995/v1', + models: [ + { id: 'gpt-4', name: 'gpt-4' }, + { id: 'gpt-4.1', name: 'gpt-4.1' }, + ], + }, + }, + }, + }) + }) +}) + describe('shouldShowCredentialProbeControl', () => { it('keeps the setup flow free of realtime API-key probe controls', () => { expect(shouldShowCredentialProbeControl('openai', 'openai-api-key')).toBe(false) diff --git a/src/pages/__tests__/models-page-state.test.ts b/src/pages/__tests__/models-page-state.test.ts index 3874281..5efbb09 100644 --- a/src/pages/__tests__/models-page-state.test.ts +++ b/src/pages/__tests__/models-page-state.test.ts @@ -642,6 +642,51 @@ describe('models page state helpers', () => { ]) }) + it('keeps custom-openai visible by merging runtime default models when the shared catalog has no provider entries yet', () => { + const state = resolveModelsPageCatalogState({ + catalog: [], + envVars: { + OPENAI_BASE_URL: 'http://192.168.31.139:12995/v1', + }, + config: null, + statusData: { + auth: { + providers: [{ provider: 'custom-openai', status: 'static', profiles: [{ profileId: 'custom-openai:local' }] }], + }, + defaultModel: 'custom-openai/gpt-4', + resolvedDefault: 'custom-openai/gpt-4', + }, + mode: 'all', + }) + + expect(state.configuredProviders).toEqual([ + expect.objectContaining({ + id: 'custom-openai', + name: '自定义 OpenAI 兼容', + }), + ]) + expect(state.visibleCatalog).toEqual([ + { + key: 'custom-openai/gpt-4', + provider: 'custom-openai', + name: 'gpt-4', + available: true, + verificationState: 'verified-available', + tags: ['configured'], + }, + ]) + expect(state.scopedCatalog).toEqual([ + { + key: 'custom-openai/gpt-4', + provider: 'custom-openai', + name: 'gpt-4', + available: true, + verificationState: 'verified-available', + tags: ['configured'], + }, + ]) + }) + it('applies persisted verification records across alias-equivalent minimax models in the merged provider card', () => { const state = resolveModelsPageCatalogState({ catalog: [ diff --git a/src/shared/model-catalog-state.ts b/src/shared/model-catalog-state.ts index 61d6f7f..31b50aa 100644 --- a/src/shared/model-catalog-state.ts +++ b/src/shared/model-catalog-state.ts @@ -315,6 +315,62 @@ export function mergeConfiguredProviderModelsIntoCatalog( + catalog: T[], + providers: ModelsPageConfiguredProvider[], + statusData: Record | null +): T[] { + const merged = Array.isArray(catalog) ? [...catalog] : [] + if (merged.length === 0 && providers.length === 0) return merged + + const keyToIndex = new Map() + const runtimeKeyToIndex = new Map() + merged.forEach((item, index) => { + const key = String(item?.key || '').trim().toLowerCase() + if (key) keyToIndex.set(key, index) + const runtimeKey = toRuntimeModelEquivalenceKey(item?.key) + if (runtimeKey) { + runtimeKeyToIndex.set(runtimeKey, index) + } + }) + + for (const provider of providers) { + const providerId = canonicalizeModelProviderId(provider.id) + if (!providerId) continue + + for (const runtimeModelKey of collectRuntimeProviderModelKeys(providerId, statusData)) { + const normalizedRuntimeKey = String(runtimeModelKey || '').trim() + if (!normalizedRuntimeKey) continue + + const existingIndex = keyToIndex.get(normalizedRuntimeKey.toLowerCase()) + if (existingIndex !== undefined) { + continue + } + + const runtimeEquivalenceKey = toRuntimeModelEquivalenceKey(normalizedRuntimeKey) + if (runtimeEquivalenceKey && runtimeKeyToIndex.has(runtimeEquivalenceKey)) { + continue + } + + const modelName = String(normalizedRuntimeKey.split('/').slice(1).join('/') || normalizedRuntimeKey).trim() + merged.push({ + key: normalizedRuntimeKey, + provider: providerId, + name: modelName, + available: false, + verificationState: 'unverified', + tags: ['configured'], + } as T) + keyToIndex.set(normalizedRuntimeKey.toLowerCase(), merged.length - 1) + if (runtimeEquivalenceKey) { + runtimeKeyToIndex.set(runtimeEquivalenceKey, merged.length - 1) + } + } + } + + return merged +} + export function canSwitchModelsPageCatalogItem(item: ModelsPageCatalogItem | null | undefined): boolean { return Boolean(item) } @@ -493,23 +549,28 @@ export function resolveModelsPageCatalogState(p statusData: params.statusData, }) const catalogWithConfiguredModels = mergeConfiguredProviderModelsIntoCatalog(params.catalog, params.config) - const effectiveCatalog = buildEffectiveModelCatalog(catalogWithConfiguredModels, { + const catalogWithRuntimeProviderModels = mergeRuntimeProviderModelsIntoCatalog( + catalogWithConfiguredModels, + locallyConfiguredProviders, + params.statusData + ) + const effectiveCatalogWithRuntimeModels = buildEffectiveModelCatalog(catalogWithRuntimeProviderModels, { statusData: params.statusData, preferredModelKey: params.preferredModelKey, configuredProviderIds: locallyConfiguredProviders.map((provider) => provider.id), verificationRecords: params.verificationRecords || [], }) const visibleCatalog = filterCatalogForDisplay( - effectiveCatalog, + effectiveCatalogWithRuntimeModels, params.mode || 'available' ) as T[] const configuredProviders = filterConfiguredProvidersWithVisibleModels( locallyConfiguredProviders, visibleCatalog, - effectiveCatalog + effectiveCatalogWithRuntimeModels ) const scopedCatalog = filterModelsPageCatalogByConfiguredProviders( - effectiveCatalog, + effectiveCatalogWithRuntimeModels, configuredProviders ) as T[] const scopedVisibleCatalog = filterModelsPageCatalogByConfiguredProviders( @@ -518,7 +579,7 @@ export function resolveModelsPageCatalogState(p ) as T[] return { - effectiveCatalog, + effectiveCatalog: effectiveCatalogWithRuntimeModels, visibleCatalog: scopedVisibleCatalog, scopedCatalog, configuredProviders, From bea8a5ebdfc689ce15b1f33c9fb43b841070e098 Mon Sep 17 00:00:00 2001 From: lizhelang Date: Wed, 1 Apr 2026 21:18:48 +0800 Subject: [PATCH 2/3] fix: reuse shared model selector in dashboard --- src/pages/Dashboard.tsx | 29 ++++++--------- .../__tests__/dashboard-entry-flow.test.tsx | 37 +++++++++++++++++++ 2 files changed, 48 insertions(+), 18 deletions(-) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 1e44e28..d866a7b 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -10,18 +10,14 @@ import { runManagedChannelRepairFlow } from '../shared/managed-channel-repair' import { runDashboardInitialLoad } from './dashboard-initial-load' import { buildModelCatalogDisplaySummary, - filterCatalogForDisplay, isCatalogModelAvailable, type ModelCatalogDisplayMode, } from '../lib/model-catalog-display' import { listAllModelCatalogItems } from '../lib/model-catalog-pagination' import { - buildEffectiveModelCatalog, - buildModelsPageConfiguredProviders, - filterModelsPageCatalogByConfiguredProviders, - filterConfiguredProvidersWithVisibleModels, getModelsPageProviderModels, resolveConfiguredProviderRuntimeState, + resolveModelsPageCatalogState, resolveModelsPageActiveModel, resolveVisibleConfiguredActiveModel, } from './models-page-state' @@ -472,28 +468,25 @@ export default function Dashboard({ const pluginCenterProgressRef = useRef(0) const pluginCenterProgressTimerRef = useRef | null>(null) const activeModelHint = resolveModelsPageActiveModel(modelStatus, config) - const locallyConfiguredProviders = buildModelsPageConfiguredProviders({ + const { + effectiveCatalog, + visibleCatalog, + scopedCatalog: configuredCatalog, + configuredProviders, + } = resolveModelsPageCatalogState({ + catalog, envVars, config, statusData: modelStatus, - }) - const effectiveCatalog = buildEffectiveModelCatalog(catalog, { - statusData: modelStatus, - preferredModelKey: activeModelHint, - configuredProviderIds: locallyConfiguredProviders.map((provider) => provider.id), verificationRecords, + preferredModelKey: activeModelHint, + mode: catalogMode, }) - const visibleCatalog = filterCatalogForDisplay(effectiveCatalog, catalogMode) - const providers: ModelProvider[] = filterConfiguredProvidersWithVisibleModels( - locallyConfiguredProviders, - visibleCatalog, - effectiveCatalog - ).map((provider) => ({ + const providers: ModelProvider[] = configuredProviders.map((provider) => ({ id: provider.id, name: provider.name, logo: provider.logo, })) - const configuredCatalog = filterModelsPageCatalogByConfiguredProviders(effectiveCatalog, providers) const catalogSummary = buildModelCatalogDisplaySummary(configuredCatalog, catalogMode) const displayedPluginRepairResult = selectDashboardPluginRepairResult(pluginRepairResult, pluginCenterRepairResult) const pluginRepairErrorSummary = diff --git a/src/pages/__tests__/dashboard-entry-flow.test.tsx b/src/pages/__tests__/dashboard-entry-flow.test.tsx index 563d267..74d2476 100644 --- a/src/pages/__tests__/dashboard-entry-flow.test.tsx +++ b/src/pages/__tests__/dashboard-entry-flow.test.tsx @@ -145,6 +145,43 @@ describe('dashboard entry bootstrap flow', () => { expect(api.getModelStatus).not.toHaveBeenCalled() }) + it('renders saved custom-openai provider models from the shared selector path', () => { + const html = renderToStaticMarkup( + + + + ) + + expect(html).toContain('自定义 OpenAI 兼容') + expect(html).toContain('gpt-4') + }) + it('allows dashboard entry when gateway probes fail and keeps gatewayRunning false', async () => { const api = createBootstrapApi({ gatewayHealth: vi.fn().mockRejectedValue(new Error('health down')), From 54b753d8c9b02ac9ac1dbeafb9f08a6be7032ec9 Mon Sep 17 00:00:00 2001 From: lizhelang Date: Thu, 2 Apr 2026 05:47:39 +0800 Subject: [PATCH 3/3] fix: avoid custom-provider false matches and honor fallback timeouts --- .../__tests__/openclaw-model-config.test.ts | 42 +++++++++++++++++++ electron/main/openclaw-model-config.ts | 19 +++++++-- src/pages/__tests__/model-center.test.tsx | 22 ++++++++++ src/shared/custom-provider-config-match.ts | 12 ++++-- 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/electron/main/__tests__/openclaw-model-config.test.ts b/electron/main/__tests__/openclaw-model-config.test.ts index f71c29d..3cab844 100644 --- a/electron/main/__tests__/openclaw-model-config.test.ts +++ b/electron/main/__tests__/openclaw-model-config.test.ts @@ -645,4 +645,46 @@ describe('scanLocalModels', () => { ]) expect((result.data as any).count).toBe(2) }) + + it('applies timeoutMs to the custom-openai /models HTTP fallback', async () => { + vi.useFakeTimers() + try { + const runCommand = vi.fn(async () => ok('No models found.')) + const fetchMock = vi.fn((_: string, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => { + const error = new Error('aborted') + ;(error as Error & { name: string }).name = 'AbortError' + reject(error) + }) + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const resultPromise = scanLocalModels( + { + provider: 'custom-openai', + baseUrl: 'http://127.0.0.1:1234/v1', + apiKey: 'sk-test', + timeoutMs: 25, + }, + { runCommand } + ) + + await vi.advanceTimersByTimeAsync(25) + const result = await resultPromise + + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:1234/v1/models', + expect.objectContaining({ + signal: expect.any(AbortSignal), + }) + ) + expect(result.ok).toBe(true) + expect((result.data as any).count).toBe(0) + expect((result.data as any).models).toEqual([]) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/electron/main/openclaw-model-config.ts b/electron/main/openclaw-model-config.ts index a07025f..2b06f0e 100644 --- a/electron/main/openclaw-model-config.ts +++ b/electron/main/openclaw-model-config.ts @@ -366,7 +366,8 @@ function normalizeLocalScanPayload(payload: unknown, provider: string): { count: } async function tryDiscoverCustomOpenAiModelsViaHttp( - input: LocalModelScanInput + input: LocalModelScanInput, + timeoutMs: number ): Promise<{ count: number; models: Array<{ key: string; name: string }> } | null> { if (!isCustomOpenAiProvider(input.provider)) return null @@ -381,10 +382,18 @@ async function tryDiscoverCustomOpenAiModelsViaHttp( headers.Authorization = `Bearer ${normalizedApiKey}` } + const controller = timeoutMs > 0 ? new AbortController() : null + const timeoutId = controller + ? setTimeout(() => { + controller.abort() + }, timeoutMs) + : null + try { const response = await fetch(`${normalizedBaseUrl}/models`, { method: 'GET', headers, + ...(controller ? { signal: controller.signal } : {}), }) if (!response.ok) return null @@ -392,6 +401,10 @@ async function tryDiscoverCustomOpenAiModelsViaHttp( return normalizeLocalScanPayload(payload, input.provider) } catch { return null + } finally { + if (timeoutId) { + clearTimeout(timeoutId) + } } } @@ -612,7 +625,7 @@ export async function scanLocalModels( if (parsed.ok) { const normalizedPayload = normalizeLocalScanPayload(parsed.data, provider) if (normalizedPayload.count === 0) { - const httpFallback = await tryDiscoverCustomOpenAiModelsViaHttp(input) + const httpFallback = await tryDiscoverCustomOpenAiModelsViaHttp(input, effectiveTimeout) if (httpFallback) { return { ...parsed, @@ -634,7 +647,7 @@ export async function scanLocalModels( // Some local provider bridges return a plain-text success message when no models are loaded. // Treat it as an empty model list so the UI can guide the user to pull/load models. if (parsed.errorCode === 'parse_error' && NO_MODELS_FOUND_OUTPUT_REGEX.test(normalizedStdout)) { - const httpFallback = await tryDiscoverCustomOpenAiModelsViaHttp(input) + const httpFallback = await tryDiscoverCustomOpenAiModelsViaHttp(input, effectiveTimeout) if (httpFallback) { return { ok: true, diff --git a/src/pages/__tests__/model-center.test.tsx b/src/pages/__tests__/model-center.test.tsx index 747f95e..5d4ef28 100644 --- a/src/pages/__tests__/model-center.test.tsx +++ b/src/pages/__tests__/model-center.test.tsx @@ -898,6 +898,28 @@ describe('findConfiguredCustomProviderId', () => { expect(configuredProviderId).toBe('') }) + + it('ignores local custom-openai snapshots when resolving manual custom providers', () => { + const configuredProviderId = findConfiguredCustomProviderId( + { + models: { + providers: { + 'custom-openai': { + baseUrl: 'https://gateway.example.com/v1', + models: ['acme-chat'], + }, + }, + }, + }, + { + baseUrl: 'https://gateway.example.com/v1', + modelId: 'acme-chat', + compatibility: 'openai', + } + ) + + expect(configuredProviderId).toBe('') + }) }) describe('buildVerificationProviderCandidates', () => { diff --git a/src/shared/custom-provider-config-match.ts b/src/shared/custom-provider-config-match.ts index 5558b1f..0afff19 100644 --- a/src/shared/custom-provider-config-match.ts +++ b/src/shared/custom-provider-config-match.ts @@ -18,6 +18,8 @@ export type CustomProviderConfigMatchResult = status: 'missing' } +const LOCAL_PROVIDER_SNAPSHOT_IDS = new Set(['ollama', 'vllm', 'custom-openai']) + function isAzureCustomProviderUrl(baseUrl: string): boolean { try { const host = new URL(baseUrl).hostname.toLowerCase() @@ -88,14 +90,16 @@ export function resolveConfiguredCustomProviderMatchFromConfig( const matchedProviderIds: string[] = [] for (const [providerId, providerConfig] of Object.entries(providers)) { + const normalizedProviderId = String(providerId || '').trim() + if (!normalizedProviderId || LOCAL_PROVIDER_SNAPSHOT_IDS.has(normalizedProviderId)) { + continue + } + const actualBaseUrl = String(providerConfig?.baseUrl || '').trim().replace(/\/+$/, '') const models = Array.isArray(providerConfig?.models) ? providerConfig.models : [] const hasModel = models.some((model: unknown) => getConfiguredProviderModelCandidates(model).includes(expectedModelId)) if (actualBaseUrl === expectedBaseUrl && hasModel) { - const normalizedProviderId = String(providerId || '').trim() - if (normalizedProviderId) { - matchedProviderIds.push(normalizedProviderId) - } + matchedProviderIds.push(normalizedProviderId) } }