Model capabilities 734 (#842)

internal/config,server: implement model capabilities

- define the capabilities of a model using a simple config block on the
model
- v1/models renders out capabilities to be compatible with openrouter,
huggingface chat, and mistral formats for broader compatibility
- add support for capabilities in UI

Fixes #734
This commit is contained in:
Benson Wong
2026-06-13 23:23:19 -07:00
committed by GitHub
parent 62aea0e83d
commit 92b90447e8
16 changed files with 868 additions and 35 deletions
@@ -145,7 +145,7 @@
<div class="flex flex-col h-full">
<!-- Model selector -->
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select an audio model..." disabled={isTranscribing} />
<ModelSelector bind:value={$selectedModelStore} placeholder="Select an audio model..." disabled={isTranscribing} capabilities={["audio_transcriptions"]} />
</div>
<!-- Empty state for no models configured -->
@@ -193,7 +193,7 @@
<div class="flex flex-col h-full">
<!-- Model selector and mode toggle -->
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select an image model..." disabled={isGenerating} />
<ModelSelector bind:value={$selectedModelStore} placeholder="Select an image model..." disabled={isGenerating} capabilities={["image_generation", "image_to_image"]} matchAny={true} />
<select
class="px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
@@ -6,12 +6,15 @@
value: string;
placeholder?: string;
disabled?: boolean;
capabilities?: string[];
matchAny?: boolean;
}
let { value = $bindable(), placeholder = "Select a model...", disabled = false }: Props = $props();
let { value = $bindable(), placeholder = "Select a model...", disabled = false, capabilities, matchAny = false }: Props = $props();
let grouped = $derived(groupModels($models));
let hasModels = $derived(grouped.local.length > 0 || Object.keys(grouped.peersByProvider).length > 0);
let grouped = $derived(groupModels($models, capabilities, matchAny));
let hasMatching = $derived(grouped.localMatching.length > 0);
let hasModels = $derived(hasMatching || grouped.local.length > 0 || Object.keys(grouped.peersByProvider).length > 0);
</script>
{#if hasModels}
@@ -21,6 +24,18 @@
{disabled}
>
<option value="">{placeholder}</option>
{#if hasMatching}
<optgroup label="Matching Capabilities">
{#each grouped.localMatching as model (model.id)}
<option value={model.id}>{model.id}</option>
{#if model.aliases}
{#each model.aliases as alias (alias)}
<option value={alias}> {alias}</option>
{/each}
{/if}
{/each}
</optgroup>
{/if}
{#if grouped.local.length > 0}
<optgroup label="Local">
{#each grouped.local as model (model.id)}
@@ -264,7 +264,7 @@
<div class="flex flex-col h-full">
<!-- Top bar: model selector + query input (table mode) + mode toggle -->
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a rerank model..." disabled={isLoading} />
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a rerank model..." disabled={isLoading} capabilities={["reranker"]} />
{#if editorMode === "table"}
<input
type="text"
@@ -206,7 +206,7 @@
<div class="flex flex-col h-full">
<!-- Model and voice selectors -->
<div class="shrink-0 flex gap-2 mb-4">
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a speech model..." disabled={isGenerating} />
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a speech model..." disabled={isGenerating} capabilities={["audio_speech"]} />
<div class="flex gap-2">
<select
class="shrink-0 px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest";
import { matchesCapabilities, groupModels } from "./modelUtils";
import type { Model } from "./types";
function makeModel(overrides: Partial<Model> = {}): Model {
return {
id: "test-model",
state: "ready",
name: "Test Model",
description: "",
unlisted: false,
peerID: "",
...overrides,
};
}
describe("matchesCapabilities", () => {
it("returns true when required is empty", () => {
const model = makeModel();
expect(matchesCapabilities(model, [])).toBe(true);
});
it("returns false when model has no capabilities", () => {
const model = makeModel();
expect(matchesCapabilities(model, ["vision"])).toBe(false);
});
it("returns false when model has empty capabilities object", () => {
const model = makeModel({ capabilities: {} });
expect(matchesCapabilities(model, ["vision"])).toBe(false);
});
it("returns true when model has the single required capability", () => {
const model = makeModel({ capabilities: { vision: true } });
expect(matchesCapabilities(model, ["vision"])).toBe(true);
});
it("returns false when model lacks the required capability", () => {
const model = makeModel({ capabilities: { vision: true } });
expect(matchesCapabilities(model, ["audio_transcriptions"])).toBe(false);
});
it("AND semantics: returns true only when all required are present", () => {
const model = makeModel({ capabilities: { vision: true, audio_transcriptions: true } });
expect(matchesCapabilities(model, ["vision", "audio_transcriptions"])).toBe(true);
expect(matchesCapabilities(model, ["vision", "reranker"])).toBe(false);
});
it("matchAny=true: returns true when at least one required is present", () => {
const model = makeModel({ capabilities: { vision: true } });
expect(matchesCapabilities(model, ["vision", "reranker"], true)).toBe(true);
expect(matchesCapabilities(model, ["audio_transcriptions", "reranker"], true)).toBe(false);
});
it("matchAny=true with empty required returns true", () => {
const model = makeModel();
expect(matchesCapabilities(model, [], true)).toBe(true);
});
});
describe("groupModels", () => {
const models: Model[] = [
makeModel({ id: "chat-model", capabilities: { vision: true } }),
makeModel({ id: "audio-model", capabilities: { audio_transcriptions: true } }),
makeModel({ id: "no-caps-model" }),
makeModel({ id: "peer-model", peerID: "peer1" }),
makeModel({ id: "unlisted-model", unlisted: true, capabilities: { vision: true } }),
];
it("filters out unlisted models", () => {
const result = groupModels(models);
expect(result.localMatching.length + result.local.length).toBe(3);
expect([...result.localMatching, ...result.local].every((m) => !m.unlisted)).toBe(true);
});
it("separates peer models into peersByProvider", () => {
const result = groupModels(models);
expect(result.peersByProvider["peer1"]).toHaveLength(1);
expect(result.peersByProvider["peer1"][0].id).toBe("peer-model");
});
it("without capabilities, all local models go to local (non-matching)", () => {
const result = groupModels(models);
expect(result.localMatching).toHaveLength(0);
expect(result.local).toHaveLength(3);
});
it("with capabilities, matching models go to localMatching", () => {
const result = groupModels(models, ["vision"]);
expect(result.localMatching).toHaveLength(1);
expect(result.localMatching[0].id).toBe("chat-model");
expect(result.local).toHaveLength(2);
});
it("with capabilities, models without capabilities go to local", () => {
const result = groupModels(models, ["vision"]);
expect(result.local.find((m) => m.id === "no-caps-model")).toBeDefined();
});
it("with matchAny, matches models with any listed capability", () => {
const result = groupModels(models, ["vision", "audio_transcriptions"], true);
expect(result.localMatching).toHaveLength(2);
expect(result.localMatching.map((m) => m.id)).toContain("chat-model");
expect(result.localMatching.map((m) => m.id)).toContain("audio-model");
expect(result.local).toHaveLength(1);
});
it("with empty capabilities array, all local go to local (non-matching)", () => {
const result = groupModels(models, []);
expect(result.localMatching).toHaveLength(0);
expect(result.local).toHaveLength(3);
});
});
+28 -2
View File
@@ -2,14 +2,40 @@ import type { Model } from "./types";
export interface GroupedModels {
local: Model[];
localMatching: Model[];
peersByProvider: Record<string, Model[]>;
}
export function groupModels(models: Model[]): GroupedModels {
export function matchesCapabilities(model: Model, required: string[], matchAny = false): boolean {
if (!required.length) return true;
if (!model.capabilities) return false;
const caps = model.capabilities as Record<string, boolean>;
if (matchAny) {
return required.some((cap) => caps[cap] === true);
}
return required.every((cap) => caps[cap] === true);
}
export function groupModels(models: Model[], capabilities?: string[], matchAny = false): GroupedModels {
const available = models.filter((m) => !m.unlisted);
const local = available.filter((m) => !m.peerID);
const peerModels = available.filter((m) => m.peerID);
let localMatching: Model[] = [];
let localRest: Model[] = [];
if (capabilities && capabilities.length > 0) {
for (const model of local) {
if (matchesCapabilities(model, capabilities, matchAny)) {
localMatching.push(model);
} else {
localRest.push(model);
}
}
} else {
localRest = local;
}
const peersByProvider = peerModels.reduce(
(acc, model) => {
const peerId = model.peerID || "unknown";
@@ -20,5 +46,5 @@ export function groupModels(models: Model[]): GroupedModels {
{} as Record<string, Model[]>
);
return { local, peersByProvider };
return { local: localRest, localMatching, peersByProvider };
}
+11
View File
@@ -2,6 +2,16 @@ export type ConnectionState = "connected" | "connecting" | "disconnected";
export type ModelStatus = "ready" | "starting" | "stopping" | "stopped" | "shutdown" | "unknown";
export interface ModelCapabilities {
vision?: boolean;
audio_transcriptions?: boolean;
audio_speech?: boolean;
image_generation?: boolean;
image_to_image?: boolean;
function_calling?: boolean;
reranker?: boolean;
}
export interface Model {
id: string;
state: ModelStatus;
@@ -10,6 +20,7 @@ export interface Model {
unlisted: boolean;
peerID: string;
aliases?: string[];
capabilities?: ModelCapabilities;
}
export interface TokenMetrics {