fix(providers): preserve local model discovery and retry ownership (#114582)

This commit is contained in:
Peter Steinberger
2026-07-27 09:14:23 -04:00
committed by GitHub
parent 9e5849a0e8
commit 43ff420492
15 changed files with 751 additions and 98 deletions
+57
View File
@@ -541,6 +541,31 @@ describe("lmstudio stream wrapper", () => {
expect(baseStream).toHaveBeenCalledTimes(2);
});
it("preserves all 29 agent tools while preload failure backoff remains active", async () => {
ensureLmstudioModelLoadedMock.mockRejectedValueOnce(new Error("out of memory"));
const baseStream = buildDoneStreamFn();
const wrapped = createWrappedLmstudioStream(baseStream);
const tools = Array.from({ length: 29 }, (_, index) => ({
name: `agent_tool_${index}`,
description: `Agent tool ${index}`,
parameters: { type: "object" },
}));
for (let attempt = 0; attempt < 2; attempt += 1) {
const events = await collectEvents(
runWrappedLmstudioStream(wrapped, {}, undefined, { tools }),
);
expectSingleDoneEvent(events);
const call = (baseStream as unknown as { mock: { calls: unknown[][] } }).mock.calls[attempt];
expect(call).toBeDefined();
expect(requireRecord(call?.[1], "base stream context").tools).toEqual(tools);
}
expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1);
expect(baseStream).toHaveBeenCalledTimes(2);
});
it("retries preload once the cooldown expires", async () => {
ensureLmstudioModelLoadedMock.mockRejectedValueOnce(new Error("out of memory"));
ensureLmstudioModelLoadedMock.mockResolvedValueOnce(undefined);
@@ -597,6 +622,38 @@ describe("lmstudio stream wrapper", () => {
nowSpy.mockRestore();
});
it("keeps increasing preload backoff across expired consecutive failures", async () => {
ensureLmstudioModelLoadedMock.mockRejectedValue(new Error("out of memory"));
const baseStream = buildDoneStreamFn();
const wrapped = createWrappedLmstudioStream(baseStream);
const baseTime = 1_000_000;
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(baseTime);
await collectEvents(runWrappedLmstudioStream(wrapped, {}));
expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1);
nowSpy.mockReturnValue(baseTime + 5_001);
await collectEvents(runWrappedLmstudioStream(wrapped, {}));
expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(2);
nowSpy.mockReturnValue(baseTime + 10_001);
await collectEvents(runWrappedLmstudioStream(wrapped, {}));
expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(2);
nowSpy.mockReturnValue(baseTime + 15_002);
await collectEvents(runWrappedLmstudioStream(wrapped, {}));
expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(3);
nowSpy.mockReturnValue(baseTime + 30_002);
await collectEvents(runWrappedLmstudioStream(wrapped, {}));
expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(3);
nowSpy.mockReturnValue(baseTime + 35_003);
await collectEvents(runWrappedLmstudioStream(wrapped, {}));
expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(4);
expect(baseStream).toHaveBeenCalledTimes(6);
});
it("forces supportsUsageInStreaming compat before calling the underlying stream", async () => {
const baseStream = buildDoneStreamFn();
const wrapped = wrapLmstudioInferencePreload({
-1
View File
@@ -78,7 +78,6 @@ function isPreloadCoolingDown(preloadKey: string, now: number): PreloadCooldownE
return undefined;
}
if (entry.untilMs <= now) {
preloadCooldown.delete(preloadKey);
return undefined;
}
return entry;
+290
View File
@@ -33,6 +33,7 @@ const configureOllamaNonInteractiveMock = vi.hoisted(() => vi.fn());
const fetchOllamaModelsMock = vi.hoisted(() => vi.fn());
const buildOllamaProviderMock = vi.hoisted(() => vi.fn());
const queryOllamaModelShowInfoMock = vi.hoisted(() => vi.fn());
const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn());
const buildOllamaModelDefinitionMock = vi.hoisted(() =>
vi.fn((modelId: string, contextWindow?: number, capabilities?: string[]) => {
const normalized = modelId.trim().toLowerCase();
@@ -68,6 +69,16 @@ vi.mock("./api.js", () => ({
buildOllamaModelDefinition: buildOllamaModelDefinitionMock,
}));
vi.mock("openclaw/plugin-sdk/secret-input-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/secret-input-runtime")>();
return {
...actual,
resolveConfiguredSecretInputString: resolveConfiguredSecretInputStringMock.mockImplementation(
actual.resolveConfiguredSecretInputString,
),
};
});
vi.mock("./src/setup.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./src/setup.js")>()),
checkOllamaCloudAuth: checkOllamaCloudAuthMock,
@@ -91,6 +102,7 @@ beforeEach(() => {
fetchOllamaModelsMock.mockReset();
buildOllamaProviderMock.mockReset();
queryOllamaModelShowInfoMock.mockReset();
resolveConfiguredSecretInputStringMock.mockClear();
queryOllamaModelShowInfoMock.mockResolvedValue({
contextWindow: 32_768,
capabilities: ["completion", "tools"],
@@ -1042,6 +1054,7 @@ describe("ollama plugin", () => {
expect(resolved?.baseUrl).toBe("https://ollama.example.com/v1");
expect(buildOllamaProviderMock).toHaveBeenCalledWith("https://ollama.example.com/v1", {
quiet: true,
apiKey: "ollama-live",
});
} finally {
if (previous === undefined) {
@@ -1052,6 +1065,283 @@ describe("ollama plugin", () => {
}
});
it("authenticates configured dynamic Ollama discovery and model probes", async () => {
const provider = registerProvider();
const baseUrl = "https://dynamic-ollama.example.com";
const config = {
models: {
providers: {
ollama: {
baseUrl,
api: "ollama" as const,
apiKey: "dynamic-discovery-access",
models: [],
},
},
},
};
buildOllamaProviderMock.mockResolvedValueOnce({ baseUrl, api: "ollama", models: [] });
await provider.prepareDynamicModel?.({
config,
provider: "ollama",
modelId: "private-dynamic-model",
modelRegistry: { find: vi.fn(() => null) },
} as never);
expect(buildOllamaProviderMock).toHaveBeenCalledWith(baseUrl, {
quiet: true,
apiKey: "dynamic-discovery-access",
});
expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith(baseUrl, "private-dynamic-model", {
apiKey: "dynamic-discovery-access",
});
expect(
provider.resolveDynamicModel?.({
config,
provider: "ollama",
modelId: "private-dynamic-model",
modelRegistry: { find: vi.fn(() => null) },
} as never)?.id,
).toBe("private-dynamic-model");
});
it("scopes dynamic Ollama model caches to the effective credential", async () => {
const provider = registerProvider();
const baseUrl = "https://shared-dynamic-ollama.example.com";
const modelId = "tenant-dynamic-model";
const configFor = (apiKey: string) => ({
models: {
providers: {
ollama: { baseUrl, api: "ollama" as const, apiKey, models: [] },
},
},
});
const discoveredFor = (name: string) => ({
baseUrl,
api: "ollama",
models: [{ id: modelId, name, contextWindow: 8192, maxTokens: 2048 }],
});
buildOllamaProviderMock
.mockResolvedValueOnce(discoveredFor("First tenant model"))
.mockResolvedValueOnce(discoveredFor("Second tenant model"));
for (const config of [configFor("first-tenant-access"), configFor("second-tenant-access")]) {
await provider.prepareDynamicModel?.({
config,
provider: "ollama",
modelId,
modelRegistry: { find: vi.fn(() => null) },
} as never);
}
const resolveFor = (apiKey: string) =>
provider.resolveDynamicModel?.({
config: configFor(apiKey),
provider: "ollama",
modelId,
modelRegistry: { find: vi.fn(() => null) },
} as never);
expect(resolveFor("first-tenant-access")?.name).toBe("First tenant model");
expect(resolveFor("second-tenant-access")?.name).toBe("Second tenant model");
expect(resolveFor("unprepared-tenant-access")).toBeUndefined();
expect(buildOllamaProviderMock).toHaveBeenNthCalledWith(1, baseUrl, {
quiet: true,
apiKey: "first-tenant-access",
});
expect(buildOllamaProviderMock).toHaveBeenNthCalledWith(2, baseUrl, {
quiet: true,
apiKey: "second-tenant-access",
});
});
it.each(["secretref-dynamic-access", "OLLAMA_API_KEY", OLLAMA_DEFAULT_API_KEY])(
"preserves opaque environment-backed SecretRef value %s for dynamic discovery",
async (secretValue) => {
const provider = registerProvider();
const baseUrl = "https://secretref-dynamic-ollama.example.com";
const envId = "VITEST_OLLAMA_DYNAMIC_DISCOVERY_KEY";
const previous = process.env[envId];
process.env[envId] = secretValue;
const config = {
models: {
providers: {
ollama: {
baseUrl,
api: "ollama" as const,
apiKey: { source: "env" as const, provider: "default", id: envId },
models: [],
},
},
},
};
buildOllamaProviderMock.mockResolvedValueOnce({ baseUrl, api: "ollama", models: [] });
try {
await provider.prepareDynamicModel?.({
config,
provider: "ollama",
modelId: "secretref-dynamic-model",
modelRegistry: { find: vi.fn(() => null) },
} as never);
expect(buildOllamaProviderMock).toHaveBeenCalledWith(baseUrl, {
quiet: true,
apiKey: secretValue,
});
expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith(
baseUrl,
"secretref-dynamic-model",
{ apiKey: secretValue },
);
} finally {
if (previous === undefined) {
delete process.env[envId];
} else {
process.env[envId] = previous;
}
}
},
);
it("fails closed when a dynamic Ollama SecretRef cannot be resolved", async () => {
const provider = registerProvider();
const envId = "VITEST_OLLAMA_DYNAMIC_MISSING_KEY";
const previous = process.env[envId];
delete process.env[envId];
try {
await provider.prepareDynamicModel?.({
config: {
models: {
providers: {
ollama: {
baseUrl: "https://missing-secretref-ollama.example.com",
api: "ollama",
apiKey: { source: "env", provider: "default", id: envId },
models: [],
},
},
},
},
provider: "ollama",
modelId: "unreachable-private-model",
modelRegistry: { find: vi.fn(() => null) },
} as never);
expect(buildOllamaProviderMock).not.toHaveBeenCalled();
expect(queryOllamaModelShowInfoMock).not.toHaveBeenCalled();
} finally {
if (previous !== undefined) {
process.env[envId] = previous;
}
}
});
it("invalidates managed dynamic model caches when their SecretRef stops resolving", async () => {
const provider = registerProvider();
const baseUrl = "https://managed-dynamic-ollama.example.com";
const modelId = "managed-private-model";
const config = {
models: {
providers: {
ollama: {
baseUrl,
api: "ollama" as const,
apiKey: { source: "file" as const, provider: "default", id: "/ollama/apiKey" },
models: [],
},
},
},
};
resolveConfiguredSecretInputStringMock
.mockResolvedValueOnce({ value: "managed-dynamic-access" })
.mockResolvedValueOnce({ unresolvedRefReason: "managed credential is unavailable" });
buildOllamaProviderMock.mockResolvedValueOnce({
baseUrl,
api: "ollama",
models: [{ id: modelId, name: "Managed private model", contextWindow: 8192 }],
});
const context = {
config,
provider: "ollama",
modelId,
modelRegistry: { find: vi.fn(() => null) },
};
await provider.prepareDynamicModel?.(context as never);
expect(provider.resolveDynamicModel?.(context as never)?.id).toBe(modelId);
await provider.prepareDynamicModel?.(context as never);
expect(provider.resolveDynamicModel?.(context as never)).toBeUndefined();
expect(buildOllamaProviderMock).toHaveBeenCalledOnce();
});
it("isolates identically named managed SecretRefs by their resolved configuration", async () => {
const provider = registerProvider();
const baseUrl = "https://shared-managed-ollama.example.com";
const modelId = "managed-tenant-model";
const configFor = (tenant: string) => ({
secrets: {
providers: {
default: { source: "file" as const, path: `/run/secrets/${tenant}.json` },
},
},
models: {
providers: {
ollama: {
baseUrl,
api: "ollama" as const,
apiKey: { source: "file" as const, provider: "default", id: "/ollama/apiKey" },
models: [],
},
},
},
});
const firstConfig = configFor("first-tenant");
const secondConfig = configFor("second-tenant");
resolveConfiguredSecretInputStringMock
.mockResolvedValueOnce({ value: "first-managed-tenant-access" })
.mockResolvedValueOnce({ value: "second-managed-tenant-access" });
buildOllamaProviderMock
.mockResolvedValueOnce({
baseUrl,
api: "ollama",
models: [{ id: modelId, name: "First managed tenant model", contextWindow: 8192 }],
})
.mockResolvedValueOnce({
baseUrl,
api: "ollama",
models: [{ id: modelId, name: "Second managed tenant model", contextWindow: 8192 }],
});
const contextFor = (config: typeof firstConfig) => ({
config,
provider: "ollama",
modelId,
modelRegistry: { find: vi.fn(() => null) },
});
await provider.prepareDynamicModel?.(contextFor(firstConfig) as never);
await provider.prepareDynamicModel?.(contextFor(secondConfig) as never);
expect(provider.resolveDynamicModel?.(contextFor(firstConfig) as never)?.name).toBe(
"First managed tenant model",
);
expect(provider.resolveDynamicModel?.(contextFor(secondConfig) as never)?.name).toBe(
"Second managed tenant model",
);
expect(buildOllamaProviderMock).toHaveBeenNthCalledWith(1, baseUrl, {
quiet: true,
apiKey: "first-managed-tenant-access",
});
expect(buildOllamaProviderMock).toHaveBeenNthCalledWith(2, baseUrl, {
quiet: true,
apiKey: "second-managed-tenant-access",
});
});
it("resolves requested Ollama cloud models that are omitted from tags but confirmed by show", async () => {
const provider = registerProvider();
const previous = process.env.OLLAMA_API_KEY;
+119 -5
View File
@@ -1,4 +1,5 @@
// Ollama plugin entrypoint registers its OpenClaw integration.
import { createHash } from "node:crypto";
import { collectConfiguredModelRefValues } from "@openclaw/model-catalog-core/configured-model-refs";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
@@ -108,6 +109,7 @@ function classifyOllamaFailoverReason(errorMessage: string): "server_error" | un
}
const dynamicModelCache = new Map<string, ProviderRuntimeModel[]>();
const dynamicManagedCredentialFingerprints = new WeakMap<OpenClawConfig, Map<string, string>>();
const OLLAMA_CLOUD_DEFAULT_MODEL_REF = `${OLLAMA_CLOUD_PROVIDER_ID}/${OLLAMA_CLOUD_DEFAULT_MODELS[0].id}`;
const OLLAMA_CONFIGURED_SHOW_CONCURRENCY = 4;
const OLLAMA_CONFIGURED_SHOW_MAX_MODELS = 8;
@@ -290,8 +292,42 @@ async function discoverAppGuidedOllamaModel(ctx: ProviderAppGuidedSetupContext)
};
}
function buildDynamicCacheKey(provider: string, baseUrl: string | undefined): string {
return `${provider}\0${baseUrl ?? ""}`;
function buildDynamicManagedSecretScope(
provider: string,
baseUrl: string | undefined,
configuredApiKey: unknown,
): string | undefined {
const secretRef = coerceSecretRef(configuredApiKey);
if (!secretRef || secretRef.source === "env") {
return undefined;
}
return `${provider}\0${resolveOllamaApiBase(baseUrl)}\0${secretRef.source}\0${secretRef.provider}\0${secretRef.id}`;
}
function buildDynamicCacheKey(
provider: string,
baseUrl: string | undefined,
configuredApiKey: unknown,
config?: OpenClawConfig,
): string {
const secretRef = coerceSecretRef(configuredApiKey);
const managedSecretScope = buildDynamicManagedSecretScope(provider, baseUrl, configuredApiKey);
const apiKey = readUsableOllamaShowApiKey({
env: process.env,
allowAmbientEnvFallback: !isLocalOllamaBaseUrl(baseUrl),
explicitApiKey: configuredApiKey,
});
// Managed secrets resolve asynchronously; retain their resolved fingerprint
// per config so synchronous lookups cannot cross secret-provider ownership.
const managedCredentialFingerprint =
managedSecretScope && config
? dynamicManagedCredentialFingerprints.get(config)?.get(managedSecretScope)
: undefined;
const credentialScope =
apiKey ?? (secretRef ? `${secretRef.source}\0${secretRef.provider}\0${secretRef.id}` : "");
const credentialFingerprint =
managedCredentialFingerprint ?? createHash("sha256").update(credentialScope).digest("hex");
return `${provider}\0${resolveOllamaApiBase(baseUrl)}\0${credentialFingerprint}`;
}
function hasOllamaDiscoverySignal(providerConfig: ModelProviderConfig | undefined): boolean {
@@ -1000,7 +1036,77 @@ export default definePluginEntry({
return;
}
const baseUrl = readProviderBaseUrl(providerConfig);
const provider = await buildLocalOllamaProvider(baseUrl, { quiet: true });
const managedSecretScope = buildDynamicManagedSecretScope(
ctx.provider,
baseUrl,
providerConfig?.apiKey,
);
let dynamicCacheKey = buildDynamicCacheKey(
ctx.provider,
baseUrl,
providerConfig?.apiKey,
ctx.config,
);
let discoveryApiKey: string | undefined;
if (providerConfig?.apiKey !== undefined && providerConfig.apiKey !== null) {
const resolved = await resolveConfiguredSecretInputString({
config: ctx.config ?? {},
env: process.env,
value: providerConfig.apiKey,
path: `models.providers.${ctx.provider}.apiKey`,
unresolvedReasonStyle: "detailed",
});
if (resolved.unresolvedRefReason) {
dynamicModelCache.delete(dynamicCacheKey);
if (managedSecretScope && ctx.config) {
dynamicManagedCredentialFingerprints.get(ctx.config)?.delete(managedSecretScope);
}
return;
}
const resolvedApiKey = readConfiguredOllamaApiKey(resolved.value);
const configuredSecretRef = coerceSecretRef(providerConfig.apiKey);
discoveryApiKey = configuredSecretRef
? resolvedApiKey
: resolvedApiKey === "OLLAMA_API_KEY"
? readConcreteOllamaApiKey(process.env.OLLAMA_API_KEY)
: readConcreteOllamaApiKey(resolvedApiKey);
if (configuredSecretRef && !discoveryApiKey) {
dynamicModelCache.delete(dynamicCacheKey);
if (managedSecretScope && ctx.config) {
dynamicManagedCredentialFingerprints.get(ctx.config)?.delete(managedSecretScope);
}
return;
}
} else if (!isLocalOllamaBaseUrl(baseUrl)) {
discoveryApiKey = readConcreteOllamaApiKey(process.env.OLLAMA_API_KEY);
}
if (managedSecretScope && ctx.config && discoveryApiKey) {
let fingerprints = dynamicManagedCredentialFingerprints.get(ctx.config);
if (!fingerprints) {
fingerprints = new Map();
dynamicManagedCredentialFingerprints.set(ctx.config, fingerprints);
}
const resolvedCredentialFingerprint = createHash("sha256")
.update(discoveryApiKey)
.digest("hex");
if (
fingerprints.has(managedSecretScope) &&
fingerprints.get(managedSecretScope) !== resolvedCredentialFingerprint
) {
dynamicModelCache.delete(dynamicCacheKey);
}
fingerprints.set(managedSecretScope, resolvedCredentialFingerprint);
dynamicCacheKey = buildDynamicCacheKey(
ctx.provider,
baseUrl,
providerConfig?.apiKey,
ctx.config,
);
}
const provider = await buildLocalOllamaProvider(baseUrl, {
quiet: true,
...(discoveryApiKey ? { apiKey: discoveryApiKey } : {}),
});
const dynamicApi = providerConfig?.api ?? provider.api;
const dynamicProvider = {
...provider,
@@ -1023,13 +1129,14 @@ export default definePluginEntry({
provider: ctx.provider,
providerConfig: dynamicProvider,
modelId: ctx.modelId,
showApiKey: discoveryApiKey,
capContextTokens: true,
});
if (requestedModel) {
dynamicModels.push(requestedModel);
}
}
dynamicModelCache.set(buildDynamicCacheKey(ctx.provider, baseUrl), dynamicModels);
dynamicModelCache.set(dynamicCacheKey, dynamicModels);
},
resolveDynamicModel: (ctx) => {
const providerConfig = resolveConfiguredOllamaProviderConfig({
@@ -1037,7 +1144,14 @@ export default definePluginEntry({
providerId: ctx.provider,
});
return dynamicModelCache
.get(buildDynamicCacheKey(ctx.provider, readProviderBaseUrl(providerConfig)))
.get(
buildDynamicCacheKey(
ctx.provider,
readProviderBaseUrl(providerConfig),
providerConfig?.apiKey,
ctx.config,
),
)
?.find((model) => model.id === ctx.modelId);
},
buildUnknownModelHint: () =>
+159 -1
View File
@@ -1,7 +1,7 @@
// Ollama tests cover discovery shared plugin behavior.
import { expectDefined } from "@openclaw/normalization-core";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
isLocalOllamaBaseUrl,
resolveOllamaDiscoveryResult,
@@ -420,6 +420,164 @@ describe("resolveOllamaDiscoveryResult — hosted Ollama Cloud guard", () => {
// Local base URL should still reach the discovery path
expect(result).not.toBeNull();
});
it.each([
{
name: "a remote endpoint",
baseUrl: "https://ollama-secure.example/v1",
discoveredBaseUrl: "https://ollama-secure.example",
},
{
name: "a loopback endpoint",
baseUrl: "http://127.0.0.1:11434",
discoveredBaseUrl: "http://127.0.0.1:11434",
},
{
name: "a private-network endpoint",
baseUrl: "http://192.168.10.8:11434",
discoveredBaseUrl: "http://192.168.10.8:11434",
},
])(
"authenticates live discovery at $name with its resolved SecretRef",
async ({ baseUrl, discoveredBaseUrl }) => {
const buildProvider = vi.fn(
async (
_configuredBaseUrl?: string,
_opts?: { apiKey?: string; quiet?: boolean },
): Promise<ModelProviderConfig> => ({
baseUrl: discoveredBaseUrl,
api: "ollama",
models: [discoveredModel],
}),
);
const result = await resolveOllamaDiscoveryResult({
ctx: {
config: {
models: {
providers: {
ollama: {
baseUrl,
api: "ollama",
apiKey: { source: "env", provider: "default", id: "OLLAMA_DISCOVERY_TOKEN" },
},
},
},
},
env: {},
resolveProviderApiKey: () => ({
apiKey: "OLLAMA_DISCOVERY_TOKEN",
discoveryApiKey: "resolved-ollama-discovery-token",
}),
},
pluginConfig: {},
buildProvider,
});
expect(buildProvider).toHaveBeenCalledWith(baseUrl, {
quiet: false,
apiKey: "resolved-ollama-discovery-token",
});
expect(result?.provider.apiKey).toBe("resolved-ollama-discovery-token");
expect(result?.provider.models).toEqual([discoveredModel]);
},
);
it.each(["OLLAMA_API_KEY", "ollama-local"])(
"preserves resolved opaque SecretRef credential %s during live discovery",
async (secretValue) => {
const baseUrl = `https://opaque-secretref-${secretValue.toLowerCase().replaceAll("_", "-")}.example`;
const buildProvider = vi.fn(
async (
_configuredBaseUrl?: string,
_opts?: { apiKey?: string; quiet?: boolean },
): Promise<ModelProviderConfig> => ({
baseUrl,
api: "ollama",
models: [discoveredModel],
}),
);
const result = await resolveOllamaDiscoveryResult({
ctx: {
config: {
models: {
providers: {
ollama: {
baseUrl,
api: "ollama",
apiKey: { source: "file", provider: "default", id: "/ollama/apiKey" },
},
},
},
},
env: { OLLAMA_API_KEY: "different-ambient-ollama-credential" },
resolveProviderApiKey: () => ({
apiKey: "secretref-managed",
discoveryApiKey: secretValue,
}),
},
pluginConfig: {},
buildProvider,
});
expect(buildProvider).toHaveBeenCalledWith(baseUrl, {
quiet: false,
apiKey: secretValue,
});
expect(result?.provider.apiKey).toBe(secretValue);
expect(result?.provider.models).toEqual([discoveredModel]);
},
);
it("isolates discovered catalogs by their effective authentication credential", async () => {
const buildProvider = vi.fn(
async (
_configuredBaseUrl?: string,
opts?: { apiKey?: string; quiet?: boolean },
): Promise<ModelProviderConfig> => ({
baseUrl: "https://ollama-cache-scope.example",
api: "ollama",
models: [
{
...discoveredModel,
id: `model-for-${opts?.apiKey}`,
name: `model-for-${opts?.apiKey}`,
},
],
}),
);
const discoverWithCredential = async (apiKey: string) =>
await resolveOllamaDiscoveryResult({
ctx: {
config: {
models: {
providers: {
ollama: {
baseUrl: "https://ollama-cache-scope.example/v1",
api: "ollama",
apiKey,
},
},
},
},
env: {},
resolveProviderApiKey: () => ({ apiKey }),
},
pluginConfig: {},
buildProvider,
});
const first = await discoverWithCredential("ollama-cache-token-a");
const second = await discoverWithCredential("ollama-cache-token-b");
const cachedFirst = await discoverWithCredential("ollama-cache-token-a");
expect(buildProvider).toHaveBeenCalledTimes(2);
expect(first?.provider.models[0]?.id).toBe("model-for-ollama-cache-token-a");
expect(second?.provider.models[0]?.id).toBe("model-for-ollama-cache-token-b");
expect(cachedFirst?.provider.models[0]?.id).toBe("model-for-ollama-cache-token-a");
});
});
describe("shouldUseSyntheticOllamaAuth", () => {
+22 -4
View File
@@ -69,6 +69,7 @@ function resolveOllamaDiscoveryApiKey(params: {
env: NodeJS.ProcessEnv;
baseUrl?: string;
explicitApiKey?: string;
explicitApiKeyIsResolvedSecret?: boolean;
resolvedApiKey?: unknown;
resolvedDiscoveryApiKey?: unknown;
}): string | undefined {
@@ -76,7 +77,10 @@ function resolveOllamaDiscoveryApiKey(params: {
const resolvedApiKey = normalizeOptionalString(params.resolvedApiKey);
const resolvedDiscoveryApiKey = normalizeOptionalString(params.resolvedDiscoveryApiKey);
const explicitApiKey = normalizeOptionalString(params.explicitApiKey);
if (explicitApiKey && !isOllamaApiKeyMarker(explicitApiKey)) {
if (
explicitApiKey &&
(params.explicitApiKeyIsResolvedSecret || !isOllamaApiKeyMarker(explicitApiKey))
) {
return explicitApiKey;
}
if (!isLocalOllamaBaseUrl(params.baseUrl)) {
@@ -273,7 +277,7 @@ export async function resolveOllamaDiscoveryResult(params: {
pluginConfig: OllamaPluginConfig;
buildProvider: (
configuredBaseUrl?: string,
opts?: { quiet?: boolean },
opts?: { apiKey?: string; quiet?: boolean },
) => Promise<ModelProviderConfig>;
}): Promise<{ provider: ModelProviderConfig } | null> {
const explicit = params.ctx.config.models?.providers?.ollama;
@@ -318,6 +322,7 @@ export async function resolveOllamaDiscoveryResult(params: {
env: params.ctx.env,
baseUrl: discoveredBaseUrl,
explicitApiKey,
explicitApiKeyIsResolvedSecret: Boolean(explicitApiKeyRef),
resolvedApiKey: ollamaKey,
resolvedDiscoveryApiKey: ollamaDiscoveryKey,
});
@@ -346,17 +351,30 @@ export async function resolveOllamaDiscoveryResult(params: {
}
const quiet = !hasRealOllamaKey && !hasMeaningfulExplicitConfig;
const resolvedDiscoveryApiKey = resolveOllamaDiscoveryApiKey({
env: params.ctx.env,
baseUrl: configuredBaseUrl,
explicitApiKey,
explicitApiKeyIsResolvedSecret: Boolean(explicitApiKeyRef),
resolvedApiKey: ollamaKey,
resolvedDiscoveryApiKey: ollamaDiscoveryKey,
});
const discoveryApiKey =
resolvedDiscoveryApiKey === OLLAMA_DEFAULT_API_KEY && !explicitApiKeyRef
? undefined
: resolvedDiscoveryApiKey;
const provider = await getCachedLiveCatalogValue({
keyParts: [
OLLAMA_PROVIDER_ID,
"models",
configuredBaseUrl ?? OLLAMA_DEFAULT_BASE_URL,
ollamaKey,
resolveOllamaApiBase(configuredBaseUrl),
discoveryApiKey,
quiet,
],
load: async () =>
await params.buildProvider(configuredBaseUrl, {
quiet,
...(discoveryApiKey ? { apiKey: discoveryApiKey } : {}),
}),
});
if (provider.models?.length === 0 && !ollamaKey && !explicit?.apiKey) {
+5 -29
View File
@@ -1,7 +1,10 @@
// Ollama provider module implements model/runtime integration.
import { createHash } from "node:crypto";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import {
isCloudModelRef,
type ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard";
import { fetchWithSsrFGuard, type LookupFn } from "openclaw/plugin-sdk/ssrf-runtime";
import {
@@ -255,35 +258,8 @@ export async function enrichOllamaModelsWithContext(
return enriched;
}
type OllamaModelSource = "cloud" | "local";
function parseOllamaModelSourceSuffix(
modelName: string,
): { base: string; source: OllamaModelSource } | undefined {
const sourceSeparator = modelName.lastIndexOf(":");
if (sourceSeparator < 0) {
return undefined;
}
const source = modelName.slice(sourceSeparator + 1);
if (source === "cloud" || source === "local") {
return { base: modelName.slice(0, sourceSeparator), source };
}
if (!source.includes("/") && source.endsWith("-cloud")) {
return {
base: modelName.slice(0, sourceSeparator + 1) + source.slice(0, -"-cloud".length),
source: "cloud",
};
}
return undefined;
}
export function isOllamaCloudModel(modelName: string | undefined): boolean {
const normalized = modelName?.trim().toLowerCase();
if (!normalized) {
return false;
}
const parsed = parseOllamaModelSourceSuffix(normalized);
return parsed?.source === "cloud" && parseOllamaModelSourceSuffix(parsed.base) === undefined;
return isCloudModelRef(modelName);
}
export function isReasoningModelHeuristic(modelId: string): boolean {
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
buildModelCatalogMergeKey,
buildModelCatalogRef,
isCloudModelRef,
parseModelCatalogRef,
parseProviderModelRef,
} from "./model-catalog-refs.js";
@@ -33,4 +34,18 @@ describe("model catalog refs", () => {
expect(parseModelCatalogRef(value)).toBeNull();
},
);
it.each([
["glm-5.2:cloud", true],
["ollama/gpt-oss:120b-cloud", true],
[" OLLAMA/KIMI-K2.5:CLOUD ", true],
["local-cloud", false],
["invalid:cloud-cloud", false],
["invalid:local:cloud", false],
["invalid:local-cloud", false],
["invalid:cloud:local", false],
[undefined, false],
])("classifies hosted model source %j", (modelRef, expected) => {
expect(isCloudModelRef(modelRef)).toBe(expected);
});
});
@@ -13,6 +13,36 @@ export type ProviderModelRef = {
model: string;
};
type ModelSourceSuffix = {
base: string;
source: "cloud" | "local";
};
function parseModelSourceSuffix(modelRef: string): ModelSourceSuffix | undefined {
const sourceSeparator = modelRef.lastIndexOf(":");
if (sourceSeparator < 0) {
return undefined;
}
const source = modelRef.slice(sourceSeparator + 1);
if (source === "cloud" || source === "local") {
return { base: modelRef.slice(0, sourceSeparator), source };
}
if (!source.includes("/") && source.endsWith("-cloud")) {
return { base: modelRef.slice(0, -"-cloud".length), source: "cloud" };
}
return undefined;
}
/** Recognizes one unambiguous hosted source suffix on a bare or qualified model ref. */
export function isCloudModelRef(modelRef: string | undefined): boolean {
const normalized = modelRef?.trim().toLowerCase();
if (!normalized) {
return false;
}
const source = parseModelSourceSuffix(normalized);
return source?.source === "cloud" && parseModelSourceSuffix(source.base) === undefined;
}
/** Normalize provider ids for catalog refs. */
export function normalizeModelCatalogProviderId(provider: string): string {
return normalizeLowercaseStringOrEmpty(provider);
@@ -421,6 +421,15 @@ describe("resolveLlmIdleTimeoutMs", () => {
},
}),
).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
expect(
resolveLlmIdleTimeoutMs({
model: {
provider: "ollama",
id: "ollama/gpt-oss:120b-cloud",
baseUrl: "http://127.0.0.1:11434",
},
}),
).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
});
it.each([
@@ -588,6 +597,15 @@ describe("resolveLlmFirstEventTimeoutMs", () => {
model: { provider: "ollama", id: "ollama/kimi-k2.6:cloud", baseUrl: "http://127.0.0.1" },
}),
).toBe(CLOUD_LLM_FIRST_EVENT_TIMEOUT_MS);
expect(
resolveLlmFirstEventTimeoutMs({
model: {
provider: "ollama",
id: "ollama/gpt-oss:120b-cloud",
baseUrl: "http://127.0.0.1:11434",
},
}),
).toBe(CLOUD_LLM_FIRST_EVENT_TIMEOUT_MS);
});
it("honors explicit provider request timeouts", () => {
@@ -1,4 +1,5 @@
import { onLlmRequestActivity } from "@openclaw/ai/internal/runtime";
import { isCloudModelRef } from "@openclaw/model-catalog-core/model-catalog-refs";
/**
* Wraps LLM streams with idle-timeout detection and diagnostics.
*/
@@ -194,10 +195,7 @@ function isOllamaCloudModel(model: { id?: string; provider?: string } | undefine
return false;
}
const modelId = rawModelId.trim().toLowerCase();
const slashIndex = modelId.indexOf("/");
const bareModelId = slashIndex >= 0 ? modelId.slice(slashIndex + 1) : modelId;
return bareModelId.endsWith(":cloud");
return isCloudModelRef(rawModelId);
}
type RuntimeModelLocality = {
@@ -283,41 +283,15 @@ export async function applyNonInteractivePluginProviderChoice(params: {
});
const previousModel = enableResult.config.agents?.defaults?.model;
const previousAutoModel = enableResult.config.wizard?.localModelLeanAutoModel;
const restoreAutoModelOwnership =
const retainsAutoModelOwnership =
previousAutoModel !== undefined &&
previousAutoModel === resolveAgentModelPrimaryValue(previousModel) &&
previousAutoModel === copilotInstall.cfg.wizard?.localModelLeanAutoModel;
// Provider setup already replaced the default model. Restore its old value
// only while checking whether onboarding still owns the lean setting.
const leanConfig = applyAutoLocalModelLean({
config: restoreAutoModelOwnership
? {
...copilotInstall.cfg,
agents: {
...copilotInstall.cfg.agents,
defaults: {
...copilotInstall.cfg.agents?.defaults,
model: previousModel,
},
},
}
: copilotInstall.cfg,
return applyAutoLocalModelLean({
config: copilotInstall.cfg,
providerId: providerChoice.provider.id,
modelRef: selectedModel,
...(retainsAutoModelOwnership ? { previousModelRef: previousAutoModel } : {}),
}).config;
if (!restoreAutoModelOwnership) {
return leanConfig;
}
return {
...leanConfig,
agents: {
...leanConfig.agents,
defaults: {
...leanConfig.agents?.defaults,
model: copilotInstall.cfg.agents?.defaults?.model,
},
},
};
}
+23
View File
@@ -151,4 +151,27 @@ describe("local model lean onboarding defaults", () => {
expect(result.config.agents?.defaults?.experimental?.localModelLean).toBe(true);
expect(result.config.wizard?.localModelLeanAutoModel).toBeUndefined();
});
it("accepts explicit previous-model ownership after provider setup replaces the default", () => {
const previousModelRef = "ollama/qwen3:8b";
const selectedModelRef = "openai/gpt-5.6-luna";
const result = applyAutoLocalModelLean({
config: {
wizard: { localModelLeanAutoModel: previousModelRef },
agents: {
defaults: {
model: { primary: selectedModelRef },
experimental: { localModelLean: true },
},
},
},
providerId: "openai",
modelRef: selectedModelRef,
previousModelRef,
});
expect(result.config.agents?.defaults?.model).toEqual({ primary: selectedModelRef });
expect(result.config.agents?.defaults?.experimental?.localModelLean).toBeUndefined();
expect(result.config.wizard?.localModelLeanAutoModel).toBeUndefined();
});
});
+6 -24
View File
@@ -1,25 +1,9 @@
import { isCloudModelRef } from "@openclaw/model-catalog-core/model-catalog-refs";
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import type { OpenClawConfig } from "./types.openclaw.js";
const AUTO_LOCAL_MODEL_LEAN_PROVIDER_IDS = new Set(["lmstudio", "ollama"]);
function parseOllamaModelSourceSuffix(
modelRef: string,
): { base: string; source: "cloud" | "local" } | undefined {
const sourceSeparator = modelRef.lastIndexOf(":");
if (sourceSeparator < 0) {
return undefined;
}
const source = modelRef.slice(sourceSeparator + 1);
if (source === "cloud" || source === "local") {
return { base: modelRef.slice(0, sourceSeparator), source };
}
if (!source.includes("/") && source.endsWith("-cloud")) {
return { base: modelRef.slice(0, -"-cloud".length), source: "cloud" };
}
return undefined;
}
/** Returns true only for local runtimes that onboarding can identify without model-name guesses. */
function shouldAutoEnableLocalModelLean(providerId: string, modelRef: string): boolean {
const normalizedProviderId = normalizeProviderId(providerId);
@@ -29,12 +13,8 @@ function shouldAutoEnableLocalModelLean(providerId: string, modelRef: string): b
if (normalizedProviderId !== "ollama") {
return true;
}
// Ollama can route hosted source-tagged models through the same local daemon.
// Nested source suffixes are ambiguous and must retain the owner's local classification.
const modelSource = parseOllamaModelSourceSuffix(modelRef.trim().toLowerCase());
return (
modelSource?.source !== "cloud" || parseOllamaModelSourceSuffix(modelSource.base) !== undefined
);
// Hosted source-tagged models can be routed through the same local daemon.
return !isCloudModelRef(modelRef);
}
function resolveDefaultModelRef(config: OpenClawConfig): string | undefined {
@@ -53,6 +33,7 @@ export function applyAutoLocalModelLean(params: {
config: OpenClawConfig;
providerId: string;
modelRef: string;
previousModelRef?: string;
}): {
config: OpenClawConfig;
changed: boolean;
@@ -61,7 +42,8 @@ export function applyAutoLocalModelLean(params: {
const localModelLean = params.config.agents?.defaults?.experimental?.localModelLean;
const autoModel = params.config.wizard?.localModelLeanAutoModel;
const onboardingOwnsSetting =
autoModel !== undefined && resolveDefaultModelRef(params.config) === autoModel;
autoModel !== undefined &&
(params.previousModelRef ?? resolveDefaultModelRef(params.config)) === autoModel;
if (!shouldAutoEnableLocalModelLean(params.providerId, params.modelRef)) {
if (!autoModel) {
return { config: params.config, changed: false, enabled: false };
+1
View File
@@ -47,6 +47,7 @@ export type {
UnifiedModelCatalogKind,
UnifiedModelCatalogSource,
} from "@openclaw/model-catalog-core/model-catalog-types";
export { isCloudModelRef } from "@openclaw/model-catalog-core/model-catalog-refs";
export type {
BedrockDiscoveryConfig,
ModelCompatConfig,