mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
fix(core): stop an empty capability list from stripping image input (#13583)
`modelHasCapability` documents a missing or empty capability list as
carrying no signal, so each gate declares its own default. Two readers
bypassed it and read `capabilities` directly, where an empty list is not
nullish but `[].includes(x)` is false:
- the session runtime's `modelSupportsImages` metadata used
`capabilities?.includes("images") ?? true`, so the intended fail-open
never fired for an empty list and the file-read tool silently dropped
every image from the request;
- `toProviderModel` projected an empty list onto `false`, telling pickers
a model definitively lacks vision, attachments, and reasoning when
nothing had been declared.
Both now route through the shared helpers, which state their unspecified
default explicitly: `modelSupportsImageInput` fails open for a capability
gate, and `declaredCapability` preserves `undefined` for `ProviderModel`'s
tri-state booleans. A populated list stays authoritative in both.
A thinking config now short-circuits `supportsReasoning` instead of being
OR-ed with the capability read, so its absence no longer collapses the
tri-state to `false`.
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
This commit is contained in:
@@ -906,6 +906,51 @@ it("derives tool image support metadata from resolved provider model catalog", a
|
||||
expect(runtimeConfig.toolContextMetadata?.telemetry).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["absent", undefined],
|
||||
["empty", []],
|
||||
])("keeps image support enabled when the capability list is %s", async (_label, capabilities) => {
|
||||
const { deps, configs } = withCapturingFakeRuntime();
|
||||
const session = new SessionRuntime(
|
||||
makeAgentConfig({
|
||||
knownModels: {
|
||||
"claude-3-5-sonnet": {
|
||||
id: "claude-3-5-sonnet",
|
||||
...(capabilities === undefined ? {} : { capabilities }),
|
||||
},
|
||||
},
|
||||
}),
|
||||
deps,
|
||||
);
|
||||
|
||||
await session.run("inspect image");
|
||||
|
||||
expect(configs[0]?.toolContextMetadata).toEqual(
|
||||
expect.objectContaining({ modelSupportsImages: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables image support when a populated capability list omits images", async () => {
|
||||
const { deps, configs } = withCapturingFakeRuntime();
|
||||
const session = new SessionRuntime(
|
||||
makeAgentConfig({
|
||||
knownModels: {
|
||||
"claude-3-5-sonnet": {
|
||||
id: "claude-3-5-sonnet",
|
||||
capabilities: ["tools", "prompt-cache"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
deps,
|
||||
);
|
||||
|
||||
await session.run("inspect image");
|
||||
|
||||
expect(configs[0]?.toolContextMetadata).toEqual(
|
||||
expect.objectContaining({ modelSupportsImages: false }),
|
||||
);
|
||||
});
|
||||
|
||||
describe("SessionRuntime.run", () => {
|
||||
it("invokes the injected AgentRuntime and returns an AgentResult", async () => {
|
||||
const { deps, calls } = withFakeRuntime({
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
type MessageWithMetadata,
|
||||
type ModelInfo,
|
||||
mergeModelOptions,
|
||||
modelSupportsImageInput,
|
||||
modelSupportsToolCalling,
|
||||
type ToolCallRecord,
|
||||
usesImageGenerationOperation,
|
||||
@@ -890,8 +891,7 @@ export class SessionRuntime {
|
||||
telemetry: this.telemetry,
|
||||
tools,
|
||||
toolContextMetadata: {
|
||||
modelSupportsImages:
|
||||
modelInfo?.capabilities?.includes("images") ?? true,
|
||||
modelSupportsImages: modelSupportsImageInput(modelInfo ?? {}),
|
||||
...this.config.toolContextMetadata,
|
||||
},
|
||||
hooks: this.createRuntimeHooks(),
|
||||
|
||||
@@ -219,6 +219,23 @@ export async function writeModelsFile(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects one capability onto `ProviderModel`'s tri-state booleans, where
|
||||
* `undefined` means "not declared" and drives each picker's own default.
|
||||
* A missing OR empty capability list carries no signal (see
|
||||
* `modelHasCapability`), so both must stay `undefined` rather than collapsing
|
||||
* to a `false` that reads as an authoritative denial.
|
||||
*/
|
||||
function declaredCapability(
|
||||
capabilities: ModelInfo["capabilities"],
|
||||
capability: ModelCapability,
|
||||
): boolean | undefined {
|
||||
if (capabilities === undefined || capabilities.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return capabilities.includes(capability);
|
||||
}
|
||||
|
||||
export function toProviderModel(
|
||||
modelId: string,
|
||||
info: Pick<
|
||||
@@ -241,10 +258,15 @@ export function toProviderModel(
|
||||
...(info.contextWindow !== undefined
|
||||
? { contextWindow: info.contextWindow }
|
||||
: {}),
|
||||
supportsAttachments: info.capabilities?.includes("files"),
|
||||
supportsVision: info.capabilities?.includes("images"),
|
||||
supportsAttachments: declaredCapability(info.capabilities, "files"),
|
||||
supportsVision: declaredCapability(info.capabilities, "images"),
|
||||
// A thinking config is positive evidence on its own; its absence is
|
||||
// not evidence of absence, so fall back to whatever the capability
|
||||
// list declares (including "not declared").
|
||||
supportsReasoning:
|
||||
info.capabilities?.includes("reasoning") || info.thinkingConfig != null,
|
||||
info.thinkingConfig != null
|
||||
? true
|
||||
: declaredCapability(info.capabilities, "reasoning"),
|
||||
operationModes: info.operationModes,
|
||||
inputModalities: info.modalities?.input,
|
||||
outputModalities: info.modalities?.output,
|
||||
|
||||
@@ -959,6 +959,37 @@ describe("addLocalProvider – capabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["absent", undefined],
|
||||
["empty", [] as const],
|
||||
])("leaves capability support undeclared when the list is %s", (_label, capabilities) => {
|
||||
expect(
|
||||
toProviderModel("sparse-model", {
|
||||
name: "Sparse Model",
|
||||
...(capabilities === undefined
|
||||
? {}
|
||||
: { capabilities: [...capabilities] }),
|
||||
}),
|
||||
).toMatchObject({
|
||||
supportsVision: undefined,
|
||||
supportsAttachments: undefined,
|
||||
supportsReasoning: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a populated capability list as authoritative", () => {
|
||||
expect(
|
||||
toProviderModel("vision-only", {
|
||||
name: "Vision Only",
|
||||
capabilities: ["images"],
|
||||
}),
|
||||
).toMatchObject({
|
||||
supportsVision: true,
|
||||
supportsAttachments: false,
|
||||
supportsReasoning: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets supportsVision and supportsAttachments when capability is 'vision'", async () => {
|
||||
await addLocalProvider(manager, {
|
||||
providerId: "vision-provider",
|
||||
|
||||
@@ -209,6 +209,7 @@ export {
|
||||
ModelStatusSchema,
|
||||
modelHasCapability,
|
||||
modelProducesImages,
|
||||
modelSupportsImageInput,
|
||||
modelSupportsToolCalling,
|
||||
supportsChatModalities,
|
||||
type ThinkingConfig,
|
||||
|
||||
@@ -234,6 +234,7 @@ export {
|
||||
ModelStatusSchema,
|
||||
modelHasCapability,
|
||||
modelProducesImages,
|
||||
modelSupportsImageInput,
|
||||
modelSupportsToolCalling,
|
||||
supportsChatModalities,
|
||||
type ThinkingConfig,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
isChatCompatibleModel,
|
||||
ModelInfoSchema,
|
||||
modelHasCapability,
|
||||
modelSupportsImageInput,
|
||||
modelSupportsToolCalling,
|
||||
supportsChatModalities,
|
||||
} from "./model-info";
|
||||
@@ -138,3 +139,17 @@ describe("modelSupportsToolCalling", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelSupportsImageInput", () => {
|
||||
it("fails open when capability metadata is missing or empty", () => {
|
||||
expect(modelSupportsImageInput({})).toBe(true);
|
||||
expect(modelSupportsImageInput({ capabilities: [] })).toBe(true);
|
||||
});
|
||||
|
||||
it("trusts a populated capability list", () => {
|
||||
expect(modelSupportsImageInput({ capabilities: ["images"] })).toBe(true);
|
||||
expect(
|
||||
modelSupportsImageInput({ capabilities: ["tools", "prompt-cache"] }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,6 +220,19 @@ export function modelSupportsToolCalling(model: {
|
||||
return modelHasCapability(model, "tools", { assumeWhenUnspecified: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a model can receive image parts in a request. Fails open on the
|
||||
* same grounds as `modelSupportsToolCalling`: a host boundary that reports no
|
||||
* capabilities at all has not declared the model text-only, and stripping
|
||||
* images from a vision-capable model loses user content silently. A populated
|
||||
* list without `images` is authoritative.
|
||||
*/
|
||||
export function modelSupportsImageInput(model: {
|
||||
capabilities?: readonly string[];
|
||||
}): boolean {
|
||||
return modelHasCapability(model, "images", { assumeWhenUnspecified: true });
|
||||
}
|
||||
|
||||
export const ModelInfoSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
|
||||
Reference in New Issue
Block a user