mirror of
https://github.com/cline/cline.git
synced 2026-09-17 17:45:33 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
276965b485 |
@@ -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(/\/+$/, "");
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user