custom-providers: live loaded-model status + thinking support for llamacpp

- Extend ProviderStatus with loadedModels[] populated from /running;
  llamacpp panel now shows active model name instead of just "N models"
- Return DiscoverResult from discoverProvider (models + loadedModels)
- All llamacpp models get reasoning:true + compat.thinkingFormat:"qwen-chat-template"
  so Ctrl+T / Shift+Tab work for both Qwen3 and Gemma4 (verified live)
This commit is contained in:
EislM0203
2026-06-29 16:28:30 +02:00
committed by markus.eisl
parent 336d26bfcd
commit 9d05a67512
5 changed files with 89 additions and 82 deletions
+40 -8
View File
@@ -105,11 +105,12 @@ describe("discoverProvider dispatch", () => {
apiKey: "ollama",
defaultCtx: 8192,
};
const models = await discoverProvider(p);
expect(models?.length).toBe(2);
const result = await discoverProvider(p);
expect(result?.models.length).toBe(2);
expect(calls.some((u) => u.endsWith("/running"))).toBe(false);
expect(calls.filter((u) => u.endsWith("/api/show")).length).toBe(2);
expect(models?.[0].contextWindow).toBe(16384);
expect(result?.models[0].contextWindow).toBe(16384);
expect(result?.loadedModels).toEqual([]);
});
it("llamacpp provider: probes /running once, never /api/show", async () => {
@@ -120,13 +121,14 @@ describe("discoverProvider dispatch", () => {
apiKey: "llamacpp",
defaultCtx: 60000,
};
const models = await discoverProvider(p);
expect(models?.length).toBe(2);
const result = await discoverProvider(p);
expect(result?.models.length).toBe(2);
expect(calls.some((u) => u.endsWith("/api/show"))).toBe(false);
expect(calls.filter((u) => u.endsWith("/running")).length).toBe(1);
const byId = new Map(models?.map((m) => [m.id, m.contextWindow]));
const byId = new Map(result?.models.map((m) => [m.id, m.contextWindow]));
expect(byId.get("llama3")).toBe(32768);
expect(byId.get("qwen3")).toBe(65536);
expect(result?.loadedModels).toEqual(["llama3", "qwen3"]);
});
it("strips a trailing /v1 from baseUrl when deriving the native URL", async () => {
@@ -157,7 +159,37 @@ describe("discoverProvider dispatch", () => {
apiKey: "ollama",
defaultCtx: 4242,
};
const models = await discoverProvider(p);
expect(models?.[0].contextWindow).toBe(4242);
const result = await discoverProvider(p);
expect(result?.models[0].contextWindow).toBe(4242);
});
it("marks all llamacpp models as reasoning with qwen-chat-template compat", async () => {
const p: CustomProvider = {
name: "llamacpp",
kind: "llamacpp",
baseUrl: "http://localhost:9000/v1",
apiKey: "llamacpp",
defaultCtx: 60000,
};
const result = await discoverProvider(p);
for (const m of result?.models ?? []) {
expect(m.reasoning).toBe(true);
expect(m.compat?.thinkingFormat).toBe("qwen-chat-template");
}
});
it("ollama models are not marked as reasoning", async () => {
const p: CustomProvider = {
name: "ollama",
kind: "ollama",
baseUrl: "http://localhost:11434/v1",
apiKey: "ollama",
defaultCtx: 8192,
};
const result = await discoverProvider(p);
for (const m of result?.models ?? []) {
expect(m.reasoning).toBe(false);
expect(m.compat).toBeUndefined();
}
});
});
+35 -12
View File
@@ -4,6 +4,7 @@ export interface DiscoveredModel {
id: string;
name: string;
reasoning: boolean;
compat?: { thinkingFormat: "qwen-chat-template" };
input: Array<"text" | "image">;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
@@ -13,6 +14,12 @@ export interface DiscoveredModel {
export interface ProviderStatus {
ok: boolean;
modelCount: number;
loadedModels: string[];
}
export interface DiscoverResult {
models: DiscoveredModel[];
loadedModels: string[];
}
async function fetchJson<T>(
@@ -74,21 +81,31 @@ export function parseContextFromCmd(cmd: string): number | null {
return Number.isFinite(n) && n > 0 ? n : null;
}
async function fetchLlamaSwapContexts(nativeUrl: string): Promise<Map<string, number> | null> {
interface LlamaSwapInfo {
contexts: Map<string, number>;
loadedModels: string[];
}
async function fetchLlamaSwapContexts(nativeUrl: string): Promise<LlamaSwapInfo | null> {
const running = await fetchJson<{ running?: Array<{ model?: string; cmd?: string }> }>(
`${nativeUrl}/running`,
);
if (!running || !Array.isArray(running.running)) return null;
const map = new Map<string, number>();
const contexts = new Map<string, number>();
const loadedModels: string[] = [];
for (const entry of running.running) {
if (!entry.model || !entry.cmd) continue;
const ctx = parseContextFromCmd(entry.cmd);
if (ctx !== null) map.set(entry.model, ctx);
if (!entry.model) continue;
loadedModels.push(entry.model);
if (entry.cmd) {
const ctx = parseContextFromCmd(entry.cmd);
if (ctx !== null) contexts.set(entry.model, ctx);
}
}
return map;
return { contexts, loadedModels };
}
export async function discoverProvider(p: CustomProvider): Promise<DiscoveredModel[] | null> {
export async function discoverProvider(p: CustomProvider): Promise<DiscoverResult | null> {
const baseUrl = p.baseUrl.replace(/\/$/, "");
const list = await fetchJson<{ data?: Array<{ id: string }> }>(`${baseUrl}/models`);
if (!list?.data?.length) return null;
@@ -96,22 +113,27 @@ export async function discoverProvider(p: CustomProvider): Promise<DiscoveredMod
if (!chatModels.length) return null;
const nativeUrl = baseUrl.replace(/\/v1$/, "");
const llamaSwapContexts =
const llamaSwapInfo =
p.kind === "llamacpp" ? await fetchLlamaSwapContexts(nativeUrl) : null;
const loadedModels = llamaSwapInfo?.loadedModels ?? [];
return Promise.all(
const models = await Promise.all(
chatModels.map(async (m) => {
let ctx = p.defaultCtx;
if (p.kind === "llamacpp" && llamaSwapContexts) {
ctx = llamaSwapContexts.get(m.id) ?? p.defaultCtx;
if (p.kind === "llamacpp" && llamaSwapInfo) {
ctx = llamaSwapInfo.contexts.get(m.id) ?? p.defaultCtx;
} else if (p.kind === "ollama") {
const real = await ollamaContext(nativeUrl, m.id);
if (real) ctx = real;
}
const thinking: { reasoning: true; compat: { thinkingFormat: "qwen-chat-template" } } | { reasoning: false } =
p.kind === "llamacpp"
? { reasoning: true, compat: { thinkingFormat: "qwen-chat-template" } }
: { reasoning: false };
return {
id: m.id,
name: m.id,
reasoning: false,
...thinking,
input: ["text"] as Array<"text" | "image">,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: ctx,
@@ -119,4 +141,5 @@ export async function discoverProvider(p: CustomProvider): Promise<DiscoveredMod
};
}),
);
return { models, loadedModels };
}
+5 -5
View File
@@ -12,19 +12,19 @@ export default async function (pi: ExtensionAPI) {
pi.unregisterProvider(p.name);
registered.delete(p.name);
}
const models = await discoverProvider(p);
if (models && models.length > 0) {
const result = await discoverProvider(p);
if (result && result.models.length > 0) {
pi.registerProvider(p.name, {
name: p.name,
api: "openai-completions",
baseUrl: p.baseUrl,
apiKey: p.apiKey || p.name,
models,
models: result.models,
});
registered.add(p.name);
statusByName.set(p.name, { ok: true, modelCount: models.length });
statusByName.set(p.name, { ok: true, modelCount: result.models.length, loadedModels: result.loadedModels });
} else {
statusByName.set(p.name, { ok: false, modelCount: 0 });
statusByName.set(p.name, { ok: false, modelCount: 0, loadedModels: [] });
}
}
+9 -3
View File
@@ -24,13 +24,19 @@ type PanelAction =
| { kind: "remove"; name: string }
| { kind: "refresh"; name: string };
function statusLabel(status: ProviderStatus | undefined): string {
function statusLabel(p: CustomProvider, status: ProviderStatus | undefined): string {
if (!status) return "not checked";
return status.ok ? `${status.modelCount} models` : "unreachable";
if (!status.ok) return "unreachable";
if (p.kind === "llamacpp" && status.loadedModels.length > 0) {
const active = status.loadedModels[0];
const rest = status.modelCount - 1;
return rest > 0 ? `${active} (+${rest})` : active;
}
return `${status.modelCount} models`;
}
function providerLine(p: CustomProvider, status: ProviderStatus | undefined): string {
return `${p.name.padEnd(12)} [${p.kind.padEnd(8)}] ${p.baseUrl} [${statusLabel(status)}]`;
return `${p.name.padEnd(12)} [${p.kind.padEnd(8)}] ${p.baseUrl} [${statusLabel(p, status)}]`;
}
function showList(
-54
View File
@@ -1456,9 +1456,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1476,9 +1473,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1496,9 +1490,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1516,9 +1507,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1536,9 +1524,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1788,9 +1773,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1805,9 +1787,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1822,9 +1801,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1839,9 +1815,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1856,9 +1829,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1873,9 +1843,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1890,9 +1857,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1907,9 +1871,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1924,9 +1885,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1941,9 +1899,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1958,9 +1913,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1975,9 +1927,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1992,9 +1941,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [