Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions electron/main/__tests__/openclaw-model-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function failedWithStdout(stdout: string, code = 1): CliCommandResult {

beforeEach(() => {
resetOpenClawLegacyEnvWarningsForTests()
vi.unstubAllGlobals()
})

describe('applyModelConfigAction', () => {
Expand Down Expand Up @@ -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)
})
})
60 changes: 59 additions & 1 deletion electron/main/openclaw-model-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> {
if (Array.isArray(payload)) {
return payload.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === 'object'))
Expand Down Expand Up @@ -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<string, string> = {
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<string, { hostKey?: string; apiKeyKey?: string }> = {
ollama: { hostKey: 'OLLAMA_HOST', apiKeyKey: 'OLLAMA_API_KEY' },
vllm: { hostKey: 'VLLM_BASE_URL', apiKeyKey: 'VLLM_API_KEY' },
Expand Down Expand Up @@ -576,9 +610,20 @@ export async function scanLocalModels(

const parsed = parseJsonResult<Record<string, unknown>>('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,
}
}

Expand All @@ -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',
Expand Down
29 changes: 11 additions & 18 deletions src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -472,28 +468,25 @@ export default function Dashboard({
const pluginCenterProgressRef = useRef(0)
const pluginCenterProgressTimerRef = useRef<ReturnType<typeof setInterval> | 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 =
Expand Down
102 changes: 102 additions & 0 deletions src/pages/ModelCenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,82 @@ export function buildLocalProviderEnvUpdatesForSubmit(params: {
return {}
}

function cloneConfigValue(config: Record<string, any> | null | undefined): Record<string, any> {
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return {}
}
return JSON.parse(JSON.stringify(config)) as Record<string, any>
}

function ensureObjectRecord(parent: Record<string, any>, key: string): Record<string, any> {
const current = parent[key]
if (current && typeof current === 'object' && !Array.isArray(current)) {
return current as Record<string, any>
}
parent[key] = {}
return parent[key] as Record<string, any>
}

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<string, any> | null | undefined
providerId: string
baseUrl: string
selectedModelKey: string
discoveredModels?: Array<{ key: string; name: string }> | null
}): Record<string, any> {
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<string, any>) }
: {}

const persistedModels = new Map<string, { id: string; name: string }>()
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
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions src/pages/__tests__/dashboard-entry-flow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MantineProvider>
<Dashboard
entrySnapshot={{
gatewayRunning: true,
loadedAt: '2026-04-01T00:00:00.000Z',
pairingSummary: null,
config: {
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' },
],
},
},
},
},
modelStatus: {
defaultModel: 'custom-openai/gpt-4',
resolvedDefault: 'custom-openai/gpt-4',
auth: {
providers: [{ provider: 'custom-openai', status: 'static', profiles: [{ profileId: 'custom-openai:local' }] }],
},
},
}}
/>
</MantineProvider>
)

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')),
Expand Down
Loading