feat(HAI-098): add model selection and configuration support

- Extend Settings type with model provider and model name fields
- Add backend API endpoint to list available models
- Add frontend API client for fetching models
- Add model settings section to SettingsModal with provider/model dropdowns
- Update engine executor to use the selected model from settings
This commit is contained in:
Dustin Byrne
2026-03-26 20:09:23 -04:00
parent 615f31c777
commit a8b767d6fc
12 changed files with 350 additions and 6 deletions

View File

@@ -4,7 +4,7 @@ import http from "node:http";
import { createApiRoutes } from "./routes.js";
import type { TaskStore, TaskAttachment } from "@hai/core";
import type { TaskDetail } from "@hai/core";
import type { AuthStorageLike } from "./routes.js";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
@@ -376,6 +376,75 @@ describe("Attachment routes", () => {
});
});
// --- Models route tests ---
function createMockModelRegistry(overrides: Partial<ModelRegistryLike> = {}): ModelRegistryLike {
return {
refresh: vi.fn(),
getAvailable: vi.fn().mockReturnValue([
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic", reasoning: true, contextWindow: 200000 },
{ id: "gpt-4o", name: "GPT-4o", provider: "openai", reasoning: false, contextWindow: 128000 },
]),
...overrides,
};
}
describe("GET /models", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp(modelRegistry?: ModelRegistryLike) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { modelRegistry }));
return app;
}
it("returns available models from registry", async () => {
const modelRegistry = createMockModelRegistry();
const res = await GET(buildApp(modelRegistry), "/api/models");
expect(res.status).toBe(200);
expect(res.body).toEqual([
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
]);
expect(modelRegistry.refresh).toHaveBeenCalled();
});
it("returns empty array when no model registry is provided", async () => {
const res = await GET(buildApp(), "/api/models");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns empty array when registry has no available models", async () => {
const modelRegistry = createMockModelRegistry({
getAvailable: vi.fn().mockReturnValue([]),
});
const res = await GET(buildApp(modelRegistry), "/api/models");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns 500 when registry throws", async () => {
const modelRegistry = createMockModelRegistry({
getAvailable: vi.fn().mockImplementation(() => {
throw new Error("registry error");
}),
});
const res = await GET(buildApp(modelRegistry), "/api/models");
expect(res.status).toBe(500);
expect(res.body.error).toBe("registry error");
});
});
// --- Auth route tests ---
function createMockAuthStorage(overrides: Partial<AuthStorageLike> = {}): AuthStorageLike {

View File

@@ -5,6 +5,17 @@ import type { TaskStore, Column, MergeResult } from "@hai/core";
import { COLUMNS } from "@hai/core";
import type { ServerOptions } from "./server.js";
/**
* Minimal interface matching pi-coding-agent's ModelRegistry API surface
* used by the models route. Avoids a direct dependency on the pi-coding-agent package.
*/
export interface ModelRegistryLike {
/** Reload models from disk to pick up changes. */
refresh(): void;
/** Get models that have auth configured. */
getAvailable(): Array<{ id: string; name: string; provider: string; reasoning: boolean; contextWindow: number }>;
}
/**
* Minimal interface matching pi-coding-agent's AuthStorage API surface
* used by the auth routes. Avoids a direct dependency on the pi-coding-agent package.
@@ -65,6 +76,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// Models
registerModelsRoute(router, options?.modelRegistry);
// List all tasks
router.get("/tasks", async (_req, res) => {
try {
@@ -277,6 +291,33 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return router;
}
/**
* Register the GET /api/models route.
* Returns available AI models from the ModelRegistry for the UI model selector.
* If no ModelRegistry is provided, returns an empty array.
*/
function registerModelsRoute(router: Router, modelRegistry?: ModelRegistryLike): void {
router.get("/models", (_req, res) => {
try {
if (!modelRegistry) {
res.json([]);
return;
}
modelRegistry.refresh();
const models = modelRegistry.getAvailable().map((m) => ({
provider: m.provider,
id: m.id,
name: m.name,
reasoning: m.reasoning,
contextWindow: m.contextWindow,
}));
res.json(models);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
}
/**
* Register authentication status, login, and logout routes.
* Uses pi-coding-agent's AuthStorage for credential management.

View File

@@ -3,7 +3,7 @@ import { join, dirname } from "node:path";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { TaskStore, MergeResult } from "@hai/core";
import type { AuthStorageLike } from "./routes.js";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
import { createApiRoutes } from "./routes.js";
import { createSSE } from "./sse.js";
import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
@@ -17,6 +17,8 @@ export interface ServerOptions {
maxConcurrent?: number;
/** Optional AuthStorage instance for auth routes — if not provided, one is created internally */
authStorage?: AuthStorageLike;
/** Optional ModelRegistry instance for the models API — if not provided, the endpoint returns an empty list */
modelRegistry?: ModelRegistryLike;
}
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {