diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 6da7617261ac..3e9ea875716f 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -10,7 +10,6 @@ import { OLLAMA_DEFAULT_API_KEY } from "./src/discovery-shared.js"; const promptAndConfigureOllamaMock = vi.hoisted(() => vi.fn(async () => ({ - credential: "ollama-local", defaultModel: "ollama/qwen-tool", config: { models: { @@ -18,7 +17,8 @@ const promptAndConfigureOllamaMock = vi.hoisted(() => ollama: { baseUrl: "http://127.0.0.1:11434", api: "ollama", - models: [], + apiKey: "ollama-local", + models: [{ id: "qwen-tool", name: "qwen-tool" }], }, }, }, @@ -526,12 +526,15 @@ describe("ollama plugin", () => { ollama: { baseUrl: "http://127.0.0.1:11434", api: "ollama", - models: [], + apiKey: "ollama-local", + models: [{ id: "qwen-tool", name: "qwen-tool" }], }, }, }, }); + expect(result.profiles).toEqual([]); expect(result.defaultModel).toBe("ollama/qwen-tool"); + expect(result.configPatch?.models?.providers?.ollama?.apiKey).toBe("ollama-local"); }); it("discovers and prepares a loaded tool-capable model without pulling it", async () => { diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 49c790a787d5..f59d4ecc207b 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -412,14 +412,9 @@ async function discoverAppGuidedOllamaModel( ? providerModels.map((candidate) => (candidate.id === model.id ? model : candidate)) : [...providerModels, model], }); - let ownerValue = connection.existing?.apiKey; - if (ownerValue === undefined) { - if (connection.accessValue) { - ownerValue = "OLLAMA_API_KEY"; - } else { - ownerValue = OLLAMA_DEFAULT_API_KEY; - } - } + const ownerValue = + connection.existing?.apiKey ?? + (connection.accessValue ? "OLLAMA_API_KEY" : OLLAMA_DEFAULT_API_KEY); return { existing: connection.existing, provider: preparedProvider, @@ -1026,8 +1021,6 @@ export default definePluginEntry({ } // Keep discovery ownership explicit so the live probe and persisted // route use the same local marker, env marker, or configured input. - const ownerValue = discovered.ownerValue; - const ownerAccess = { apiKey: ownerValue }; return { profiles: [], defaultModel: ctx.modelRef, @@ -1038,7 +1031,7 @@ export default definePluginEntry({ [OLLAMA_PROVIDER_ID]: { ...discovered.existing, ...discovered.provider, - ...ownerAccess, + ...(discovered.ownerValue ? { apiKey: discovered.ownerValue } : {}), models: discovered.provider.models, }, }, @@ -1059,22 +1052,24 @@ export default definePluginEntry({ allowSecretRefPrompt: ctx.allowSecretRefPrompt, }); return { - profiles: [ - { - profileId: "ollama:default", - credential: buildApiKeyCredential( - OLLAMA_PROVIDER_ID, - result.credential, - undefined, - result.credentialMode - ? { - secretInputMode: result.credentialMode, - config: ctx.config, - } - : undefined, - ), - }, - ], + profiles: result.credential + ? [ + { + profileId: "ollama:default", + credential: buildApiKeyCredential( + OLLAMA_PROVIDER_ID, + result.credential, + undefined, + result.credentialMode + ? { + secretInputMode: result.credentialMode, + config: ctx.config, + } + : undefined, + ), + }, + ] + : [], configPatch: result.config, ...(result.defaultModel ? { defaultModel: result.defaultModel } : {}), }; diff --git a/extensions/ollama/src/setup-model-selection.test.ts b/extensions/ollama/src/setup-model-selection.test.ts index c4aa828d024d..ddeb22d85b06 100644 --- a/extensions/ollama/src/setup-model-selection.test.ts +++ b/extensions/ollama/src/setup-model-selection.test.ts @@ -81,6 +81,34 @@ describe("Ollama onboarding model selection", () => { ).toBe("qwen3:0.6b"); }); + it("prefers the smallest non-reasoning setup model", () => { + expect( + selectAppGuidedOllamaModelId([ + { + id: "deepseek-r1:8b", + contextWindow: 131_072, + supportsTools: true, + reasoning: true, + size: 1_000, + }, + { + id: "orieg/gemma3-tools:12b-ft", + contextWindow: 131_072, + supportsTools: true, + reasoning: false, + size: 8_000, + }, + { + id: "llama3.2:latest", + contextWindow: 131_072, + supportsTools: true, + reasoning: false, + size: 2_000, + }, + ]), + ).toBe("llama3.2:latest"); + }); + it("aborts pending model discovery with the setup signal", async () => { const controller = new AbortController(); vi.stubGlobal( diff --git a/extensions/ollama/src/setup-model-selection.ts b/extensions/ollama/src/setup-model-selection.ts index f5eea51052ac..a67f3cbf6584 100644 --- a/extensions/ollama/src/setup-model-selection.ts +++ b/extensions/ollama/src/setup-model-selection.ts @@ -82,17 +82,25 @@ export function selectAppGuidedOllamaModelId( id: string; contextWindow?: number; supportsTools?: boolean; + reasoning?: boolean; + size?: number; }>, ): string | undefined { - const eligibleIds = [...models] - .filter( - (model) => - model.supportsTools === true && - model.contextWindow !== undefined && - model.contextWindow >= OLLAMA_APP_GUIDED_MIN_CONTEXT_TOKENS, - ) - .map((model) => model.id); - return orderPreferredOllamaModelIds(eligibleIds)[0]; + const eligible = [...models].filter( + (model) => + model.supportsTools === true && + model.contextWindow !== undefined && + model.contextWindow >= OLLAMA_APP_GUIDED_MIN_CONTEXT_TOKENS, + ); + const nonReasoning = eligible.filter((model) => model.reasoning !== true); + const pool = nonReasoning.length > 0 ? nonReasoning : eligible; + const measuredSizes = pool + .map((model) => model.size) + .filter((size): size is number => typeof size === "number" && size > 0); + const smallestSize = measuredSizes.length > 0 ? Math.min(...measuredSizes) : undefined; + const fastest = + smallestSize === undefined ? pool : pool.filter((model) => model.size === smallestSize); + return orderPreferredOllamaModelIds(fastest.map((model) => model.id))[0]; } export function buildOllamaModelsConfig( diff --git a/extensions/ollama/src/setup.non-interactive-auth.test.ts b/extensions/ollama/src/setup.non-interactive-auth.test.ts index 5569cfcd5858..4754024ae780 100644 --- a/extensions/ollama/src/setup.non-interactive-auth.test.ts +++ b/extensions/ollama/src/setup.non-interactive-auth.test.ts @@ -137,7 +137,8 @@ describe("Ollama non-interactive onboarding", () => { expect(fetchMock.mock.calls.map((call) => requestUrl(call[0]))).not.toContain( "http://127.0.0.1:11434/api/pull", ); - expect(upsertAuthProfileWithLock).toHaveBeenCalledTimes(1); + expect(result.models?.providers?.ollama?.apiKey).toBe("ollama-local"); + expect(upsertAuthProfileWithLock).not.toHaveBeenCalled(); }); it("keeps an installed suggested local model first in non-interactive setup", async () => { @@ -162,7 +163,8 @@ describe("Ollama non-interactive onboarding", () => { expect(fetchMock.mock.calls.map((call) => requestUrl(call[0]))).not.toContain( "http://127.0.0.1:11434/api/pull", ); - expect(upsertAuthProfileWithLock).toHaveBeenCalledTimes(1); + expect(result.models?.providers?.ollama?.apiKey).toBe("ollama-local"); + expect(upsertAuthProfileWithLock).not.toHaveBeenCalled(); }); it("preserves the capabilities of an explicitly selected model beyond the discovery limit", async () => { diff --git a/extensions/ollama/src/setup.runtime.ts b/extensions/ollama/src/setup.runtime.ts index dd92ea1b1ac1..f6d3bdbf16a8 100644 --- a/extensions/ollama/src/setup.runtime.ts +++ b/extensions/ollama/src/setup.runtime.ts @@ -10,7 +10,6 @@ import { isNonSecretApiKeyMarker, normalizeApiKeyInput, normalizeOptionalSecretInput, - upsertAuthProfileWithLock, validateApiKeyInput, } from "openclaw/plugin-sdk/provider-auth"; import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; @@ -25,12 +24,14 @@ import { OLLAMA_DEFAULT_MODEL, resolveOllamaSetupDefaultBaseUrl, } from "./defaults.js"; +import { OLLAMA_DEFAULT_API_KEY } from "./discovery-shared.js"; import { readProviderBaseUrl } from "./provider-base-url.js"; import { buildOllamaBaseUrlSsrFPolicy, buildOllamaProvider, enrichOllamaModelsWithContext, fetchOllamaModels, + isReasoningModelHeuristic, isOllamaCloudModel, resolveOllamaApiBase, type OllamaModelWithContext, @@ -66,7 +67,7 @@ type OllamaSetupOptions = { type OllamaSetupResult = { config: OpenClawConfig; - credential: SecretInput; + credential?: SecretInput; credentialMode?: SecretInputMode; defaultModel?: string; }; @@ -202,7 +203,7 @@ function applyOllamaProviderConfig( baseUrl: string, modelNames: string[], discoveredModelsByName?: Map, - apiKey: SecretInput = "OLLAMA_API_KEY", + apiKey: SecretInput = OLLAMA_DEFAULT_API_KEY, defaultModels: readonly OllamaCloudDefaultModel[] = [], ): OpenClawConfig { return { @@ -223,14 +224,6 @@ function applyOllamaProviderConfig( }; } -async function storeOllamaCredential(agentDir?: string): Promise { - await upsertAuthProfileWithLock({ - profileId: "ollama:default", - credential: { type: "api_key", provider: "ollama", key: "ollama-local" }, - agentDir, - }); -} - async function promptForOllamaBaseUrl( prompter: WizardPrompter, env: NodeJS.ProcessEnv = process.env, @@ -378,13 +371,15 @@ async function promptAndConfigureHostBackedOllama(params: { id: model.name, contextWindow: model.contextWindow, supportsTools: model.capabilities?.includes("tools") === true, + reasoning: + model.capabilities?.includes("thinking") === true || isReasoningModelHeuristic(model.name), + size: model.size, })), ); const cloudDefaultModelId = suggestedModelNames.find(isOllamaCloudModel); const defaultModelId = localDefaultModelId ?? cloudDefaultModelId; return { - credential: "ollama-local", ...(defaultModelId ? { defaultModel: `ollama/${defaultModelId}` } : {}), config: applyOllamaProviderConfig( params.cfg, @@ -554,9 +549,6 @@ export async function configureOllamaNonInteractive(params: { discoveredModelsByName.set(defaultModelId, selectedModel); } - // Failed setup must not leave a durable local profile behind. - await storeOllamaCredential(params.agentDir); - const config = applyOllamaProviderConfig( params.nextConfig, baseUrl, diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index a15961b425eb..0e20eef67544 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -153,6 +153,8 @@ describe("ollama setup", () => { const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id); expect(modelIds?.[0]).toBe("gemma4"); + expect(result.config.models?.providers?.ollama?.apiKey).toBe("ollama-local"); + expect(result.credential).toBeUndefined(); }); it("Docker setup defaults to the host Ollama endpoint", async () => { @@ -249,7 +251,8 @@ describe("ollama setup", () => { "llama3:8b", ]); expect(result.config.models?.providers?.ollama?.baseUrl).toBe("http://127.0.0.1:11434"); - expect(result.credential).toBe("ollama-local"); + expect(result.config.models?.providers?.ollama?.apiKey).toBe("ollama-local"); + expect(result.credential).toBeUndefined(); }); it("mode selection affects model ordering (local)", async () => { @@ -980,7 +983,8 @@ describe("ollama setup", () => { primary: "ollama/qwen2.5-coder:7b", fallbacks: ["anthropic/claude-sonnet-4-5"], }); - expect(upsertAuthProfileWithLock).toHaveBeenCalledTimes(1); + expect(result.models?.providers?.ollama?.apiKey).toBe("ollama-local"); + expect(upsertAuthProfileWithLock).not.toHaveBeenCalled(); }); it("normalizes ollama/ prefix in non-interactive custom model download", async () => { @@ -1003,7 +1007,8 @@ describe("ollama setup", () => { const pullRequest = mockCallArg(fetchMock, 1, 1) as RequestInit | undefined; expect(JSON.parse(requestBodyText(pullRequest?.body))).toEqual({ model: "llama3.2:latest" }); expect(result.agents?.defaults?.model).toEqual({ primary: "ollama/llama3.2:latest" }); - expect(upsertAuthProfileWithLock).toHaveBeenCalledTimes(1); + expect(result.models?.providers?.ollama?.apiKey).toBe("ollama-local"); + expect(upsertAuthProfileWithLock).not.toHaveBeenCalled(); }); it("uses the discovered latest tag as the non-interactive default without pulling", async () => { @@ -1027,7 +1032,8 @@ describe("ollama setup", () => { ]); expect(result.agents?.defaults?.model).toEqual({ primary: "ollama/gemma4:latest" }); expect(runtime.log).toHaveBeenCalledWith("Default Ollama model: gemma4:latest"); - expect(upsertAuthProfileWithLock).toHaveBeenCalledTimes(1); + expect(result.models?.providers?.ollama?.apiKey).toBe("ollama-local"); + expect(upsertAuthProfileWithLock).not.toHaveBeenCalled(); }); it.each(["kimi-k2.5:cloud", "gpt-oss:120b-cloud"])( diff --git a/src/agents/agent-run-result.ts b/src/agents/agent-run-result.ts new file mode 100644 index 000000000000..946913f159b2 --- /dev/null +++ b/src/agents/agent-run-result.ts @@ -0,0 +1,52 @@ +/** Minimal agent-run result projection shared by setup and diagnostic probes. */ +export type AgentRunResultView = { + payloads?: Array<{ text?: string; isError?: boolean; isReasoning?: boolean }>; + meta?: { + executionTrace?: { winnerProvider?: string; winnerModel?: string }; + finalAssistantVisibleText?: string; + finalAssistantRawText?: string; + livenessState?: string; + error?: { kind?: string; message?: string }; + }; +}; + +export function extractAgentRunText(result: AgentRunResultView): string | undefined { + return ( + result.meta?.finalAssistantVisibleText ?? + result.meta?.finalAssistantRawText ?? + result.payloads + ?.map((payload) => payload.text?.trim()) + .filter(Boolean) + .join("\n") + ); +} + +export function extractAgentRunTerminalError(result: AgentRunResultView): string | undefined { + const errorPayload = result.payloads?.find((payload) => payload.isError === true)?.text?.trim(); + const livenessState = result.meta?.livenessState?.trim().toLowerCase(); + if ( + !errorPayload && + !result.meta?.error && + livenessState !== "blocked" && + livenessState !== "abandoned" + ) { + return undefined; + } + return ( + result.meta?.error?.message?.trim() || + errorPayload || + (livenessState ? `Inference ended in the ${livenessState} state.` : "Inference failed.") + ); +} + +export function agentRunHasVisibleReply(result: AgentRunResultView): boolean { + if (result.meta?.finalAssistantVisibleText?.trim()) { + return true; + } + return ( + result.payloads?.some( + (payload) => + payload.isError !== true && payload.isReasoning !== true && Boolean(payload.text?.trim()), + ) === true + ); +} diff --git a/src/agents/embedded-agent-runner/run-orchestrator.ts b/src/agents/embedded-agent-runner/run-orchestrator.ts index bdec33052c0a..b783097b9d1a 100644 --- a/src/agents/embedded-agent-runner/run-orchestrator.ts +++ b/src/agents/embedded-agent-runner/run-orchestrator.ts @@ -264,6 +264,9 @@ async function runEmbeddedAgentInternal( workspaceDir: requestedWorkspaceResolution.workspaceDir, preserveWorkspaceDirOnRefresh: !requestedWorkspaceResolution.isCanonicalWorkspace, ...(params.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}), + ...(params.preparedModelRuntimeMode === "isolated-read-only" + ? { loadRuntimePlugins: true } + : {}), runtimePluginSelections, }; startupStages.mark("harness-selection"); diff --git a/src/agents/prepared-model-runtime.inbound-registry.ts b/src/agents/prepared-model-runtime.inbound-registry.ts index 0c8e7d49c60d..728fb73ecef0 100644 --- a/src/agents/prepared-model-runtime.inbound-registry.ts +++ b/src/agents/prepared-model-runtime.inbound-registry.ts @@ -20,6 +20,7 @@ export function preparedModelRuntimeWorkspaceFactsKey(input: PreparedModelRuntim config: hashRuntimeConfigValue(input.config), env: hashRuntimeConfigValue(input.env ?? process.env), readOnly: input.readOnly === true, + loadRuntimePlugins: input.loadRuntimePlugins === true, workspaceDir: input.workspaceDir, allowGatewaySubagentBinding: input.allowGatewaySubagentBinding === true, runtimePluginSelections: input.runtimePluginSelections, @@ -54,9 +55,9 @@ export function prepareWorkspacePluginRegistries( runtimePluginRegistry?: PluginRegistry; inboundPluginRegistry?: PluginRegistry; } { - // Read-only catalog owners stay runtime-free, but setup probes carry an exact harness selection. - // That selected registry must belong to the generation instead of leaking from an outer scope. - if (input.readOnly && !input.runtimePluginSelections) { + // Read-only catalog owners stay runtime-free. Executable probes opt in to provider runtime, + // while non-core harness probes carry the exact selected plugin generation. + if (input.readOnly && !input.loadRuntimePlugins && !input.runtimePluginSelections) { return {}; } const inboundPluginRegistry = input.readOnly ? undefined : loadInboundRegistry?.(input); diff --git a/src/agents/prepared-model-runtime.owner.ts b/src/agents/prepared-model-runtime.owner.ts index c9c6be8a320d..93fb7a8c1912 100644 --- a/src/agents/prepared-model-runtime.owner.ts +++ b/src/agents/prepared-model-runtime.owner.ts @@ -258,6 +258,7 @@ export function ownerKey(input: PreparedModelRuntimeInput): string { agentDir: input.agentDir, inheritedAuthDir: input.inheritedAuthDir, readOnly: input.readOnly === true, + loadRuntimePlugins: input.loadRuntimePlugins === true, skipCredentials: input.skipCredentials === true, workspaceDir: input.workspaceDir, env: environmentFingerprint(input.env), @@ -288,6 +289,7 @@ export function resolvePublishedOwner( owner.input.agentDir === input.agentDir && owner.input.inheritedAuthDir === input.inheritedAuthDir && owner.input.readOnly === input.readOnly && + owner.input.loadRuntimePlugins === input.loadRuntimePlugins && owner.input.skipCredentials === input.skipCredentials && owner.input.allowGatewaySubagentBinding === input.allowGatewaySubagentBinding && (input.runtimePluginSelections === undefined || @@ -309,6 +311,7 @@ export function hasSameLifecycleInput( left.agentId === right.agentId && left.inheritedAuthDir === right.inheritedAuthDir && left.readOnly === right.readOnly && + left.loadRuntimePlugins === right.loadRuntimePlugins && left.skipCredentials === right.skipCredentials && left.workspaceDir === right.workspaceDir && environmentFingerprint(left.env) === environmentFingerprint(right.env) && diff --git a/src/agents/prepared-model-runtime.test.ts b/src/agents/prepared-model-runtime.test.ts index 94610a557bff..b3134c1673aa 100644 --- a/src/agents/prepared-model-runtime.test.ts +++ b/src/agents/prepared-model-runtime.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { requireActivePluginRegistry } from "../plugins/runtime.js"; import { startSerializedSnapshotBuild } from "./prepared-model-runtime.build.js"; +import { prepareWorkspacePluginRegistries } from "./prepared-model-runtime.inbound-registry.js"; import { acquireReadOnlyPreparedModelRuntime, activateStandalonePreparedModelRuntime, @@ -90,6 +91,7 @@ describe("prepared model runtime snapshots", () => { config: stagedConfig, agentDir: "/tmp/setup-probe-agent", workspaceDir: "/tmp/setup-probe-workspace", + pluginRegistry: expect.any(Object), }); expect(lease.snapshot.pluginRegistry?.agentHarnesses.map((entry) => entry.harness.id)).toEqual([ "codex", @@ -102,6 +104,23 @@ describe("prepared model runtime snapshots", () => { lease.release(); }); + it("loads provider runtime for an isolated native-harness probe", () => { + const pluginRegistry = createEmptyPluginRegistry(); + mocks.loadAgentRuntimePluginRegistryHandle.mockReturnValue(pluginRegistry); + + expect( + prepareWorkspacePluginRegistries({ + config: {}, + agentDir: "/tmp/native-provider-probe", + readOnly: true, + loadRuntimePlugins: true, + }).runtimePluginRegistry, + ).toBe(pluginRegistry); + expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledWith( + expect.objectContaining({ selections: undefined }), + ); + }); + it("reactivates a standalone read-only owner after a publication boundary", async () => { const input = { agentDir: "/tmp/prepared-model-runtime-read-only-reactivation", diff --git a/src/agents/prepared-model-runtime.types.ts b/src/agents/prepared-model-runtime.types.ts index 5290a1bacea1..69a7cc9dfd50 100644 --- a/src/agents/prepared-model-runtime.types.ts +++ b/src/agents/prepared-model-runtime.types.ts @@ -84,6 +84,8 @@ export type PreparedModelRuntimeInput = { workspaceDir?: string; preserveWorkspaceDirOnRefresh?: boolean; readOnly?: boolean; + /** Load the exact runtime plugin generation for an isolated executable probe. */ + loadRuntimePlugins?: boolean; skipCredentials?: boolean; env?: NodeJS.ProcessEnv; allowGatewaySubagentBinding?: boolean; diff --git a/src/commands/models/list.probe.ollama.test.ts b/src/commands/models/list.probe.ollama.test.ts new file mode 100644 index 000000000000..cc24c309feb6 --- /dev/null +++ b/src/commands/models/list.probe.ollama.test.ts @@ -0,0 +1,145 @@ +// Ollama probe planning tests cover keyless runtime auth and provider-scoped catalog reads. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; + +const loadPreparedModelCatalog = vi.fn(async () => [ + { provider: "ollama", id: "llama3.2:latest" }, + { provider: "ollama", id: "gemma4:latest" }, +]); + +vi.mock("../../agents/prepared-model-catalog.js", () => ({ loadPreparedModelCatalog })); +vi.mock("../../agents/auth-profiles.js", () => ({ + externalCliDiscoveryScoped: () => undefined, + ensureAuthProfileStore: () => ({ version: 1, profiles: {}, order: {} }), + listProfilesForProvider: () => [], + resolveAuthProfileDisplayLabel: ({ profileId }: { profileId: string }) => profileId, +})); +vi.mock("../../agents/model-auth.js", () => ({ + hasSyntheticLocalProviderAuthConfig: ({ + cfg, + provider, + }: { + cfg: OpenClawConfig; + provider: string; + }) => { + const configured = cfg.models?.providers?.[provider]; + return ( + provider === "ollama" && + configured?.api === "ollama" && + configured.apiKey === undefined && + configured.baseUrl === "http://127.0.0.1:11434" + ); + }, + hasUsableCustomProviderApiKey: (cfg: OpenClawConfig, provider: string) => + cfg.models?.providers?.[provider]?.apiKey === "ollama-local", + resolveEnvApiKey: () => null, + resolveProviderEntryApiKeyBinding: vi.fn(), + resolveProviderEntryApiKeyProfileReference: ({ + cfg, + provider, + }: { + cfg: OpenClawConfig; + provider: string; + }) => + cfg.models?.providers?.[provider]?.apiKey === "ollama-local" + ? { kind: "marker" } + : { kind: "none" }, + resolveUsableCustomProviderApiKey: ({ + cfg, + provider, + }: { + cfg: OpenClawConfig; + provider: string; + }) => + cfg.models?.providers?.[provider]?.apiKey === "ollama-local" + ? { apiKey: "ollama-local", source: "models.json (local marker)" } + : null, +})); +vi.mock("../../agents/provider-auth-aliases.js", () => ({ + resolveProviderIdForAuth: (provider: string) => provider, +})); + +const { buildProbeTargets } = await import("./list.probe.js"); + +const options = { + includeDirectKeys: true, + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 8, +}; + +describe("Ollama probe targets", () => { + beforeEach(() => loadPreparedModelCatalog.mockClear()); + + it("builds a runtime-auth target for a configured keyless local provider", async () => { + const cfg = { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [], + }, + }, + }, + } satisfies OpenClawConfig; + + const plan = await buildProbeTargets({ + cfg, + providers: ["ollama"], + modelCandidates: ["ollama/gemma4:latest"], + options, + }); + + expect(plan.results).toEqual([]); + expect(loadPreparedModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + readOnly: true, + providerDiscoveryProviderIds: ["ollama"], + }), + ); + expect(plan.targets).toEqual([ + { + provider: "ollama", + model: { provider: "ollama", model: "gemma4:latest" }, + label: "models.json", + source: "models.json", + mode: "api_key", + useRuntimeAuth: true, + }, + ]); + }); + + it("presents a local no-auth marker as provider configuration", async () => { + const cfg = { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + apiKey: "ollama-local", + models: [], + }, + }, + }, + } satisfies OpenClawConfig; + + const plan = await buildProbeTargets({ + cfg, + providers: ["ollama"], + modelCandidates: ["ollama/llama3.2:latest"], + options, + }); + + expect(plan.results).toEqual([]); + expect(plan.targets).toEqual([ + expect.objectContaining({ + provider: "ollama", + label: "provider", + source: "models.json", + boundValue: "ollama-local", + useRuntimeAuth: true, + }), + ]); + }); +}); diff --git a/src/commands/models/list.probe.targets.test.ts b/src/commands/models/list.probe.targets.test.ts index a5fba3606c05..cc46010518b7 100644 --- a/src/commands/models/list.probe.targets.test.ts +++ b/src/commands/models/list.probe.targets.test.ts @@ -24,6 +24,7 @@ vi.mock("../../agents/prepared-model-catalog.js", () => ({ loadPreparedModelCatalog: loadModelCatalogMock, })); vi.mock("../../agents/model-auth.js", () => ({ + hasSyntheticLocalProviderAuthConfig: () => false, hasUsableCustomProviderApiKey: (cfg: OpenClawConfig, provider: string) => { const raw = cfg.models?.providers?.[provider]?.apiKey; return typeof raw === "string" && raw.trim().length > 0 && raw !== "ollama-local"; diff --git a/src/commands/models/list.probe.test.ts b/src/commands/models/list.probe.test.ts index f741ecbb42a9..29445d049288 100644 --- a/src/commands/models/list.probe.test.ts +++ b/src/commands/models/list.probe.test.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { AgentRunResultView } from "../../agents/agent-run-result.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { acquireGatewayLock, type GatewayLockOptions } from "../../infra/gateway-lock.js"; @@ -178,13 +179,13 @@ describe("runAuthProbes", () => { authProfileId?: string; authProfileIdSource?: string; config?: OpenClawConfig; - }) => { + }): Promise => { if (params.agentHarnessRuntimeOverride !== "openclaw") { throw new Error( 'Requested agent harness "codex" does not support openai/gpt-5.5 (Codex cannot reproduce authored request transport overrides).', ); } - return { text: "OK" }; + return { payloads: [{ text: "OK" }] }; }, ); vi.doMock("../../agents/embedded-agent.js", () => ({ runEmbeddedAgent })); @@ -256,10 +257,42 @@ describe("runAuthProbes", () => { agentHarnessRuntimeOverride: "openclaw", modelRun: true, disableTools: true, + modelFallbacksOverride: [], authProfileId: "openai:profile", authProfileIdSource: "user", }), ); + + runEmbeddedAgent.mockResolvedValueOnce({ + payloads: [{ text: "LLM request timed out.", isError: true }], + meta: { livenessState: "abandoned" }, + }); + const failed = await module.runAuthProbes({ + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + agentRuntime: { id: "codex" }, + models: [], + }, + }, + }, + } satisfies OpenClawConfig, + agentId: "probe-agent", + agentDir: "/tmp/openclaw-probe-agent", + workspaceDir: "/tmp/openclaw-probe-workspace", + providers: ["openai"], + modelCandidates: ["openai/gpt-5.5"], + options: { + provider: "openai", + profileIds: ["openai:profile"], + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 8, + }, + }); + expect(failed.results[0]).toMatchObject({ status: "timeout" }); } finally { vi.doUnmock("../../agents/embedded-agent.js"); vi.doUnmock("../../agents/auth-profiles.js"); @@ -275,7 +308,7 @@ describe("runAuthProbes", () => { authProfileId?: string; authProfileIdSource?: string; config?: OpenClawConfig; - }) => ({ text: "OK" }), + }) => ({ payloads: [{ text: "OK" }] }), ); vi.doMock("../../agents/embedded-agent.js", () => ({ runEmbeddedAgent })); const upsertAuthProfileWithLock = vi.fn( @@ -389,7 +422,7 @@ describe("runAuthProbes", () => { it("isolates marker credentials from stored profiles without pinning a synthetic one", async () => { const runEmbeddedAgent = vi.fn( async (_params: { agentDir?: string; authProfileId?: string; config?: OpenClawConfig }) => ({ - text: "OK", + payloads: [{ text: "OK" }], }), ); const upsertAuthProfileWithLock = vi.fn(); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 52f0941ab2c6..53b806c511bd 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -6,6 +6,11 @@ import path from "node:path"; import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; import pMap from "p-map"; import { prepareSystemAgentRunAdmission } from "../../agents/admitted-run-context.js"; +import { + type AgentRunResultView, + agentRunHasVisibleReply, + extractAgentRunTerminalError, +} from "../../agents/agent-run-result.js"; import { resolveAgentDir, resolveAgentWorkspaceDir, @@ -30,7 +35,9 @@ import { prepareInternalSessionEffectsSession, removeInternalSessionEffectsSession, } from "../../agents/internal-session-effects.js"; +import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js"; import { + hasSyntheticLocalProviderAuthConfig, hasUsableCustomProviderApiKey, resolveEnvApiKey, resolveProviderEntryApiKeyBinding, @@ -363,6 +370,10 @@ export async function buildProbeTargets(params: { ...(params.agentId ? { agentId: params.agentId } : {}), ...(agentDir ? { agentDir } : {}), ...(workspaceDir ? { workspaceDir } : {}), + // A provider probe only needs candidate selection. Keep it request-scoped so it cannot + // supersede or be superseded by the Gateway's concurrent full-catalog materialization. + readOnly: true, + providerDiscoveryProviderIds: providers, }); const candidates = buildProbeCandidateMap(modelCandidates); const targets: AuthProbeTarget[] = []; @@ -438,6 +449,12 @@ export async function buildProbeTargets(params: { : null; const environmentValue = resolvedEnvironmentValue?.apiKey === configuredValue ? null : resolvedEnvironmentValue; + const configuredTargetLabel = + configuredReference.kind === "marker" && + configuredValue && + isNonSecretApiKeyMarker(configuredValue, { includeEnvVarName: false }) + ? "provider" + : "config"; const appendDirectTargets = () => { if (includeConfigKey) { @@ -498,7 +515,7 @@ export async function buildProbeTargets(params: { targets.push({ provider: providerKey, model, - label: "config", + label: configuredTargetLabel, source: "models.json", mode: configuredMode, boundValue: configuredValue, @@ -511,7 +528,7 @@ export async function buildProbeTargets(params: { results.push({ provider: providerKey, model: undefined, - label: "config", + label: configuredTargetLabel, source: "models.json", mode: configuredMode, status: "no_model", @@ -676,7 +693,11 @@ export async function buildProbeTargets(params: { continue; } const hasUsableModelsJsonKey = hasUsableCustomProviderApiKey(cfg, providerKey); - if (orderResolution.hasExplicitOrder && !hasUsableModelsJsonKey) { + const hasSyntheticLocalAuth = hasSyntheticLocalProviderAuthConfig({ + cfg, + provider: providerKey, + }); + if (orderResolution.hasExplicitOrder && !hasUsableModelsJsonKey && !hasSyntheticLocalAuth) { continue; } @@ -686,7 +707,7 @@ export async function buildProbeTargets(params: { config: cfg, workspaceDir, }); - if (!envKey && !hasUsableModelsJsonKey) { + if (!envKey && !hasUsableModelsJsonKey && !hasSyntheticLocalAuth) { continue; } @@ -714,6 +735,9 @@ export async function buildProbeTargets(params: { label, source, mode, + ...(hasSyntheticLocalAuth && !envKey && !hasUsableModelsJsonKey + ? { useRuntimeAuth: true } + : {}), }); } @@ -736,10 +760,10 @@ async function probeTarget(params: { // "config" probe must reflect only that credential — empty the provider auth // order and isolate the agent dir so stored profiles cannot satisfy it via // failover. Direct bound values instead pin an isolated synthetic profile. - const probeConfig = !target.boundValue - ? cfg - : target.useRuntimeAuth - ? withoutProfileFallback(cfg, target.provider) + const probeConfig = target.useRuntimeAuth + ? withoutProfileFallback(cfg, target.provider) + : !target.boundValue + ? cfg : withDirectCredential(cfg, target.provider, target.boundValue, target.mode); if (!target.model) { return { @@ -785,7 +809,7 @@ async function probeTarget(params: { // absent and cannot satisfy the probe via failover. Direct values pin a // synthetic profile; marker values are resolved by the runtime from the // profile-order-cleared config. - if (target.boundValue) { + if (target.boundValue || target.useRuntimeAuth) { // Canonicalize so the isolated agent DB registers and unregisters under // one path. os.tmpdir() is a symlink on macOS (/var -> /private/var), and // disposeOpenClawAgentDatabaseByPath's exact-path guard would otherwise @@ -825,7 +849,7 @@ async function probeTarget(params: { agentId, "models.auth-probe", ); - await runEmbeddedAgent({ + const runResult = (await runEmbeddedAgent({ preparedRunAdmission, sessionId: sessionTarget.sessionId, sessionKey: sessionTarget.sessionKey, @@ -837,6 +861,7 @@ async function probeTarget(params: { prompt: PROBE_PROMPT, provider: target.model.provider, model: target.model.model, + modelFallbacksOverride: [], authProfileId: isolatedProfileId ?? target.profileId, authProfileIdSource: isolatedProfileId || target.profileId ? "user" : undefined, timeoutMs, @@ -851,7 +876,18 @@ async function probeTarget(params: { modelRun: true, cleanupBundleMcpOnRunEnd: true, abortSignal: params.abortSignal, - }); + })) as AgentRunResultView; + const terminalError = extractAgentRunTerminalError(runResult); + if (terminalError) { + const described = describeFailoverError(new Error(terminalError)); + return buildResult( + mapFailoverReasonToProbeStatus(described.reason), + redactAuthProbeError(described.message), + ); + } + if (!agentRunHasVisibleReply(runResult)) { + return buildResult("format", "The model did not return a visible probe response."); + } return buildResult("ok"); } catch (err) { const described = describeFailoverError(err); diff --git a/src/gateway/server-methods/models-probe.test.ts b/src/gateway/server-methods/models-probe.test.ts index 0bc86555a942..9c255bd76190 100644 --- a/src/gateway/server-methods/models-probe.test.ts +++ b/src/gateway/server-methods/models-probe.test.ts @@ -48,6 +48,7 @@ function summary(results: AuthProbeSummary["results"]): AuthProbeSummary { function createOptions(params: Record, cfg: OpenClawConfig = {}) { const respond = vi.fn(); + const warn = vi.fn(); return { options: { req: { type: "req", id: "probe-1", method: "models.probe", params }, @@ -55,9 +56,10 @@ function createOptions(params: Record, cfg: OpenClawConfig = {} client: null, isWebchatConnect: () => false, respond, - context: { getRuntimeConfig: () => cfg } as never, + context: { getRuntimeConfig: () => cfg, logGateway: { warn } } as never, } as GatewayRequestHandlerOptions, respond, + warn, }; } @@ -283,12 +285,69 @@ describe("models.probe", () => { results: [ { profileId: "old", - label: "Old", + label: "Profile Old", status: "auth", latencyMs: 20, error: "Authentication failed.", }, - { profileId: "work", label: "Work", status: "ok", latencyMs: 125 }, + { profileId: "work", label: "Profile Work", status: "ok", latencyMs: 125 }, + ], + }, + undefined, + ); + }); + + it("names mixed probe routes and keeps preflight failures actionable", async () => { + mocks.runAuthProbes.mockResolvedValue( + summary([ + { + provider: "ollama", + model: "ollama/gemma4:latest", + label: "config", + source: "models.json", + mode: "api_key", + status: "unknown", + reasonCode: "unresolved_ref", + error: "Configured API key could not be resolved.", + }, + { + provider: "ollama", + model: "ollama/gemma4:latest", + profileId: "ollama:default", + label: "ollama:default", + source: "profile", + mode: "api_key", + status: "ok", + latencyMs: 16121, + }, + ]), + ); + const cfg = { + agents: { defaults: { model: { primary: "ollama/gemma4:latest" } } }, + } satisfies OpenClawConfig; + const { options, respond } = createOptions({ provider: "ollama" }, cfg); + + await handler(options); + + expect(respond).toHaveBeenCalledWith( + true, + { + provider: "ollama", + status: "ok", + latencyMs: 16121, + results: [ + { + label: "Configured credential · ollama/gemma4:latest", + status: "unknown", + error: + "The configured credential could not be resolved. Update or remove it, then retry.", + }, + { + profileId: "ollama:default", + label: "Profile ollama:default · ollama/gemma4:latest", + status: "ok", + latencyMs: 16121, + }, ], }, undefined, @@ -315,4 +374,25 @@ describe("models.probe", () => { expect(JSON.stringify(payload)).not.toContain(secret); expect(JSON.stringify(payload)).toContain("Authentication failed."); }); + + it("records a redacted typed diagnostic when probe execution fails", async () => { + const secret = ["AI", "za", "SyOpaqueProviderCredential"].join(""); + mocks.runAuthProbes.mockRejectedValue(new Error(`runtime failed for ${secret}`)); + const { options, respond, warn } = createOptions({ provider: "ollama", timeoutMs: 9_000 }); + + await handler(options); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "UNAVAILABLE", message: "Connection probe failed." }), + ); + expect(warn).toHaveBeenCalledWith("Model connection probe failed.", { + event: "models_probe_failed", + provider: "ollama", + timeoutMs: 9_000, + error: expect.stringContaining("runtime failed for"), + }); + expect(JSON.stringify(warn.mock.calls)).not.toContain(secret); + }); }); diff --git a/src/gateway/server-methods/models-probe.ts b/src/gateway/server-methods/models-probe.ts index 782ada103f00..cf4092b99fe0 100644 --- a/src/gateway/server-methods/models-probe.ts +++ b/src/gateway/server-methods/models-probe.ts @@ -10,10 +10,13 @@ import { import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { type AuthProbeResult, + type AuthProbeReasonCode, type AuthProbeStatus, + redactAuthProbeError, runAuthProbes, } from "../../commands/models/list.probe.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { formatForLog } from "../ws-log.js"; import { modelAuthAgentScopeError, resolveModelAuthAgentScope } from "./model-auth-agent-scope.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -48,6 +51,42 @@ function safeProbeError(status: AuthProbeStatus): string | undefined { return status === "ok" ? undefined : PROBE_ERROR_MESSAGES[status]; } +const PROBE_REASON_MESSAGES: Partial> = { + excluded_by_auth_order: + "This profile is excluded by the provider auth order. Update the order or choose another profile, then retry.", + missing_credential: + "This credential is missing. Add it or remove the stale configuration, then retry.", + expired: "This credential has expired. Sign in again or replace it, then retry.", + invalid_expires: + "This credential has invalid expiry metadata. Sign in again or replace it, then retry.", + unresolved_ref: + "The configured credential could not be resolved. Update or remove it, then retry.", + ineligible_profile: + "This profile is not compatible with the provider configuration. Choose another profile, then retry.", + no_model: "No model is available for this provider. Configure a model, then retry.", +}; + +function safeProbeTargetLabel(result: AuthProbeResult): string { + const owner = + result.source === "profile" + ? `Profile ${result.label}` + : result.source === "models.json" + ? result.label === "config" + ? "Configured credential" + : "Provider configuration" + : `Environment credential (${result.label})`; + return result.model ? `${owner} · ${result.model}` : owner; +} + +function safeProbeTargetError(result: AuthProbeResult): string | undefined { + if (result.status === "ok") { + return undefined; + } + return ( + (result.reasonCode && PROBE_REASON_MESSAGES[result.reasonCode]) ?? safeProbeError(result.status) + ); +} + function modelCandidatesFromConfig(cfg: OpenClawConfig): string[] { const configured = cfg.agents?.defaults?.model; const primary = typeof configured === "string" ? configured : configured?.primary; @@ -81,13 +120,16 @@ function mapProbeResult(provider: string, results: AuthProbeResult[]): ModelsPro status, ...(latencyMs !== undefined ? { latencyMs } : {}), ...(error ? { error } : {}), - results: results.map((result) => ({ - ...(result.profileId ? { profileId: result.profileId } : {}), - label: result.label, - status: result.status, - ...(result.latencyMs !== undefined ? { latencyMs: result.latencyMs } : {}), - ...(result.error ? { error: safeProbeError(result.status) } : {}), - })), + results: results.map((result) => { + const targetError = safeProbeTargetError(result); + return { + ...(result.profileId ? { profileId: result.profileId } : {}), + label: safeProbeTargetLabel(result), + status: result.status, + ...(result.latencyMs !== undefined ? { latencyMs: result.latencyMs } : {}), + ...(targetError ? { error: targetError } : {}), + }; + }), }; } @@ -144,7 +186,13 @@ export const modelsProbeHandlers: GatewayRequestHandlers = { result.error = "No probe targets are available for this provider."; } respond(true, result, undefined); - } catch { + } catch (error) { + context.logGateway.warn("Model connection probe failed.", { + event: "models_probe_failed", + provider, + timeoutMs, + error: redactAuthProbeError(formatForLog(error)), + }); respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "Connection probe failed.")); } }, diff --git a/src/system-agent/setup-inference-persist.ts b/src/system-agent/setup-inference-persist.ts index 3340e3f6716e..67daf3f63236 100644 --- a/src/system-agent/setup-inference-persist.ts +++ b/src/system-agent/setup-inference-persist.ts @@ -4,6 +4,11 @@ import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { prepareSystemAgentRunAdmission } from "../agents/admitted-run-context.js"; +import { + type AgentRunResultView, + extractAgentRunTerminalError, + extractAgentRunText, +} from "../agents/agent-run-result.js"; import { listAgentEntries } from "../agents/agent-scope.js"; import { normalizeAuthProfileCredential } from "../agents/auth-profiles/credential-normalize.js"; import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js"; @@ -28,10 +33,7 @@ import { setupInferenceLog, } from "./setup-inference-core.js"; import { - type RunResult, type SetupInferenceTestPlan, - extractRunTerminalError, - extractRunText, extractRunWinnerError, mapFailoverReasonToSetupStatus, resolveStrictSetupAuthProfileError, @@ -488,6 +490,18 @@ export async function runSetupInferenceTest(params: { const sessionKey = `agent:${effectiveAgentId}:setup-inference:incognito-${runId}`; const timeoutMs = deps.timeoutMs ?? SETUP_INFERENCE_TEST_TIMEOUT_MS; const started = Date.now(); + const failed = (status: SetupInferenceFailureStatus, error: string) => { + setupInferenceLog.warn("Inference setup probe failed.", { + event: "setup_inference_probe_failed", + provider: plan.provider, + model: plan.model, + runner: plan.runner, + status, + timeoutMs, + durationMs: Date.now() - started, + }); + return { ok: false as const, status, error }; + }; const preparedRunAdmission = prepareSystemAgentRunAdmission( plan.config, runId, @@ -499,7 +513,7 @@ export async function runSetupInferenceTest(params: { if (plan.runner === "cli") { const unsupportedError = resolveToolFreeCliSetupError(plan); if (unsupportedError) { - return { ok: false, status: "unavailable", error: unsupportedError }; + return failed("unavailable", unsupportedError); } } const strictProfileError = resolveStrictSetupAuthProfileError({ @@ -508,10 +522,10 @@ export async function runSetupInferenceTest(params: { deps, }); if (strictProfileError) { - return { ok: false, status: "auth", error: strictProfileError }; + return failed("auth", strictProfileError); } - let result: RunResult; + let result: AgentRunResultView; if (plan.runner === "cli") { const runCli = deps.runCliAgent ?? (await import("../agents/cli-runner.js")).runCliAgent; result = (await runCli({ @@ -540,7 +554,7 @@ export async function runSetupInferenceTest(params: { successfulAuth = binding; }, ...(params.signal ? { abortSignal: params.signal } : {}), - })) as RunResult; + })) as AgentRunResultView; } else { const runEmbedded = deps.runEmbeddedAgent ?? (await import("../agents/embedded-agent.js")).runEmbeddedAgent; @@ -589,39 +603,32 @@ export async function runSetupInferenceTest(params: { successfulAuth = binding; }, ...(params.signal ? { abortSignal: params.signal } : {}), - })) as RunResult; + })) as AgentRunResultView; } if (params.signal?.aborted) { throw new SetupInferenceCancelledError(); } - const terminalError = extractRunTerminalError(result); + const terminalError = extractAgentRunTerminalError(result); if (terminalError) { const described = describeFailoverError(new Error(terminalError)); - return { - ok: false, - status: mapFailoverReasonToSetupStatus(described.reason), - error: described.message, - }; + return failed(mapFailoverReasonToSetupStatus(described.reason), described.message); } - const text = extractRunText(result)?.trim(); + const text = extractAgentRunText(result)?.trim(); if (!text) { - return { - ok: false, - status: "format", - error: "The model started but did not send a reply. Try again or pick another option.", - }; + return failed( + "format", + "The model started but did not send a reply. Try again or pick another option.", + ); } const winnerError = await extractRunWinnerError(plan, result); if (winnerError) { - return { ok: false, status: "unknown", error: winnerError }; + return failed("unknown", winnerError); } if (requireExecutionOwner && !successfulAuth) { - return { - ok: false, - status: "unknown", - error: - "Inference succeeded, but its runtime did not report an owner that OpenClaw can safely reuse.", - }; + return failed( + "unknown", + "Inference succeeded, but its runtime did not report an owner that OpenClaw can safely reuse.", + ); } return { ok: true, @@ -633,11 +640,7 @@ export async function runSetupInferenceTest(params: { }; } catch (error) { const described = describeFailoverError(error); - return { - ok: false, - status: mapFailoverReasonToSetupStatus(described.reason), - error: described.message, - }; + return failed(mapFailoverReasonToSetupStatus(described.reason), described.message); } finally { preparedRunAdmission.close(); } diff --git a/src/system-agent/setup-inference-plan-helpers.ts b/src/system-agent/setup-inference-plan-helpers.ts index 6584cf20b63f..7f03d5844729 100644 --- a/src/system-agent/setup-inference-plan-helpers.ts +++ b/src/system-agent/setup-inference-plan-helpers.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { expectDefined } from "@openclaw/normalization-core"; +import type { AgentRunResultView } from "../agents/agent-run-result.js"; import { listAgentEntries, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { loadAuthProfileStoreForRuntime } from "../agents/auth-profiles/store.js"; import { resolveCliBackendConfig } from "../agents/cli-backends.js"; @@ -77,51 +78,9 @@ export function configureCodexCliPreparedAuth(cfg: OpenClawConfig): OpenClawConf }; } -export type RunResult = { - payloads?: Array<{ text?: string; isError?: boolean }>; - meta?: { - executionTrace?: { winnerProvider?: string; winnerModel?: string }; - finalAssistantVisibleText?: string; - finalAssistantRawText?: string; - livenessState?: string; - error?: { kind?: string; message?: string }; - }; -}; - -export function extractRunText(result: RunResult): string | undefined { - return ( - result.meta?.finalAssistantVisibleText ?? - result.meta?.finalAssistantRawText ?? - result.payloads - ?.map((payload) => payload.text?.trim()) - .filter(Boolean) - .join("\n") - ); -} - -export function extractRunTerminalError(result: RunResult): string | undefined { - const errorPayload = result.payloads?.find((payload) => payload.isError === true)?.text?.trim(); - const hasMetaError = result.meta?.error !== undefined; - const metaError = result.meta?.error?.message?.trim(); - const livenessState = result.meta?.livenessState?.trim().toLowerCase(); - if ( - !errorPayload && - !hasMetaError && - livenessState !== "blocked" && - livenessState !== "abandoned" - ) { - return undefined; - } - return ( - metaError || - errorPayload || - (livenessState ? `Inference ended in the ${livenessState} state.` : "Inference failed.") - ); -} - export async function extractRunWinnerError( plan: SetupInferenceTestPlan, - result: RunResult, + result: AgentRunResultView, ): Promise { const winnerProvider = result.meta?.executionTrace?.winnerProvider?.trim(); const winnerModel = result.meta?.executionTrace?.winnerModel?.trim(); diff --git a/src/system-agent/setup-inference-plan.ts b/src/system-agent/setup-inference-plan.ts index 9c5c38877222..6c199808e391 100644 --- a/src/system-agent/setup-inference-plan.ts +++ b/src/system-agent/setup-inference-plan.ts @@ -475,12 +475,17 @@ export async function buildTestPlan(params: { if (!guidedSetup) { return { error: "That provider setup is not available on this Gateway." }; } - const candidate = await guidedSetup.detect({ - config: preparedConfig, - env: process.env, - workspaceDir: params.pluginWorkspaceDir, - ...(params.signal ? { signal: params.signal } : {}), - }); + const selectedModelRef = result.defaultModel + ? normalizeAgentModelRefForConfig(result.defaultModel) + : ""; + const candidate = selectedModelRef + ? { modelRef: selectedModelRef } + : await guidedSetup.detect({ + config: preparedConfig, + env: process.env, + workspaceDir: params.pluginWorkspaceDir, + ...(params.signal ? { signal: params.signal } : {}), + }); if (!candidate) { return { error: `${resolved.provider.label} setup completed, but no compatible model was found. Add a compatible model and try again.`, @@ -554,7 +559,7 @@ export async function buildTestPlan(params: { const modelRef = result.defaultModel ? normalizeAgentModelRefForConfig(result.defaultModel) : ""; - if (!modelRef || result.profiles.length === 0) { + if (!modelRef) { return { error: `${resolved.provider.label} does not expose a starter model for app-guided setup.`, }; @@ -569,20 +574,32 @@ export async function buildTestPlan(params: { (profile) => normalizeProviderId(profile.credential.provider) === normalizeProviderId(ref.provider), ); - if (!matchingProfile) { + if (result.profiles.length > 0 && !matchingProfile) { return { error: `${resolved.provider.label} did not return credentials for its starter model.`, }; } - const preparedAuth = prepareManualAuthForActivation({ - baseConfig: enableResult.config, - preparedConfig, - profiles: result.profiles, - selectedProfileId: matchingProfile.profileId, - modelRef, - providerId: ref.provider, - ...(resolved.provider.pluginId ? { pluginId: resolved.provider.pluginId } : {}), - }); + const preparedAuth = matchingProfile + ? prepareManualAuthForActivation({ + baseConfig: enableResult.config, + preparedConfig, + profiles: result.profiles, + selectedProfileId: matchingProfile.profileId, + modelRef, + providerId: ref.provider, + ...(resolved.provider.pluginId ? { pluginId: resolved.provider.pluginId } : {}), + }) + : { + config: projectManualInferenceConfig({ + baseConfig: enableResult.config, + preparedConfig, + modelRef, + providerId: ref.provider, + ...(resolved.provider.pluginId ? { pluginId: resolved.provider.pluginId } : {}), + }), + profiles: [] as ProviderAuthResult["profiles"], + selectedProfileId: undefined, + }; return { runner: "embedded", ...ref, @@ -591,7 +608,9 @@ export async function buildTestPlan(params: { config: preparedAuth.config, agentId: "openclaw", routeAgentId: resolveDefaultAgentId(preparedAuth.config), - authProfileId: preparedAuth.selectedProfileId, + ...(preparedAuth.selectedProfileId + ? { authProfileId: preparedAuth.selectedProfileId } + : {}), persistModelRef: modelRef, manualAuth: { profiles: preparedAuth.profiles, diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index 6b621e79adf2..752dbcc728bf 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -45,6 +45,7 @@ import { cleanupSystemAgentSession, createSystemAgentSession } from "./agent-tur import { runSystemAgentTurnWithDeps } from "./agent-turn.test-support.js"; import { resolveSystemAgentConfiguredRouteFromConfig } from "./inference-route.js"; import { applySystemAgentModelSelection } from "./setup-apply.js"; +import { setupInferenceLog } from "./setup-inference-core.js"; import { runSetupInferenceTest } from "./setup-inference-persist.js"; import { resolveSetupInferenceProbeStreamParams } from "./setup-inference-probe.js"; import { @@ -2642,23 +2643,14 @@ describe("activateSetupInference", () => { it("runs provider-owned local setup from an app-guided discovery choice", async () => { const { stateDir, initialConfig } = await createMainAgentFixture(); const runAuth = vi.fn(async () => ({ - profiles: [ - { - profileId: "local-test:default", - credential: { - type: "api_key" as const, - provider: "local-test", - key: "local-test-key", - }, - }, - ], + profiles: [], + defaultModel: "local-test/gemma4", configPatch: { models: { providers: { "local-test": { baseUrl: "http://127.0.0.1:12345", api: "ollama" as const, - apiKey: "local-test-key", models: [], }, }, @@ -2666,19 +2658,18 @@ describe("activateSetupInference", () => { }, })); const detect = vi.fn(async () => ({ - modelRef: "local-test/qwen-test", - detail: "qwen-test at http://127.0.0.1:12345", + modelRef: "local-test/deepseek-r1", + detail: "deepseek-r1 at http://127.0.0.1:12345", })); - const prepare = vi.fn(async () => ({ + const prepare = vi.fn(async (params: { modelRef: string }) => ({ profiles: [], - defaultModel: "local-test/qwen-test", + defaultModel: params.modelRef, configPatch: { models: { providers: { "local-test": { baseUrl: "http://127.0.0.1:12345", api: "ollama" as const, - apiKey: "local-test-key", models: [], }, }, @@ -2701,7 +2692,7 @@ describe("activateSetupInference", () => { }; const runEmbeddedAgent = vi.fn( async (params: SuccessfulRunParams & { authProfileId?: string }) => - successfulRun("local-test", "qwen-test", params), + successfulRun("local-test", "gemma4", params), ); const configHarness = createConfigTransformHarness(initialConfig); @@ -2729,25 +2720,14 @@ describe("activateSetupInference", () => { }, }); - expect(result).toMatchObject({ ok: true, modelRef: "local-test/qwen-test" }); + expect(result).toMatchObject({ ok: true, modelRef: "local-test/gemma4" }); expect(runAuth).toHaveBeenCalledOnce(); - expect(detect).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - models: { - providers: { - "local-test": expect.objectContaining({ - baseUrl: "http://127.0.0.1:12345", - apiKey: "local-test-key", - }), - }, - }, - }), - }), - ); + expect(detect).not.toHaveBeenCalled(); expect(prepare).toHaveBeenCalledWith( - expect.objectContaining({ modelRef: "local-test/qwen-test" }), + expect.objectContaining({ modelRef: "local-test/gemma4" }), ); + expect(runEmbeddedAgent).toHaveBeenCalledWith(expect.objectContaining({ model: "gemma4" })); + expect(runEmbeddedAgent.mock.calls[0]?.[0].authProfileId).toBeUndefined(); } finally { await removeOAuthTestTempRoot(stateDir); } @@ -5855,6 +5835,7 @@ describe("verifySetupInference", () => { }); it("returns a refreshed staged profile when the live inference test fails", async () => { + const warn = vi.spyOn(setupInferenceLog, "warn").mockImplementation(() => {}); const profileId = "openai:default"; const runEmbeddedAgent = vi.fn(async (params: { agentDir?: string }) => { expect(params.agentDir).toBeDefined(); @@ -5903,6 +5884,17 @@ describe("verifySetupInference", () => { }, ], }); + expect(warn).toHaveBeenCalledWith("Inference setup probe failed.", { + event: "setup_inference_probe_failed", + provider: "openai", + model: "gpt-5.5", + runner: "embedded", + status: "timeout", + timeoutMs: 90_000, + durationMs: expect.any(Number), + }); + expect(JSON.stringify(warn.mock.calls)).not.toContain("request timed out"); + warn.mockRestore(); }); it("rejects a staged credential that differs from the configured profile pin", async () => { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index c6b7ebfb1ca8..31f9abdcf6c6 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2271,6 +2271,8 @@ export const en: TranslationMap = { auth: "Review the provider credential or sign-in, then retry.", rateLimit: "Wait for the provider limit to reset, then retry.", billing: "Restore provider billing or quota, then retry.", + timeout: + "The model did not finish the setup test in time. Warm it or choose a faster model, then retry.", unavailable: "Make sure the provider service is running and reachable, then retry.", format: "Check that the endpoint exposes a compatible chat model, then retry.", unknown: "Review the connection details, then retry.", @@ -4086,6 +4088,7 @@ export const en: TranslationMap = { format: "Invalid response", unknown: "Connection failed", no_model: "No models available", + partial: "Connected with warnings", }, }, readiness: { diff --git a/ui/src/pages/model-providers/view.test.ts b/ui/src/pages/model-providers/view.test.ts index 5118359f52a4..85da5344a2e7 100644 --- a/ui/src/pages/model-providers/view.test.ts +++ b/ui/src/pages/model-providers/view.test.ts @@ -662,7 +662,7 @@ describe("renderModelProviders", () => { } }); - it("shows config key provenance when auth status is unavailable", () => { + it("does not invent config key provenance when auth status is unavailable", () => { const container = mount( props({ cards: [card({ apiKey: undefined, hasConfigApiKey: true })], @@ -670,8 +670,42 @@ describe("renderModelProviders", () => { ); const provider = container.querySelector('[data-provider-id="openai"]'); - expect(text(provider)).toContain("API key set in config"); - expect(text(provider)).not.toContain("Not configured"); + expect(text(provider)).not.toContain("API key set in config"); + expect(text(provider)).toContain("Not configured"); + }); + + it("renders mixed credential probes as connected with warnings", () => { + const container = mount( + props({ + probeResults: { + openai: { + provider: "openai", + status: "ok", + latencyMs: 145, + results: [ + { + label: "Configured credential · openai/gpt-5.6-sol", + status: "unknown", + error: + "The configured credential could not be resolved. Update or remove it, then retry.", + }, + { + profileId: "openai:default", + label: "Profile Default · openai/gpt-5.6-sol", + status: "ok", + latencyMs: 145, + }, + ], + }, + }, + }), + ); + + const probe = container.querySelector(".model-providers__probe--warning"); + expect(text(probe)).toContain("Connected with warnings"); + expect(text(probe)).toContain("Configured credential · openai/gpt-5.6-sol"); + expect(text(probe)).toContain("Profile Default · openai/gpt-5.6-sol"); + expect(text(probe)).toContain("Update or remove it, then retry"); }); it("renders categorized probe errors", () => { diff --git a/ui/src/pages/model-providers/view.ts b/ui/src/pages/model-providers/view.ts index cdc9eab45f8b..5e43239f2b5d 100644 --- a/ui/src/pages/model-providers/view.ts +++ b/ui/src/pages/model-providers/view.ts @@ -249,7 +249,7 @@ function renderCredentialSummary(card: ModelProviderCard, agentLabel: string) { if (tokenCount > 0) { parts.push(t("modelProviders.credentials.tokenProfiles", { count: String(tokenCount) })); } - if (card.apiKey?.source === "config" || (!card.apiKey && card.hasConfigApiKey)) { + if (card.apiKey?.source === "config") { parts.push(t("modelProviders.credentials.configKey")); } else if (card.apiKey?.source === "env") { parts.push( @@ -274,15 +274,17 @@ function renderProbeResult(result: ModelsProbeResult | undefined) { if (!result) { return nothing; } + const hasWarnings = + result.status === "ok" && result.results.some((target) => target.status !== "ok"); + const presentation = hasWarnings ? "warning" : result.status === "ok" ? "success" : "error"; return html` -
+
- ${t(`modelProviders.probe.status.${result.status}`)} + ${hasWarnings + ? t("modelProviders.probe.status.partial") + : t(`modelProviders.probe.status.${result.status}`)} ${result.latencyMs !== undefined ? html`${t("modelProviders.probe.latency", { ms: String(result.latencyMs) })} void) { +function mount( + result: SystemAgentSetupDetectResult, + onContinue?: () => void, + verify: ModelSetupVerifyState = { + phase: "failed", + status: "unavailable", + error: "connect ECONNREFUSED", + }, +) { const container = document.createElement("div"); document.body.append(container); const onVerify = vi.fn(); render( renderConfiguredModel({ result, - verify: { - phase: "failed", - status: "unavailable", - error: "connect ECONNREFUSED", - }, + verify, canVerify: true, actionsDisabled: false, onVerify, @@ -117,4 +122,27 @@ describe("renderConfiguredModel", () => { button?.click(); expect(onContinue).toHaveBeenCalledOnce(); }); + + it("explains a setup timeout without claiming the provider is unreachable", () => { + const result: SystemAgentSetupDetectResult = { + candidates: [], + manualProviders: [], + prepareOptions: [], + workspace: "/tmp/workspace", + configuredModel: "ollama/gemma4:latest", + setupComplete: true, + }; + const { container } = mount(result, undefined, { + phase: "failed", + status: "timeout", + error: "LLM request timed out.", + }); + + expect(text(container)).toContain("Timed out. LLM request timed out."); + expect(text(container)).toContain( + "The model did not finish the setup test in time. Warm it or choose a faster model, then retry.", + ); + expect(text(container)).not.toContain("isn’t responding"); + expect(text(container)).not.toContain("service is running and reachable"); + }); }); diff --git a/ui/src/pages/model-setup/configured-model.ts b/ui/src/pages/model-setup/configured-model.ts index e3eb52719ecc..75655472e9cd 100644 --- a/ui/src/pages/model-setup/configured-model.ts +++ b/ui/src/pages/model-setup/configured-model.ts @@ -29,7 +29,7 @@ function failureGuidance(status: string): string { auth: t("modelSetup.failureGuidance.auth"), rate_limit: t("modelSetup.failureGuidance.rateLimit"), billing: t("modelSetup.failureGuidance.billing"), - timeout: t("modelSetup.failureGuidance.unavailable"), + timeout: t("modelSetup.failureGuidance.timeout"), format: t("modelSetup.failureGuidance.format"), unavailable: t("modelSetup.failureGuidance.unavailable"), unknown: t("modelSetup.failureGuidance.unknown"), @@ -124,7 +124,7 @@ export function renderConfiguredModel(props: { })}
` : props.verify.phase === "failed" - ? props.verify.status === "unavailable" || props.verify.status === "timeout" + ? props.verify.status === "unavailable" ? html`