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:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user