Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- Fixed llama.cpp model discovery to honor per-model `architecture.input_modalities` from `/v1/models`, so router presets that advertise image input are no longer treated as text-only ([#4719](https://github.com/can1357/oh-my-pi/issues/4719)).

## [16.3.10] - 2026-07-06

### Changed
Expand Down
20 changes: 18 additions & 2 deletions packages/coding-agent/src/config/model-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ type LlamaCppDiscoveredModelRuntimeMetadata = {

type LlamaCppModelListEntry = {
id: string;
input?: ("text" | "image")[];
runtimeContextWindow?: number;
/**
* `--ctx-size` extracted from the entry's `status.args` (rendered CLI arg
Expand Down Expand Up @@ -276,6 +277,20 @@ function extractLlamaCppModelContextWindows(
};
}

function extractLlamaCppModelInputCapabilities(item: Record<string, unknown>): ("text" | "image")[] | undefined {
const architecture = item.architecture;
if (!isRecord(architecture) || !Array.isArray(architecture.input_modalities)) {
return undefined;
}
const modalities = new Set<string>();
for (const modality of architecture.input_modalities) {
if (typeof modality === "string") {
modalities.add(modality.toLowerCase());
}
}
return modalities.has("image") ? ["text", "image"] : ["text"];
}

function parseLlamaCppModelList(payload: unknown): LlamaCppModelListEntry[] {
if (!isRecord(payload) || !Array.isArray(payload.data)) {
return [];
Expand All @@ -287,6 +302,7 @@ function parseLlamaCppModelList(payload: unknown): LlamaCppModelListEntry[] {
return [
{
id: item.id,
input: extractLlamaCppModelInputCapabilities(item),
...extractLlamaCppModelContextWindows(item),
configuredContextWindow: extractLlamaCppConfiguredContextWindow(item),
},
Expand Down Expand Up @@ -545,7 +561,7 @@ export async function discoverLlamaCppModels(
provider: providerConfig.provider,
baseUrl,
reasoning: false,
input: serverMetadata?.input ?? ["text"],
input: item.input ?? serverMetadata?.input ?? ["text"],
imageInputDecoder: "stb",
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow,
Expand Down Expand Up @@ -595,7 +611,7 @@ export async function discoverLlamaCppModelRuntimeMetadata(
entry.configuredContextWindow ??
serverMetadata?.contextWindow ??
entry.trainingContextWindow;
const input = serverMetadata?.input;
const input = entry.input ?? serverMetadata?.input;
if (contextWindow === undefined) {
return input === undefined ? undefined : { input };
}
Expand Down
99 changes: 99 additions & 0 deletions packages/coding-agent/test/model-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,41 @@ describe("ModelRegistry runtime discovery", () => {
expect(llama?.input).toEqual(["text", "image"]);
});

test("llama.cpp discovery marks per-model architecture image modalities as vision-capable", async () => {
const fetchMock: FetchImpl = async input => {
const url = String(input);
if (url === "http://127.0.0.1:8080/models") {
return new Response(
JSON.stringify({
data: [
{
id: "q51q41_mtp_30tps_120k",
architecture: {
input_modalities: ["text", "image"],
output_modalities: ["text"],
},
meta: { n_ctx: 123904 },
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === "http://127.0.0.1:8080/props") {
return new Response(JSON.stringify({ default_generation_settings: { n_ctx: 123904 } }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
throw new Error(`Unexpected URL: ${url}`);
};
const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock });
await registry.refresh();
const llama = registry.find("llama.cpp", "q51q41_mtp_30tps_120k");
expect(llama?.contextWindow).toBe(123904);
expect(llama?.input).toEqual(["text", "image"]);
});

test("llama.cpp discovery ignores positive props defaults as per-request limits, not hard caps", async () => {
const fetchMock: FetchImpl = async input => {
const url = String(input);
Expand Down Expand Up @@ -1108,6 +1143,70 @@ describe("ModelRegistry runtime discovery", () => {
expect(registry.find("llama.cpp", "vision-model")?.input).toEqual(["text", "image"]);
});

test("llama.cpp selected model refresh reads image capability from per-model architecture", async () => {
writeModelCache(
"llama.cpp",
Date.now(),
[
buildModel({
id: "router-vision-model",
name: "router-vision-model",
provider: "llama.cpp",
api: "openai-responses",
baseUrl: "http://127.0.0.1:8080",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 32768,
}),
],
true,
"",
cacheDbPath,
);
const fetchMock: FetchImpl = async input => {
const url = String(input);
if (url === "http://127.0.0.1:8080/models") {
return new Response(
JSON.stringify({
data: [
{
id: "router-vision-model",
architecture: {
input_modalities: ["text", "image"],
output_modalities: ["text"],
},
meta: { n_ctx: 239104 },
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === "http://127.0.0.1:8080/props") {
return new Response(
JSON.stringify({
default_generation_settings: {
n_ctx: 239104,
params: { max_tokens: -1, n_predict: -1 },
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
throw new Error(`Unexpected URL: ${url}`);
};
const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock });
const stale = registry.find("llama.cpp", "router-vision-model");
if (!stale) throw new Error("cached llama.cpp model missing");
expect(stale.input).toEqual(["text"]);
const refreshed = await registry.refreshSelectedModelMetadata(stale);
expect(refreshed.contextWindow).toBe(239104);
expect(refreshed.input).toEqual(["text", "image"]);
expect(registry.find("llama.cpp", "router-vision-model")?.input).toEqual(["text", "image"]);
});

test("llama.cpp selected model refresh leaves the cached model untouched when /models no longer lists it", async () => {
writeModelCache(
"llama.cpp",
Expand Down
Loading