91b5b04d97
SparkControl is a self-hosted local-inference gateway with an OpenAI-compatible API, reached over the internal same-box StartOS address (http://spark-control.startos:9999/v1, plain HTTP). It takes no API key, so generateOpenAIStyle gained a { requireApiKey } option and now omits the Authorization header when no key is set. The Settings form auto-detects the loaded vLLM model via SparkControl's /api/endpoints probe, mirroring the Ollama auto-detect; it's $0 in the cost UI. Custom-URL => admin-only + SSRF-guarded, same as Ollama. Also fixes a config footgun behind the empty-response report: a custom base URL could ride along to a fixed-URL provider (claude/openai/gemini) whose form field is hidden, get stored, and be silently ignored (the provider always hits its hardcoded endpoint). Both config write paths now null baseUrl for non-custom-URL providers, and the form clears it on provider change. No schema/data change (AIConfigProfile.provider is free-text). 259 tests pass; built + sideloaded to immense-voyage.local with a clean non-root launch.
5.8 KiB
5.8 KiB
paths
| paths | ||
|---|---|---|
|
AI subsystem
Scoped guidance for the AI generation subsystem (proof-of-work/lib/ai/** and the
generate/generations route handlers). Whole-repo rules live in AGENTS.md.
Architecture
generate/route.tskicks off a detached background runner (generationRunner.ts) and returns an id; the client attaches via SSE (generations/[id]/stream) and can also poll the row. Navigating away does NOT cancel generation.- System prompt =
systemPromptBase.ts(output contract: JSON-only, libraryexerciseIds only, suggested weights) + the template's coaching prompt +PROGRAM_OUTPUT_SHAPE+ library + optional history block (historyContext.ts). - Multi-config:
AIConfigProfilerows per user;UserPreferences.activeAIConfigIdpoints at the active one and is mirrored into the legacyai*columns for back-compat.
Two generation kinds (AIGeneration.kind)
The runner spine is shared by two output shapes, discriminated by AIGeneration.kind
("program" | "workout", default "program"). The runner picks the parser by kind and
stores the JSON in the (reused) parsedProgram column.
- program (
kind: 'program') —generate/route.ts→programSchema.ts(PROGRAM_OUTPUT_SHAPE/parseAIProgram). Applied to DB rows viaapply.ts. Shown in AI · History (which filterskind: 'program'). - workout (
kind: 'workout') —generate-workout/route.ts(usesworkoutPrompt.ts+workoutSchema.ts:WORKOUT_OUTPUT_SHAPE/parseAIWorkout). A single day's session. No server-side apply: the client (GenerateWorkoutClient.tsx) stashes the reviewed suggestion insessionStorageand routes to/main/workouts/new?from=ai, whereAiWorkoutPrefill.tsxexpands it (viaworkoutDraft.ts::buildPrefillExercises) and pre-fills the normalWorkoutForm— nothing persists until the user saves through the regular workout path. Refine = a new workout generation seeded with the prior suggestion JSON (priorWorkoutin the route body → REVISION mode inworkoutPrompt.ts). These rows are ephemeral, so they're excluded from the program-shaped AI · History. - Adding a new kind: extend the union in
KickoffOpts, add a parser + output-shape, branch the parser selection ingenerationRunner.ts, and decide whether it belongs in History (filtered by kind).
Provider abstraction
- Each provider yields an async iterable of
GenerateChunk(text/usage/done/error); add new ones underlib/ai/providers/and register inindex.ts.openai.tsexports bothopenaiandopenai-compatible, so the five provider files register 6 providers (claude,openai,openai-compatible,gemini,ollama,sparkcontrol). - SparkControl (
sparkcontrol.ts) — the operator's own self-hosted local-inference gateway. OpenAI-compatible wire format, so it reusesgenerateOpenAIStylewith{ requireApiKey: false }(keyless on the LAN — the streamer omits theAuthorizationheader when no key is set). Reached over the internal same-box StartOS address (http://spark-control.startos:9999/v1, plain HTTP — no TLS, no cert-skip). Custom base URL ⇒ SSRF-guarded + admin-only, same as Ollama. The Settings UI auto-detects the loaded vLLM model viaapp/api/ai/sparkcontrol/model(probes SparkControl's/api/endpoints→vllm.model), mirroring the Ollama/api/tagsauto-detect. Free in the cost UI. - Base-URL hygiene: only custom-URL providers (
requiresBaseUrl: ollama, openai-compatible, sparkcontrol) store a base URL. Both config write paths (configsPOST +[id]PATCH) null it for fixed-URL providers, and the Settings form clears it on provider change — otherwise a stale URL silently rides along to claude/openai/gemini, which ignore it and hit their hardcoded endpoints. - Streaming AI uses SSE; partial JSON is recovered with
lib/ai/lenientJson.ts. - Pricing/model menus live in
lib/ai/pricing.ts(PRICES,MODEL_MENU) — keep them paired so every menu model has a price entry (there's a test enforcing this). - Adding a provider (precedent:
sparkcontrol, 1.2.0:7) is a fan-out across ~8 spots — miss one and it half-works: the provider file +ProviderIdunion (types.ts) + register inproviders/index.ts(ALL+PROVIDER_ORDER); the zodproviderenum in bothconfigsPOST and[id]PATCH (+defaultNamePRETTY map); the UIPROVIDERSlist inAIIntegration.tsx(requiresKey/requiresUrlmust mirror the serverrequiresApiKey/requiresBaseUrl);MODEL_MENU([]if no curated menu) + anestimateCostbranch (free/null for self-hosted). A custom-URL provider is admin-only + SSRF-guarded everywhere (configs POST/PATCH,ai/test, any probe route) and must appear in those routes' 403 enumeration strings.ai/testandgeneratework for free once it's ingetProvider.
SSRF / provider-URL safety
- Any
fetchto a user-supplied provider base URL MUST go throughassertSafeProviderUrl(lib/ai/safeUrl.ts) first — it enforces http(s) and blocks link-local/cloud-metadata (169.254/16, fe80::/10) + unspecified. Private-LAN + loopback are allowed on purpose (reachingollama.startos/LAN gateways is the feature). Currently wired intoproviders/ollama.ts, theopenai-compatiblepath inproviders/openai.ts(NOT the fixedapi.openai.compath), and theai/ollama/modelsprobe. Add the guard to any new user-URL fetch path. - Custom-URL providers (those with
requiresBaseUrl: ollama, openai-compatible) are admin-only —isCustomUrlProvidergatesai/configsPOST +[id]PATCH +ai/test, andai/ollama/modelsis fully admin-only. The Settings UI hides them from non-admins. This is a second defense layer on top of the IP block; keep both when adding routes.