Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
87 changes: 87 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,90 @@ 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)
})

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()
}
})
})
73 changes: 72 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,49 @@ function normalizeLocalScanPayload(payload: unknown, provider: string): { count:
}
}

async function tryDiscoverCustomOpenAiModelsViaHttp(
input: LocalModelScanInput,
timeoutMs: number
): 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}`
}

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

const payload = await response.json()
return normalizeLocalScanPayload(payload, input.provider)
} catch {
return null
} finally {
if (timeoutId) {
clearTimeout(timeoutId)
}
}
}

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 +623,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, effectiveTimeout)
if (httpFallback) {
return {
...parsed,
data: httpFallback,
}
}
}

return {
...parsed,
data: normalizeLocalScanPayload(parsed.data, provider),
data: normalizedPayload,
}
}

Expand All @@ -589,6 +647,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, effectiveTimeout)
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
Loading