Files
openclaw_openclaw/extensions/ollama/src/provider-models.ts
T
Bruce MacDonald ac3f55504c feat(ollama): detect vision capability from /api/show and set image i… (#62193)
Merged via squash.

Prepared head SHA: 85f85d10362ff69211e569de43705ba47ba6ebe5
Co-authored-by: BruceMacD <5853428+BruceMacD@users.noreply.github.com>
Co-authored-by: BruceMacD <5853428+BruceMacD@users.noreply.github.com>
Reviewed-by: @BruceMacD
2026-04-06 17:29:40 -07:00

201 lines
5.5 KiB
TypeScript

import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import {
OLLAMA_DEFAULT_BASE_URL,
OLLAMA_DEFAULT_CONTEXT_WINDOW,
OLLAMA_DEFAULT_COST,
OLLAMA_DEFAULT_MAX_TOKENS,
} from "./defaults.js";
export type OllamaTagModel = {
name: string;
modified_at?: string;
size?: number;
digest?: string;
remote_host?: string;
details?: {
family?: string;
parameter_size?: string;
};
};
export type OllamaTagsResponse = {
models?: OllamaTagModel[];
};
export type OllamaModelWithContext = OllamaTagModel & {
contextWindow?: number;
capabilities?: string[];
};
const OLLAMA_SHOW_CONCURRENCY = 8;
export function buildOllamaBaseUrlSsrFPolicy(baseUrl: string): SsrFPolicy | undefined {
const trimmed = baseUrl.trim();
if (!trimmed) {
return undefined;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return undefined;
}
return {
allowedHostnames: [parsed.hostname],
hostnameAllowlist: [parsed.hostname],
};
} catch {
return undefined;
}
}
export function resolveOllamaApiBase(configuredBaseUrl?: string): string {
if (!configuredBaseUrl) {
return OLLAMA_DEFAULT_BASE_URL;
}
const trimmed = configuredBaseUrl.replace(/\/+$/, "");
return trimmed.replace(/\/v1$/i, "");
}
export type OllamaModelShowInfo = {
contextWindow?: number;
capabilities?: string[];
};
export async function queryOllamaModelShowInfo(
apiBase: string,
modelName: string,
): Promise<OllamaModelShowInfo> {
try {
const { response, release } = await fetchWithSsrFGuard({
url: `${apiBase}/api/show`,
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: modelName }),
signal: AbortSignal.timeout(3000),
},
policy: buildOllamaBaseUrlSsrFPolicy(apiBase),
auditContext: "ollama-provider-models.show",
});
try {
if (!response.ok) {
return {};
}
const data = (await response.json()) as {
model_info?: Record<string, unknown>;
capabilities?: unknown;
};
let contextWindow: number | undefined;
if (data.model_info) {
for (const [key, value] of Object.entries(data.model_info)) {
if (
key.endsWith(".context_length") &&
typeof value === "number" &&
Number.isFinite(value)
) {
const ctx = Math.floor(value);
if (ctx > 0) {
contextWindow = ctx;
break;
}
}
}
}
const capabilities = Array.isArray(data.capabilities)
? (data.capabilities as unknown[]).filter((c): c is string => typeof c === "string")
: undefined;
return { contextWindow, capabilities };
} finally {
await release();
}
} catch {
return {};
}
}
/** @deprecated Use queryOllamaModelShowInfo instead. */
export async function queryOllamaContextWindow(
apiBase: string,
modelName: string,
): Promise<number | undefined> {
return (await queryOllamaModelShowInfo(apiBase, modelName)).contextWindow;
}
export async function enrichOllamaModelsWithContext(
apiBase: string,
models: OllamaTagModel[],
opts?: { concurrency?: number },
): Promise<OllamaModelWithContext[]> {
const concurrency = Math.max(1, Math.floor(opts?.concurrency ?? OLLAMA_SHOW_CONCURRENCY));
const enriched: OllamaModelWithContext[] = [];
for (let index = 0; index < models.length; index += concurrency) {
const batch = models.slice(index, index + concurrency);
const batchResults = await Promise.all(
batch.map(async (model) => {
const showInfo = await queryOllamaModelShowInfo(apiBase, model.name);
return {
...model,
contextWindow: showInfo.contextWindow,
capabilities: showInfo.capabilities,
};
}),
);
enriched.push(...batchResults);
}
return enriched;
}
export function isReasoningModelHeuristic(modelId: string): boolean {
return /r1|reasoning|think|reason/i.test(modelId);
}
export function buildOllamaModelDefinition(
modelId: string,
contextWindow?: number,
capabilities?: string[],
): ModelDefinitionConfig {
const hasVision = capabilities?.includes("vision") ?? false;
const input: ("text" | "image")[] = hasVision ? ["text", "image"] : ["text"];
return {
id: modelId,
name: modelId,
reasoning: isReasoningModelHeuristic(modelId),
input,
cost: OLLAMA_DEFAULT_COST,
contextWindow: contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW,
maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
};
}
export async function fetchOllamaModels(
baseUrl: string,
): Promise<{ reachable: boolean; models: OllamaTagModel[] }> {
try {
const apiBase = resolveOllamaApiBase(baseUrl);
const { response, release } = await fetchWithSsrFGuard({
url: `${apiBase}/api/tags`,
init: {
signal: AbortSignal.timeout(5000),
},
policy: buildOllamaBaseUrlSsrFPolicy(apiBase),
auditContext: "ollama-provider-models.tags",
});
try {
if (!response.ok) {
return { reachable: true, models: [] };
}
const data = (await response.json()) as OllamaTagsResponse;
const models = (data.models ?? []).filter((m) => m.name);
return { reachable: true, models };
} finally {
await release();
}
} catch {
return { reachable: false, models: [] };
}
}