openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
488 lines (487 loc) • 19 kB
JavaScript
import { a as normalizeLowercaseStringOrEmpty, s as normalizeOptionalLowercaseString } from "./string-coerce-mnp54Vah.js";
import { i as formatErrorMessage } from "./errors-BXgSefBE.js";
import { r as fetchWithSsrFGuard } from "./fetch-guard-BttkNCLm.js";
import { t as normalizeOptionalSecretInput } from "./normalize-secret-input-OwRUfByL.js";
import { s as upsertAuthProfileWithLock } from "./profiles-BMWtmBgj.js";
import { d as isNonSecretApiKeyMarker } from "./model-auth-markers-Sq8HdQFX.js";
import "./error-runtime-C8vbtAJt.js";
import "./string-coerce-runtime-CEGJWkQ_.js";
import { i as normalizeApiKeyInput, n as ensureApiKeyFromOptionEnvOrPrompt, s as validateApiKeyInput } from "./provider-auth-input-CjZ71i7e.js";
import "./provider-auth-DAOC_qI9.js";
import { n as applyAgentDefaultModelPrimary } from "./provider-onboard-CBhOzm7U.js";
import { t as WizardCancelledError } from "./prompts-E0FayNjJ.js";
import "./setup-DHfdBmUU.js";
import "./ssrf-runtime-BOGN5pUi.js";
import { c as OLLAMA_DEFAULT_MODEL, i as OLLAMA_DEFAULT_BASE_URL, l as OLLAMA_DOCKER_HOST_BASE_URL, n as OLLAMA_CLOUD_DEFAULT_MODELS, t as OLLAMA_CLOUD_BASE_URL } from "./defaults-CPlCYE-I.js";
import { a as enrichOllamaModelsWithContext, n as buildOllamaBaseUrlSsrFPolicy, o as fetchOllamaModels, r as buildOllamaModelDefinition, t as readProviderBaseUrl, u as resolveOllamaApiBase } from "./provider-base-url-BEd2ujfU.js";
//#region extensions/ollama/src/setup.ts
const OLLAMA_SUGGESTED_MODELS_LOCAL = [OLLAMA_DEFAULT_MODEL];
const OLLAMA_SUGGESTED_MODELS_CLOUD = [...OLLAMA_CLOUD_DEFAULT_MODELS];
const OLLAMA_CONTEXT_ENRICH_LIMIT = 200;
const OLLAMA_CLOUD_MAX_DISCOVERED_MODELS = 500;
const OLLAMA_PULL_RESPONSE_TIMEOUT_MS = 3e4;
const OLLAMA_PULL_STREAM_IDLE_TIMEOUT_MS = 3e5;
function isTruthyEnvValue(value) {
return [
"1",
"true",
"yes",
"on"
].includes(value?.trim().toLowerCase() ?? "");
}
function resolveOllamaSetupDefaultBaseUrl(env = process.env) {
return isTruthyEnvValue(env.OPENCLAW_DOCKER_SETUP) ? OLLAMA_DOCKER_HOST_BASE_URL : OLLAMA_DEFAULT_BASE_URL;
}
const HOST_BACKED_OLLAMA_MODE_CONFIG = {
"cloud-local": {
includeCloudModels: true,
noteTitle: "Ollama Cloud + Local"
},
"local-only": {
includeCloudModels: false,
noteTitle: "Ollama"
}
};
function buildOllamaUnreachableLines(baseUrl) {
return [
`Ollama could not be reached at ${baseUrl}.`,
"Download it at https://ollama.com/download",
"",
"Start Ollama and re-run setup."
];
}
function buildOllamaCloudSigninLines(signinUrl) {
return [
"Cloud models on this Ollama host need `ollama signin`.",
signinUrl ?? "Run `ollama signin` on the configured Ollama host.",
"",
"Continuing with local models only for now."
];
}
function normalizeOllamaModelName(value) {
const trimmed = value?.trim();
if (!trimmed) return;
if (normalizeLowercaseStringOrEmpty(trimmed).startsWith("ollama/")) return trimmed.slice(7).trim() || void 0;
return trimmed;
}
function isOllamaCloudModel(modelName) {
return normalizeOptionalLowercaseString(modelName)?.endsWith(":cloud") === true;
}
function formatOllamaPullStatus(status) {
const trimmed = status.trim();
const partStatusMatch = trimmed.match(/^([a-z-]+)\s+(?:sha256:)?[a-f0-9]{8,}$/i);
if (partStatusMatch) return {
text: `${partStatusMatch[1]} part`,
hidePercent: false
};
if (/^verifying\b.*\bdigest\b/i.test(trimmed)) return {
text: "verifying digest",
hidePercent: true
};
return {
text: trimmed,
hidePercent: false
};
}
async function checkOllamaCloudAuth(baseUrl) {
try {
const apiBase = resolveOllamaApiBase(baseUrl);
const { response, release } = await fetchWithSsrFGuard({
url: `${apiBase}/api/me`,
init: {
method: "POST",
signal: AbortSignal.timeout(5e3)
},
policy: buildOllamaBaseUrlSsrFPolicy(apiBase),
auditContext: "ollama-setup.me"
});
try {
if (response.status === 401) return {
signedIn: false,
signinUrl: (await response.json()).signin_url
};
if (!response.ok) return { signedIn: false };
return { signedIn: true };
} finally {
await release();
}
} catch {
return { signedIn: false };
}
}
async function readOllamaPullChunkWithIdleTimeout(reader) {
let timeoutId;
let timedOut = false;
return await new Promise((resolve, reject) => {
const clear = () => {
if (timeoutId !== void 0) {
clearTimeout(timeoutId);
timeoutId = void 0;
}
};
timeoutId = setTimeout(() => {
timedOut = true;
clear();
reader.cancel().catch(() => void 0);
reject(/* @__PURE__ */ new Error(`Ollama pull stalled: no data received for ${Math.round(OLLAMA_PULL_STREAM_IDLE_TIMEOUT_MS / 1e3)}s`));
}, OLLAMA_PULL_STREAM_IDLE_TIMEOUT_MS);
reader.read().then((result) => {
clear();
if (!timedOut) resolve(result);
}, (err) => {
clear();
if (!timedOut) reject(toLintErrorObject(err, "Non-Error rejection"));
});
});
}
async function pullOllamaModelCore(params) {
const baseUrl = resolveOllamaApiBase(params.baseUrl);
const modelName = normalizeOllamaModelName(params.modelName) ?? params.modelName.trim();
const responseController = new AbortController();
const responseTimeout = setTimeout(responseController.abort.bind(responseController), OLLAMA_PULL_RESPONSE_TIMEOUT_MS);
try {
const { response, release } = await fetchWithSsrFGuard({
url: `${baseUrl}/api/pull`,
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: modelName })
},
signal: responseController.signal,
policy: buildOllamaBaseUrlSsrFPolicy(baseUrl),
auditContext: "ollama-setup.pull"
});
clearTimeout(responseTimeout);
try {
if (!response.ok) return {
ok: false,
message: `Failed to download ${modelName} (HTTP ${response.status})`
};
if (!response.body) return {
ok: false,
message: `Failed to download ${modelName} (no response body)`
};
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const layers = /* @__PURE__ */ new Map();
const parseLine = (line) => {
const trimmed = line.trim();
if (!trimmed) return { ok: true };
try {
const chunk = JSON.parse(trimmed);
if (chunk.error) return {
ok: false,
message: `Download failed: ${chunk.error}`
};
if (!chunk.status) return { ok: true };
if (chunk.total && chunk.completed !== void 0) {
layers.set(chunk.status, {
total: chunk.total,
completed: chunk.completed
});
let totalSum = 0;
let completedSum = 0;
for (const layer of layers.values()) {
totalSum += layer.total;
completedSum += layer.completed;
}
params.onStatus?.(chunk.status, totalSum > 0 ? Math.round(completedSum / totalSum * 100) : null);
} else params.onStatus?.(chunk.status, null);
} catch {}
return { ok: true };
};
for (;;) {
const { done, value } = await readOllamaPullChunkWithIdleTimeout(reader);
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const parsed = parseLine(line);
if (!parsed.ok) return parsed;
}
}
const trailing = buffer.trim();
if (trailing) {
const parsed = parseLine(trailing);
if (!parsed.ok) return parsed;
}
return { ok: true };
} finally {
await release();
}
} catch (err) {
return {
ok: false,
message: `Failed to download ${modelName}: ${formatErrorMessage(err)}`
};
} finally {
clearTimeout(responseTimeout);
}
}
async function pullOllamaModel(baseUrl, modelName, prompter) {
const spinner = prompter.progress(`Downloading ${modelName}...`);
const result = await pullOllamaModelCore({
baseUrl,
modelName,
onStatus: (status, percent) => {
const displayStatus = formatOllamaPullStatus(status);
if (displayStatus.hidePercent) spinner.update(`Downloading ${modelName} - ${displayStatus.text}`);
else spinner.update(`Downloading ${modelName} - ${displayStatus.text} - ${percent ?? 0}%`);
}
});
if (!result.ok) {
spinner.stop(result.message);
return false;
}
spinner.stop(`Downloaded ${modelName}`);
return true;
}
async function pullOllamaModelNonInteractive(baseUrl, modelName, runtime) {
runtime.log(`Downloading ${modelName}...`);
const result = await pullOllamaModelCore({
baseUrl,
modelName
});
if (!result.ok) {
runtime.error(result.message);
return false;
}
runtime.log(`Downloaded ${modelName}`);
return true;
}
async function promptForOllamaCloudCredential(params) {
const captured = {};
const optionToken = normalizeOptionalSecretInput(params.opts?.ollamaApiKey);
await ensureApiKeyFromOptionEnvOrPrompt({
token: optionToken ?? normalizeOptionalSecretInput(params.opts?.token),
tokenProvider: optionToken ? "ollama" : normalizeOptionalSecretInput(params.opts?.tokenProvider),
secretInputMode: params.allowSecretRefPrompt === false ? params.secretInputMode ?? "plaintext" : params.secretInputMode,
config: params.cfg,
env: params.env,
expectedProviders: ["ollama"],
provider: "ollama",
envLabel: "OLLAMA_API_KEY",
promptMessage: "Ollama API key",
normalize: normalizeApiKeyInput,
validate: validateApiKeyInput,
prompter: params.prompter,
setCredential: async (apiKey, mode) => {
captured.credential = apiKey;
captured.credentialMode = mode;
}
});
if (!captured.credential) throw new Error("Missing Ollama API key input.");
if (typeof captured.credential === "string" && isNonSecretApiKeyMarker(captured.credential, { includeEnvVarName: false })) throw new Error("Cloud-only Ollama setup requires a real OLLAMA_API_KEY.");
return {
credential: captured.credential,
credentialMode: captured.credentialMode
};
}
function buildOllamaModelsConfig(modelNames, discoveredModelsByName) {
return modelNames.map((name) => {
const discovered = discoveredModelsByName?.get(name);
const capabilities = discovered?.capabilities ?? (name === "kimi-k2.5:cloud" ? ["vision"] : void 0);
return buildOllamaModelDefinition(name, discovered?.contextWindow, capabilities);
});
}
function getOllamaLatestDedupeKey(name) {
const normalized = normalizeLowercaseStringOrEmpty(name);
return normalized.endsWith(":latest") ? normalized.slice(0, -7) : normalized;
}
function isExplicitLatestOllamaModel(name) {
return normalizeLowercaseStringOrEmpty(name).endsWith(":latest");
}
function shouldReplaceOllamaModelName(existing, candidate) {
return !isExplicitLatestOllamaModel(existing) && isExplicitLatestOllamaModel(candidate);
}
function mergeUniqueModelNames(...groups) {
const indexByKey = /* @__PURE__ */ new Map();
const merged = [];
for (const group of groups) for (const name of group) {
const key = getOllamaLatestDedupeKey(name);
const existingIndex = indexByKey.get(key);
if (existingIndex !== void 0) {
if (shouldReplaceOllamaModelName(merged[existingIndex], name)) merged[existingIndex] = name;
continue;
}
indexByKey.set(key, merged.length);
merged.push(name);
}
return merged;
}
function findAvailableOllamaModelName(modelName, availableModelNames) {
const wantedKey = getOllamaLatestDedupeKey(modelName);
for (const available of availableModelNames) if (getOllamaLatestDedupeKey(available) === wantedKey) return available;
}
function applyOllamaProviderConfig(cfg, baseUrl, modelNames, discoveredModelsByName, apiKey = "OLLAMA_API_KEY") {
return {
...cfg,
models: {
...cfg.models,
mode: cfg.models?.mode ?? "merge",
providers: {
...cfg.models?.providers,
ollama: {
baseUrl,
api: "ollama",
apiKey,
models: buildOllamaModelsConfig(modelNames, discoveredModelsByName)
}
}
}
};
}
async function storeOllamaCredential(agentDir) {
await upsertAuthProfileWithLock({
profileId: "ollama:default",
credential: {
type: "api_key",
provider: "ollama",
key: "ollama-local"
},
agentDir
});
}
async function promptForOllamaBaseUrl(prompter, env = process.env) {
const defaultBaseUrl = resolveOllamaSetupDefaultBaseUrl(env);
return resolveOllamaApiBase((await prompter.text({
message: "Ollama base URL",
initialValue: defaultBaseUrl,
placeholder: defaultBaseUrl,
validate: (value) => value?.trim() ? void 0 : "Required"
}) ?? defaultBaseUrl).trim().replace(/\/+$/, ""));
}
async function resolveHostBackedSuggestedModelNames(params) {
const modeConfig = HOST_BACKED_OLLAMA_MODE_CONFIG[params.mode];
if (!modeConfig.includeCloudModels) return OLLAMA_SUGGESTED_MODELS_LOCAL;
const auth = await checkOllamaCloudAuth(params.baseUrl);
if (auth.signedIn) return mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_LOCAL, OLLAMA_SUGGESTED_MODELS_CLOUD);
await params.prompter.note(buildOllamaCloudSigninLines(auth.signinUrl).join("\n"), modeConfig.noteTitle);
return OLLAMA_SUGGESTED_MODELS_LOCAL;
}
async function promptAndConfigureHostBackedOllama(params) {
const baseUrl = await promptForOllamaBaseUrl(params.prompter, params.env);
const { reachable, models } = await fetchOllamaModels(baseUrl);
if (!reachable) {
await params.prompter.note(buildOllamaUnreachableLines(baseUrl).join("\n"), "Ollama");
throw new WizardCancelledError("Ollama not reachable");
}
const enrichedModels = await enrichOllamaModelsWithContext(baseUrl, models.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT));
const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model]));
const discoveredModelNames = models.map((model) => model.name);
const suggestedModelNames = await resolveHostBackedSuggestedModelNames({
mode: params.mode,
baseUrl,
prompter: params.prompter
});
return {
credential: "ollama-local",
config: applyOllamaProviderConfig(params.cfg, baseUrl, mergeUniqueModelNames(suggestedModelNames, discoveredModelNames), discoveredModelsByName)
};
}
async function promptAndConfigureOllama(params) {
const mode = await params.prompter.select({
message: "Ollama mode",
options: [
{
value: "cloud-local",
label: "Cloud + Local",
hint: "Route cloud and local models through your Ollama host"
},
{
value: "cloud-only",
label: "Cloud only",
hint: "Hosted Ollama models via ollama.com"
},
{
value: "local-only",
label: "Local only",
hint: "Local models only"
}
]
});
if (mode === "cloud-only") {
const { credential, credentialMode } = await promptForOllamaCloudCredential({
cfg: params.cfg,
env: params.env,
opts: params.opts,
prompter: params.prompter,
secretInputMode: params.secretInputMode,
allowSecretRefPrompt: params.allowSecretRefPrompt
});
const { models: rawDiscoveredModels } = await fetchOllamaModels(OLLAMA_CLOUD_BASE_URL);
const discoveredModelNames = rawDiscoveredModels.slice(0, OLLAMA_CLOUD_MAX_DISCOVERED_MODELS).map((model) => model.name);
const modelNames = discoveredModelNames.length > 0 ? mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_CLOUD, discoveredModelNames) : OLLAMA_SUGGESTED_MODELS_CLOUD;
return {
credential,
credentialMode,
config: applyOllamaProviderConfig(params.cfg, OLLAMA_CLOUD_BASE_URL, modelNames, void 0, credential)
};
}
return await promptAndConfigureHostBackedOllama({
cfg: params.cfg,
mode,
prompter: params.prompter,
env: params.env
});
}
async function configureOllamaNonInteractive(params) {
const baseUrl = resolveOllamaApiBase((params.opts.customBaseUrl?.trim() || resolveOllamaSetupDefaultBaseUrl()).replace(/\/+$/, ""));
const { reachable, models } = await fetchOllamaModels(baseUrl);
const explicitModel = normalizeOllamaModelName(params.opts.customModelId);
if (!reachable) {
params.runtime.error(buildOllamaUnreachableLines(baseUrl).slice(0, 2).join("\n"));
params.runtime.exit(1);
return params.nextConfig;
}
await storeOllamaCredential(params.agentDir);
const enrichedModels = await enrichOllamaModelsWithContext(baseUrl, models.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT));
const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model]));
const modelNames = models.map((model) => model.name);
const orderedModelNames = mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_LOCAL, modelNames);
const requestedDefaultModelId = explicitModel ?? OLLAMA_SUGGESTED_MODELS_LOCAL[0];
const availableModelNames = new Set(modelNames);
const availableDefaultModelId = findAvailableOllamaModelName(requestedDefaultModelId, availableModelNames);
const requestedCloudModel = isOllamaCloudModel(requestedDefaultModelId);
let pulledRequestedModel = false;
if (requestedCloudModel) availableModelNames.add(requestedDefaultModelId);
else if (!availableDefaultModelId) {
pulledRequestedModel = await pullOllamaModelNonInteractive(baseUrl, requestedDefaultModelId, params.runtime);
if (pulledRequestedModel) availableModelNames.add(requestedDefaultModelId);
}
let allModelNames = orderedModelNames;
let defaultModelId = availableDefaultModelId ?? requestedDefaultModelId;
if ((pulledRequestedModel || requestedCloudModel) && !allModelNames.includes(requestedDefaultModelId)) allModelNames = [...allModelNames, requestedDefaultModelId];
if (!findAvailableOllamaModelName(defaultModelId, availableModelNames)) {
if (availableModelNames.size === 0) {
params.runtime.error([`No Ollama models are available at ${baseUrl}.`, "Pull a model first, then re-run setup."].join("\n"));
params.runtime.exit(1);
return params.nextConfig;
}
defaultModelId = allModelNames.find((name) => findAvailableOllamaModelName(name, availableModelNames)) ?? Array.from(availableModelNames)[0];
params.runtime.log(`Ollama model ${requestedDefaultModelId} was not available; using ${defaultModelId} instead.`);
}
const config = applyOllamaProviderConfig(params.nextConfig, baseUrl, allModelNames, discoveredModelsByName);
params.runtime.log(`Default Ollama model: ${defaultModelId}`);
return applyAgentDefaultModelPrimary(config, `ollama/${defaultModelId}`);
}
async function ensureOllamaModelPulled(params) {
if (!params.model.startsWith("ollama/")) return;
const baseUrl = readProviderBaseUrl(params.config.models?.providers?.ollama) ?? "http://127.0.0.1:11434";
const modelName = params.model.slice(7);
if (isOllamaCloudModel(modelName)) return;
const { models } = await fetchOllamaModels(baseUrl);
if (findAvailableOllamaModelName(modelName, models.map((model) => model.name))) return;
if (!await pullOllamaModel(baseUrl, modelName, params.prompter)) throw new WizardCancelledError("Failed to download selected Ollama model");
}
function toLintErrorObject(value, fallbackMessage) {
if (value instanceof Error) return value;
if (typeof value === "string") return new Error(value);
const error = new Error(fallbackMessage, { cause: value });
if (typeof value === "object" && value !== null || typeof value === "function") Object.assign(error, value);
return error;
}
//#endregion
export { promptAndConfigureOllama as i, configureOllamaNonInteractive as n, ensureOllamaModelPulled as r, checkOllamaCloudAuth as t };