- frontend: src/features/{agent,recipes,discover,settings,usage,plugins,setup,logs,dashboard}; src/ui reduced to primitives; src/components retired; agent trees merged (lib/agent + hooks/agent + app/agent/_components -> features/agent) - controller: RouteRegistrar type across modules, core/validation.ts body helpers, studio provider routes split out - shared API route helpers (app/api/_lib/route-helpers.ts), themes data/API split, lib/types consolidated to recipes.ts + system.ts - structure validator rewritten to enforce the new layering Verified: check:quality (incl. production build), controller integration 49/49, frontend e2e 155/155, contracts check.
Showing the changes to controller/src/modules (12 of 334 files). Show all
Unified · Split
| @@ -1,7 +1,7 @@ | |||
| 1 | import type { Hono } from "hono"; | 1 | import type { RouteRegistrar } from "../../http/route-registrar"; |
| 2 | import type { AppContext } from "../../types/context"; | ||
| 3 | import { delay } from "../../core/async"; | 2 | import { delay } from "../../core/async"; |
| 4 | import { HttpStatus, badRequest, notFound, serviceUnavailable } from "../../core/errors"; | 3 | import { HttpStatus, badRequest, notFound, serviceUnavailable } from "../../core/errors"; |
| 4 | import { optionalEnum, optionalStringArray, parseJsonObjectBody } from "../../core/validation"; | ||
| 5 | import { observeControllerFunction } from "../../core/function-observability"; | 5 | import { observeControllerFunction } from "../../core/function-observability"; |
| 6 | import { parseRecipe } from "../models/recipes/recipe-serializer"; | 6 | import { parseRecipe } from "../models/recipes/recipe-serializer"; |
| 7 | import { Event } from "../system/event-manager"; | 7 | import { Event } from "../system/event-manager"; |
| @@ -40,37 +40,30 @@ const resolveHfToken = ( · 32 unmodified lines | |||
| 40 | return bodyToken || headerToken || envToken; | 40 | return bodyToken || headerToken || envToken; |
| 41 | }; | 41 | }; |
| 42 | 42 | ||
| 43 | const RUNTIME_JOB_BACKENDS = ["vllm", "sglang", "llamacpp", "mlx", "cuda", "rocm"] as const; | ||
| 44 | const RUNTIME_JOB_TYPES = ["install", "update", "download", "inspect"] as const; | ||
| 45 | |||
| 43 | const parseRuntimeJobBody = async (ctx: { | 46 | const parseRuntimeJobBody = async (ctx: { |
| 44 | req: { json: () => Promise<unknown> }; | 47 | req: { json: () => Promise<unknown> }; |
| 45 | }): Promise<{ | 48 | }): Promise<{ |
| 46 | backend?: "vllm" | "sglang" | "llamacpp" | "mlx" | "cuda" | "rocm"; | 49 | backend?: (typeof RUNTIME_JOB_BACKENDS)[number]; |
| 47 | targetId?: string; | 50 | targetId?: string; |
| 48 | type?: "install" | "update" | "download" | "inspect"; | 51 | type?: (typeof RUNTIME_JOB_TYPES)[number]; |
| 49 | command?: string; | 52 | command?: string; |
| 50 | args?: string[]; | 53 | args?: string[]; |
| 51 | version?: string; | 54 | version?: string; |
| 52 | preferBundled?: boolean; | 55 | preferBundled?: boolean; |
| 53 | }> => { | 56 | }> => { |
| 54 | const body = await ctx.req.json().catch(() => ({})); | 57 | const record = await parseJsonObjectBody(ctx); |
| 55 | if (!body || typeof body !== "object" || Array.isArray(body)) throw badRequest("Invalid payload"); | 58 | const backend = optionalEnum(record, "backend", RUNTIME_JOB_BACKENDS); |
| 56 | const record = body as Record<string, unknown>; | 59 | const type = optionalEnum(record, "type", RUNTIME_JOB_TYPES, "job type"); |
| 57 | const backend = typeof record["backend"] === "string" ? record["backend"] : undefined; | 60 | const args = optionalStringArray(record, "args"); |
| 58 | if (backend && !["vllm", "sglang", "llamacpp", "mlx", "cuda", "rocm"].includes(backend)) | ||
| 59 | throw badRequest("Invalid backend"); | ||
| 60 | const type = typeof record["type"] === "string" ? record["type"] : undefined; | ||
| 61 | if (type && !["install", "update", "download", "inspect"].includes(type)) | ||
| 62 | throw badRequest("Invalid job type"); | ||
| 63 | const args = Array.isArray(record["args"]) ? record["args"] : undefined; | ||
| 64 | if (args?.some((value) => typeof value !== "string")) | ||
| 65 | throw badRequest("args must be an array of strings"); | ||
| 66 | return { | 61 | return { |
| 67 | ...(backend | 62 | ...(backend ? { backend } : {}), |
| 68 | ? { backend: backend as "vllm" | "sglang" | "llamacpp" | "mlx" | "cuda" | "rocm" } | ||
| 69 | : {}), | ||
| 70 | ...(typeof record["targetId"] === "string" ? { targetId: record["targetId"] } : {}), | 63 | ...(typeof record["targetId"] === "string" ? { targetId: record["targetId"] } : {}), |
| 71 | ...(type ? { type: type as "install" | "update" | "download" | "inspect" } : {}), | 64 | ...(type ? { type } : {}), |
| 72 | ...(typeof record["command"] === "string" ? { command: record["command"] } : {}), | 65 | ...(typeof record["command"] === "string" ? { command: record["command"] } : {}), |
| 73 | ...(args ? { args: args as string[] } : {}), | 66 | ...(args ? { args } : {}), |
| 74 | ...(typeof record["version"] === "string" ? { version: record["version"] } : {}), | 67 | ...(typeof record["version"] === "string" ? { version: record["version"] } : {}), |
| 75 | ...(typeof record["prefer_bundled"] === "boolean" | 68 | ...(typeof record["prefer_bundled"] === "boolean" |
| 76 | ? { preferBundled: record["prefer_bundled"] } | 69 | ? { preferBundled: record["prefer_bundled"] } |
| @@ -78,7 +71,7 @@ const parseRuntimeJobBody = async (ctx: { · 1 unmodified line | |||
| 78 | }; | 71 | }; |
| 79 | }; | 72 | }; |
| 80 | 73 | ||
| 81 | export const registerEngineRoutes = (app: Hono, context: AppContext): void => { | 74 | export const registerEngineRoutes: RouteRegistrar = (app, context) => { |
| 82 | const launchAbortControllers = new Map<string, AbortController>(); | 75 | const launchAbortControllers = new Map<string, AbortController>(); |
| 83 | 76 | ||
| 84 | app.get("/recipes", async (ctx) => { | 77 | app.get("/recipes", async (ctx) => { |
| @@ -1,8 +1,7 @@ | |||
| 1 | import type { Hono } from "hono"; | ||
| 2 | import { basename, dirname, resolve } from "node:path"; | 1 | import { basename, dirname, resolve } from "node:path"; |
| 3 | import { existsSync } from "node:fs"; | 2 | import { existsSync } from "node:fs"; |
| 4 | import { homedir } from "node:os"; | 3 | import { homedir } from "node:os"; |
| 5 | import type { AppContext } from "../../types/context"; | 4 | import type { RouteRegistrar } from "../../http/route-registrar"; |
| 6 | import type { Recipe } from "../models/types"; | 5 | import type { Recipe } from "../models/types"; |
| 7 | 6 | ||
| 8 | /** | 7 | /** |
| @@ -28,13 +27,11 @@ interface OpenAIModelList { · 19 unmodified lines | |||
| 28 | import { buildModelInfo, discoverModelDirectories } from "./model-browser"; | 27 | import { buildModelInfo, discoverModelDirectories } from "./model-browser"; |
| 29 | import { notFound } from "../../core/errors"; | 28 | import { notFound } from "../../core/errors"; |
| 30 | import { observeControllerFunction } from "../../core/function-observability"; | 29 | import { observeControllerFunction } from "../../core/function-observability"; |
| 30 | import { parseBooleanFlag } from "../../core/validation"; | ||
| 31 | import { fetchInference } from "../../services/inference/inference-client"; | 31 | import { fetchInference } from "../../services/inference/inference-client"; |
| 32 | 32 | ||
| 33 | function isMockInferenceEnabled(): boolean { | 33 | function isMockInferenceEnabled(): boolean { |
| 34 | const raw = process.env["VLLM_STUDIO_MOCK_INFERENCE"]; | 34 | return parseBooleanFlag(process.env["VLLM_STUDIO_MOCK_INFERENCE"]); |
| 35 | if (!raw) return false; | ||
| 36 | const normalized = String(raw).trim().toLowerCase(); | ||
| 37 | return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; | ||
| 38 | } | 35 | } |
| 39 | 36 | ||
| 40 | function recipeMetadata(recipe: Recipe): Record<string, unknown> | undefined { | 37 | function recipeMetadata(recipe: Recipe): Record<string, unknown> | undefined { |
| @@ -45,7 +42,7 @@ function recipeMetadata(recipe: Recipe): Record<string, unknown> | undefined { · 4 unmodified lines | |||
| 45 | return metadata as Record<string, unknown>; | 42 | return metadata as Record<string, unknown>; |
| 46 | } | 43 | } |
| 47 | 44 | ||
| 48 | export const registerModelsRoutes = (app: Hono, context: AppContext): void => { | 45 | export const registerModelsRoutes: RouteRegistrar = (app, context) => { |
| 49 | app.get("/v1/models", async (ctx) => { | 46 | app.get("/v1/models", async (ctx) => { |
| 50 | const recipes = context.stores.recipeStore.list(); | 47 | const recipes = context.stores.recipeStore.list(); |
| 51 | const current = await observeControllerFunction( | 48 | const current = await observeControllerFunction( |
| @@ -1,4 +1 @@ | |||
| 1 | import type { Hono } from "hono"; | 1 | export type { RouteRegistrar as ProxyRouteRegistrar } from "../../http/route-registrar"; |
| 2 | import type { AppContext } from "../../types/context"; | ||
| 3 | |||
| 4 | export type ProxyRouteRegistrar = (app: Hono, context: AppContext) => void; | ||
| @@ -1,9 +1,8 @@ | |||
| 1 | import { performance } from "node:perf_hooks"; | 1 | import { performance } from "node:perf_hooks"; |
| 2 | import type { Hono } from "hono"; | ||
| 3 | import { HttpStatus, notFound, serviceUnavailable } from "../../core/errors"; | 2 | import { HttpStatus, notFound, serviceUnavailable } from "../../core/errors"; |
| 4 | import { isRecipeRunning } from "../models/recipes/recipe-matching"; | 3 | import { isRecipeRunning } from "../models/recipes/recipe-matching"; |
| 5 | import { buildSseHeaders } from "../../http/sse"; | 4 | import { buildSseHeaders } from "../../http/sse"; |
| 6 | import type { AppContext } from "../../types/context"; | 5 | import type { RouteRegistrar } from "../../http/route-registrar"; |
| 7 | import type { Recipe } from "../models/types"; | 6 | import type { Recipe } from "../models/types"; |
| 8 | import { getDefaultReasoningParser } from "../engines/process/model-runtime-defaults"; | 7 | import { getDefaultReasoningParser } from "../engines/process/model-runtime-defaults"; |
| 9 | import { buildInferenceUrl } from "../../services/inference/inference-client"; | 8 | import { buildInferenceUrl } from "../../services/inference/inference-client"; |
| @@ -89,7 +88,7 @@ const shouldBufferImplicitReasoningContent = ( · 79 unmodified lines | |||
| 89 | ); | 88 | ); |
| 90 | }; | 89 | }; |
| 91 | 90 | ||
| 92 | export const registerOpenAIRoutes = (app: Hono, context: AppContext): void => { | 91 | export const registerOpenAIRoutes: RouteRegistrar = (app, context) => { |
| 93 | const nonRunningModelWarnings = new Map<string, NonRunningModelWarningState>(); | 92 | const nonRunningModelWarnings = new Map<string, NonRunningModelWarningState>(); |
| 94 | 93 | ||
| 95 | const warnNonRunningModel = (details: { | 94 | const warnNonRunningModel = (details: { |
| @@ -1,9 +1,8 @@ | |||
| 1 | import type { Hono } from "hono"; | 1 | import type { RouteRegistrar } from "../../http/route-registrar"; |
| 2 | import type { AppContext } from "../../types/context"; | ||
| 3 | import { registerOpenAIRoutes } from "./openai-routes"; | 2 | import { registerOpenAIRoutes } from "./openai-routes"; |
| 4 | import { registerTokenizationRoutes } from "./tokenization-routes"; | 3 | import { registerTokenizationRoutes } from "./tokenization-routes"; |
| 5 | 4 | ||
| 6 | export const registerAllProxyRoutes = (app: Hono, context: AppContext): void => { | 5 | export const registerAllProxyRoutes: RouteRegistrar = (app, context) => { |
| 7 | registerOpenAIRoutes(app, context); | 6 | registerOpenAIRoutes(app, context); |
| 8 | registerTokenizationRoutes(app, context); | 7 | registerTokenizationRoutes(app, context); |
| 9 | }; | 8 | }; |
| @@ -1,9 +1,8 @@ | |||
| 1 | import type { Hono } from "hono"; | ||
| 2 | import { observeControllerFunction } from "../../core/function-observability"; | 1 | import { observeControllerFunction } from "../../core/function-observability"; |
| 3 | import type { AppContext } from "../../types/context"; | 2 | import type { RouteRegistrar } from "../../http/route-registrar"; |
| 4 | import { fetchInference } from "../../services/inference/inference-client"; | 3 | import { fetchInference } from "../../services/inference/inference-client"; |
| 5 | 4 | ||
| 6 | export const registerTokenizationRoutes = (app: Hono, context: AppContext): void => { | 5 | export const registerTokenizationRoutes: RouteRegistrar = (app, context) => { |
| 7 | app.post("/v1/tokenize", async (ctx) => { | 6 | app.post("/v1/tokenize", async (ctx) => { |
| 8 | const current = await observeControllerFunction(context, "tokenize.findInferenceProcess", () => | 7 | const current = await observeControllerFunction(context, "tokenize.findInferenceProcess", () => |
| 9 | context.processManager.findInferenceProcess(context.config.inference_port) | 8 | context.processManager.findInferenceProcess(context.config.inference_port) |
| @@ -0,0 +1,119 @@ | |||
| 1 | import { badRequest, notFound } from "../../core/errors"; | ||
| 2 | import { parseJsonObjectBody } from "../../core/validation"; | ||
| 3 | import type { RouteRegistrar } from "../../http/route-registrar"; | ||
| 4 | import { savePersistedConfig, type ProviderConfig } from "../../config/persisted-config"; | ||
| 5 | |||
| 6 | /** CRUD for external inference providers plus model discovery across them. */ | ||
| 7 | export const registerStudioProviderRoutes: RouteRegistrar = (app, context) => { | ||
| 8 | app.get("/studio/providers", async (ctx) => { | ||
| 9 | const providers = context.config.providers.map((p) => ({ | ||
| 10 | id: p.id, | ||
| 11 | name: p.name, | ||
| 12 | base_url: p.base_url, | ||
| 13 | enabled: p.enabled, | ||
| 14 | has_api_key: Boolean(p.api_key), | ||
| 15 | })); | ||
| 16 | return ctx.json({ providers }); | ||
| 17 | }); | ||
| 18 | |||
| 19 | app.post("/studio/providers", async (ctx) => { | ||
| 20 | const body = await parseJsonObjectBody(ctx); | ||
| 21 | |||
| 22 | const id = typeof body["id"] === "string" ? body["id"].trim().toLowerCase() : ""; | ||
| 23 | const name = typeof body["name"] === "string" ? body["name"].trim() : ""; | ||
| 24 | const baseUrl = typeof body["base_url"] === "string" ? body["base_url"].trim() : ""; | ||
| 25 | const apiKey = typeof body["api_key"] === "string" ? body["api_key"].trim() : ""; | ||
| 26 | const enabled = typeof body["enabled"] === "boolean" ? body["enabled"] : true; | ||
| 27 | |||
| 28 | if (!id) throw badRequest("id is required"); | ||
| 29 | if (!name) throw badRequest("name is required"); | ||
| 30 | if (!baseUrl) throw badRequest("base_url is required"); | ||
| 31 | |||
| 32 | const existing = context.config.providers.find((p) => p.id === id); | ||
| 33 | if (existing) throw badRequest(`Provider "${id}" already exists`); | ||
| 34 | |||
| 35 | const provider: ProviderConfig = { id, name, base_url: baseUrl, api_key: apiKey, enabled }; | ||
| 36 | const providers = [...context.config.providers, provider]; | ||
| 37 | savePersistedConfig(context.config.data_dir, { providers }); | ||
| 38 | context.config.providers = providers; | ||
| 39 | |||
| 40 | return ctx.json({ | ||
| 41 | success: true, | ||
| 42 | provider: { id, name, base_url: baseUrl, enabled, has_api_key: Boolean(apiKey) }, | ||
| 43 | }); | ||
| 44 | }); | ||
| 45 | |||
| 46 | app.put("/studio/providers/:id", async (ctx) => { | ||
| 47 | const providerId = ctx.req.param("id"); | ||
| 48 | const body = await ctx.req.json().catch(() => ({})); | ||
| 49 | if (!body || typeof body !== "object") throw badRequest("Invalid payload"); | ||
| 50 | |||
| 51 | const index = context.config.providers.findIndex((p) => p.id === providerId); | ||
| 52 | if (index < 0) throw notFound(`Provider "${providerId}" not found`); | ||
| 53 | |||
| 54 | const current = context.config.providers[index]; | ||
| 55 | if (!current) throw notFound(`Provider "${providerId}" not found`); | ||
| 56 | |||
| 57 | const name = typeof body.name === "string" ? body.name.trim() : current.name; | ||
| 58 | const baseUrl = typeof body.base_url === "string" ? body.base_url.trim() : current.base_url; | ||
| 59 | const apiKey = typeof body.api_key === "string" ? body.api_key.trim() : current.api_key; | ||
| 60 | const enabled = typeof body.enabled === "boolean" ? body.enabled : current.enabled; | ||
| 61 | |||
| 62 | const updated: ProviderConfig = { | ||
| 63 | id: providerId, | ||
| 64 | name, | ||
| 65 | base_url: baseUrl, | ||
| 66 | api_key: apiKey, | ||
| 67 | enabled, | ||
| 68 | }; | ||
| 69 | const providers = [...context.config.providers]; | ||
| 70 | providers[index] = updated; | ||
| 71 | savePersistedConfig(context.config.data_dir, { providers }); | ||
| 72 | context.config.providers = providers; | ||
| 73 | |||
| 74 | return ctx.json({ | ||
| 75 | success: true, | ||
| 76 | provider: { id: providerId, name, base_url: baseUrl, enabled, has_api_key: Boolean(apiKey) }, | ||
| 77 | }); | ||
| 78 | }); | ||
| 79 | |||
| 80 | app.delete("/studio/providers/:id", async (ctx) => { | ||
| 81 | const providerId = ctx.req.param("id"); | ||
| 82 | const index = context.config.providers.findIndex((p) => p.id === providerId); | ||
| 83 | if (index < 0) throw notFound(`Provider "${providerId}" not found`); | ||
| 84 | |||
| 85 | const providers = context.config.providers.filter((p) => p.id !== providerId); | ||
| 86 | savePersistedConfig(context.config.data_dir, { providers }); | ||
| 87 | context.config.providers = providers; | ||
| 88 | |||
| 89 | return ctx.json({ success: true }); | ||
| 90 | }); | ||
| 91 | |||
| 92 | // Fetch models from all configured providers | ||
| 93 | app.get("/studio/provider-models", async (ctx) => { | ||
| 94 | const enabledProviders = context.config.providers.filter((p) => p.enabled && p.api_key); | ||
| 95 | const results: Array<{ provider: string; models: Array<{ id: string; name?: string }> }> = []; | ||
| 96 | |||
| 97 | await Promise.all( | ||
| 98 | enabledProviders.map(async (provider) => { | ||
| 99 | try { | ||
| 100 | const url = `${provider.base_url.replace(/\/+$/, "")}/v1/models`; | ||
| 101 | const res = await fetch(url, { | ||
| 102 | headers: { Authorization: `Bearer ${provider.api_key}` }, | ||
| 103 | signal: AbortSignal.timeout(10_000), | ||
| 104 | }); | ||
| 105 | if (!res.ok) return; | ||
| 106 | const data = (await res.json()) as { data?: Array<{ id?: string }> }; | ||
| 107 | const models = (data.data ?? []) | ||
| 108 | .filter((m) => typeof m.id === "string" && m.id.length > 0) | ||
| 109 | .map((m) => ({ id: m.id as string })); | ||
| 110 | results.push({ provider: provider.id, models }); | ||
| 111 | } catch { | ||
| 112 | // skip unreachable providers | ||
| 113 | } | ||
| 114 | }) | ||
| 115 | ); | ||
| 116 | |||
| 117 | return ctx.json({ providers: results }); | ||
| 118 | }); | ||
| 119 | }; | ||
| @@ -1,4 +1,3 @@ | |||
| 1 | import type { Hono } from "hono"; | ||
| 2 | import { cpus, freemem, totalmem, platform, arch, release } from "node:os"; | 1 | import { cpus, freemem, totalmem, platform, arch, release } from "node:os"; |
| 3 | import { | 2 | import { |
| 4 | existsSync, | 3 | existsSync, |
| @@ -12,7 +11,8 @@ import { · 7 unmodified lines | |||
| 12 | } from "node:fs"; | 11 | } from "node:fs"; |
| 13 | import { basename, resolve, sep } from "node:path"; | 12 | import { basename, resolve, sep } from "node:path"; |
| 14 | import { badRequest, notFound } from "../../core/errors"; | 13 | import { badRequest, notFound } from "../../core/errors"; |
| 15 | import type { AppContext } from "../../types/context"; | 14 | import type { RouteRegistrar } from "../../http/route-registrar"; |
| 15 | import { registerStudioProviderRoutes } from "./provider-routes"; | ||
| 16 | import { getGpuInfo } from "../system/platform/gpu"; | 16 | import { getGpuInfo } from "../system/platform/gpu"; |
| 17 | import type { GpuInfo } from "../models/types"; | 17 | import type { GpuInfo } from "../models/types"; |
| 18 | import { discoverModelDirectories, estimateWeightsSizeBytes } from "../models/model-browser"; | 18 | import { discoverModelDirectories, estimateWeightsSizeBytes } from "../models/model-browser"; |
| @@ -21,7 +21,6 @@ import { · 2 unmodified lines | |||
| 21 | getPersistedConfigPath, | 21 | getPersistedConfigPath, |
| 22 | loadPersistedConfig, | 22 | loadPersistedConfig, |
| 23 | savePersistedConfig, | 23 | savePersistedConfig, |
| 24 | type ProviderConfig, | ||
| 25 | } from "../../config/persisted-config"; | 24 | } from "../../config/persisted-config"; |
| 26 | import { getVllmRuntimeInfo } from "../engines/runtimes/vllm-runtime"; | 25 | import { getVllmRuntimeInfo } from "../engines/runtimes/vllm-runtime"; |
| 27 | 26 | ||
| @@ -111,7 +110,7 @@ const parseUiPreferencesUpdate = (value: unknown): Record<string, string> | null · 83 unmodified lines | |||
| 111 | return clean; | 110 | return clean; |
| 112 | }; | 111 | }; |
| 113 | 112 | ||
| 114 | export const registerStudioRoutes = (app: Hono, context: AppContext): void => { | 113 | export const registerStudioRoutes: RouteRegistrar = (app, context) => { |
| 115 | const buildSettingsPayload = (): { | 114 | const buildSettingsPayload = (): { |
| 116 | config_path: string; | 115 | config_path: string; |
| 117 | persisted: { | 116 | persisted: { |
| @@ -318,116 +317,5 @@ export const registerStudioRoutes = (app: Hono, context: AppContext): void => { · 200 unmodified lines | |||
| 318 | return ctx.json({ success: true, target }); | 317 | return ctx.json({ success: true, target }); |
| 319 | }); | 318 | }); |
| 320 | 319 | ||
| 321 | app.get("/studio/providers", async (ctx) => { | 320 | registerStudioProviderRoutes(app, context); |
| 322 | const providers = context.config.providers.map((p) => ({ | ||
| 323 | id: p.id, | ||
| 324 | name: p.name, | ||
| 325 | base_url: p.base_url, | ||
| 326 | enabled: p.enabled, | ||
| 327 | has_api_key: Boolean(p.api_key), | ||
| 328 | })); | ||
| 329 | return ctx.json({ providers }); | ||
| 330 | }); | ||
| 331 | |||
| 332 | app.post("/studio/providers", async (ctx) => { | ||
| 333 | const body = await ctx.req.json().catch(() => ({})); | ||
| 334 | if (!body || typeof body !== "object") throw badRequest("Invalid payload"); | ||
| 335 | |||
| 336 | const id = typeof body.id === "string" ? body.id.trim().toLowerCase() : ""; | ||
| 337 | const name = typeof body.name === "string" ? body.name.trim() : ""; | ||
| 338 | const baseUrl = typeof body.base_url === "string" ? body.base_url.trim() : ""; | ||
| 339 | const apiKey = typeof body.api_key === "string" ? body.api_key.trim() : ""; | ||
| 340 | const enabled = typeof body.enabled === "boolean" ? body.enabled : true; | ||
| 341 | |||
| 342 | if (!id) throw badRequest("id is required"); | ||
| 343 | if (!name) throw badRequest("name is required"); | ||
| 344 | if (!baseUrl) throw badRequest("base_url is required"); | ||
| 345 | |||
| 346 | const existing = context.config.providers.find((p) => p.id === id); | ||
| 347 | if (existing) throw badRequest(`Provider "${id}" already exists`); | ||
| 348 | |||
| 349 | const provider: ProviderConfig = { id, name, base_url: baseUrl, api_key: apiKey, enabled }; | ||
| 350 | const providers = [...context.config.providers, provider]; | ||
| 351 | savePersistedConfig(context.config.data_dir, { providers }); | ||
| 352 | context.config.providers = providers; | ||
| 353 | |||
| 354 | return ctx.json({ | ||
| 355 | success: true, | ||
| 356 | provider: { id, name, base_url: baseUrl, enabled, has_api_key: Boolean(apiKey) }, | ||
| 357 | }); | ||
| 358 | }); | ||
| 359 | |||
| 360 | app.put("/studio/providers/:id", async (ctx) => { | ||
| 361 | const providerId = ctx.req.param("id"); | ||
| 362 | const body = await ctx.req.json().catch(() => ({})); | ||
| 363 | if (!body || typeof body !== "object") throw badRequest("Invalid payload"); | ||
| 364 | |||
| 365 | const index = context.config.providers.findIndex((p) => p.id === providerId); | ||
| 366 | if (index < 0) throw notFound(`Provider "${providerId}" not found`); | ||
| 367 | |||
| 368 | const current = context.config.providers[index]; | ||
| 369 | if (!current) throw notFound(`Provider "${providerId}" not found`); | ||
| 370 | |||
| 371 | const name = typeof body.name === "string" ? body.name.trim() : current.name; | ||
| 372 | const baseUrl = typeof body.base_url === "string" ? body.base_url.trim() : current.base_url; | ||
| 373 | const apiKey = typeof body.api_key === "string" ? body.api_key.trim() : current.api_key; | ||
| 374 | const enabled = typeof body.enabled === "boolean" ? body.enabled : current.enabled; | ||
| 375 | |||
| 376 | const updated: ProviderConfig = { | ||
| 377 | id: providerId, | ||
| 378 | name, | ||
| 379 | base_url: baseUrl, | ||
| 380 | api_key: apiKey, | ||
| 381 | enabled, | ||
| 382 | }; | ||
| 383 | const providers = [...context.config.providers]; | ||
| 384 | providers[index] = updated; | ||
| 385 | savePersistedConfig(context.config.data_dir, { providers }); | ||
| 386 | context.config.providers = providers; | ||
| 387 | |||
| 388 | return ctx.json({ | ||
| 389 | success: true, | ||
| 390 | provider: { id: providerId, name, base_url: baseUrl, enabled, has_api_key: Boolean(apiKey) }, | ||
| 391 | }); | ||
| 392 | }); | ||
| 393 | |||
| 394 | app.delete("/studio/providers/:id", async (ctx) => { | ||
| 395 | const providerId = ctx.req.param("id"); | ||
| 396 | const index = context.config.providers.findIndex((p) => p.id === providerId); | ||
| 397 | if (index < 0) throw notFound(`Provider "${providerId}" not found`); | ||
| 398 | |||
| 399 | const providers = context.config.providers.filter((p) => p.id !== providerId); | ||
| 400 | savePersistedConfig(context.config.data_dir, { providers }); | ||
| 401 | context.config.providers = providers; | ||
| 402 | |||
| 403 | return ctx.json({ success: true }); | ||
| 404 | }); | ||
| 405 | |||
| 406 | // Fetch models from all configured providers | ||
| 407 | app.get("/studio/provider-models", async (ctx) => { | ||
| 408 | const enabledProviders = context.config.providers.filter((p) => p.enabled && p.api_key); | ||
| 409 | const results: Array<{ provider: string; models: Array<{ id: string; name?: string }> }> = []; | ||
| 410 | |||
| 411 | await Promise.all( | ||
| 412 | enabledProviders.map(async (provider) => { | ||
| 413 | try { | ||
| 414 | const url = `${provider.base_url.replace(/\/+$/, "")}/v1/models`; | ||
| 415 | const res = await fetch(url, { | ||
| 416 | headers: { Authorization: `Bearer ${provider.api_key}` }, | ||
| 417 | signal: AbortSignal.timeout(10_000), | ||
| 418 | }); | ||
| 419 | if (!res.ok) return; | ||
| 420 | const data = (await res.json()) as { data?: Array<{ id?: string }> }; | ||
| 421 | const models = (data.data ?? []) | ||
| 422 | .filter((m) => typeof m.id === "string" && m.id.length > 0) | ||
| 423 | .map((m) => ({ id: m.id as string })); | ||
| 424 | results.push({ provider: provider.id, models }); | ||
| 425 | } catch { | ||
| 426 | // skip unreachable providers | ||
| 427 | } | ||
| 428 | }) | ||
| 429 | ); | ||
| 430 | |||
| 431 | return ctx.json({ providers: results }); | ||
| 432 | }); | ||
| 433 | }; | 321 | }; |
| @@ -1,9 +1,8 @@ | |||
| 1 | import type { Hono } from "hono"; | ||
| 2 | import { spawn, spawnSync } from "node:child_process"; | 1 | import { spawn, spawnSync } from "node:child_process"; |
| 3 | import { unlinkSync } from "node:fs"; | 2 | import { unlinkSync } from "node:fs"; |
| 4 | import { createInterface } from "node:readline"; | 3 | import { createInterface } from "node:readline"; |
| 5 | import { PassThrough } from "node:stream"; | 4 | import { PassThrough } from "node:stream"; |
| 6 | import type { AppContext } from "../../types/context"; | 5 | import type { RouteRegistrar } from "../../http/route-registrar"; |
| 7 | import { badRequest, notFound } from "../../core/errors"; | 6 | import { badRequest, notFound } from "../../core/errors"; |
| 8 | import { observeControllerFunction } from "../../core/function-observability"; | 7 | import { observeControllerFunction } from "../../core/function-observability"; |
| 9 | import { streamAsyncStrings, buildSseHeaders, withSseHeartbeat } from "../../http/sse"; | 8 | import { streamAsyncStrings, buildSseHeaders, withSseHeartbeat } from "../../http/sse"; |
| @@ -21,7 +20,7 @@ import { · 11 unmodified lines | |||
| 21 | tailFileLines, | 20 | tailFileLines, |
| 22 | } from "../../core/log-files"; | 21 | } from "../../core/log-files"; |
| 23 | 22 | ||
| 24 | export const registerLogsRoutes = (app: Hono, context: AppContext): void => { | 23 | export const registerLogsRoutes: RouteRegistrar = (app, context) => { |
| 25 | let lastCleanupAt = 0; | 24 | let lastCleanupAt = 0; |
| 26 | 25 | ||
| 27 | const maybeCleanup = (): void => { | 26 | const maybeCleanup = (): void => { |
| @@ -1,6 +1,6 @@ | |||
| 1 | import type { Hono } from "hono"; | ||
| 2 | import { performance } from "node:perf_hooks"; | 1 | import { performance } from "node:perf_hooks"; |
| 3 | import { observeControllerFunction } from "../../core/function-observability"; | 2 | import { observeControllerFunction } from "../../core/function-observability"; |
| 3 | import type { RouteRegistrar } from "../../http/route-registrar"; | ||
| 4 | import type { AppContext } from "../../types/context"; | 4 | import type { AppContext } from "../../types/context"; |
| 5 | import { getGpuInfo } from "./platform/gpu"; | 5 | import { getGpuInfo } from "./platform/gpu"; |
| 6 | import { fetchInference } from "../../services/inference/inference-client"; | 6 | import { fetchInference } from "../../services/inference/inference-client"; |
| @@ -241,7 +241,7 @@ const buildCurrentMetrics = async (context: AppContext): Promise<Record<string, · 234 unmodified lines | |||
| 241 | }; | 241 | }; |
| 242 | }; | 242 | }; |
| 243 | 243 | ||
| 244 | export const registerMonitoringRoutes = (app: Hono, context: AppContext): void => { | 244 | export const registerMonitoringRoutes: RouteRegistrar = (app, context) => { |
| 245 | app.get("/metrics", async (_ctx) => { | 245 | app.get("/metrics", async (_ctx) => { |
| 246 | const current = await observeControllerFunction( | 246 | const current = await observeControllerFunction( |
| 247 | context, | 247 | context, |
| @@ -1,11 +1,11 @@ | |||
| 1 | import type { Hono } from "hono"; | ||
| 2 | import { connect } from "node:net"; | 1 | import { connect } from "node:net"; |
| 3 | import { hostname } from "node:os"; | 2 | import { hostname } from "node:os"; |
| 4 | import { existsSync, readFileSync } from "node:fs"; | 3 | import { existsSync, readFileSync } from "node:fs"; |
| 5 | import { join, resolve, sep } from "node:path"; | 4 | import { join, resolve, sep } from "node:path"; |
| 6 | import type { AppContext } from "../../types/context"; | 5 | import type { RouteRegistrar } from "../../http/route-registrar"; |
| 7 | import type { SystemConfigResponse } from "../models/types"; | 6 | import type { SystemConfigResponse } from "../models/types"; |
| 8 | import { badRequest, notFound } from "../../core/errors"; | 7 | import { badRequest, notFound } from "../../core/errors"; |
| 8 | import { parseJsonObjectBody } from "../../core/validation"; | ||
| 9 | import { observeControllerFunction } from "../../core/function-observability"; | 9 | import { observeControllerFunction } from "../../core/function-observability"; |
| 10 | import { estimateWeightsSizeBytes } from "../models/model-browser"; | 10 | import { estimateWeightsSizeBytes } from "../models/model-browser"; |
| 11 | import { getGpuInfo } from "./platform/gpu"; | 11 | import { getGpuInfo } from "./platform/gpu"; |
| @@ -21,7 +21,7 @@ import { · 9 unmodified lines | |||
| 21 | SYSTEM_SERVICE_CHECK_HOST, | 21 | SYSTEM_SERVICE_CHECK_HOST, |
| 22 | } from "./configs"; | 22 | } from "./configs"; |
| 23 | 23 | ||
| 24 | export const registerSystemRoutes = (app: Hono, context: AppContext): void => { | 24 | export const registerSystemRoutes: RouteRegistrar = (app, context) => { |
| 25 | const checkService = ( | 25 | const checkService = ( |
| 26 | host: string, | 26 | host: string, |
| 27 | port: number, | 27 | port: number, |
| @@ -87,10 +87,7 @@ export const registerSystemRoutes = (app: Hono, context: AppContext): void => { · 59 unmodified lines | |||
| 87 | }); | 87 | }); |
| 88 | 88 | ||
| 89 | app.post("/vram-calculator", async (ctx) => { | 89 | app.post("/vram-calculator", async (ctx) => { |
| 90 | const body = await ctx.req.json().catch(() => ({})); | 90 | const body = await parseJsonObjectBody(ctx); |
| 91 | if (!body || typeof body !== "object") { | ||
| 92 | throw badRequest("Invalid payload"); | ||
| 93 | } | ||
| 94 | 91 | ||
| 95 | const model = typeof body["model"] === "string" ? body["model"].trim() : ""; | 92 | const model = typeof body["model"] === "string" ? body["model"].trim() : ""; |
| 96 | const contextLength = Number(body["context_length"] ?? 0); | 93 | const contextLength = Number(body["context_length"] ?? 0); |
| @@ -1,5 +1,5 @@ | |||
| 1 | import type { Hono } from "hono"; | ||
| 2 | import { observeControllerFunction } from "../../core/function-observability"; | 1 | import { observeControllerFunction } from "../../core/function-observability"; |
| 2 | import type { RouteRegistrar } from "../../http/route-registrar"; | ||
| 3 | import type { AppContext } from "../../types/context"; | 3 | import type { AppContext } from "../../types/context"; |
| 4 | import { getUsageFromPiSessions } from "./usage/pi-sessions"; | 4 | import { getUsageFromPiSessions } from "./usage/pi-sessions"; |
| 5 | import { emptyResponse } from "./usage/usage-utilities"; | 5 | import { emptyResponse } from "./usage/usage-utilities"; |
| @@ -20,7 +20,7 @@ const collectKnownModels = async (context: AppContext): Promise<Set<string>> => · 14 unmodified lines | |||
| 20 | return knownModels; | 20 | return knownModels; |
| 21 | }; | 21 | }; |
| 22 | 22 | ||
| 23 | export const registerUsageRoutes = (app: Hono, context: AppContext): void => { | 23 | export const registerUsageRoutes: RouteRegistrar = (app, context) => { |
| 24 | app.get("/usage", async (ctx) => { | 24 | app.get("/usage", async (ctx) => { |
| 25 | try { | 25 | try { |
| 26 | const knownModels = await observeControllerFunction(context, "usage.collectKnownModels", () => | 26 | const knownModels = await observeControllerFunction(context, "usage.collectKnownModels", () => |