v1.2.0:7 — add SparkControl AI provider + fix base-URL footgun
CI / proof-of-work (Next.js app) (push) Waiting to run
CI / start9/0.4 (StartOS package code) (push) Waiting to run

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.
This commit is contained in:
Keysat
2026-06-19 14:47:30 -05:00
parent b2587d767b
commit 91b5b04d97
16 changed files with 635 additions and 27 deletions
+5 -5
View File
@@ -26,7 +26,7 @@ proof-of-work/ ← the Next.js app (THIS is where you run npm)
app/main/ ← authed UI; navigation.tsx = sidebar
components/ ← React components (workouts/, ai/, settings/)
lib/ai/ ← AI subsystem (see below)
lib/ai/providers/ ← claude.ts openai.ts gemini.ts ollama.ts + index.ts (getProvider; openai.ts exports both openai + openai-compatible = 5 registered providers)
lib/ai/providers/ ← claude.ts openai.ts gemini.ts ollama.ts sparkcontrol.ts + index.ts (getProvider; openai.ts exports both openai + openai-compatible = 6 registered providers)
prisma/schema.prisma ← schema (mirror; real DB migrates via entrypoint ALTERs)
prisma/*.seed.json ← curated exercise library + AI templates (reconciled each boot)
tests/ ← Vitest specs (ai-*.test.ts, routes-*.test.ts, ...)
@@ -116,15 +116,15 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
## Current state
Latest version is **1.2.0:6****AI "generate today's workout"**: describe one session in plain words → a streamed, ready-to-log workout (suggested weights/reps/set-counts grounded in 90-day history) → inline-edit or **Refine** (round-trips changes back to the LLM) → **Use this workout** pre-fills the New Workout form (nothing persists until you save). Reuses the program-generation spine via a new `AIGeneration.kind` discriminant (`"program" | "workout"`); workout rows are ephemeral (the saved Workout is the durable record) so they're filtered out of the program-shaped AI History. New `AIGeneration.kind` column (default "program") via boot-time guarded `ALTER`. **Architecture + hand-off (sessionStorage `?from=ai` → `AiWorkoutPrefill`, `EditWorkoutData.id?`→CREATE, per-exercise weight-unit grounding) are documented in `docs/guides/ai-subsystem.md` → "Two generation kinds".** **Built + sideloaded** (`immense-voyage.local`, 2026-06-19, `master`) as `proof-of-work_x86_64.s9pk` (80M). tsc clean (app + packaging), lint clean (pre-existing only), **251 tests pass**, `next build` + s9pk build succeed. Registry empty, **publishing parked** (sideload-only via `make install`).
Latest version is **1.2.0:7****SparkControl AI provider + base-URL footgun fix**. Adds a 6th provider, **SparkControl (local)** — the operator's own self-hosted local-inference gateway. OpenAI-compatible wire format (reuses `generateOpenAIStyle`), **keyless** on the LAN (`requireApiKey:false` → no `Authorization` header), reached over the **internal same-box StartOS address** `http://spark-control.startos:9999/v1` (plain HTTP — no TLS, no cert-skip; the public LAN interface is HTTPS w/ a self-signed cert we deliberately avoid). The Settings form **auto-detects the loaded vLLM model** via SparkControl's `/api/endpoints` (`app/api/ai/sparkcontrol/model`, admin-only + SSRF-guarded), mirroring Ollama auto-detect; $0 in the cost UI. Also fixes a **config footgun** (origin of this session): 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 hits its hardcoded endpoint) — both config write paths (`configs` POST + `[id]` PATCH) now null `baseUrl` for non-custom-URL providers and the form clears it on provider change. **No schema/data change** (`AIConfigProfile.provider` is a free-text column). Details: `docs/guides/ai-subsystem.md`Provider abstraction (incl. the new "adding a provider" fan-out checklist). **Built + sideloaded** (`immense-voyage.local`, 2026-06-19, `master`) as `proof-of-work_x86_64.s9pk` (80M). tsc clean (app + packaging), lint clean (pre-existing only), **259 tests pass**, `next build` + s9pk build succeed. Registry empty, **publishing parked** (sideload-only via `make install`).
**Design contract established this session (2026-06-19, committed `7fda9ce`, no UI code changed):** the `design/` folder now holds the durable contract — `DESIGN.md` (9-section brief) + `tokens.tokens.json` (DTCG) + `brand/palette.css` + `inspiration/` provenance. From a Case-B *document-as-is* extract of the as-built dark UI: **monochrome gym-brutalist** (`#0A0A0A` canvas, zinc-only neutral, white primary button, Bebas-uppercase/tracked headings, flat border-based depth), plus two owner calls — **red elevated to the single brand accent `#DC2626`** and a **two-tier radius** (4px controls / 8px containers). AGENTS.md carries the read-before-UI Design line; `ROADMAP.md`**Design** holds the `design-checker` cleanup backlog (gray→zinc, green→emerald, yellow→amber, `rounded-md``rounded`, overlay-only shadows, and a shared `<Button>`). **Read `design/DESIGN.md` before any UI work.**
**Confirmed on-box (2026-06-19, via `start-cli`):** box runs `1.2.0:6`; entrypoint logged `adding AIGeneration.kind (default 'program')` once, then launched `as nextjs` with no errors (clears the long-standing non-root check); read-only `SELECT` confirms the `AIGeneration.kind` column exists and the existing generation row backfilled to `program`. Recent prior ships (1.2.0 line): **1.2.0:5** Gear replaces RPE for cardio; **1.2.0:4** watts as first-class set field; **1.2.0:3** P3 hardening (login timing oracle + `exerciseId` ownership).
**Confirmed on-box (2026-06-19, via `start-cli`):** box runs `1.2.0:7`; launched `as nextjs` with no errors, "Ready in 221ms", and (correctly) **no migration ran** — this release adds no column. SparkControl itself is runtime-config (the operator must add a SparkControl config in Settings → AI to exercise it). Recent prior ships (1.2.0 line): **1.2.0:6** AI "today's workout"; **1.2.0:5** Gear replaces RPE for cardio; **1.2.0:4** watts as first-class set field.
**No on-box checks pending.** Known bug (tracked in `ROADMAP.md` → Known bugs): the **1.2.0:2** Safari first-tap retry did NOT fix the mobile-Safari first-login failure — reproduced through 1.2.0:5 (the workout feature didn't touch auth), first tap shows "An unexpected error occurred", second tap works. Diagnosis captured; the fix is gated on one data point — the first failed request's error code from Safari Web Inspector (`-1005` → client delayed-retry; `502`/`503` → Node keep-alive tuning).
**On-box follow-up (user action):** the live AI config is still the misconfigured one that triggered this session — `provider=gemini` with a LAN `baseUrl` the gemini provider ignores (so it silently hit Google's cloud and returned "Empty response"). The provider can't change on edit, so **delete that config and add a SparkControl one** (Settings → AI → Add config → SparkControl; URL auto-fills to `http://spark-control.startos:9999/v1`, model auto-detects, no key). Known bug (tracked in `ROADMAP.md` → Known bugs): the **1.2.0:2** Safari first-tap retry did NOT fix the mobile-Safari first-login failure — reproduced through 1.2.0:5 (the workout feature didn't touch auth), first tap shows "An unexpected error occurred", second tap works. Diagnosis captured; the fix is gated on one data point — the first failed request's error code from Safari Web Inspector (`-1005` → client delayed-retry; `502`/`503` → Node keep-alive tuning).
Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, **single-workout generation + refine**, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history).
Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (6 providers incl. **SparkControl**, multi-config, background generation, single-workout generation + refine, history detail, cost/duration, Ollama + SparkControl auto-detect, infinite-scroll exercise history).
Next steps (priority order):
1. **Finish the P3 hardening batch** (`ROADMAP.md` → Security & hardening — timing oracle + exerciseId ownership now DONE): CSP `unsafe-eval`, `/api/health` info disclosure, rate-limit map leak, configurable/shorter sessions (currently 30-day), text max-length. Also unify the 3rd JSON-parse pattern (`try{json}catch{→{}}`) in `programs/[id]/days/[dayId]/start`.
+25 -2
View File
@@ -47,11 +47,34 @@ stores the JSON in the (reused) `parsedProgram` column.
- Each provider yields an async iterable of `GenerateChunk` (`text` / `usage` / `done` /
`error`); add new ones under `lib/ai/providers/` and register in `index.ts`.
`openai.ts` exports both `openai` and `openai-compatible`, so the four provider files
register **5** providers (`claude`, `openai`, `openai-compatible`, `gemini`, `ollama`).
`openai.ts` exports both `openai` and `openai-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 reuses `generateOpenAIStyle` with
`{ requireApiKey: false }` (keyless on the LAN — the streamer omits the `Authorization`
header 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 via `app/api/ai/sparkcontrol/model` (probes SparkControl's `/api/endpoints`
`vllm.model`), mirroring the Ollama `/api/tags` auto-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
(`configs` POST + `[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 + `ProviderId` union (`types.ts`) + register
in `providers/index.ts` (`ALL` + `PROVIDER_ORDER`); the zod `provider` enum in **both**
`configs` POST and `[id]` PATCH (+ `defaultName` PRETTY map); the UI `PROVIDERS` list in
`AIIntegration.tsx` (`requiresKey`/`requiresUrl` must mirror the server `requiresApiKey`/
`requiresBaseUrl`); `MODEL_MENU` (`[]` if no curated menu) + an `estimateCost` branch
(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/test` and `generate` work for free once it's in `getProvider`.
## SSRF / provider-URL safety
@@ -84,7 +84,7 @@ export async function PATCH(
return NextResponse.json(
{
error:
'Only an admin can configure providers with a custom base URL (Ollama / OpenAI-compatible).',
'Only an admin can configure providers with a custom base URL (Ollama, SparkControl, OpenAI-compatible).',
},
{ status: 403 },
);
@@ -93,8 +93,13 @@ export async function PATCH(
const data: Record<string, string | null> = {};
if (parsed.data.name !== undefined) data.name = parsed.data.name;
if (parsed.data.model !== undefined) data.model = parsed.data.model;
// Fixed-URL providers (claude/openai/gemini) ignore a base URL — never let an
// edit attach one (the footgun that produced a gemini config carrying a stale
// baseUrl). Provider can't change on PATCH, so `existing.provider` is authoritative.
if (parsed.data.baseUrl !== undefined)
data.baseUrl = parsed.data.baseUrl || null;
data.baseUrl = isCustomUrlProvider(existing.provider)
? parsed.data.baseUrl || null
: null;
if (parsed.data.apiKey !== undefined)
data.apiKey = parsed.data.apiKey || null;
+27 -7
View File
@@ -18,7 +18,14 @@ import { isCustomUrlProvider } from '@/lib/ai/providers';
* [id]/activate/route.ts so the action is explicit + auditable.
*/
const PROVIDERS = ['claude', 'openai', 'openai-compatible', 'gemini', 'ollama'] as const;
const PROVIDERS = [
'claude',
'openai',
'openai-compatible',
'gemini',
'ollama',
'sparkcontrol',
] as const;
export async function GET() {
const user = await getCurrentUser();
@@ -82,32 +89,44 @@ export async function POST(request: NextRequest) {
const { name, provider, model, baseUrl, apiKey, setActive } = parsed.data;
// Custom-URL providers (Ollama / OpenAI-compatible) are admin-only — a
// non-admin pointing the server at an arbitrary URL is the SSRF actor
// vector. Fixed-URL cloud providers stay per-user.
// Custom-URL providers (Ollama, SparkControl, OpenAI-compatible) are
// admin-only — a non-admin pointing the server at an arbitrary URL is the
// SSRF actor vector. Fixed-URL cloud providers stay per-user.
if (!user.isAdmin && (baseUrl || isCustomUrlProvider(provider))) {
return NextResponse.json(
{
error:
'Only an admin can configure providers with a custom base URL (Ollama / OpenAI-compatible).',
'Only an admin can configure providers with a custom base URL (Ollama, SparkControl, OpenAI-compatible).',
},
{ status: 403 },
);
}
// Only custom-URL providers (Ollama / OpenAI-compatible / SparkControl) carry
// a base URL. Fixed-URL providers (claude/openai/gemini) hit their hardcoded
// endpoint and ignore it — so we drop any stale baseUrl here rather than
// storing an impossible config (the footgun behind the gemini-with-a-baseURL
// mismatch). The UI also clears the field on provider change.
const normalizedBaseUrl = isCustomUrlProvider(provider) ? baseUrl || null : null;
const profile = await prisma.aIConfigProfile.create({
data: {
userId: user.id,
name: name ?? defaultName(provider, model),
provider,
model,
baseUrl: baseUrl || null,
baseUrl: normalizedBaseUrl,
apiKey: apiKey || null,
},
});
if (setActive) {
await activate(user.id, profile.id, { provider, model, baseUrl, apiKey });
await activate(user.id, profile.id, {
provider,
model,
baseUrl: normalizedBaseUrl,
apiKey,
});
}
return NextResponse.json({
@@ -128,6 +147,7 @@ function defaultName(provider: string, model: string): string {
'openai-compatible': 'Custom',
gemini: 'Gemini',
ollama: 'Ollama',
sparkcontrol: 'SparkControl',
};
const label = PRETTY[provider] ?? provider;
return `${label} · ${model}`;
@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
import { assertSafeProviderUrl } from '@/lib/ai/safeUrl';
/**
* GET /api/ai/sparkcontrol/model?baseUrl=...
*
* Probes SparkControl's service-discovery endpoint (`/api/endpoints`) and
* returns the model vLLM currently has loaded, so the Settings UI can
* auto-fill the model field (the same role the Ollama /api/tags probe plays).
* When no baseUrl is given it walks the canonical same-box StartOS addresses
* and, on a hit, hands back a `baseUrl` (with the `/v1` chat suffix) the UI
* can pre-fill.
*
* `/api/endpoints` lives at the host root, NOT under `/v1` — so we strip a
* trailing `/v1` off the configured chat base URL before probing.
*
* Authenticated + admin-only: pointing the server at an arbitrary URL is the
* SSRF surface (same gate as the Ollama probe), and a non-admin shouldn't be
* able to fingerprint the local network.
*
* Response:
* { ok: true, baseUrl, model: string | null, ready: boolean | null, ms }
* { ok: false, baseUrl, error, ms }
*/
const PROBE_TIMEOUT_MS = 5_000;
// Canonical same-box addresses (StartOS 0.4 `.startos`, legacy 0.3 `.embassy`),
// including the `/v1` chat suffix the config field expects.
const DEFAULT_CANDIDATES = [
'http://spark-control.startos:9999/v1',
'http://spark-control.embassy:9999/v1',
];
export async function GET(request: NextRequest) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
// Custom-URL surface ⇒ admin-only (same as the Ollama probe).
if (!user.isAdmin)
return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 });
const url = new URL(request.url);
const explicit = url.searchParams.get('baseUrl');
const candidates = explicit ? [explicit] : DEFAULT_CANDIDATES;
for (const candidate of candidates) {
const result = await probe(candidate);
if (result.ok) return NextResponse.json(result);
// For an explicit URL, surface the failure right away.
if (explicit) return NextResponse.json(result);
}
return NextResponse.json({
ok: false,
baseUrl: candidates[0],
error: 'No SparkControl instance responded at the default StartOS addresses.',
ms: 0,
});
}
interface EndpointsResponse {
vllm?: { ready?: boolean; model?: string | null; disabled?: boolean };
}
async function probe(baseUrl: string) {
const t0 = Date.now();
let url: string;
try {
// `/api/endpoints` lives at the host root, independent of the `/v1` chat
// path — so probe the origin, not a string-derived prefix. (new URL also
// throws on a malformed baseUrl, which this catch turns into ok:false.)
url = new URL(baseUrl).origin + '/api/endpoints';
await assertSafeProviderUrl(url);
} catch (e) {
return { ok: false as const, baseUrl, error: (e as Error).message, ms: Date.now() - t0 };
}
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: ctrl.signal });
clearTimeout(timer);
if (!res.ok) {
return {
ok: false as const,
baseUrl,
error: `SparkControl returned HTTP ${res.status}`,
ms: Date.now() - t0,
};
}
const body = (await res.json()) as EndpointsResponse;
return {
ok: true as const,
baseUrl,
model: body.vllm?.model ?? null,
ready: body.vllm?.ready ?? null,
ms: Date.now() - t0,
};
} catch (e) {
clearTimeout(timer);
return {
ok: false as const,
baseUrl,
error: ctrl.signal.aborted
? `Timed out after ${PROBE_TIMEOUT_MS / 1000}s`
: (e as Error).message,
ms: Date.now() - t0,
};
}
}
+1 -1
View File
@@ -120,7 +120,7 @@ export async function POST(request: NextRequest) {
{
ok: false,
error:
'Only an admin can test providers with a custom base URL (Ollama / OpenAI-compatible).',
'Only an admin can test providers with a custom base URL (Ollama, SparkControl, OpenAI-compatible).',
},
{ status: 403 },
);
@@ -29,6 +29,12 @@ const PROVIDERS = [
},
{ id: 'gemini', label: 'Google Gemini', requiresKey: true, requiresUrl: false },
{ id: 'ollama', label: 'Ollama (self-hosted)', requiresKey: false, requiresUrl: true },
{
id: 'sparkcontrol',
label: 'SparkControl (local)',
requiresKey: false,
requiresUrl: true,
},
] as const;
type ProviderId = (typeof PROVIDERS)[number]['id'];
@@ -382,6 +388,11 @@ function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps)
const [ollamaProbing, setOllamaProbing] = useState(false);
const [ollamaProbeError, setOllamaProbeError] = useState<string | null>(null);
// SparkControl auto-detect (the currently-loaded vLLM model via /api/endpoints).
const [sparkModel, setSparkModel] = useState<string | null>(null);
const [sparkProbing, setSparkProbing] = useState(false);
const [sparkProbeError, setSparkProbeError] = useState<string | null>(null);
const meta = PROVIDERS.find((p) => p.id === provider);
// Probe Ollama on provider switch (or baseUrl change while ollama).
@@ -427,6 +438,48 @@ function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider, baseUrl]);
// Probe SparkControl on provider switch (or baseUrl change while selected):
// pre-fill the canonical same-box URL and the loaded model name.
useEffect(() => {
if (provider !== 'sparkcontrol') {
setSparkModel(null);
setSparkProbeError(null);
return;
}
let cancelled = false;
setSparkProbing(true);
setSparkProbeError(null);
const url = baseUrl
? `/api/ai/sparkcontrol/model?baseUrl=${encodeURIComponent(baseUrl)}`
: '/api/ai/sparkcontrol/model';
fetch(url)
.then((r) => r.json())
.then((b) => {
if (cancelled) return;
if (b.ok) {
setSparkModel(b.model ?? null);
setSparkProbeError(null);
// Pre-fill the URL (with /v1) if the user hadn't typed one yet.
if (!baseUrl && b.baseUrl) setBaseUrl(b.baseUrl);
// Pre-pick the loaded model in create mode if the field is empty.
if (!isEdit && !model && b.model) setModel(b.model);
} else {
setSparkModel(null);
setSparkProbeError(b.error ?? 'Probe failed');
}
})
.catch((e) => {
if (!cancelled) setSparkProbeError((e as Error).message);
})
.finally(() => {
if (!cancelled) setSparkProbing(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider, baseUrl]);
// Reset draft test result whenever the user changes any input — so the
// green "✓ Connected" indicator never lingers from a previous attempt.
useEffect(() => {
@@ -511,6 +564,9 @@ function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps)
setProvider(e.target.value as ProviderId);
setModel(''); // reset on provider change
setModelMode('menu');
// Clear any URL typed for a previous (custom-URL) provider so it
// can't ride along to a fixed-URL provider whose field is hidden.
setBaseUrl('');
}}
className={inputClass}
disabled={isEdit}
@@ -570,6 +626,30 @@ function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps)
/>
)}
</Field>
) : provider === 'sparkcontrol' ? (
<Field
label={
<>
Model{' '}
{sparkProbing ? (
<span className="text-zinc-500 normal-case font-normal">· detecting</span>
) : sparkModel ? (
<span className="text-emerald-400 normal-case font-normal">· detected</span>
) : sparkProbeError ? (
<span className="text-amber-400 normal-case font-normal">
· could not reach SparkControl (type a name)
</span>
) : null}
</>
}
>
<input
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={sparkModel ?? 'auto-detected from SparkControl'}
className={inputClass}
/>
</Field>
) : showMenu ? (
<Field label="Model">
<select
@@ -628,7 +708,9 @@ function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps)
placeholder={
meta.id === 'ollama'
? 'http://ollama.startos:11434'
: 'https://your-gateway.example.com/v1'
: meta.id === 'sparkcontrol'
? 'http://spark-control.startos:9999/v1'
: 'https://your-gateway.example.com/v1'
}
className={inputClass}
/>
+5 -2
View File
@@ -123,10 +123,12 @@ export const MODEL_MENU: Record<string, ModelOption[]> = {
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash (legacy)' },
],
// openai-compatible + ollama: no curated menu — model names are
// gateway- or host-specific. Ollama auto-detects via /api/tags.
// openai-compatible + ollama + sparkcontrol: no curated menu — model names
// are gateway- or host-specific. Ollama auto-detects via /api/tags;
// SparkControl auto-detects the loaded model via /api/endpoints.
'openai-compatible': [],
ollama: [],
sparkcontrol: [],
};
/** Find the price entry whose key is a (case-insensitive) prefix of the model string. */
@@ -153,6 +155,7 @@ export function estimateCost(opts: {
tokensOut: number | null;
}): number | null {
if (opts.provider === 'ollama') return 0; // self-hosted, no per-token cost
if (opts.provider === 'sparkcontrol') return 0; // self-hosted local inference, free
if (opts.provider === 'openai-compatible') return null; // we don't know the gateway's pricing
if (opts.tokensIn == null || opts.tokensOut == null) return null;
const price = findPrice(opts.model);
+6 -3
View File
@@ -3,6 +3,7 @@ import { ollama } from './ollama';
import { claude } from './claude';
import { openai, openaiCompatible } from './openai';
import { gemini } from './gemini';
import { sparkcontrol } from './sparkcontrol';
const ALL: Record<ProviderId, LLMProvider> = {
claude,
@@ -10,6 +11,7 @@ const ALL: Record<ProviderId, LLMProvider> = {
'openai-compatible': openaiCompatible,
gemini,
ollama,
sparkcontrol,
};
export function getProvider(id: string): LLMProvider | null {
@@ -17,10 +19,10 @@ export function getProvider(id: string): LLMProvider | null {
}
/**
* True for providers that take a user-supplied base URL (Ollama,
* True for providers that take a user-supplied base URL (Ollama, SparkControl,
* OpenAI-compatible). Configuring these is admin-only — a non-admin pointing
* the server at an arbitrary URL is the SSRF actor vector (EVALUATION.md P1).
* The fixed-URL cloud providers (claude/openai/gemini) stay per-user.
* the server at an arbitrary URL is the SSRF actor vector. The fixed-URL cloud
* providers (claude/openai/gemini) stay per-user.
*/
export function isCustomUrlProvider(id: string): boolean {
return !!getProvider(id)?.requiresBaseUrl;
@@ -33,6 +35,7 @@ export const PROVIDER_ORDER: ProviderId[] = [
'openai-compatible',
'gemini',
'ollama',
'sparkcontrol',
];
export const PROVIDERS = ALL;
+6 -2
View File
@@ -17,8 +17,9 @@ export async function* generateOpenAIStyle(
opts: GenerateOpts,
baseUrl: string,
providerLabel: string,
{ requireApiKey = true }: { requireApiKey?: boolean } = {},
): AsyncGenerator<GenerateChunk, void, void> {
if (!opts.apiKey) {
if (requireApiKey && !opts.apiKey) {
yield { type: 'error', message: `${providerLabel} API key is required.` };
return;
}
@@ -29,7 +30,10 @@ export async function* generateOpenAIStyle(
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${opts.apiKey}`,
// Only send Authorization when we actually have a key. SparkControl
// and other keyless LAN gateways take no auth; an empty Bearer would
// be wrong (and some servers reject it).
...(opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {}),
},
body: JSON.stringify({
model: opts.model,
@@ -0,0 +1,50 @@
import type { LLMProvider } from '../types';
import { generateOpenAIStyle } from './openai';
import { assertSafeProviderUrl } from '../safeUrl';
/**
* SparkControl — a self-hosted local-inference gateway (the operator's own
* StartOS package). Its LLM surface is OpenAI-compatible
* (`POST {baseUrl}/chat/completions`, SSE `data:` frames, `[DONE]`), so we
* reuse the OpenAI-style streamer wholesale.
*
* Two differences from the generic `openai-compatible` provider:
* 1. **No API key.** SparkControl takes no auth on the LAN, so the key is
* optional (`requireApiKey: false`) — the streamer omits the
* Authorization header when none is set.
* 2. **Reached over the internal same-box address** (e.g.
* `http://spark-control.startos:9999/v1`) — plain HTTP, no TLS to worry
* about. The public LAN interface is HTTPS with a self-signed Start9
* cert; we deliberately don't go there, so no cert-verification games.
*
* Custom base URL ⇒ SSRF-guarded + admin-only, same as Ollama. The model name
* is whatever vLLM currently has loaded; the Settings UI auto-detects it via
* SparkControl's `/api/endpoints` discovery (see app/api/ai/sparkcontrol/model).
*/
export const sparkcontrol: LLMProvider = {
id: 'sparkcontrol',
label: 'SparkControl (local)',
requiresApiKey: false,
requiresBaseUrl: true,
async *generate(opts) {
if (!opts.baseUrl) {
yield {
type: 'error',
message:
'Base URL is required (e.g. http://spark-control.startos:9999/v1).',
};
return;
}
// User-supplied base URL → SSRF guard (private-LAN + loopback allowed on
// purpose; reaching spark-control.startos is the feature).
try {
await assertSafeProviderUrl(opts.baseUrl);
} catch (e) {
yield { type: 'error', message: `SparkControl: ${(e as Error).message}` };
return;
}
yield* generateOpenAIStyle(opts, opts.baseUrl, 'SparkControl', {
requireApiKey: false,
});
},
};
+2 -1
View File
@@ -20,7 +20,8 @@ export type ProviderId =
| 'openai'
| 'openai-compatible'
| 'gemini'
| 'ollama';
| 'ollama'
| 'sparkcontrol';
export interface GenerateOpts {
/** API key. Null/undefined for ollama on a trusted LAN. */
@@ -0,0 +1,99 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { sparkcontrol } from '@/lib/ai/providers/sparkcontrol';
import type { GenerateChunk } from '@/lib/ai/types';
/**
* SparkControl provider: OpenAI-style streaming over the internal same-box
* URL, with NO API key (and therefore no Authorization header).
*/
function sse(frames: string[]): string {
return frames.map((f) => `data: ${f}\n\n`).join('');
}
async function collect(
gen: AsyncIterable<GenerateChunk>,
): Promise<GenerateChunk[]> {
const out: GenerateChunk[] = [];
for await (const c of gen) out.push(c);
return out;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('sparkcontrol provider', () => {
it('errors when no base URL is configured', async () => {
const chunks = await collect(
sparkcontrol.generate({
model: 'whatever',
systemPrompt: 'sys',
userPrompt: 'hi',
}),
);
expect(chunks[0]).toEqual({
type: 'error',
message: expect.stringContaining('Base URL is required'),
});
});
it('streams deltas + usage and sends NO Authorization header (keyless)', async () => {
const fetchMock = vi.fn(
// Typed params so mock.calls[0] is a [url, init] tuple, not [].
async (_url: string, _init: RequestInit) =>
new Response(
sse([
'{"choices":[{"delta":{"content":"Hello"}}]}',
'{"choices":[{"delta":{"content":" there"}}]}',
'{"usage":{"prompt_tokens":5,"completion_tokens":2}}',
'[DONE]',
]),
{ status: 200, headers: { 'content-type': 'text/event-stream' } },
),
);
vi.stubGlobal('fetch', fetchMock);
const chunks = await collect(
sparkcontrol.generate({
// Loopback literal → passes the SSRF guard without DNS.
baseUrl: 'http://127.0.0.1:9999/v1',
model: 'RedHatAI/Qwen3.6-35B-A3B-NVFP4',
systemPrompt: 'sys',
userPrompt: 'hi',
}),
);
// POSTs to {baseUrl}/chat/completions.
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://127.0.0.1:9999/v1/chat/completions');
// Keyless: no Authorization header at all.
const headers = init.headers as Record<string, string>;
expect('authorization' in headers).toBe(false);
expect(headers.authorization).toBeUndefined();
// Streamed text + usage made it through.
const text = chunks
.filter((c): c is Extract<GenerateChunk, { type: 'text' }> => c.type === 'text')
.map((c) => c.delta)
.join('');
expect(text).toBe('Hello there');
expect(chunks).toContainEqual({ type: 'usage', tokensIn: 5, tokensOut: 2 });
expect(chunks.some((c) => c.type === 'done')).toBe(true);
});
it('blocks an SSRF-unsafe base URL (cloud metadata)', async () => {
const chunks = await collect(
sparkcontrol.generate({
baseUrl: 'http://169.254.169.254/v1',
model: 'x',
systemPrompt: 'sys',
userPrompt: 'hi',
}),
);
expect(chunks[0].type).toBe('error');
expect((chunks[0] as { message: string }).message).toContain('SparkControl');
});
});
@@ -0,0 +1,170 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
const { getCurrentUserMock } = vi.hoisted(() => ({
getCurrentUserMock: vi.fn(),
}));
vi.mock('@/lib/auth', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, getCurrentUser: getCurrentUserMock };
});
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
import { POST as createConfig } from '@/app/api/ai/configs/route';
import { PATCH as patchConfig } from '@/app/api/ai/configs/[id]/route';
/**
* Base-URL hygiene on AI config writes:
* - Fixed-URL providers (claude/openai/gemini) must NEVER store a base URL,
* even if one rides along in the body (the footgun that produced a gemini
* config silently pointed at a custom URL the gemini provider ignores).
* - Custom-URL providers (incl. SparkControl) keep it. SparkControl also
* needs no API key.
*/
function jsonReq(url: string, body: unknown, method = 'POST'): NextRequest {
return new NextRequest(url, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
} as ConstructorParameters<typeof NextRequest>[1]);
}
async function makeAdmin(email: string) {
return prisma.user.create({
data: { email, passwordHash: 'fake', isAdmin: true },
});
}
beforeEach(async () => {
await prisma.aIConfigProfile.deleteMany();
await prisma.userPreferences.deleteMany();
await prisma.user.deleteMany();
getCurrentUserMock.mockReset();
});
describe('POST /api/ai/configs — base-URL hygiene', () => {
it('drops a base URL on a fixed-URL provider (gemini) and its active mirror', async () => {
const u = await makeAdmin('a@x.com');
getCurrentUserMock.mockResolvedValue(u);
const res = await createConfig(
jsonReq('http://x/api/ai/configs', {
provider: 'gemini',
model: 'gemini-2.5-flash',
baseUrl: 'http://192.168.1.72:62419/v1',
apiKey: 'sk-real',
setActive: true,
}),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.baseUrl).toBeNull();
const profile = await prisma.aIConfigProfile.findFirstOrThrow({
where: { userId: u.id },
});
expect(profile.baseUrl).toBeNull();
// The legacy mirror in UserPreferences must also be clean.
const prefs = await prisma.userPreferences.findUnique({ where: { userId: u.id } });
expect(prefs?.aiBaseUrl).toBeNull();
});
it('keeps the base URL for SparkControl and needs no API key', async () => {
const u = await makeAdmin('b@x.com');
getCurrentUserMock.mockResolvedValue(u);
const res = await createConfig(
jsonReq('http://x/api/ai/configs', {
provider: 'sparkcontrol',
model: 'RedHatAI/Qwen3.6-35B-A3B-NVFP4',
baseUrl: 'http://spark-control.startos:9999/v1',
setActive: true,
}),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.baseUrl).toBe('http://spark-control.startos:9999/v1');
expect(body.keyConfigured).toBe(false);
const profile = await prisma.aIConfigProfile.findFirstOrThrow({
where: { userId: u.id },
});
expect(profile.baseUrl).toBe('http://spark-control.startos:9999/v1');
expect(profile.apiKey).toBeNull();
const prefs = await prisma.userPreferences.findUnique({ where: { userId: u.id } });
expect(prefs?.aiBaseUrl).toBe('http://spark-control.startos:9999/v1');
});
it('rejects a non-admin creating a SparkControl config and names it in the error', async () => {
const u = await prisma.user.create({
data: { email: 'nonadmin@x.com', passwordHash: 'fake', isAdmin: false },
});
getCurrentUserMock.mockResolvedValue(u);
const res = await createConfig(
jsonReq('http://x/api/ai/configs', {
provider: 'sparkcontrol',
model: 'some-model',
baseUrl: 'http://spark-control.startos:9999/v1',
}),
);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toContain('SparkControl');
});
});
describe('PATCH /api/ai/configs/[id] — base-URL hygiene', () => {
it('refuses to attach a base URL to a fixed-URL provider on edit', async () => {
const u = await makeAdmin('c@x.com');
getCurrentUserMock.mockResolvedValue(u);
const created = await prisma.aIConfigProfile.create({
data: { userId: u.id, name: 'g', provider: 'gemini', model: 'gemini-2.5-flash' },
});
const res = await patchConfig(
jsonReq(`http://x/api/ai/configs/${created.id}`, {
baseUrl: 'http://192.168.1.72:62419/v1',
}, 'PATCH'),
{ params: Promise.resolve({ id: created.id }) },
);
expect(res.status).toBe(200);
const after = await prisma.aIConfigProfile.findFirstOrThrow({ where: { id: created.id } });
expect(after.baseUrl).toBeNull();
});
it('cleans the active-config mirror when stripping a base URL on edit', async () => {
const u = await makeAdmin('d@x.com');
getCurrentUserMock.mockResolvedValue(u);
// Create + activate a gemini config (mirrors into UserPreferences).
const createRes = await createConfig(
jsonReq('http://x/api/ai/configs', {
provider: 'gemini',
model: 'gemini-2.5-flash',
apiKey: 'sk-real',
setActive: true,
}),
);
const { id } = await createRes.json();
const res = await patchConfig(
jsonReq(`http://x/api/ai/configs/${id}`, {
baseUrl: 'http://192.168.1.72:62419/v1',
}, 'PATCH'),
{ params: Promise.resolve({ id }) },
);
expect(res.status).toBe(200);
const after = await prisma.aIConfigProfile.findFirstOrThrow({ where: { id } });
expect(after.baseUrl).toBeNull();
// The active mirror in UserPreferences must be clean too.
const prefs = await prisma.userPreferences.findUnique({ where: { userId: u.id } });
expect(prefs?.aiBaseUrl).toBeNull();
});
});
+7 -1
View File
@@ -21,6 +21,7 @@ import { v_1_2_0_3 } from './v1.2.0.3'
import { v_1_2_0_4 } from './v1.2.0.4'
import { v_1_2_0_5 } from './v1.2.0.5'
import { v_1_2_0_6 } from './v1.2.0.6'
import { v_1_2_0_7 } from './v1.2.0.7'
/**
* Version graph for the `proof-of-work` package.
@@ -86,9 +87,13 @@ import { v_1_2_0_6 } from './v1.2.0.6'
* inline-edit + refine-with-AI, then pre-fill the workout log.
* Reuses the generation spine via a new AIGeneration.kind
* discriminant (boot ALTER, default "program"). No data changes.
* v1.2.0:7 — SparkControl AI provider (6th provider): keyless, same-box
* internal address, model auto-detected via /api/endpoints. Plus a
* base-URL footgun fix (a custom URL could attach to a fixed-URL
* provider and be silently ignored). No schema/data change.
*/
export const versionGraph = VersionGraph.of({
current: v_1_2_0_6,
current: v_1_2_0_7,
other: [
v_1_0_0_1,
v_1_0_0_2,
@@ -111,5 +116,6 @@ export const versionGraph = VersionGraph.of({
v_1_2_0_3,
v_1_2_0_4,
v_1_2_0_5,
v_1_2_0_6,
],
})
+33
View File
@@ -0,0 +1,33 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:7 — SparkControl AI provider + base-URL footgun fix (2026-06-19).
*
* Adds a 6th AI provider, "SparkControl (local)" — the operator's own
* self-hosted local-inference gateway. Its LLM surface is OpenAI-compatible,
* so it reuses the OpenAI-style streamer, with two twists: no API key (keyless
* on the LAN) and reached over the internal same-box StartOS address
* (http://spark-control.startos:9999/v1, plain HTTP — no TLS games). The
* Settings UI auto-detects the loaded vLLM model via SparkControl's
* /api/endpoints discovery, mirroring the Ollama auto-detect.
*
* Also fixes a config footgun: a base URL could ride along to a fixed-URL
* provider (Claude/OpenAI/Gemini) whose form field is hidden — stored but
* silently ignored by the provider, which always hits its hardcoded endpoint.
* Both config write paths now drop a base URL for non-custom-URL providers,
* and the form clears it on provider change.
*
* No schema or data change: `AIConfigProfile.provider` is a free-text column,
* so the new value needs no migration. Existing configs are untouched.
*/
export const v_1_2_0_7 = VersionInfo.of({
version: '1.2.0:7',
releaseNotes: {
en_US:
'New AI provider: SparkControl — your self-hosted local-inference gateway. Reached over the same-box address with no API key; the model is auto-detected. Also fixes a bug where a custom base URL could attach to a fixed-URL provider (Claude/OpenAI/Gemini) and be silently ignored. No data changes.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})