v1.2.0:7 — add SparkControl AI provider + fix base-URL footgun
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:
@@ -26,7 +26,7 @@ proof-of-work/ ← the Next.js app (THIS is where you run npm)
|
|||||||
app/main/ ← authed UI; navigation.tsx = sidebar
|
app/main/ ← authed UI; navigation.tsx = sidebar
|
||||||
components/ ← React components (workouts/, ai/, settings/)
|
components/ ← React components (workouts/, ai/, settings/)
|
||||||
lib/ai/ ← AI subsystem (see below)
|
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/schema.prisma ← schema (mirror; real DB migrates via entrypoint ALTERs)
|
||||||
prisma/*.seed.json ← curated exercise library + AI templates (reconciled each boot)
|
prisma/*.seed.json ← curated exercise library + AI templates (reconciled each boot)
|
||||||
tests/ ← Vitest specs (ai-*.test.ts, routes-*.test.ts, ...)
|
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
|
## 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.**
|
**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):
|
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`.
|
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`.
|
||||||
|
|||||||
@@ -47,11 +47,34 @@ stores the JSON in the (reused) `parsedProgram` column.
|
|||||||
|
|
||||||
- Each provider yields an async iterable of `GenerateChunk` (`text` / `usage` / `done` /
|
- Each provider yields an async iterable of `GenerateChunk` (`text` / `usage` / `done` /
|
||||||
`error`); add new ones under `lib/ai/providers/` and register in `index.ts`.
|
`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
|
`openai.ts` exports both `openai` and `openai-compatible`, so the five provider files
|
||||||
register **5** providers (`claude`, `openai`, `openai-compatible`, `gemini`, `ollama`).
|
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`.
|
- 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
|
- 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).
|
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
|
## SSRF / provider-URL safety
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export async function PATCH(
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error:
|
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 },
|
{ status: 403 },
|
||||||
);
|
);
|
||||||
@@ -93,8 +93,13 @@ export async function PATCH(
|
|||||||
const data: Record<string, string | null> = {};
|
const data: Record<string, string | null> = {};
|
||||||
if (parsed.data.name !== undefined) data.name = parsed.data.name;
|
if (parsed.data.name !== undefined) data.name = parsed.data.name;
|
||||||
if (parsed.data.model !== undefined) data.model = parsed.data.model;
|
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)
|
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)
|
if (parsed.data.apiKey !== undefined)
|
||||||
data.apiKey = parsed.data.apiKey || null;
|
data.apiKey = parsed.data.apiKey || null;
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,14 @@ import { isCustomUrlProvider } from '@/lib/ai/providers';
|
|||||||
* [id]/activate/route.ts so the action is explicit + auditable.
|
* [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() {
|
export async function GET() {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
@@ -82,32 +89,44 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const { name, provider, model, baseUrl, apiKey, setActive } = parsed.data;
|
const { name, provider, model, baseUrl, apiKey, setActive } = parsed.data;
|
||||||
|
|
||||||
// Custom-URL providers (Ollama / OpenAI-compatible) are admin-only — a
|
// Custom-URL providers (Ollama, SparkControl, OpenAI-compatible) are
|
||||||
// non-admin pointing the server at an arbitrary URL is the SSRF actor
|
// admin-only — a non-admin pointing the server at an arbitrary URL is the
|
||||||
// vector. Fixed-URL cloud providers stay per-user.
|
// SSRF actor vector. Fixed-URL cloud providers stay per-user.
|
||||||
if (!user.isAdmin && (baseUrl || isCustomUrlProvider(provider))) {
|
if (!user.isAdmin && (baseUrl || isCustomUrlProvider(provider))) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error:
|
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 },
|
{ 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({
|
const profile = await prisma.aIConfigProfile.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
name: name ?? defaultName(provider, model),
|
name: name ?? defaultName(provider, model),
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
baseUrl: baseUrl || null,
|
baseUrl: normalizedBaseUrl,
|
||||||
apiKey: apiKey || null,
|
apiKey: apiKey || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (setActive) {
|
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({
|
return NextResponse.json({
|
||||||
@@ -128,6 +147,7 @@ function defaultName(provider: string, model: string): string {
|
|||||||
'openai-compatible': 'Custom',
|
'openai-compatible': 'Custom',
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
ollama: 'Ollama',
|
ollama: 'Ollama',
|
||||||
|
sparkcontrol: 'SparkControl',
|
||||||
};
|
};
|
||||||
const label = PRETTY[provider] ?? provider;
|
const label = PRETTY[provider] ?? provider;
|
||||||
return `${label} · ${model}`;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,7 +120,7 @@ export async function POST(request: NextRequest) {
|
|||||||
{
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
error:
|
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 },
|
{ status: 403 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ const PROVIDERS = [
|
|||||||
},
|
},
|
||||||
{ id: 'gemini', label: 'Google Gemini', requiresKey: true, requiresUrl: false },
|
{ id: 'gemini', label: 'Google Gemini', requiresKey: true, requiresUrl: false },
|
||||||
{ id: 'ollama', label: 'Ollama (self-hosted)', requiresKey: false, requiresUrl: true },
|
{ id: 'ollama', label: 'Ollama (self-hosted)', requiresKey: false, requiresUrl: true },
|
||||||
|
{
|
||||||
|
id: 'sparkcontrol',
|
||||||
|
label: 'SparkControl (local)',
|
||||||
|
requiresKey: false,
|
||||||
|
requiresUrl: true,
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type ProviderId = (typeof PROVIDERS)[number]['id'];
|
type ProviderId = (typeof PROVIDERS)[number]['id'];
|
||||||
@@ -382,6 +388,11 @@ function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps)
|
|||||||
const [ollamaProbing, setOllamaProbing] = useState(false);
|
const [ollamaProbing, setOllamaProbing] = useState(false);
|
||||||
const [ollamaProbeError, setOllamaProbeError] = useState<string | null>(null);
|
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);
|
const meta = PROVIDERS.find((p) => p.id === provider);
|
||||||
|
|
||||||
// Probe Ollama on provider switch (or baseUrl change while ollama).
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [provider, baseUrl]);
|
}, [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
|
// Reset draft test result whenever the user changes any input — so the
|
||||||
// green "✓ Connected" indicator never lingers from a previous attempt.
|
// green "✓ Connected" indicator never lingers from a previous attempt.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -511,6 +564,9 @@ function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps)
|
|||||||
setProvider(e.target.value as ProviderId);
|
setProvider(e.target.value as ProviderId);
|
||||||
setModel(''); // reset on provider change
|
setModel(''); // reset on provider change
|
||||||
setModelMode('menu');
|
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}
|
className={inputClass}
|
||||||
disabled={isEdit}
|
disabled={isEdit}
|
||||||
@@ -570,6 +626,30 @@ function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps)
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</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 ? (
|
) : showMenu ? (
|
||||||
<Field label="Model">
|
<Field label="Model">
|
||||||
<select
|
<select
|
||||||
@@ -628,7 +708,9 @@ function ConfigForm({ initial, isAdmin, onCancel, onCreated }: ConfigFormProps)
|
|||||||
placeholder={
|
placeholder={
|
||||||
meta.id === 'ollama'
|
meta.id === 'ollama'
|
||||||
? 'http://ollama.startos:11434'
|
? '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}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -123,10 +123,12 @@ export const MODEL_MENU: Record<string, ModelOption[]> = {
|
|||||||
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||||
{ id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash (legacy)' },
|
{ id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash (legacy)' },
|
||||||
],
|
],
|
||||||
// openai-compatible + ollama: no curated menu — model names are
|
// openai-compatible + ollama + sparkcontrol: no curated menu — model names
|
||||||
// gateway- or host-specific. Ollama auto-detects via /api/tags.
|
// are gateway- or host-specific. Ollama auto-detects via /api/tags;
|
||||||
|
// SparkControl auto-detects the loaded model via /api/endpoints.
|
||||||
'openai-compatible': [],
|
'openai-compatible': [],
|
||||||
ollama: [],
|
ollama: [],
|
||||||
|
sparkcontrol: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Find the price entry whose key is a (case-insensitive) prefix of the model string. */
|
/** 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;
|
tokensOut: number | null;
|
||||||
}): number | null {
|
}): number | null {
|
||||||
if (opts.provider === 'ollama') return 0; // self-hosted, no per-token cost
|
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.provider === 'openai-compatible') return null; // we don't know the gateway's pricing
|
||||||
if (opts.tokensIn == null || opts.tokensOut == null) return null;
|
if (opts.tokensIn == null || opts.tokensOut == null) return null;
|
||||||
const price = findPrice(opts.model);
|
const price = findPrice(opts.model);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ollama } from './ollama';
|
|||||||
import { claude } from './claude';
|
import { claude } from './claude';
|
||||||
import { openai, openaiCompatible } from './openai';
|
import { openai, openaiCompatible } from './openai';
|
||||||
import { gemini } from './gemini';
|
import { gemini } from './gemini';
|
||||||
|
import { sparkcontrol } from './sparkcontrol';
|
||||||
|
|
||||||
const ALL: Record<ProviderId, LLMProvider> = {
|
const ALL: Record<ProviderId, LLMProvider> = {
|
||||||
claude,
|
claude,
|
||||||
@@ -10,6 +11,7 @@ const ALL: Record<ProviderId, LLMProvider> = {
|
|||||||
'openai-compatible': openaiCompatible,
|
'openai-compatible': openaiCompatible,
|
||||||
gemini,
|
gemini,
|
||||||
ollama,
|
ollama,
|
||||||
|
sparkcontrol,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getProvider(id: string): LLMProvider | null {
|
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
|
* 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 server at an arbitrary URL is the SSRF actor vector. The fixed-URL cloud
|
||||||
* The fixed-URL cloud providers (claude/openai/gemini) stay per-user.
|
* providers (claude/openai/gemini) stay per-user.
|
||||||
*/
|
*/
|
||||||
export function isCustomUrlProvider(id: string): boolean {
|
export function isCustomUrlProvider(id: string): boolean {
|
||||||
return !!getProvider(id)?.requiresBaseUrl;
|
return !!getProvider(id)?.requiresBaseUrl;
|
||||||
@@ -33,6 +35,7 @@ export const PROVIDER_ORDER: ProviderId[] = [
|
|||||||
'openai-compatible',
|
'openai-compatible',
|
||||||
'gemini',
|
'gemini',
|
||||||
'ollama',
|
'ollama',
|
||||||
|
'sparkcontrol',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PROVIDERS = ALL;
|
export const PROVIDERS = ALL;
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ export async function* generateOpenAIStyle(
|
|||||||
opts: GenerateOpts,
|
opts: GenerateOpts,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
providerLabel: string,
|
providerLabel: string,
|
||||||
|
{ requireApiKey = true }: { requireApiKey?: boolean } = {},
|
||||||
): AsyncGenerator<GenerateChunk, void, void> {
|
): AsyncGenerator<GenerateChunk, void, void> {
|
||||||
if (!opts.apiKey) {
|
if (requireApiKey && !opts.apiKey) {
|
||||||
yield { type: 'error', message: `${providerLabel} API key is required.` };
|
yield { type: 'error', message: `${providerLabel} API key is required.` };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -29,7 +30,10 @@ export async function* generateOpenAIStyle(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'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({
|
body: JSON.stringify({
|
||||||
model: opts.model,
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -20,7 +20,8 @@ export type ProviderId =
|
|||||||
| 'openai'
|
| 'openai'
|
||||||
| 'openai-compatible'
|
| 'openai-compatible'
|
||||||
| 'gemini'
|
| 'gemini'
|
||||||
| 'ollama';
|
| 'ollama'
|
||||||
|
| 'sparkcontrol';
|
||||||
|
|
||||||
export interface GenerateOpts {
|
export interface GenerateOpts {
|
||||||
/** API key. Null/undefined for ollama on a trusted LAN. */
|
/** 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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_4 } from './v1.2.0.4'
|
||||||
import { v_1_2_0_5 } from './v1.2.0.5'
|
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_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.
|
* 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.
|
* inline-edit + refine-with-AI, then pre-fill the workout log.
|
||||||
* Reuses the generation spine via a new AIGeneration.kind
|
* Reuses the generation spine via a new AIGeneration.kind
|
||||||
* discriminant (boot ALTER, default "program"). No data changes.
|
* 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({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_1_2_0_6,
|
current: v_1_2_0_7,
|
||||||
other: [
|
other: [
|
||||||
v_1_0_0_1,
|
v_1_0_0_1,
|
||||||
v_1_0_0_2,
|
v_1_0_0_2,
|
||||||
@@ -111,5 +116,6 @@ export const versionGraph = VersionGraph.of({
|
|||||||
v_1_2_0_3,
|
v_1_2_0_3,
|
||||||
v_1_2_0_4,
|
v_1_2_0_4,
|
||||||
v_1_2_0_5,
|
v_1_2_0_5,
|
||||||
|
v_1_2_0_6,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user