- 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 (14 of 334 files). Show all
Unified · Split
| @@ -0,0 +1,68 @@ | ||
| 1 | +import { badRequest } from "./errors"; | |
| 2 | + | |
| 3 | +type JsonBodyCtx = { req: { json: () => Promise<unknown> } }; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Parse a JSON request body, tolerating an empty/invalid body, and require a | |
| 7 | + * plain object. The standard guard used by mutating routes; throws the same | |
| 8 | + * `badRequest("Invalid payload")` the routes previously raised inline. | |
| 9 | + */ | |
| 10 | +export const parseJsonObjectBody = async (ctx: JsonBodyCtx): Promise<Record<string, unknown>> => { | |
| 11 | + const body = await ctx.req.json().catch(() => ({})); | |
| 12 | + if (!body || typeof body !== "object" || Array.isArray(body)) { | |
| 13 | + throw badRequest("Invalid payload"); | |
| 14 | + } | |
| 15 | + return body as Record<string, unknown>; | |
| 16 | +}; | |
| 17 | + | |
| 18 | +export const optionalString = ( | |
| 19 | + record: Record<string, unknown>, | |
| 20 | + key: string, | |
| 21 | +): string | undefined => (typeof record[key] === "string" ? (record[key] as string) : undefined); | |
| 22 | + | |
| 23 | +export const optionalBoolean = ( | |
| 24 | + record: Record<string, unknown>, | |
| 25 | + key: string, | |
| 26 | +): boolean | undefined => (typeof record[key] === "boolean" ? (record[key] as boolean) : undefined); | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * Optional string-array field. Matches the historical inline guards: | |
| 30 | + * non-array values are treated as absent; an array containing non-string | |
| 31 | + * entries is rejected. | |
| 32 | + */ | |
| 33 | +export const optionalStringArray = ( | |
| 34 | + record: Record<string, unknown>, | |
| 35 | + key: string, | |
| 36 | +): string[] | undefined => { | |
| 37 | + const value = record[key]; | |
| 38 | + if (!Array.isArray(value)) return undefined; | |
| 39 | + if (value.some((entry) => typeof entry !== "string")) { | |
| 40 | + throw badRequest(`${key} must be an array of strings`); | |
| 41 | + } | |
| 42 | + return value as string[]; | |
| 43 | +}; | |
| 44 | + | |
| 45 | +/** | |
| 46 | + * Optional string-enum field. Matches the historical inline guards: values | |
| 47 | + * that are missing, non-string, or empty are treated as absent; only a | |
| 48 | + * non-empty string outside `values` is rejected. | |
| 49 | + */ | |
| 50 | +export const optionalEnum = <T extends string>( | |
| 51 | + record: Record<string, unknown>, | |
| 52 | + key: string, | |
| 53 | + values: readonly T[], | |
| 54 | + label?: string, | |
| 55 | +): T | undefined => { | |
| 56 | + const value = optionalString(record, key); | |
| 57 | + if (!value) return undefined; | |
| 58 | + if (!values.includes(value as T)) throw badRequest(`Invalid ${label ?? key}`); | |
| 59 | + return value as T; | |
| 60 | +}; | |
| 61 | + | |
| 62 | +/** Interpret env-var style truthy strings ("1", "true", "yes", "on"). */ | |
| 63 | +export const parseBooleanFlag = (raw: unknown): boolean => { | |
| 64 | + if (typeof raw === "boolean") return raw; | |
| 65 | + if (raw === undefined || raw === null) return false; | |
| 66 | + const normalized = String(raw).trim().toLowerCase(); | |
| 67 | + return ["1", "true", "yes", "on"].includes(normalized); | |
| 68 | +}; | |
| @@ -0,0 +1,9 @@ | ||
| 1 | +import type { Hono } from "hono"; | |
| 2 | +import type { AppContext } from "../types/context"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Common shape for every module's route registration entry point. Modules | |
| 6 | + * export `register<Module>Routes: RouteRegistrar` and http/app.ts composes | |
| 7 | + * them; no module wires itself into the app directly. | |
| 8 | + */ | |
| 9 | +export type RouteRegistrar = (app: Hono, context: AppContext) => void; | |
| @@ -1,7 +1,7 @@ | ||
| 1 | −import type { Hono } from "hono"; | |
| 2 | −import type { AppContext } from "../../types/context"; | |
| 1 | +import type { RouteRegistrar } from "../../http/route-registrar"; | |
| 3 | 2 | import { delay } from "../../core/async"; |
| 4 | 3 | import { HttpStatus, badRequest, notFound, serviceUnavailable } from "../../core/errors"; |
| 4 | +import { optionalEnum, optionalStringArray, parseJsonObjectBody } from "../../core/validation"; | |
| 5 | 5 | import { observeControllerFunction } from "../../core/function-observability"; |
| 6 | 6 | import { parseRecipe } from "../models/recipes/recipe-serializer"; |
| 7 | 7 | import { Event } from "../system/event-manager"; |
| @@ -40,37 +40,30 @@ const resolveHfToken = ( · 32 unmodified lines | ||
| 40 | 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 | 46 | const parseRuntimeJobBody = async (ctx: { |
| 44 | 47 | req: { json: () => Promise<unknown> }; |
| 45 | 48 | }): Promise<{ |
| 46 | − backend?: "vllm" | "sglang" | "llamacpp" | "mlx" | "cuda" | "rocm"; | |
| 49 | + backend?: (typeof RUNTIME_JOB_BACKENDS)[number]; | |
| 47 | 50 | targetId?: string; |
| 48 | − type?: "install" | "update" | "download" | "inspect"; | |
| 51 | + type?: (typeof RUNTIME_JOB_TYPES)[number]; | |
| 49 | 52 | command?: string; |
| 50 | 53 | args?: string[]; |
| 51 | 54 | version?: string; |
| 52 | 55 | preferBundled?: boolean; |
| 53 | 56 | }> => { |
| 54 | − const body = await ctx.req.json().catch(() => ({})); | |
| 55 | − if (!body || typeof body !== "object" || Array.isArray(body)) throw badRequest("Invalid payload"); | |
| 56 | − const record = body as Record<string, unknown>; | |
| 57 | − const backend = typeof record["backend"] === "string" ? record["backend"] : undefined; | |
| 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"); | |
| 57 | + const record = await parseJsonObjectBody(ctx); | |
| 58 | + const backend = optionalEnum(record, "backend", RUNTIME_JOB_BACKENDS); | |
| 59 | + const type = optionalEnum(record, "type", RUNTIME_JOB_TYPES, "job type"); | |
| 60 | + const args = optionalStringArray(record, "args"); | |
| 66 | 61 | return { |
| 67 | − ...(backend | |
| 68 | − ? { backend: backend as "vllm" | "sglang" | "llamacpp" | "mlx" | "cuda" | "rocm" } | |
| 69 | − : {}), | |
| 62 | + ...(backend ? { backend } : {}), | |
| 70 | 63 | ...(typeof record["targetId"] === "string" ? { targetId: record["targetId"] } : {}), |
| 71 | − ...(type ? { type: type as "install" | "update" | "download" | "inspect" } : {}), | |
| 64 | + ...(type ? { type } : {}), | |
| 72 | 65 | ...(typeof record["command"] === "string" ? { command: record["command"] } : {}), |
| 73 | − ...(args ? { args: args as string[] } : {}), | |
| 66 | + ...(args ? { args } : {}), | |
| 74 | 67 | ...(typeof record["version"] === "string" ? { version: record["version"] } : {}), |
| 75 | 68 | ...(typeof record["prefer_bundled"] === "boolean" |
| 76 | 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 | 75 | const launchAbortControllers = new Map<string, AbortController>(); |
| 83 | 76 | |
| 84 | 77 | app.get("/recipes", async (ctx) => { |
| @@ -1,8 +1,7 @@ | ||
| 1 | −import type { Hono } from "hono"; | |
| 2 | 1 | import { basename, dirname, resolve } from "node:path"; |
| 3 | 2 | import { existsSync } from "node:fs"; |
| 4 | 3 | import { homedir } from "node:os"; |
| 5 | −import type { AppContext } from "../../types/context"; | |
| 4 | +import type { RouteRegistrar } from "../../http/route-registrar"; | |
| 6 | 5 | import type { Recipe } from "../models/types"; |
| 7 | 6 | |
| 8 | 7 | /** |
| @@ -28,13 +27,11 @@ interface OpenAIModelList { · 19 unmodified lines | ||
| 28 | 27 | import { buildModelInfo, discoverModelDirectories } from "./model-browser"; |
| 29 | 28 | import { notFound } from "../../core/errors"; |
| 30 | 29 | import { observeControllerFunction } from "../../core/function-observability"; |
| 30 | +import { parseBooleanFlag } from "../../core/validation"; | |
| 31 | 31 | import { fetchInference } from "../../services/inference/inference-client"; |
| 32 | 32 | |
| 33 | 33 | function isMockInferenceEnabled(): boolean { |
| 34 | − const raw = 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"; | |
| 34 | + return parseBooleanFlag(process.env["VLLM_STUDIO_MOCK_INFERENCE"]); | |
| 38 | 35 | } |
| 39 | 36 | |
| 40 | 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 | 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 | 46 | app.get("/v1/models", async (ctx) => { |
| 50 | 47 | const recipes = context.stores.recipeStore.list(); |
| 51 | 48 | const current = await observeControllerFunction( |
| @@ -1,4 +1 @@ | ||
| 1 | −import type { Hono } from "hono"; | |
| 2 | −import type { AppContext } from "../../types/context"; | |
| 3 | − | |
| 4 | −export type ProxyRouteRegistrar = (app: Hono, context: AppContext) => void; | |
| 1 | +export type { RouteRegistrar as ProxyRouteRegistrar } from "../../http/route-registrar"; | |
| @@ -1,9 +1,8 @@ | ||
| 1 | 1 | import { performance } from "node:perf_hooks"; |
| 2 | −import type { Hono } from "hono"; | |
| 3 | 2 | import { HttpStatus, notFound, serviceUnavailable } from "../../core/errors"; |
| 4 | 3 | import { isRecipeRunning } from "../models/recipes/recipe-matching"; |
| 5 | 4 | import { buildSseHeaders } from "../../http/sse"; |
| 6 | −import type { AppContext } from "../../types/context"; | |
| 5 | +import type { RouteRegistrar } from "../../http/route-registrar"; | |
| 7 | 6 | import type { Recipe } from "../models/types"; |
| 8 | 7 | import { getDefaultReasoningParser } from "../engines/process/model-runtime-defaults"; |
| 9 | 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 | 92 | const nonRunningModelWarnings = new Map<string, NonRunningModelWarningState>(); |
| 94 | 93 | |
| 95 | 94 | const warnNonRunningModel = (details: { |
| @@ -1,9 +1,8 @@ | ||
| 1 | −import type { Hono } from "hono"; | |
| 2 | −import type { AppContext } from "../../types/context"; | |
| 1 | +import type { RouteRegistrar } from "../../http/route-registrar"; | |
| 3 | 2 | import { registerOpenAIRoutes } from "./openai-routes"; |
| 4 | 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 | 6 | registerOpenAIRoutes(app, context); |
| 8 | 7 | registerTokenizationRoutes(app, context); |
| 9 | 8 | }; |
| @@ -1,9 +1,8 @@ | ||
| 1 | −import type { Hono } from "hono"; | |
| 2 | 1 | import { observeControllerFunction } from "../../core/function-observability"; |
| 3 | −import type { AppContext } from "../../types/context"; | |
| 2 | +import type { RouteRegistrar } from "../../http/route-registrar"; | |
| 4 | 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 | 6 | app.post("/v1/tokenize", async (ctx) => { |
| 8 | 7 | const current = await observeControllerFunction(context, "tokenize.findInferenceProcess", () => |
| 9 | 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 | 1 | import { cpus, freemem, totalmem, platform, arch, release } from "node:os"; |
| 3 | 2 | import { |
| 4 | 3 | existsSync, |
| @@ -12,7 +11,8 @@ import { · 7 unmodified lines | ||
| 12 | 11 | } from "node:fs"; |
| 13 | 12 | import { basename, resolve, sep } from "node:path"; |
| 14 | 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 | 16 | import { getGpuInfo } from "../system/platform/gpu"; |
| 17 | 17 | import type { GpuInfo } from "../models/types"; |
| 18 | 18 | import { discoverModelDirectories, estimateWeightsSizeBytes } from "../models/model-browser"; |
| @@ -21,7 +21,6 @@ import { · 2 unmodified lines | ||
| 21 | 21 | getPersistedConfigPath, |
| 22 | 22 | loadPersistedConfig, |
| 23 | 23 | savePersistedConfig, |
| 24 | − type ProviderConfig, | |
| 25 | 24 | } from "../../config/persisted-config"; |
| 26 | 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 | 110 | return clean; |
| 112 | 111 | }; |
| 113 | 112 | |
| 114 | −export const registerStudioRoutes = (app: Hono, context: AppContext): void => { | |
| 113 | +export const registerStudioRoutes: RouteRegistrar = (app, context) => { | |
| 115 | 114 | const buildSettingsPayload = (): { |
| 116 | 115 | config_path: string; |
| 117 | 116 | persisted: { |
| @@ -318,116 +317,5 @@ export const registerStudioRoutes = (app: Hono, context: AppContext): void => { · 200 unmodified lines | ||
| 318 | 317 | return ctx.json({ success: true, target }); |
| 319 | 318 | }); |
| 320 | 319 | |
| 321 | − app.get("/studio/providers", async (ctx) => { | |
| 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 | − }); | |
| 320 | + registerStudioProviderRoutes(app, context); | |
| 433 | 321 | }; |
| @@ -1,9 +1,8 @@ | ||
| 1 | −import type { Hono } from "hono"; | |
| 2 | 1 | import { spawn, spawnSync } from "node:child_process"; |
| 3 | 2 | import { unlinkSync } from "node:fs"; |
| 4 | 3 | import { createInterface } from "node:readline"; |
| 5 | 4 | import { PassThrough } from "node:stream"; |
| 6 | −import type { AppContext } from "../../types/context"; | |
| 5 | +import type { RouteRegistrar } from "../../http/route-registrar"; | |
| 7 | 6 | import { badRequest, notFound } from "../../core/errors"; |
| 8 | 7 | import { observeControllerFunction } from "../../core/function-observability"; |
| 9 | 8 | import { streamAsyncStrings, buildSseHeaders, withSseHeartbeat } from "../../http/sse"; |
| @@ -21,7 +20,7 @@ import { · 11 unmodified lines | ||
| 21 | 20 | tailFileLines, |
| 22 | 21 | } from "../../core/log-files"; |
| 23 | 22 | |
| 24 | −export const registerLogsRoutes = (app: Hono, context: AppContext): void => { | |
| 23 | +export const registerLogsRoutes: RouteRegistrar = (app, context) => { | |
| 25 | 24 | let lastCleanupAt = 0; |
| 26 | 25 | |
| 27 | 26 | const maybeCleanup = (): void => { |
| @@ -1,6 +1,6 @@ | ||
| 1 | −import type { Hono } from "hono"; | |
| 2 | 1 | import { performance } from "node:perf_hooks"; |
| 3 | 2 | import { observeControllerFunction } from "../../core/function-observability"; |
| 3 | +import type { RouteRegistrar } from "../../http/route-registrar"; | |
| 4 | 4 | import type { AppContext } from "../../types/context"; |
| 5 | 5 | import { getGpuInfo } from "./platform/gpu"; |
| 6 | 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 | 245 | app.get("/metrics", async (_ctx) => { |
| 246 | 246 | const current = await observeControllerFunction( |
| 247 | 247 | context, |
| @@ -1,11 +1,11 @@ | ||
| 1 | −import type { Hono } from "hono"; | |
| 2 | 1 | import { connect } from "node:net"; |
| 3 | 2 | import { hostname } from "node:os"; |
| 4 | 3 | import { existsSync, readFileSync } from "node:fs"; |
| 5 | 4 | import { join, resolve, sep } from "node:path"; |
| 6 | −import type { AppContext } from "../../types/context"; | |
| 5 | +import type { RouteRegistrar } from "../../http/route-registrar"; | |
| 7 | 6 | import type { SystemConfigResponse } from "../models/types"; |
| 8 | 7 | import { badRequest, notFound } from "../../core/errors"; |
| 8 | +import { parseJsonObjectBody } from "../../core/validation"; | |
| 9 | 9 | import { observeControllerFunction } from "../../core/function-observability"; |
| 10 | 10 | import { estimateWeightsSizeBytes } from "../models/model-browser"; |
| 11 | 11 | import { getGpuInfo } from "./platform/gpu"; |
| @@ -21,7 +21,7 @@ import { · 9 unmodified lines | ||
| 21 | 21 | SYSTEM_SERVICE_CHECK_HOST, |
| 22 | 22 | } from "./configs"; |
| 23 | 23 | |
| 24 | −export const registerSystemRoutes = (app: Hono, context: AppContext): void => { | |
| 24 | +export const registerSystemRoutes: RouteRegistrar = (app, context) => { | |
| 25 | 25 | const checkService = ( |
| 26 | 26 | host: string, |
| 27 | 27 | port: number, |
| @@ -87,10 +87,7 @@ export const registerSystemRoutes = (app: Hono, context: AppContext): void => { · 59 unmodified lines | ||
| 87 | 87 | }); |
| 88 | 88 | |
| 89 | 89 | app.post("/vram-calculator", async (ctx) => { |
| 90 | − const body = await ctx.req.json().catch(() => ({})); | |
| 91 | − if (!body || typeof body !== "object") { | |
| 92 | − throw badRequest("Invalid payload"); | |
| 93 | − } | |
| 90 | + const body = await parseJsonObjectBody(ctx); | |
| 94 | 91 | |
| 95 | 92 | const model = typeof body["model"] === "string" ? body["model"].trim() : ""; |
| 96 | 93 | const contextLength = Number(body["context_length"] ?? 0); |
| @@ -1,5 +1,5 @@ | ||
| 1 | −import type { Hono } from "hono"; | |
| 2 | 1 | import { observeControllerFunction } from "../../core/function-observability"; |
| 2 | +import type { RouteRegistrar } from "../../http/route-registrar"; | |
| 3 | 3 | import type { AppContext } from "../../types/context"; |
| 4 | 4 | import { getUsageFromPiSessions } from "./usage/pi-sessions"; |
| 5 | 5 | import { emptyResponse } from "./usage/usage-utilities"; |
| @@ -20,7 +20,7 @@ const collectKnownModels = async (context: AppContext): Promise<Set<string>> => · 14 unmodified lines | ||
| 20 | 20 | return knownModels; |
| 21 | 21 | }; |
| 22 | 22 | |
| 23 | −export const registerUsageRoutes = (app: Hono, context: AppContext): void => { | |
| 23 | +export const registerUsageRoutes: RouteRegistrar = (app, context) => { | |
| 24 | 24 | app.get("/usage", async (ctx) => { |
| 25 | 25 | try { |
| 26 | 26 | const knownModels = await observeControllerFunction(context, "usage.collectKnownModels", () => |