fix(ollama): verify guided setup with runtime model (#123190)

This commit is contained in:
Peter Steinberger
2026-08-13 14:27:34 -07:00
committed by GitHub
parent 459891c035
commit 3cdbe3a511
29 changed files with 735 additions and 234 deletions
+6 -3
View File
@@ -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 () => {
+22 -27
View File
@@ -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 } : {}),
};
@@ -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(
+17 -9
View File
@@ -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(
@@ -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 () => {
+7 -15
View File
@@ -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<string, OllamaModelWithContext>,
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<void> {
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,
+10 -4
View File
@@ -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"])(
+52
View File
@@ -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
);
}
@@ -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");
@@ -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);
@@ -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) &&
+19
View File
@@ -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",
@@ -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;
@@ -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,
}),
]);
});
});
@@ -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";
+37 -4
View File
@@ -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<AgentRunResultView> => {
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();
+47 -11
View File
@@ -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);
@@ -48,6 +48,7 @@ function summary(results: AuthProbeSummary["results"]): AuthProbeSummary {
function createOptions(params: Record<string, unknown>, 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<string, unknown>, 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);
});
});
+56 -8
View File
@@ -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<Record<AuthProbeReasonCode, string>> = {
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."));
}
},
+35 -32
View File
@@ -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();
}
@@ -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<string | undefined> {
const winnerProvider = result.meta?.executionTrace?.winnerProvider?.trim();
const winnerModel = result.meta?.executionTrace?.winnerModel?.trim();
+37 -18
View File
@@ -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,
+25 -33
View File
@@ -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 () => {
+3
View File
@@ -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: {
+37 -3
View File
@@ -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", () => {
+10 -8
View File
@@ -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`
<div
class="model-providers__probe model-providers__probe--${result.status === "ok"
? "success"
: "error"}"
role="status"
>
<div class="model-providers__probe model-providers__probe--${presentation}" role="status">
<div class="model-providers__probe-summary">
<strong>${t(`modelProviders.probe.status.${result.status}`)}</strong>
<strong
>${hasWarnings
? t("modelProviders.probe.status.partial")
: t(`modelProviders.probe.status.${result.status}`)}</strong
>
${result.latencyMs !== undefined
? html`<span
>${t("modelProviders.probe.latency", { ms: String(result.latencyMs) })}</span
@@ -5,19 +5,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SystemAgentSetupDetectResult } from "../../api/types.ts";
import { i18n } from "../../i18n/index.ts";
import { renderConfiguredModel } from "./configured-model.ts";
import type { ModelSetupVerifyState } from "./state.ts";
function mount(result: SystemAgentSetupDetectResult, onContinue?: () => 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("isnt responding");
expect(text(container)).not.toContain("service is running and reachable");
});
});
+2 -2
View File
@@ -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: {
})}
</div>`
: props.verify.phase === "failed"
? props.verify.status === "unavailable" || props.verify.status === "timeout"
? props.verify.status === "unavailable"
? html`<div class="model-setup__failure" role="alert">
<span class="model-setup__failure-icon" aria-hidden="true">
${icons.alertTriangle}
+4
View File
@@ -127,6 +127,10 @@
border-color: color-mix(in srgb, var(--ok) 35%, var(--border));
}
.model-providers__probe--warning {
border-color: color-mix(in srgb, var(--warn) 35%, var(--border));
}
.model-providers__probe--error {
border-color: color-mix(in srgb, var(--danger) 35%, var(--border));
}