Compare commits

...
Author SHA1 Message Date
Max Paulus 🥪 276965b485 Respect per-model tool capabilities for Ollama
Parse model capabilities from model source payloads and preserve them in local provider registries. Only send runtime tools for Ollama models that explicitly advertise tool support.
2026-06-05 13:47:56 -07:00
6 changed files with 277 additions and 40 deletions
@@ -598,6 +598,45 @@ describe("addLocalProvider capabilities", () => {
expect(models[0].supportsReasoning).toBeFalsy();
});
it("preserves per-model capabilities fetched from a models source", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
models: [
{ name: "llama3:latest", capabilities: ["completion"] },
{
name: "qwen2.5-coder:latest",
capabilities: ["completion", "tools"],
},
],
}),
}),
);
await addLocalProvider(manager, {
providerId: "ollama-capability-provider",
name: "Ollama Capabilities",
baseUrl: "http://localhost:11434/v1",
modelsSourceUrl: "http://localhost:11434/api/tags",
});
const modelsState = await readModelsFile(
resolveModelsRegistryPath(manager),
);
expect(
modelsState.providers["ollama-capability-provider"]?.models?.[
"llama3:latest"
]?.capabilities,
).toBeUndefined();
expect(
modelsState.providers["ollama-capability-provider"]?.models?.[
"qwen2.5-coder:latest"
]?.capabilities,
).toEqual(["tools"]);
});
it("merges LiteLLM private models into the provider model listing when auth is configured", async () => {
manager.saveProviderSettings(
{
@@ -3,6 +3,7 @@ import {
type AddProviderActionRequest,
getClineEnvironmentConfig,
type ITelemetryService,
type ModelCapability,
type OAuthProviderId,
type ProviderCapability,
type ProviderConfigField,
@@ -32,8 +33,9 @@ import {
writeModelsFile,
} from "./local-provider-registry";
import {
fetchModelIdsFromSource,
fetchModelsFromSource,
resolveModelsSourceUrl,
type SourceModel,
} from "./model-source";
export { ensureCustomProvidersLoaded } from "./local-provider-registry";
@@ -279,18 +281,36 @@ function normalizeHeaders(
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function toModelCapabilities(
capabilities: ProviderCapability[] | undefined,
): ModelCapability[] | undefined {
if (!capabilities?.length) return undefined;
const next = new Set<ModelCapability>();
if (capabilities.includes("streaming")) next.add("streaming");
if (capabilities.includes("tools")) next.add("tools");
if (capabilities.includes("reasoning")) next.add("reasoning");
if (capabilities.includes("prompt-cache")) next.add("prompt-cache");
if (capabilities.includes("vision")) {
next.add("images");
next.add("files");
}
return next.size > 0 ? [...next] : undefined;
}
function buildProviderModels(
modelIds: string[],
models: SourceModel[],
capabilities: ProviderCapability[] | undefined,
) {
const supportsVision = capabilities?.includes("vision") ?? false;
const supportsReasoning = capabilities?.includes("reasoning") ?? false;
const fallbackCapabilities = toModelCapabilities(capabilities);
return Object.fromEntries(
modelIds.map((id) => [
id,
models.map((model) => [
model.id,
{
id,
name: id,
id: model.id,
name: model.id,
capabilities: model.capabilities ?? fallbackCapabilities,
supportsVision,
supportsAttachments: supportsVision,
supportsReasoning,
@@ -299,20 +319,27 @@ function buildProviderModels(
);
}
async function resolveModelIds(params: {
async function resolveModels(params: {
providerId: string;
explicitModels?: string[];
modelsSourceUrl?: string;
fallbackModelIds?: string[];
fallbackModels?: SourceModel[];
shouldRecompute: boolean;
}): Promise<string[]> {
}): Promise<SourceModel[]> {
if (!params.shouldRecompute) {
return params.fallbackModelIds ?? [];
return params.fallbackModels ?? [];
}
const modelMap = new Map<string, SourceModel>();
for (const id of params.explicitModels ?? []) {
modelMap.set(id, { id });
}
const fetchedModels = params.modelsSourceUrl
? await fetchModelIdsFromSource(params.modelsSourceUrl, params.providerId)
? await fetchModelsFromSource(params.modelsSourceUrl, params.providerId)
: [];
return [...new Set([...(params.explicitModels ?? []), ...fetchedModels])];
for (const model of fetchedModels) {
modelMap.set(model.id, model);
}
return [...modelMap.values()];
}
function removeProviderFromSettingsState(
@@ -382,12 +409,13 @@ export async function addLocalProvider(
const typedModels = uniqueTrimmed(request.models);
const sourceUrl = request.modelsSourceUrl?.trim();
const modelIds = await resolveModelIds({
const models = await resolveModels({
providerId,
explicitModels: typedModels,
modelsSourceUrl: sourceUrl,
shouldRecompute: true,
});
const modelIds = models.map((model) => model.id);
if (modelIds.length === 0) {
throw new Error(
"at least one model is required (manual or via modelsSourceUrl)",
@@ -432,7 +460,7 @@ export async function addLocalProvider(
capabilities,
modelsSourceUrl: sourceUrl,
},
models: buildProviderModels(modelIds, capabilities),
models: buildProviderModels(models, capabilities),
};
await writeModelsFile(modelsPath, modelsState);
registerCustomProvider(providerId, modelsState.providers[providerId]);
@@ -487,7 +515,10 @@ export async function updateLocalProvider(
capabilities: existingSettings.capabilities,
},
models: seedModelId
? buildProviderModels([seedModelId], existingSettings.capabilities)
? buildProviderModels(
[{ id: seedModelId }],
existingSettings.capabilities,
)
: {},
};
}
@@ -527,16 +558,20 @@ export async function updateLocalProvider(
const shouldRecomputeModels =
request.models !== undefined ||
(request.modelsSourceUrl !== undefined && !!nextModelsSourceUrl);
const existingModelIds = Object.keys(existingEntry.models ?? {})
.map((id) => id.trim())
.filter(Boolean);
const modelIds = await resolveModelIds({
const existingModels = Object.entries(existingEntry.models ?? {})
.map(([modelKey, model]) => ({
id: model.id?.trim() || modelKey.trim(),
capabilities: model.capabilities,
}))
.filter((model) => model.id.length > 0);
const models = await resolveModels({
providerId,
explicitModels,
modelsSourceUrl: nextModelsSourceUrl,
fallbackModelIds: existingModelIds,
fallbackModels: existingModels,
shouldRecompute: shouldRecomputeModels,
});
const modelIds = models.map((model) => model.id);
if (modelIds.length === 0) {
throw new Error(
"at least one model is required (manual or via modelsSourceUrl)",
@@ -593,7 +628,7 @@ export async function updateLocalProvider(
capabilities,
modelsSourceUrl: nextModelsSourceUrl,
},
models: buildProviderModels(modelIds, capabilities),
models: buildProviderModels(models, capabilities),
};
await writeModelsFile(modelsPath, modelsState);
registerCustomProvider(providerId, modelsState.providers[providerId]);
@@ -1,26 +1,64 @@
function parseModelIdList(input: unknown): string[] {
import { type ModelCapability, ModelCapabilitySchema } from "@cline/shared";
export interface SourceModel {
id: string;
capabilities?: ModelCapability[];
}
function toModelCapability(value: unknown): ModelCapability | undefined {
if (typeof value !== "string") return undefined;
const normalized =
value === "structured-output" ? "structured_output" : value;
const parsed = ModelCapabilitySchema.safeParse(normalized);
return parsed.success ? parsed.data : undefined;
}
function parseCapabilities(input: unknown): ModelCapability[] | undefined {
if (!Array.isArray(input)) return undefined;
const capabilities = [
...new Set(
input
.map(toModelCapability)
.filter((value): value is ModelCapability => value !== undefined),
),
];
return capabilities.length > 0 ? capabilities : undefined;
}
function parseModelList(input: unknown): SourceModel[] {
if (!Array.isArray(input)) return [];
return input
.map((item) => {
if (typeof item === "string") return item.trim();
.map((item): SourceModel | undefined => {
if (typeof item === "string") {
const id = item.trim();
return id ? { id } : undefined;
}
if (item && typeof item === "object") {
const entry = item as { id?: unknown; name?: unknown; model?: unknown };
const entry = item as {
id?: unknown;
name?: unknown;
model?: unknown;
capabilities?: unknown;
};
for (const value of [entry.id, entry.name, entry.model]) {
if (typeof value === "string" && value.trim()) {
return value.trim();
return {
id: value.trim(),
capabilities: parseCapabilities(entry.capabilities),
};
}
}
}
return "";
return undefined;
})
.filter((id) => id.length > 0);
.filter((model): model is SourceModel => model !== undefined);
}
export function extractModelIdsFromPayload(
export function extractModelsFromPayload(
payload: unknown,
providerId: string,
): string[] {
const rootArray = parseModelIdList(payload);
): SourceModel[] {
const rootArray = parseModelList(payload);
if (rootArray.length > 0) return rootArray;
if (!payload || typeof payload !== "object") return [];
@@ -30,7 +68,7 @@ export function extractModelIdsFromPayload(
providers?: Record<string, unknown>;
};
const direct = parseModelIdList(data.data ?? data.models);
const direct = parseModelList(data.data ?? data.models);
if (direct.length > 0) return direct;
if (
@@ -39,35 +77,51 @@ export function extractModelIdsFromPayload(
!Array.isArray(data.models)
) {
const keys = Object.keys(data.models).filter((k) => k.trim().length > 0);
if (keys.length > 0) return keys;
if (keys.length > 0) return keys.map((id) => ({ id }));
}
const scoped = data.providers?.[providerId];
if (scoped && typeof scoped === "object") {
const nested = scoped as { models?: unknown };
const list = parseModelIdList(nested.models ?? scoped);
const list = parseModelList(nested.models ?? scoped);
if (list.length > 0) return list;
}
return [];
}
export async function fetchModelIdsFromSource(
export function extractModelIdsFromPayload(
payload: unknown,
providerId: string,
): string[] {
return extractModelsFromPayload(payload, providerId).map((model) => model.id);
}
export async function fetchModelsFromSource(
url: string,
providerId: string,
): Promise<string[]> {
): Promise<SourceModel[]> {
const response = await fetch(url, { method: "GET" });
if (!response.ok) {
throw new Error(
`failed to fetch models from ${url}: HTTP ${response.status}`,
);
}
return extractModelIdsFromPayload(
return extractModelsFromPayload(
(await response.json()) as unknown,
providerId,
);
}
export async function fetchModelIdsFromSource(
url: string,
providerId: string,
): Promise<string[]> {
return (await fetchModelsFromSource(url, providerId)).map(
(model) => model.id,
);
}
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}
+27 -3
View File
@@ -229,6 +229,30 @@ function providerDisablesExternalToolExecution(
return context.provider.capabilities?.includes("provider-tools") ?? false;
}
function modelExplicitlySupportsRuntimeTools(
context: GatewayProviderContext,
): boolean {
return context.model.capabilities?.includes("tools") ?? false;
}
function providerRequiresExplicitModelToolsCapability(
context: GatewayProviderContext,
): boolean {
return (
context.provider.metadata?.requiresExplicitModelToolsCapability === true
);
}
function shouldSendRuntimeTools(context: GatewayProviderContext): boolean {
if (providerDisablesExternalToolExecution(context)) {
return false;
}
if (providerRequiresExplicitModelToolsCapability(context)) {
return modelExplicitlySupportsRuntimeTools(context);
}
return true;
}
function mergeToolCallMetadata(
current: unknown,
patch: Record<string, unknown>,
@@ -879,9 +903,9 @@ function createAiSdkProvider(kind: ProviderModuleKind): GatewayProviderFactory {
const langfuse = await ensureGatewayLangfuseTelemetry(
config.providerId,
);
const tools = providerDisablesExternalToolExecution(context)
? undefined
: toAiSdkTools(request);
const tools = shouldSendRuntimeTools(context)
? toAiSdkTools(request)
: undefined;
const systemPrompt = resolveAiSdkSystemPrompt(request);
const useSystemOption =
typeof systemPrompt === "string" && systemPrompt.trim().length > 0;
@@ -781,6 +781,7 @@ const OPENAI_COMPATIBLE_SPECS: BuiltinSpec[] = [
apiKeyEnv: ["OLLAMA_API_KEY"],
defaults: { baseUrl: "http://localhost:11434/v1" },
modelsSourceUrl: "http://localhost:11434/api/tags",
metadata: { requiresExplicitModelToolsCapability: true },
},
{
id: "lmstudio",
@@ -1601,6 +1601,90 @@ describe("sdk-gateway", () => {
);
});
it("omits runtime tools when provider requires explicit model tool capability and the model lacks it", async () => {
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([
{ type: "finish", usage: { inputTokens: 1, outputTokens: 1 } },
]),
});
const gateway = createGateway({
providerConfigs: [
{
providerId: "ollama",
defaultModelId: "llama3:latest",
models: [{ id: "llama3:latest", name: "llama3:latest" }],
},
],
});
await collect(
await gateway.stream({
providerId: "ollama",
modelId: "llama3:latest",
messages: baseMessages,
tools: [
{
name: "run_commands",
description: "Runs shell commands",
inputSchema: { type: "object" },
},
],
}),
);
expect(streamTextSpy).toHaveBeenCalledWith(
expect.objectContaining({
tools: undefined,
}),
);
});
it("passes runtime tools when provider requires explicit model tool capability and the model has it", async () => {
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([
{ type: "finish", usage: { inputTokens: 1, outputTokens: 1 } },
]),
});
const gateway = createGateway({
providerConfigs: [
{
providerId: "ollama",
defaultModelId: "qwen2.5-coder:latest",
models: [
{
id: "qwen2.5-coder:latest",
name: "qwen2.5-coder:latest",
capabilities: ["tools"],
},
],
},
],
});
await collect(
await gateway.stream({
providerId: "ollama",
modelId: "qwen2.5-coder:latest",
messages: baseMessages,
tools: [
{
name: "run_commands",
description: "Runs shell commands",
inputSchema: { type: "object" },
},
],
}),
);
expect(streamTextSpy).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({ run_commands: expect.anything() }),
}),
);
});
it("tags tool call events with provider metadata for providers that disable external tool execution", async () => {
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([