mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-17 16:12:21 -06:00
fix(ollama): verify guided setup with runtime model (#123190)
This commit is contained in:
committed by
GitHub
parent
459891c035
commit
3cdbe3a511
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])(
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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."));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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("isn’t responding");
|
||||
expect(text(container)).not.toContain("service is running and reachable");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user