mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(providers): preserve local model discovery and retry ownership (#114582)
This commit is contained in:
committed by
GitHub
parent
9e5849a0e8
commit
43ff420492
@@ -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({
|
||||
|
||||
@@ -78,7 +78,6 @@ function isPreloadCoolingDown(preloadKey: string, now: number): PreloadCooldownE
|
||||
return undefined;
|
||||
}
|
||||
if (entry.untilMs <= now) {
|
||||
preloadCooldown.delete(preloadKey);
|
||||
return undefined;
|
||||
}
|
||||
return entry;
|
||||
|
||||
@@ -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
@@ -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: () =>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user