feat(KB-184): add model presets for task creation

- Add model preset types and settings fields with API validation
- Create model preset utility helpers for size-based auto-selection
- Add settings UI for managing model presets (create, edit, delete)
- Integrate preset selection into NewTaskModal with custom override option
- Add inline preset selection to InlineCreateCard component
- Persist modelPresetId in task store and preserve through archive/unarchive
- Add changeset and update AGENTS.md with feature documentation
This commit is contained in:
gsxdsm
2026-03-31 02:04:45 -07:00
parent 82d8ebb82b
commit 714f4bc415
17 changed files with 959 additions and 43 deletions

View File

@@ -4440,6 +4440,53 @@ describe("PUT /settings", () => {
expect(store.updateSettings).toHaveBeenCalledWith({ maxWorktrees: 10 });
});
it("validates and forwards model presets", async () => {
const updatedSettings = {
...DEFAULT_SETTINGS,
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini" }],
};
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini" }] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini", validatorProvider: undefined, validatorModelId: undefined }],
}));
});
it("rejects duplicate preset ids", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget" }, { id: "budget", name: "Budget 2" }] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("duplicate id");
});
it("rejects incomplete model provider/modelId pairs", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai" }] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("must include both provider and modelId or neither");
});
it("returns 500 on store update error", async () => {
(store.updateSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Write failed"));

View File

@@ -2,7 +2,7 @@ import { Router, type Request, type Response, type NextFunction } from "express"
import multer from "multer";
import { createReadStream, existsSync } from "node:fs";
import { execSync } from "node:child_process";
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType } from "@kb/core";
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset } from "@kb/core";
import { COLUMNS, VALID_TRANSITIONS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
@@ -74,6 +74,74 @@ function normalizeModelSelectionPair(provider: string | undefined, modelId: stri
return { provider, modelId };
}
function assertConsistentOptionalPair(
provider: unknown,
modelId: unknown,
pairName: string,
): { provider?: string; modelId?: string } {
const normalizedProvider = validateOptionalModelField(provider, `${pairName} provider`);
const normalizedModelId = validateOptionalModelField(modelId, `${pairName} modelId`);
if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) {
throw new Error(`${pairName} must include both provider and modelId or neither`);
}
return {
provider: normalizedProvider,
modelId: normalizedModelId,
};
}
function validateModelPresets(value: unknown): ModelPreset[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value)) {
throw new Error("modelPresets must be an array");
}
const seenIds = new Set<string>();
return value.map((preset, index) => {
if (!preset || typeof preset !== "object") {
throw new Error(`modelPresets[${index}] must be an object`);
}
const candidate = preset as Record<string, unknown>;
const id = validateOptionalModelField(candidate.id, `modelPresets[${index}].id`);
const name = validateOptionalModelField(candidate.name, `modelPresets[${index}].name`);
if (!id) {
throw new Error(`modelPresets[${index}].id is required`);
}
if (!name) {
throw new Error(`modelPresets[${index}].name is required`);
}
if (seenIds.has(id)) {
throw new Error(`modelPresets contains duplicate id: ${id}`);
}
seenIds.add(id);
const executor = assertConsistentOptionalPair(
candidate.executorProvider,
candidate.executorModelId,
`modelPresets[${index}].executor`,
);
const validator = assertConsistentOptionalPair(
candidate.validatorProvider,
candidate.validatorModelId,
`modelPresets[${index}].validator`,
);
return {
id,
name,
executorProvider: executor.provider,
executorModelId: executor.modelId,
validatorProvider: validator.provider,
validatorModelId: validator.modelId,
};
});
}
// ── Git Remote Detection ──────────────────────────────────────────
/** Git remote info returned by the remotes endpoint */
@@ -863,10 +931,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// These are computed server-side and injected only on GET /settings.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { githubTokenConfigured, ...clientSettings } = req.body;
if (Object.prototype.hasOwnProperty.call(clientSettings, "modelPresets")) {
clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets);
}
const settings = await store.updateSettings(clientSettings);
res.json(settings);
} catch (err: any) {
res.status(500).json({ error: err.message });
const status = typeof err?.message === "string" && (
err.message.includes("modelPresets") || err.message.includes("must include both provider and modelId")
) ? 400 : 500;
res.status(status).json({ error: err.message });
}
});
@@ -952,6 +1028,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
column,
dependencies,
breakIntoSubtasks,
modelPresetId,
modelProvider,
modelId,
validatorModelProvider,
@@ -980,6 +1057,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
column,
dependencies,
breakIntoSubtasks,
modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"),
modelProvider: executorModel.provider,
modelId: executorModel.modelId,
validatorModelProvider: validatorModel.provider,