ac3f55504c
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
201 lines
5.5 KiB
TypeScript
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: [] };
|
|
}
|
|
}
|