Skip to content
Draft
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
8 changes: 5 additions & 3 deletions src/app/api/model-config/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DEFAULT_MODEL_REGISTRY } from "../../../lib/models";
import { getModelPickerOptions } from "../../../lib/models/picker";
import { DEFAULT_MODEL_REGISTRY } from "../../../lib/models/registry";
import {
getModelConfig,
readModelConfigScopeFromParams,
Expand All @@ -11,9 +12,10 @@ export const dynamic = "force-dynamic";
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const modelConfig = await getModelConfig(readModelConfigScopeFromParams(url.searchParams));
return ok({
modelConfig: await getModelConfig(readModelConfigScopeFromParams(url.searchParams)),
availableModels: Object.values(DEFAULT_MODEL_REGISTRY),
modelConfig,
availableModels: getModelPickerOptions(modelConfig, DEFAULT_MODEL_REGISTRY),
});
} catch (error) {
return handleApiError(error);
Expand Down
5 changes: 5 additions & 0 deletions src/components/AgentModelsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,11 @@ export function AgentModelsDialog({
void handleSelectModel(entry.target, event.target.value)
}
>
{activeUri && !matchedOptionUri ? (
<option value={activeUri}>
{resolveModelLabel(activeUri, availableModels)}
</option>
) : null}
{entry.target !== "default" ? (
<option value="">
Inherits default (
Expand Down
2 changes: 1 addition & 1 deletion src/components/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1512,7 +1512,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, ChatComposerProps>(fu
: `${activeModelTarget.label}: ${activeModelLabel}`
}
open={isModelMenuOpen}
disabled={isBusy || availableModels.length === 0}
disabled={isBusy}
onClick={() => {
setIsSettingsOpen(false);
setIsToolMenuOpen(false);
Expand Down
15 changes: 12 additions & 3 deletions src/components/chat/ModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { parse } from "llm-strings";
import { ChevronDown, Search, Star } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { formatModelBackendLabel } from "../../lib/models/display";
import { customModelPickerOption } from "../../lib/models/picker";
import { handleRovingFocusKeyDown } from "../accessibility/AccessibleDialog";

export type ModelPickerOption = {
Expand Down Expand Up @@ -231,7 +232,13 @@ export function ModelPickerMenu({
onSelect: (uri: string) => void;
footer?: ReactNode;
}) {
const rows = useMemo(() => models.map(toRow), [models]);
const rows = useMemo(() => {
const options = models.map(toRow);
const active = activeUri ? customModelPickerOption(activeUri) : undefined;
if (active && !options.some((row) => row.key === getModelKey(active.uri)))
options.push(toRow(active));
return options;
}, [models, activeUri]);
const activeKey = getModelKey(activeUri);
const activeVendorId = rows.find((row) => row.key === activeKey)?.vendor.id;

Expand Down Expand Up @@ -277,6 +284,8 @@ export function ModelPickerMenu({

const normalizedQuery = query.trim().toLowerCase();
const visibleRows = useMemo(() => {
const custom = customModelPickerOption(query);
if (custom) return [toRow(custom)];
if (normalizedQuery) {
return rows.filter((row) =>
`${row.label} ${row.id} ${row.uri} ${row.vendor.label}`
Expand All @@ -288,7 +297,7 @@ export function ModelPickerMenu({
return rows.filter((row) => favoriteSet.has(row.key));
}
return rows.filter((row) => row.vendor.id === railId);
}, [favoriteSet, normalizedQuery, railId, rows]);
}, [favoriteSet, normalizedQuery, query, railId, rows]);

useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
Expand Down Expand Up @@ -356,7 +365,7 @@ export function ModelPickerMenu({
<input
ref={searchRef}
value={query}
placeholder="Search models..."
placeholder="Search models or paste llm://…"
onChange={(event) => setQuery(event.target.value)}
/>
</label>
Expand Down
45 changes: 45 additions & 0 deletions src/lib/models/picker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { parse } from "llm-strings";
import type { ModelRegistry, ModelRegistryEntry } from "./registry";

export function customModelPickerOption(input: string): ModelRegistryEntry | undefined {
const uri = input.trim();
if (!uri.startsWith("llm://")) return undefined;
try {
const model = parse(uri);
if (!model.model || !model.host) return undefined;
return {
id: uri,
uri,
label: `${model.hostAlias ?? model.host}: ${model.model}`,
capabilities: ["chat"],
};
} catch {
return undefined;
}
}

export function getModelPickerOptions(
config:
| { modelUri?: string; modelOverrides?: Record<string, string | undefined> }
| null
| undefined,
registry: ModelRegistry,
): ModelRegistryEntry[] {
const options = Object.values(registry).filter(
(entry) => entry.pickerStatus === "current" && entry.capabilities.includes("chat"),
);
const key = (uri: string) => {
const model = parse(uri);
return `${model.hostAlias ?? model.host}/${model.model}`;
};
const seen = new Set(options.map((option) => key(option.uri)));
for (const uri of [config?.modelUri, ...Object.values(config?.modelOverrides ?? {})]) {
if (!uri) continue;
const custom = customModelPickerOption(uri);
if (!custom || seen.has(key(custom.uri))) continue;
const known = Object.values(registry).find((option) => key(option.uri) === key(custom.uri));
options.push(known ? { ...known, uri: custom.uri } : custom);
seen.add(key(custom.uri));
}
return options;
}
70 changes: 65 additions & 5 deletions src/lib/models/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface ModelRegistryEntry {
readonly id: string;
readonly label: string;
readonly uri: string;
readonly pickerStatus?: "current";
readonly capabilities: readonly ModelCapability[];
readonly defaultFor?: readonly string[];
readonly contextWindow?: number;
Expand Down Expand Up @@ -281,14 +282,14 @@ const BUILT_IN_MODEL_ENTRIES = [
pricingUpdatedAt: "2026-08-17",
}),
openRouterModel("deepseek/deepseek-v4-flash", "DeepSeek: DeepSeek V4 Flash", 0.07938, 0.15876, {
defaultFor: ["chat", "fast", "security-research", "multimodal", "browser-summary"],
uriParams: DEEPSEEK_SECURITY_RESEARCH_PARAMS,
pricingOptions: { cacheReadPerMillion: 0.015876 },
pricingUpdatedAt: "2026-09-06",
}),
// Catalog base rates are conservative fallbacks; exact provider usage retains
// DeepSeek's time-dependent discounts when the provider reports them.
openRouterModel("deepseek/deepseek-v4.1-flash", "DeepSeek: DeepSeek V4.1 Flash", 0.099, 0.6, {
defaultFor: ["chat", "fast", "security-research", "multimodal", "browser-summary"],
capabilities: ["chat", "reasoning", "security-research", "tool-use", "json", "vision"],
contextWindow: 1_048_576,
maxOutputTokens: 384_000,
Expand Down Expand Up @@ -813,13 +814,72 @@ const BUILT_IN_MODEL_ENTRIES = [
...DEV03_LMSTUDIO_MODELS.map(lmStudioModel),
] satisfies readonly ModelRegistryEntry[];

// Current releases by model family/tier. Older entries remain available for
// explicit model strings, pricing and historical evidence, rather than presets.
const CURRENT_PICKER_MODEL_IDS = new Set([
"anthropic/claude-sonnet-5-5",
"anthropic/claude-opus-5-5",
"anthropic/claude-sonnet-5.5",
"anthropic/claude-opus-5.5",
"anthropic/claude-haiku-4.5",
"deepseek/deepseek-flash",
"deepseek/deepseek-v4.1-flash",
"deepseek/deepseek-v4-pro",
"alibaba/qwen3.7-max",
"alibaba/qwen3.7-plus",
"google/gemini-3.8-flash",
"google/gemini-3.5-flash-lite",
"google/gemma-4-26b-a4b-it",
"google/gemma-4-31b-it",
"tencent/hy3",
"tencent/hy3:free",
"x-ai/grok-4.7",
"poolside/laguna-xs-2.1",
"poolside/laguna-s-2.1",
"upstage/solar-pro4",
"thinkingmachines/inkling",
"meituan/longcat-2.0",
"nex-agi/nex-n2-mini",
"minimax/minimax-m3",
"moonshotai/kimi-k3",
"openrouter/openai/gpt-6-astra",
"openrouter/openai/gpt-6.1-sol",
"openrouter/openai/gpt-6-luna",
"openai/gpt-6-astra",
"openai/gpt-6.1-sol",
"openai/gpt-6-luna",
"qwen/qwen3.7-flash",
"qwen/qwen3.7-plus",
"qwen/qwen3.7-max",
"qwen/qwen3.8-2.4t-a95b",
"qwen/qwen3.8-omni-flash",
"bytedance-seed/seed-2-1-turbo",
"meta/muse-spark-1.3-contributor",
"meta/muse-glimmer-30b",
"inclusionai/ling-3.0-flash",
"z-ai/glm-5.3",
"z-ai/glm-5.3-flash",
"z-ai/glm-5.3-flashx",
"xiaomi/mimo-v2.6-flash",
]);

export const DEFAULT_MODEL_REGISTRY = Object.fromEntries(
BUILT_IN_MODEL_ENTRIES.map((entry) => [entry.id, entry]),
BUILT_IN_MODEL_ENTRIES.map((entry) => [
entry.id,
{
...entry,
...(CURRENT_PICKER_MODEL_IDS.has(entry.id) ||
(entry.pricingSource === "local" &&
/(?:gemma-4|glm-5\.3|qwopus3\.6|qwopus-moe|nemotron-3-nano)/i.test(entry.id))
? { pickerStatus: "current" as const }
: {}),
},
]),
) as ModelRegistry;

export const DEFAULT_MODEL_ID = "deepseek/deepseek-v4-flash";
export const DEFAULT_RESEARCH_MODEL_ID = "deepseek/deepseek-v4-flash";
export const DEFAULT_FAST_MODEL_ID = "deepseek/deepseek-v4-flash";
export const DEFAULT_MODEL_ID = "deepseek/deepseek-v4.1-flash";
export const DEFAULT_RESEARCH_MODEL_ID = "deepseek/deepseek-v4.1-flash";
export const DEFAULT_FAST_MODEL_ID = "deepseek/deepseek-v4.1-flash";
export const DEFAULT_OLLAMA_FALLBACK_MODEL = "gemma4:12b";

export const LOCAL_EVAL_MODEL_CONFIGS = {
Expand Down
102 changes: 64 additions & 38 deletions tests/integration/model-config-api.test.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,77 @@
import { describe, expect, it, vi } from "vitest";

const { config } = vi.hoisted(() => ({
config: {
modelUri: "llm://acme/current",
modelOverrides: {} as Record<string, string>,
},
}));

vi.mock("../../src/server/chat/service", () => ({
getModelConfig: vi.fn(async () => ({
id: "model-config-default",
name: "default",
modelUri: "llm://anthropic/claude-sonnet-4-6",
})),
getModelConfig: vi.fn(async () => config),
readModelConfigScopeFromParams: vi.fn(() => ({})),
updateModelConfig: vi.fn(),
}));
vi.mock("../../src/lib/models/registry", () => ({
DEFAULT_MODEL_REGISTRY: {
current: {
id: "current",
label: "Acme: Current",
uri: "llm://acme/current",
pickerStatus: "current",
capabilities: ["chat"],
pricingSource: "provider-list",
pricing: { inputPerMillion: 1, outputPerMillion: 2 },
},
previous: {
id: "previous",
label: "Acme: Previous",
uri: "llm://acme/previous",
capabilities: ["chat"],
pricing: { inputPerMillion: 0.5, outputPerMillion: 1 },
},
embedding: {
id: "embedding",
label: "Acme: Embedding",
uri: "llm://acme/embedding",
pickerStatus: "current",
capabilities: ["embedding"],
},
},
}));

const { GET } = await import("../../src/app/api/model-config/route");

describe("model config API", () => {
it("returns structured pricing for native AI SDK adapter models", async () => {
const response = await GET(new Request("http://localhost:3210/api/model-config"));
const body = (await response.json()) as {
modelConfig: { modelUri: string };
availableModels: Array<{
id: string;
uri: string;
pricingSource?: string;
pricing?: {
inputPerMillion: number;
outputPerMillion: number;
cacheReadPerMillion?: number;
cacheWritePerMillion?: number;
};
}>;
};
describe("model config picker API", () => {
it("offers current chat presets and preserves explicit older and unknown routes with exact parameters", async () => {
config.modelUri = "llm://acme/current";
config.modelOverrides = {};
const initial = await GET(new Request("http://localhost:3210/api/model-config"));
const first = await initial.json();
expect(initial.status).toBe(200);
expect(first.availableModels).toEqual([
expect.objectContaining({
id: "current",
pricingSource: "provider-list",
pricing: { inputPerMillion: 1, outputPerMillion: 2 },
}),
]);

expect(response.status).toBe(200);
expect(body.modelConfig.modelUri).toBe("llm://anthropic/claude-sonnet-4-6");
const selected = body.availableModels.find(
(model) => model.uri.split("?", 1)[0] === body.modelConfig.modelUri,
const old = "llm://acme/previous?temperature=0.25&maxTokens=128";
const custom = "llm://custom-host/vendor/unlisted?maxTokens=777";
config.modelUri = old;
config.modelOverrides = { planner: custom, research: custom };
const configured = await GET(
new Request("http://localhost:3210/api/model-config?projectId=fixture"),
);
expect(selected).toMatchObject({
id: "anthropic/claude-sonnet-4-6",
pricingSource: "provider-list",
pricing: {
inputPerMillion: expect.any(Number),
outputPerMillion: expect.any(Number),
},
});
expect(Object.values(selected?.pricing ?? {}).every(isFiniteNonNegativeNumber)).toBe(true);
const body = await configured.json();
expect(body.modelConfig.modelUri).toBe(old);
expect(body.availableModels.map((option: { uri: string }) => option.uri)).toEqual([
"llm://acme/current",
old,
custom,
]);
expect(body.availableModels[1].pricing).toEqual({ inputPerMillion: 0.5, outputPerMillion: 1 });
expect(body.availableModels[2].label).toContain("vendor/unlisted");
});
});

const isFiniteNonNegativeNumber = (value: unknown) =>
typeof value === "number" && Number.isFinite(value) && value >= 0;
Loading