Bring-your-own keys for cloud providers (OpenAI-compatible)

Settings -> Providers takes keys for OpenAI, Anthropic (their official
OpenAI-compatibility layer), DeepSeek, Groq, OpenRouter and any custom
OpenAI-compatible endpoint. One adapter (lib/openai-compat) covers
listing, streaming chat, JSON generation and vision, and every model
flow can run on a provider: the chat picker lists each enabled
provider's models (labelled, remembered), and a "Study generation"
choice moves decks, homework, grading and attachment transcription onto
a provider model.

Keys stay in this browser exactly like the Ollama one
(freezmo.providers.v1): never stored server-side, never in a backup or
the sync payload, forwarded only with the request that needs them. The
provider test asserts the key reaches the provider while the routing
header stays on the app side.

New suite tests/providers-test.mjs (13 checks) stubs both protocols:
model listing, chat streaming, study generation and the Settings flow.
Privacy policy updated (en+cs). Contrast 0 findings, axe clean, mobile
126/126, chat 102/102, wizard 95/95, bubbles 34/34, regression 44/44,
i18n 13/13, native 33/33. Version 0.4.7, SW -> freezmo-v72.
This commit is contained in:
Martin Dvořák 2026-10-14 06:29:39 +00:00
commit 4372423167
28 changed files with 1538 additions and 150 deletions

View file

@ -1411,6 +1411,18 @@ Things I chose while building, with the reasoning:
pre-blurred wallpaper — the scene is clearly visible through the glass, and the pre-blurred wallpaper — the scene is clearly visible through the glass, and the
pixel-contrast scan still passes. pixel-contrast scan still passes.
81. **Bring-your-own keys for cloud providers.** Besides the server's Ollama, Settings → Providers takes
keys for OpenAI, Anthropic (their official OpenAI-compatibility layer), DeepSeek, Groq, OpenRouter
and any custom OpenAI-compatible endpoint. One adapter (`lib/openai-compat`) covers them all —
model listing, streaming chat, JSON generation and vision — and every model flow can run on a
provider: the chat picker lists each enabled provider's models (labelled, remembered across
visits), and a "Study generation" choice moves decks, homework, grading and attachment
transcription onto a provider model. Keys stay in this browser exactly like the Ollama one
(`freezmo.providers.v1`): never stored server-side, never in a backup or the sync payload, only
forwarded with the request that needs them — the provider test asserts the key reaches the
provider and that the app keeps the routing header to itself. The suite (`tests/providers-test`,
13 checks) covers listing, chat, generation and the Settings flow against a two-protocol stub.
80. **Chat UI pass: glass chrome, one composer panel, friendlier chips.** The chat header was a flat 80. **Chat UI pass: glass chrome, one composer panel, friendlier chips.** The chat header was a flat
blue strip and the composer area floated on the wallpaper; both are glass surfaces now (the blue strip and the composer area floated on the wallpaper; both are glass surfaces now (the
composer is one flat frosted panel behind input + chips + note). The disclaimer is plain composer is one flat frosted panel behind input + chips + note). The disclaimer is plain

View file

@ -1,7 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { extractNotes, notesToText } from "@/lib/ai"; import { extractNotes, notesToText } from "@/lib/ai";
import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@/lib/config"; import { getServerConfig } from "@/lib/config";
import { INERT_OLLAMA_AUTH, resolveModelTarget } from "@/lib/providers-server";
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
@ -18,6 +19,8 @@ const MAX_BODY_BYTES = 33554432;
const MAX_IMAGE_CHARS = 8_000_000; const MAX_IMAGE_CHARS = 8_000_000;
const requestSchema = z.object({ const requestSchema = z.object({
model: z.string().min(1).max(200).optional(),
images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
text: z.string().max(120_000).optional(), text: z.string().max(120_000).optional(),
}); });
@ -39,13 +42,14 @@ export async function POST(request: Request) {
const limit = rateLimit(`chat:${clientKey(request)}`, config.rateLimits.chat, config.rateLimits.windowMs); const limit = rateLimit(`chat:${clientKey(request)}`, config.rateLimits.chat, config.rateLimits.windowMs);
if (!limit.allowed) return rateLimitResponse(limit, locale); if (!limit.allowed) return rateLimitResponse(limit, locale);
const auth = resolveAuth(config, request); // Ollama (server-configured) or a cloud provider the browser sent a key for.
if ("error" in auth) { const resolved = resolveModelTarget(config, request, locale);
return Response.json( if ("error" in resolved) {
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, return Response.json({ error: resolved.error.message, kind: resolved.error.kind }, { status: resolved.error.status });
{ status: 401 },
);
} }
const target = resolved.target;
const provider = target.kind === "provider" ? target.target : undefined;
const baseAuth = target.kind === "provider" ? INERT_OLLAMA_AUTH : target.auth;
let payload: unknown; let payload: unknown;
try { try {
@ -68,6 +72,17 @@ export async function POST(request: Request) {
return Response.json({ error: translate(locale, "server.needContent") }, { status: 400 }); return Response.json({ error: translate(locale, "server.needContent") }, { status: 400 });
} }
// Provider runs carry the model the user picked in Settings → Providers.
const providerModel = provider ? parsed.data.model : undefined;
if (provider && !providerModel) {
return Response.json(
{ error: translate(locale, "server.providerModelNeeded"), kind: "bad_provider" },
{ status: 400 },
);
}
const textModel = provider ? providerModel! : config.textModel;
const visionModel = provider ? providerModel! : config.visionModel;
const gate = acquireModelGate(request, config, locale); const gate = acquireModelGate(request, config, locale);
if ("response" in gate) return gate.response; if ("response" in gate) return gate.response;
const abort = new AbortController(); const abort = new AbortController();
@ -78,9 +93,10 @@ export async function POST(request: Request) {
return Response.json({ text: text.slice(0, 48_000) }); return Response.json({ text: text.slice(0, 48_000) });
} }
const extraction = await extractNotes({ const extraction = await extractNotes({
model: config.visionModel, model: visionModel,
repairModel: config.textModel, repairModel: textModel,
auth: auth.auth, auth: baseAuth,
provider,
images, images,
text: text || undefined, text: text || undefined,
signal: abort.signal, signal: abort.signal,
@ -94,7 +110,7 @@ export async function POST(request: Request) {
console.error("[api/chat/extract]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); console.error("[api/chat/extract]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
return Response.json( return Response.json(
{ {
error: friendlyErrorMessage(error, auth.auth.baseUrl, locale), error: friendlyErrorMessage(error, baseAuth.baseUrl || provider?.baseUrl || '', locale),
kind: error instanceof OllamaError ? error.kind : "unknown", kind: error instanceof OllamaError ? error.kind : "unknown",
}, },
{ status: 502 }, { status: 502 },

View file

@ -1,7 +1,9 @@
import { z } from "zod"; import { z } from "zod";
import { authErrorMessage, authErrorKind, canonicalModelName, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; import { canonicalModelName, getServerConfig, resolveModelName } from "@/lib/config";
import { friendlyErrorMessage, ollamaChatStream, ollamaListModels, OllamaError } from "@/lib/ollama"; import { friendlyErrorMessage, ollamaChatStream, ollamaListModels, OllamaError } from "@/lib/ollama";
import { providerChatStream } from "@/lib/openai-compat";
import { resolveModelTarget } from "@/lib/providers-server";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard"; import { acquireModelGate } from "@/lib/model-guard";
@ -66,13 +68,12 @@ export async function POST(request: Request) {
const limit = rateLimit(`chat:${clientKey(request)}`, config.rateLimits.chat, config.rateLimits.windowMs); const limit = rateLimit(`chat:${clientKey(request)}`, config.rateLimits.chat, config.rateLimits.windowMs);
if (!limit.allowed) return rateLimitResponse(limit, locale); if (!limit.allowed) return rateLimitResponse(limit, locale);
const auth = resolveAuth(config, request); // Ollama (server-configured) or a cloud provider the browser sent a key for.
if ("error" in auth) { const resolved = resolveModelTarget(config, request, locale);
return Response.json( if ("error" in resolved) {
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, return Response.json({ error: resolved.error.message, kind: resolved.error.kind }, { status: resolved.error.status });
{ status: 401 },
);
} }
const target = resolved.target;
let payload: unknown; let payload: unknown;
try { try {
@ -106,10 +107,13 @@ export async function POST(request: Request) {
const model = parsed.data.model ?? allowed[0]; const model = parsed.data.model ?? allowed[0];
const wanted = canonicalModelName(model); const wanted = canonicalModelName(model);
const inFavourites = allowed.some((entry) => canonicalModelName(entry) === wanted); const inFavourites = allowed.some((entry) => canonicalModelName(entry) === wanted);
if (!inFavourites) { if (target.kind === "provider") {
// The provider's own catalogue is the authority (the picker lists exactly
// what /models returned); an unknown name comes back as its 404.
} else if (!inFavourites) {
let listed = false; let listed = false;
try { try {
const models = await ollamaListModels(auth.auth, config.healthTimeoutMs); const models = await ollamaListModels(target.auth, config.healthTimeoutMs);
listed = models.some((entry) => canonicalModelName(entry.name) === wanted); listed = models.some((entry) => canonicalModelName(entry.name) === wanted);
} catch { } catch {
listed = false; listed = false;
@ -166,21 +170,35 @@ export async function POST(request: Request) {
}; };
try { try {
const result = await ollamaChatStream({ const messages = [{ role: "system" as const, content: system }, ...history];
const result =
target.kind === "provider"
? await providerChatStream({
target: target.target,
model, model,
auth: auth.auth, messages,
messages: [{ role: "system", content: system }, ...history], signal: abort.signal,
onToken: (text) => send({ type: "token", text }),
onThinking: (text) => send({ type: "thinking", text }),
})
: await ollamaChatStream({
model,
auth: target.auth,
messages,
signal: abort.signal, signal: abort.signal,
onToken: (text) => send({ type: "token", text }), onToken: (text) => send({ type: "token", text }),
onThinking: (text) => send({ type: "thinking", text }), onThinking: (text) => send({ type: "thinking", text }),
}); });
send({ type: "done", model: resolveModelName(result.model, auth.auth.directCloud) }); send({
type: "done",
model: target.kind === "provider" ? result.model : resolveModelName(result.model, target.auth.directCloud),
});
} catch (error) { } catch (error) {
// A client-side stop aborts the upstream call: nothing to report. // A client-side stop aborts the upstream call: nothing to report.
if (abort.signal.aborted && request.signal.aborted) { if (abort.signal.aborted && request.signal.aborted) {
/* stopped by the user */ /* stopped by the user */
} else { } else {
const message = friendlyErrorMessage(error, auth.auth.baseUrl, locale); const message = friendlyErrorMessage(error, target.kind === "provider" ? target.target.baseUrl : target.auth.baseUrl, locale);
console.error("[api/chat]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); console.error("[api/chat]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
send({ send({
type: "error", type: "error",

View file

@ -1,7 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { extractNotes, generateActivities, notesToText, type PipelineStage } from "@/lib/ai"; import { extractNotes, generateActivities, notesToText, type PipelineStage } from "@/lib/ai";
import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; import { getServerConfig, resolveModelName } from "@/lib/config";
import { INERT_OLLAMA_AUTH, resolveModelTarget } from "@/lib/providers-server";
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
@ -19,6 +20,9 @@ export const maxDuration = 300;
const MAX_IMAGE_CHARS = 8_000_000; const MAX_IMAGE_CHARS = 8_000_000;
const requestSchema = z.object({ const requestSchema = z.object({
textModel: z.string().min(1).max(200).optional(),
visionModel: z.string().min(1).max(200).optional(),
images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
text: z.string().max(120_000).optional(), text: z.string().max(120_000).optional(),
difficulty: difficultySchema.default("medium"), difficulty: difficultySchema.default("medium"),
@ -63,13 +67,14 @@ export async function POST(request: Request) {
const limit = rateLimit(`generate:${clientKey(request)}`, config.rateLimits.generate, config.rateLimits.windowMs); const limit = rateLimit(`generate:${clientKey(request)}`, config.rateLimits.generate, config.rateLimits.windowMs);
if (!limit.allowed) return rateLimitResponse(limit, locale); if (!limit.allowed) return rateLimitResponse(limit, locale);
const auth = resolveAuth(config, request); // Ollama (server-configured) or a cloud provider the browser sent a key for.
if ("error" in auth) { const resolved = resolveModelTarget(config, request, locale);
return Response.json( if ("error" in resolved) {
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, return Response.json({ error: resolved.error.message, kind: resolved.error.kind }, { status: resolved.error.status });
{ status: 401 },
);
} }
const target = resolved.target;
const provider = target.kind === "provider" ? target.target : undefined;
const baseAuth = target.kind === "provider" ? INERT_OLLAMA_AUTH : target.auth;
let payload: unknown; let payload: unknown;
try { try {
@ -105,6 +110,19 @@ export async function POST(request: Request) {
// Concurrency + the account-wide model budget (lib/model-guard). Taken // Concurrency + the account-wide model budget (lib/model-guard). Taken
// only now, after every cheap rejection, so a 400 can never leak a slot. // only now, after every cheap rejection, so a 400 can never leak a slot.
// Provider runs carry the model the user picked in Settings → Providers;
// the server's Ollama models stay the default.
const providerTextModel = provider ? parsed.data.textModel ?? parsed.data.visionModel : undefined;
const providerVisionModel = provider ? parsed.data.visionModel ?? parsed.data.textModel : undefined;
if (provider && !providerTextModel) {
return Response.json(
{ error: translate(locale, "server.providerModelNeeded"), kind: "bad_provider" },
{ status: 400 },
);
}
const textModel = provider ? providerTextModel! : config.textModel;
const visionModel = provider ? providerVisionModel ?? providerTextModel! : config.visionModel;
const gate = acquireModelGate(request, config, locale); const gate = acquireModelGate(request, config, locale);
if ("response" in gate) return gate.response; if ("response" in gate) return gate.response;
@ -140,9 +158,10 @@ export async function POST(request: Request) {
if (imageList.length > 0) { if (imageList.length > 0) {
send({ type: "stage", stage: "vision", message: "Reading your notes…" }); send({ type: "stage", stage: "vision", message: "Reading your notes…" });
const extraction = await extractNotes({ const extraction = await extractNotes({
model: config.visionModel, model: visionModel,
repairModel: config.textModel, repairModel: textModel,
auth: auth.auth, auth: baseAuth,
provider,
images: imageList, images: imageList,
text, text,
signal: abort.signal, signal: abort.signal,
@ -165,8 +184,9 @@ export async function POST(request: Request) {
} }
const generated = await generateActivities({ const generated = await generateActivities({
model: config.textModel, model: textModel,
auth: auth.auth, auth: baseAuth,
provider,
notes: sourceText, notes: sourceText,
difficulty, difficulty,
count: requestedCount, count: requestedCount,
@ -198,12 +218,12 @@ export async function POST(request: Request) {
generated: activities.length, generated: activities.length,
repaired, repaired,
language: generated.deck.language || detectedLanguage || null, language: generated.deck.language || detectedLanguage || null,
visionModel: imageList.length ? resolveModelName(config.visionModel, auth.auth.directCloud) : null, visionModel: imageList.length ? (provider ? visionModel : resolveModelName(config.visionModel, baseAuth.directCloud)) : null,
textModel: resolveModelName(config.textModel, auth.auth.directCloud), textModel: provider ? textModel : resolveModelName(config.textModel, baseAuth.directCloud),
}, },
}); });
} catch (error) { } catch (error) {
const message = friendlyErrorMessage(error, auth.auth.baseUrl, locale); const message = friendlyErrorMessage(error, baseAuth.baseUrl || provider?.baseUrl || '', locale);
// Never log the request body: it may contain the user's notes. // Never log the request body: it may contain the user's notes.
console.error("[api/generate]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); console.error("[api/generate]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
send({ send({

View file

@ -1,7 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { gradeShortAnswer } from "@/lib/ai"; import { gradeShortAnswer } from "@/lib/ai";
import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@/lib/config"; import { getServerConfig } from "@/lib/config";
import { INERT_OLLAMA_AUTH, resolveModelTarget } from "@/lib/providers-server";
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
@ -12,6 +13,8 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const requestSchema = z.object({ const requestSchema = z.object({
model: z.string().min(1).max(200).optional(),
question: z.string().min(1).max(1000), question: z.string().min(1).max(1000),
referenceAnswer: z.string().min(1).max(2000), referenceAnswer: z.string().min(1).max(2000),
keyPoints: z.array(z.string().max(300)).max(8).default([]), keyPoints: z.array(z.string().max(300)).max(8).default([]),
@ -35,13 +38,14 @@ export async function POST(request: Request) {
const limit = rateLimit(`grade:${clientKey(request)}`, config.rateLimits.grade, config.rateLimits.windowMs); const limit = rateLimit(`grade:${clientKey(request)}`, config.rateLimits.grade, config.rateLimits.windowMs);
if (!limit.allowed) return rateLimitResponse(limit, locale); if (!limit.allowed) return rateLimitResponse(limit, locale);
const auth = resolveAuth(config, request); // Ollama (server-configured) or a cloud provider the browser sent a key for.
if ("error" in auth) { const resolved = resolveModelTarget(config, request, locale);
return Response.json( if ("error" in resolved) {
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, return Response.json({ error: resolved.error.message, kind: resolved.error.kind }, { status: resolved.error.status });
{ status: 401 },
);
} }
const target = resolved.target;
const provider = target.kind === "provider" ? target.target : undefined;
const baseAuth = target.kind === "provider" ? INERT_OLLAMA_AUTH : target.auth;
let payload: unknown; let payload: unknown;
try { try {
@ -55,12 +59,23 @@ export async function POST(request: Request) {
return Response.json({ error: translate(locale, "server.invalidGrade") }, { status: 400 }); return Response.json({ error: translate(locale, "server.invalidGrade") }, { status: 400 });
} }
// Provider runs carry the model the user picked in Settings → Providers.
const providerModel = provider ? parsed.data.model : undefined;
if (provider && !providerModel) {
return Response.json(
{ error: translate(locale, "server.providerModelNeeded"), kind: "bad_provider" },
{ status: 400 },
);
}
const textModel = provider ? providerModel! : config.textModel;
const gate = acquireModelGate(request, config, locale); const gate = acquireModelGate(request, config, locale);
if ("response" in gate) return gate.response; if ("response" in gate) return gate.response;
try { try {
const result = await gradeShortAnswer({ const result = await gradeShortAnswer({
model: config.textModel, model: textModel,
auth: auth.auth, auth: baseAuth,
provider,
...parsed.data, ...parsed.data,
// Grading is quick; don't let a hung request block the feed for minutes. // Grading is quick; don't let a hung request block the feed for minutes.
signal: AbortSignal.timeout(Math.min(config.timeoutMs, 120_000)), signal: AbortSignal.timeout(Math.min(config.timeoutMs, 120_000)),
@ -70,7 +85,7 @@ export async function POST(request: Request) {
console.error("[api/grade]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); console.error("[api/grade]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
return Response.json( return Response.json(
{ {
error: friendlyErrorMessage(error, auth.auth.baseUrl, locale), error: friendlyErrorMessage(error, baseAuth.baseUrl || provider?.baseUrl || '', locale),
kind: error instanceof OllamaError ? error.kind : "unknown", kind: error instanceof OllamaError ? error.kind : "unknown",
}, },
{ status: 502 }, { status: 502 },

View file

@ -1,7 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { refineHomeworkText } from "@/lib/ai"; import { refineHomeworkText } from "@/lib/ai";
import { authErrorKind, authErrorMessage, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; import { getServerConfig, resolveModelName } from "@/lib/config";
import { INERT_OLLAMA_AUTH, resolveModelTarget } from "@/lib/providers-server";
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { translate } from "@/lib/i18n-dict"; import { translate } from "@/lib/i18n-dict";
@ -17,6 +18,8 @@ export const dynamic = "force-dynamic";
*/ */
const requestSchema = z.object({ const requestSchema = z.object({
model: z.string().min(1).max(200).optional(),
instruction: z.enum(["shorter", "more", "explain"]), instruction: z.enum(["shorter", "more", "explain"]),
text: z.string().min(1).max(20_000), text: z.string().min(1).max(20_000),
/** The assignment, for context (optional). */ /** The assignment, for context (optional). */
@ -35,13 +38,14 @@ export async function POST(request: Request) {
const limit = rateLimit(`refine:${clientKey(request)}`, config.rateLimits.refine, config.rateLimits.windowMs); const limit = rateLimit(`refine:${clientKey(request)}`, config.rateLimits.refine, config.rateLimits.windowMs);
if (!limit.allowed) return rateLimitResponse(limit, locale); if (!limit.allowed) return rateLimitResponse(limit, locale);
const auth = resolveAuth(config, request); // Ollama (server-configured) or a cloud provider the browser sent a key for.
if ("error" in auth) { const resolved = resolveModelTarget(config, request, locale);
return Response.json( if ("error" in resolved) {
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, return Response.json({ error: resolved.error.message, kind: resolved.error.kind }, { status: resolved.error.status });
{ status: 401 },
);
} }
const target = resolved.target;
const provider = target.kind === "provider" ? target.target : undefined;
const baseAuth = target.kind === "provider" ? INERT_OLLAMA_AUTH : target.auth;
let payload: unknown; let payload: unknown;
try { try {
@ -65,18 +69,29 @@ export async function POST(request: Request) {
); );
} }
// Provider runs carry the model the user picked in Settings → Providers.
const providerModel = provider ? parsed.data.model : undefined;
if (provider && !providerModel) {
return Response.json(
{ error: translate(locale, "server.providerModelNeeded"), kind: "bad_provider" },
{ status: 400 },
);
}
const textModel = provider ? providerModel! : config.textModel;
const gate = acquireModelGate(request, config, locale); const gate = acquireModelGate(request, config, locale);
if ("response" in gate) return gate.response; if ("response" in gate) return gate.response;
try { try {
const text = await refineHomeworkText({ const text = await refineHomeworkText({
model: config.textModel, model: textModel,
auth: auth.auth, auth: baseAuth,
provider,
instruction: parsed.data.instruction, instruction: parsed.data.instruction,
text: parsed.data.text, text: parsed.data.text,
context: parsed.data.context, context: parsed.data.context,
}); });
return Response.json( return Response.json(
{ text, model: resolveModelName(config.textModel, auth.auth.directCloud) }, { text, model: provider ? textModel : resolveModelName(config.textModel, baseAuth.directCloud) },
{ headers: { "Cache-Control": "no-store" } }, { headers: { "Cache-Control": "no-store" } },
); );
} catch (error) { } catch (error) {
@ -84,7 +99,7 @@ export async function POST(request: Request) {
console.error("[api/homework/refine]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); console.error("[api/homework/refine]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
return Response.json( return Response.json(
{ {
error: friendlyErrorMessage(error, auth.auth.baseUrl, locale), error: friendlyErrorMessage(error, baseAuth.baseUrl || provider?.baseUrl || '', locale),
kind: error instanceof OllamaError ? error.kind : "unknown", kind: error instanceof OllamaError ? error.kind : "unknown",
}, },
{ status: 502 }, { status: 502 },

View file

@ -1,7 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { buildHomeworkGuide, checkHomework, extractNotes, makeHomework, notesToText, type PipelineStage } from "@/lib/ai"; import { buildHomeworkGuide, checkHomework, extractNotes, makeHomework, notesToText, type PipelineStage } from "@/lib/ai";
import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; import { getServerConfig, resolveModelName } from "@/lib/config";
import { INERT_OLLAMA_AUTH, resolveModelTarget } from "@/lib/providers-server";
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
@ -20,6 +21,9 @@ const MAX_IMAGE_CHARS = 8_000_000;
const MAX_BODY_BYTES = 33554432; const MAX_BODY_BYTES = 33554432;
const requestSchema = z.object({ const requestSchema = z.object({
textModel: z.string().min(1).max(200).optional(),
visionModel: z.string().min(1).max(200).optional(),
mode: homeworkModeSchema, mode: homeworkModeSchema,
images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
text: z.string().max(120_000).optional(), text: z.string().max(120_000).optional(),
@ -56,13 +60,14 @@ export async function POST(request: Request) {
const limit = rateLimit(`homework:${clientKey(request)}`, config.rateLimits.homework, config.rateLimits.windowMs); const limit = rateLimit(`homework:${clientKey(request)}`, config.rateLimits.homework, config.rateLimits.windowMs);
if (!limit.allowed) return rateLimitResponse(limit, locale); if (!limit.allowed) return rateLimitResponse(limit, locale);
const auth = resolveAuth(config, request); // Ollama (server-configured) or a cloud provider the browser sent a key for.
if ("error" in auth) { const resolved = resolveModelTarget(config, request, locale);
return Response.json( if ("error" in resolved) {
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, return Response.json({ error: resolved.error.message, kind: resolved.error.kind }, { status: resolved.error.status });
{ status: 401 },
);
} }
const target = resolved.target;
const provider = target.kind === "provider" ? target.target : undefined;
const baseAuth = target.kind === "provider" ? INERT_OLLAMA_AUTH : target.auth;
let payload: unknown; let payload: unknown;
try { try {
@ -99,6 +104,19 @@ export async function POST(request: Request) {
// Concurrency + the account-wide model budget (lib/model-guard), taken // Concurrency + the account-wide model budget (lib/model-guard), taken
// after every cheap rejection so a 400 can never leak a slot. // after every cheap rejection so a 400 can never leak a slot.
// Provider runs carry the model the user picked in Settings → Providers;
// the server's Ollama models stay the default.
const providerTextModel = provider ? parsed.data.textModel ?? parsed.data.visionModel : undefined;
const providerVisionModel = provider ? parsed.data.visionModel ?? parsed.data.textModel : undefined;
if (provider && !providerTextModel) {
return Response.json(
{ error: translate(locale, "server.providerModelNeeded"), kind: "bad_provider" },
{ status: 400 },
);
}
const textModel = provider ? providerTextModel! : config.textModel;
const visionModel = provider ? providerVisionModel ?? providerTextModel! : config.visionModel;
const gate = acquireModelGate(request, config, locale); const gate = acquireModelGate(request, config, locale);
if ("response" in gate) return gate.response; if ("response" in gate) return gate.response;
@ -128,9 +146,10 @@ export async function POST(request: Request) {
if (imageList.length > 0) { if (imageList.length > 0) {
send({ type: "stage", stage: "vision", message: translate(locale, "homework.loading.reading") }); send({ type: "stage", stage: "vision", message: translate(locale, "homework.loading.reading") });
const extraction = await extractNotes({ const extraction = await extractNotes({
model: config.visionModel, model: visionModel,
repairModel: config.textModel, repairModel: textModel,
auth: auth.auth, auth: baseAuth,
provider,
images: imageList, images: imageList,
text, text,
signal: abort.signal, signal: abort.signal,
@ -159,8 +178,8 @@ export async function POST(request: Request) {
const meta = { const meta = {
repaired, repaired,
language: detectedLanguage || null, language: detectedLanguage || null,
visionModel: imageList.length ? resolveModelName(config.visionModel, auth.auth.directCloud) : null, visionModel: imageList.length ? (provider ? visionModel : resolveModelName(config.visionModel, baseAuth.directCloud)) : null,
textModel: resolveModelName(config.textModel, auth.auth.directCloud), textModel: provider ? textModel : resolveModelName(config.textModel, baseAuth.directCloud),
}; };
if (mode === "check") { if (mode === "check") {
@ -174,9 +193,10 @@ export async function POST(request: Request) {
message: translate(locale, "homework.loading.readingSolution"), message: translate(locale, "homework.loading.readingSolution"),
}); });
const transcription = await extractNotes({ const transcription = await extractNotes({
model: config.visionModel, model: visionModel,
repairModel: config.textModel, repairModel: textModel,
auth: auth.auth, auth: baseAuth,
provider,
images: solutionImageList, images: solutionImageList,
signal: abort.signal, signal: abort.signal,
onProgress: (message) => send({ type: "stage", stage: "vision", message }), onProgress: (message) => send({ type: "stage", stage: "vision", message }),
@ -185,8 +205,9 @@ export async function POST(request: Request) {
} }
const result = await checkHomework({ const result = await checkHomework({
model: config.textModel, model: textModel,
auth: auth.auth, auth: baseAuth,
provider,
assignment: assignmentText, assignment: assignmentText,
solution: solutionText, solution: solutionText,
language: detectedLanguage, language: detectedLanguage,
@ -211,8 +232,9 @@ export async function POST(request: Request) {
}); });
} else if (mode === "make") { } else if (mode === "make") {
const result = await makeHomework({ const result = await makeHomework({
model: config.textModel, model: textModel,
auth: auth.auth, auth: baseAuth,
provider,
assignment: assignmentText, assignment: assignmentText,
language: detectedLanguage, language: detectedLanguage,
signal: abort.signal, signal: abort.signal,
@ -232,8 +254,9 @@ export async function POST(request: Request) {
}); });
} else { } else {
const guide = await buildHomeworkGuide({ const guide = await buildHomeworkGuide({
model: config.textModel, model: textModel,
auth: auth.auth, auth: baseAuth,
provider,
assignment: assignmentText, assignment: assignmentText,
language: detectedLanguage, language: detectedLanguage,
signal: abort.signal, signal: abort.signal,
@ -257,7 +280,7 @@ export async function POST(request: Request) {
}); });
} }
} catch (error) { } catch (error) {
const message = friendlyErrorMessage(error, auth.auth.baseUrl, locale); const message = friendlyErrorMessage(error, baseAuth.baseUrl || provider?.baseUrl || '', locale);
// Never log the request body: it may contain the student's assignment. // Never log the request body: it may contain the student's assignment.
console.error("[api/homework]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); console.error("[api/homework]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
send({ send({

View file

@ -0,0 +1,36 @@
import { getServerConfig } from "@/lib/config";
import { requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { providerListModels } from "@/lib/openai-compat";
import { resolveModelTarget } from "@/lib/providers-server";
/**
* Lists the models a cloud provider offers to the browser's key — the same
* listing the chat picker and the Settings rows show. The key travels in the
* request headers and is never stored.
*/
export async function GET(request: Request) {
const locale = requestLocale(request);
const config = getServerConfig();
const limit = rateLimit(`providers:${clientKey(request)}`, config.rateLimits.health, config.rateLimits.windowMs);
if (!limit.allowed) return rateLimitResponse(limit, locale);
const resolved = resolveModelTarget(config, request, locale);
if ("error" in resolved) {
return Response.json({ error: resolved.error.message, kind: resolved.error.kind }, { status: resolved.error.status });
}
if (resolved.target.kind !== "provider") {
return Response.json({ error: "Pick a provider to list its models.", kind: "bad_provider" }, { status: 400 });
}
try {
const models = await providerListModels(resolved.target.target, config.healthTimeoutMs * 2);
return Response.json({ provider: resolved.target.provider, models }, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
const message = error instanceof Error ? error.message : "Could not list the models.";
const kind = (error as { kind?: string }).kind ?? "unknown";
console.error("[api/providers]", kind, message);
return Response.json({ error: message, kind }, { status: 502, headers: { "Cache-Control": "no-store" } });
}
}

View file

@ -0,0 +1,242 @@
"use client";
import { useState } from "react";
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n";
import { localeHeader } from "@/lib/i18n";
import { PROVIDERS } from "@/lib/providers";
import {
clearProviderKey,
modelsFor,
providerHeaders,
saveProviderKey,
setCustomBaseUrl,
setGeneration,
setProviderModels,
useProviders,
} from "@/lib/providers-client";
import { cn } from "@/lib/utils";
/**
* Settings → Providers: bring-your-own keys for the cloud model providers.
*
* Keys live in this browser only — never on the server, never in a backup or
* the sync payload — and travel with each model call so the self-hosted app
* can reach the provider on the user's behalf. A key can also take over study
* generation (decks, homework, grading), and every enabled provider's models
* show up in the chat picker.
*/
export function ProvidersSection() {
const { t } = useI18n();
const state = useProviders();
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [busy, setBusy] = useState<string | null>(null);
const [status, setStatus] = useState<Record<string, string>>({});
const refresh = async (id: string) => {
setBusy(id);
try {
const response = await fetch("/api/providers", { headers: { ...providerHeaders(id), ...localeHeader() } });
const body = (await response.json().catch(() => null)) as { models?: string[]; error?: string } | null;
if (!response.ok || !body?.models) {
setStatus((previous) => ({ ...previous, [id]: body?.error ?? t("providers.testFailed") }));
return;
}
const models = body.models;
setProviderModels(id, models);
setStatus((previous) => ({
...previous,
[id]: t("providers.modelCount", { n: String(models.length) }),
}));
} catch {
setStatus((previous) => ({ ...previous, [id]: t("providers.testFailed") }));
} finally {
setBusy(null);
}
};
const save = (id: string) => {
const key = (drafts[id] ?? "").trim();
if (!key) return;
saveProviderKey(id, key);
setDrafts((previous) => ({ ...previous, [id]: "" }));
haptic("toggle");
void refresh(id);
};
const enabled = PROVIDERS.filter((provider) => state.keys[provider.id]);
const generation = state.generation;
return (
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<div>
<h3 className="text-sm font-semibold text-[var(--text)]">{t("providers.title")}</h3>
<p className="mt-0.5 text-[11.5px] leading-snug text-[var(--text-subtle)]">{t("providers.note")}</p>
</div>
{PROVIDERS.map((provider) => {
const saved = Boolean(state.keys[provider.id]);
const listed = state.models[provider.id] ?? [];
return (
<div key={provider.id} className="space-y-1.5 border-t border-[var(--hairline-soft)] pt-2.5">
<div className="flex items-center justify-between gap-2">
<span className="text-[13px] font-semibold text-[var(--text)]">{provider.label}</span>
{provider.docs ? (
<a
href={provider.docs}
target="_blank"
rel="noreferrer"
className="text-[11.5px] font-medium text-accent-600 underline decoration-dotted dark:text-accent-300"
>
{t("providers.getKey")}
</a>
) : null}
</div>
{provider.id === "custom" ? (
<input
value={state.customBaseUrl}
onChange={(event) => setCustomBaseUrl(event.target.value)}
placeholder={t("providers.baseUrlPlaceholder")}
aria-label={t("providers.baseUrl")}
autoComplete="off"
spellCheck={false}
className="w-full rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)] focus:border-accent-400"
/>
) : null}
<div className="flex items-center gap-2">
<input
type="password"
value={drafts[provider.id] ?? ""}
onChange={(event) => setDrafts((previous) => ({ ...previous, [provider.id]: event.target.value }))}
onKeyDown={(event) => {
if (event.key === "Enter") save(provider.id);
}}
placeholder={saved ? t("providers.keySaved") : provider.keyHint}
aria-label={`${provider.label} ${t("providers.key")}`}
autoComplete="off"
spellCheck={false}
className="min-w-0 flex-1 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)] focus:border-accent-400"
/>
<button
type="button"
onClick={() => save(provider.id)}
disabled={!(drafts[provider.id] ?? "").trim()}
className="btn-secondary h-9 shrink-0 px-3 text-[12.5px] disabled:opacity-50"
>
{t("providers.saveKey")}
</button>
{saved ? (
<>
<button
type="button"
onClick={() => void refresh(provider.id)}
disabled={busy === provider.id}
aria-label={t("providers.test")}
title={t("providers.test")}
className="grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:text-[var(--text)] disabled:opacity-50"
>
↻
</button>
<button
type="button"
onClick={() => {
clearProviderKey(provider.id);
if (generation?.provider === provider.id) setGeneration(null);
haptic("tap");
}}
aria-label={t("providers.remove")}
title={t("providers.remove")}
className="grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:text-rose-600 disabled:opacity-50"
>
×
</button>
</>
) : null}
</div>
{status[provider.id] ?? (saved && listed.length > 0 ? t("providers.modelCount", { n: String(listed.length) }) : null) ? (
<p
className={cn(
"text-[11.5px] leading-snug",
status[provider.id] && !status[provider.id].includes(String(listed.length))
? "text-rose-600 dark:text-rose-300"
: "text-[var(--text-subtle)]",
)}
>
{status[provider.id] ?? t("providers.modelCount", { n: String(listed.length) })}
</p>
) : null}
</div>
);
})}
{enabled.length > 0 ? (
<div className="space-y-2 border-t border-[var(--hairline-soft)] pt-2.5">
<span className="block text-[13px] font-semibold text-[var(--text)]">{t("providers.generation")}</span>
<p className="text-[11.5px] leading-snug text-[var(--text-subtle)]">{t("providers.generationNote")}</p>
<div className="flex flex-wrap items-center gap-2">
<select
aria-label={t("providers.generation")}
value={generation?.provider ?? ""}
onChange={(event) => {
const id = event.target.value;
if (!id) {
setGeneration(null);
return;
}
const models = modelsFor(state, id);
setGeneration({ provider: id, textModel: models[0] ?? "", visionModel: models[0] ?? "" });
haptic("toggle");
}}
className="min-w-0 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 text-[12.5px] text-[var(--text)] outline-none"
>
<option value="">{t("providers.serverDefault")}</option>
{enabled.map((provider) => (
<option key={provider.id} value={provider.id}>
{provider.label}
</option>
))}
</select>
{generation ? (
<>
<select
aria-label={t("providers.textModel")}
value={generation.textModel}
onChange={(event) =>
setGeneration({ ...generation, textModel: event.target.value, visionModel: generation.visionModel || event.target.value })
}
className="min-w-0 max-w-[46%] rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 text-[12.5px] text-[var(--text)] outline-none"
>
{(modelsFor(state, generation.provider).length > 0 ? modelsFor(state, generation.provider) : [generation.textModel]).map(
(model) => (
<option key={model} value={model}>
{model}
</option>
),
)}
</select>
<select
aria-label={t("providers.visionModel")}
value={generation.visionModel}
onChange={(event) => setGeneration({ ...generation, visionModel: event.target.value })}
className="min-w-0 max-w-[46%] rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 text-[12.5px] text-[var(--text)] outline-none"
>
{(modelsFor(state, generation.provider).length > 0 ? modelsFor(state, generation.provider) : [generation.visionModel]).map(
(model) => (
<option key={model} value={model}>
{model}
</option>
),
)}
</select>
</>
) : null}
</div>
</div>
) : null}
</section>
);
}

View file

@ -14,6 +14,7 @@ import { DataSection } from "@/components/DataSection";
import { LearningSection } from "@/components/LearningSection"; import { LearningSection } from "@/components/LearningSection";
import { ThemeSection } from "@/components/ThemeSection"; import { ThemeSection } from "@/components/ThemeSection";
import { useReducedMotionPref } from "@/lib/motion-pref"; import { useReducedMotionPref } from "@/lib/motion-pref";
import { ProvidersSection } from "@/components/ProvidersSection";
import { SyncSection } from "@/components/SyncSection"; import { SyncSection } from "@/components/SyncSection";
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings"; import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
import { useBackClose } from "@/lib/use-back-close"; import { useBackClose } from "@/lib/use-back-close";
@ -426,6 +427,9 @@ export function SettingsDialog({
/> />
</section> </section>
{/* --------------------------------------- providers */}
<ProvidersSection />
{/* ----------------------------------------- learning */} {/* ----------------------------------------- learning */}
<LearningSection level={3} /> <LearningSection level={3} />
@ -801,6 +805,9 @@ export function SettingsDialog({
/> />
</section> </section>
{/* --------------------------------------- providers */}
<ProvidersSection />
{/* ----------------------------------------- learning */} {/* ----------------------------------------- learning */}
<LearningSection level={3} /> <LearningSection level={3} />

View file

@ -11,6 +11,8 @@ import { HistorySheet } from "./HistoryPanel";
import { MessageRow } from "./MessageRow"; import { MessageRow } from "./MessageRow";
import { ModelPicker } from "./ModelPicker"; import { ModelPicker } from "./ModelPicker";
import { ChatIcon, ChatPlusIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, RefreshIcon } from "@/components/icons"; import { ChatIcon, ChatPlusIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, RefreshIcon } from "@/components/icons";
import { PROVIDERS, providerInfo } from "@/lib/providers";
import { modelsFor, useProviders } from "@/lib/providers-client";
import { ModelErrorActions } from "@/components/ModelErrorActions"; import { ModelErrorActions } from "@/components/ModelErrorActions";
import { ThemeToggle } from "@/components/ThemeToggle"; import { ThemeToggle } from "@/components/ThemeToggle";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
@ -24,7 +26,9 @@ import { chatHistoryStore, conversationMessages, deleteConversation, ensureChats
import { import {
chatMessageId, chatMessageId,
readStoredChatModel, readStoredChatModel,
readStoredChatProvider,
storeChatModel, storeChatModel,
storeChatProvider,
titleFromMessage, titleFromMessage,
type ChatAttachment, type ChatAttachment,
type ChatMessage, type ChatMessage,
@ -75,6 +79,7 @@ export function ChatScreen() {
const [attachments, setAttachments] = useState<PendingAttachment[]>([]); const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [attachBusy, setAttachBusy] = useState(false); const [attachBusy, setAttachBusy] = useState(false);
const [model, setModel] = useState(""); const [model, setModel] = useState("");
const [provider, setProvider] = useState("ollama");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [pinned, setPinned] = useState(true); const [pinned, setPinned] = useState(true);
const [scopeOpen, setScopeOpen] = useState(false); const [scopeOpen, setScopeOpen] = useState(false);
@ -106,6 +111,7 @@ export function ChatScreen() {
useEffect(() => { useEffect(() => {
setModel(readStoredChatModel() ?? ""); setModel(readStoredChatModel() ?? "");
setProvider(readStoredChatProvider());
}, []); }, []);
/* History: hydrate once, then open ?c=<id> conversations. */ /* History: hydrate once, then open ?c=<id> conversations. */
@ -124,6 +130,7 @@ export function ChatScreen() {
setDraft(""); setDraft("");
setError(null); setError(null);
if (found.model) setModel(found.model); if (found.model) setModel(found.model);
if ((found as { provider?: string }).provider) setProvider((found as { provider?: string }).provider ?? "ollama");
loadedRef.current = found.id; loadedRef.current = found.id;
chatActive.set(found.id); chatActive.set(found.id);
}, []); }, []);
@ -154,6 +161,7 @@ export function ChatScreen() {
}; };
}, []); }, []);
const providerState = useProviders();
const chatModels = useMemo<ChatModelChoice[]>(() => { const chatModels = useMemo<ChatModelChoice[]>(() => {
const fromHealth = health?.chatModels ?? []; const fromHealth = health?.chatModels ?? [];
const list = const list =
@ -163,16 +171,30 @@ export function ChatScreen() {
{ name: health?.text?.configured ?? "", badge: "text" as const, available: health?.text?.available ?? null }, { name: health?.text?.configured ?? "", badge: "text" as const, available: health?.text?.available ?? null },
{ name: health?.vision?.configured ?? "", badge: "vision" as const, available: health?.vision?.available ?? null }, { name: health?.vision?.configured ?? "", badge: "vision" as const, available: health?.vision?.available ?? null },
]; ];
return list.filter((entry) => entry.name); const ollama = list.filter((entry) => entry.name);
}, [health]); // Every enabled cloud provider contributes its models (live listing first,
// the built-in seeds otherwise), labelled with the provider.
const cloud: ChatModelChoice[] = [];
for (const info of PROVIDERS) {
if (!providerState.keys[info.id]) continue;
const models = modelsFor(providerState, info.id).slice(0, 400);
if (models.length === 0) continue;
for (const name of models) {
cloud.push({ name, badge: "text", available: true, provider: info.id });
}
}
return [...ollama, ...cloud];
}, [health, providerState]);
const activeModel = useMemo(() => { const activeModel = useMemo(() => {
if (model && chatModels.some((entry) => entry.name === model)) return model; if (model && chatModels.some((entry) => entry.name === model && (entry.provider ?? "ollama") === provider)) return model;
const sameProvider = chatModels.find((entry) => (entry.provider ?? "ollama") === provider && entry.available !== false);
if (model && sameProvider?.name === model) return model;
// The server puts the default first, but a different server may not have // The server puts the default first, but a different server may not have
// it — fall back to the first model that is actually available. // it — fall back to the first model that is actually available.
const firstAvailable = chatModels.find((entry) => entry.available !== false); const firstAvailable = chatModels.find((entry) => (entry.provider ?? "ollama") === provider && entry.available !== false);
return (firstAvailable ?? chatModels[0])?.name ?? model; return (firstAvailable ?? chatModels.find((entry) => (entry.provider ?? "ollama") === provider) ?? chatModels[0])?.name ?? model;
}, [model, chatModels]); }, [model, chatModels, provider]);
const scopeText = useMemo(() => { const scopeText = useMemo(() => {
if (scope.kind === "all") return t("chat.scope.all"); if (scope.kind === "all") return t("chat.scope.all");
@ -297,6 +319,7 @@ export function ChatScreen() {
try { try {
await streamChat({ await streamChat({
model: activeModel || undefined, model: activeModel || undefined,
provider,
messages: history.map((message) => ({ role: message.role, content: message.content })), messages: history.map((message) => ({ role: message.role, content: message.content })),
context: context.chunks.map(({ deckId, deckTitle, section, text }) => ({ deckId, deckTitle, section, text })), context: context.chunks.map(({ deckId, deckTitle, section, text }) => ({ deckId, deckTitle, section, text })),
signal: controller.signal, signal: controller.signal,
@ -689,8 +712,14 @@ export function ChatScreen() {
busy={busy} busy={busy}
online={online} online={online}
scopeLabel={scopeText} scopeLabel={scopeText}
modelLabel={activeModel ? prettyModelName(activeModel) : t("chat.modelTitle")} modelLabel={
modelTitle={activeModel || undefined} activeModel
? provider !== "ollama"
? `${providerInfo(provider)?.label ?? provider} · ${prettyModelName(activeModel)}`
: prettyModelName(activeModel)
: t("chat.modelTitle")
}
modelTitle={activeModel ? `${activeModel} · ${providerInfo(provider)?.label ?? provider}` : undefined}
scopeOpen={scopeOpen} scopeOpen={scopeOpen}
modelOpen={modelOpen} modelOpen={modelOpen}
// The chips toggle their own popover (and open one at a time), so a // The chips toggle their own popover (and open one at a time), so a
@ -713,7 +742,10 @@ export function ChatScreen() {
onClose={() => setModelOpen(false)} onClose={() => setModelOpen(false)}
models={chatModels} models={chatModels}
value={activeModel} value={activeModel}
onChange={(next) => { valueProvider={provider}
onChange={(next, nextProvider) => {
setProvider(nextProvider ?? "ollama");
storeChatProvider(nextProvider ?? "ollama");
setModel(next); setModel(next);
storeChatModel(next); storeChatModel(next);
if (messages.length > 0) persist(messages, { model: next }); if (messages.length > 0) persist(messages, { model: next });

View file

@ -5,6 +5,7 @@ import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CheckCircleIcon } from "@/components/icons"; import { CheckCircleIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { providerInfo } from "@/lib/providers";
import { springSheet } from "@/lib/motion"; import { springSheet } from "@/lib/motion";
import { useIsPhone } from "@/lib/use-phone"; import { useIsPhone } from "@/lib/use-phone";
import type { ChatModelChoice } from "@/lib/chat"; import type { ChatModelChoice } from "@/lib/chat";
@ -20,13 +21,16 @@ export function ModelPicker({
onClose, onClose,
models, models,
value, value,
valueProvider,
onChange, onChange,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
models: ChatModelChoice[]; models: ChatModelChoice[];
value: string; value: string;
onChange: (model: string) => void; /** Provider that owns `value` (Ollama when omitted). */
valueProvider?: string;
onChange: (model: string, provider?: string) => void;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const isPhone = useIsPhone(); const isPhone = useIsPhone();
@ -66,6 +70,8 @@ export function ModelPicker({
return () => document.removeEventListener("pointerdown", onDown); return () => document.removeEventListener("pointerdown", onDown);
}, [open, isPhone, onClose]); }, [open, isPhone, onClose]);
const providerLabel = (id: string) => providerInfo(id)?.label ?? id;
const badge = (model: ChatModelChoice) => const badge = (model: ChatModelChoice) =>
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText"); model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
@ -87,7 +93,7 @@ export function ModelPicker({
<li className="px-3 py-2 text-[12.5px] text-[var(--text-subtle)]">{t("chat.modelSearchEmpty")}</li> <li className="px-3 py-2 text-[12.5px] text-[var(--text-subtle)]">{t("chat.modelSearchEmpty")}</li>
) : null} ) : null}
{visible.map((model) => { {visible.map((model) => {
const selected = model.name === value; const selected = model.name === value && (model.provider ?? "ollama") === (valueProvider ?? "ollama");
const unavailable = model.available === false; const unavailable = model.available === false;
return ( return (
<li key={model.name}> <li key={model.name}>
@ -96,7 +102,7 @@ export function ModelPicker({
disabled={unavailable} disabled={unavailable}
aria-pressed={selected} aria-pressed={selected}
onClick={() => { onClick={() => {
onChange(model.name); onChange(model.name, model.provider);
onClose(); onClose();
}} }}
className={cn( className={cn(
@ -110,7 +116,9 @@ export function ModelPicker({
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-semibold text-[var(--text)]">{model.name}</span> <span className="block truncate text-[13.5px] font-semibold text-[var(--text)]">{model.name}</span>
<span className="mt-0.5 flex items-center gap-2 text-[11.5px] text-[var(--text-subtle)]"> <span className="mt-0.5 flex items-center gap-2 text-[11.5px] text-[var(--text-subtle)]">
<span className="rounded-md border border-[var(--hairline-soft)] px-1.5 py-0.5">{badge(model)}</span> <span className="rounded-md border border-[var(--hairline-soft)] px-1.5 py-0.5">
{model.provider ? `${providerLabel(model.provider)} · ${badge(model)}` : badge(model)}
</span>
{model.available === true ? ( {model.available === true ? (
<span className="text-emerald-600 dark:text-emerald-300">{t("chat.modelAvailable")}</span> <span className="text-emerald-600 dark:text-emerald-300">{t("chat.modelAvailable")}</span>
) : model.available === false ? ( ) : model.available === false ? (

View file

@ -5,6 +5,7 @@
*/ */
import { OllamaError, ollamaChat, type OllamaMessage } from "./ollama"; import { OllamaError, ollamaChat, type OllamaMessage } from "./ollama";
import { providerChatJson, type ProviderTarget } from "./openai-compat";
import { needsLanguageRepair } from "./language"; import { needsLanguageRepair } from "./language";
import { import {
buildGenerationUserPrompt, buildGenerationUserPrompt,
@ -46,6 +47,8 @@ export type PipelineStage = "vision" | "generation" | "done";
type JsonStepOptions<T> = { type JsonStepOptions<T> = {
model: string; model: string;
auth: OllamaAuth; auth: OllamaAuth;
/** Cloud provider override (OpenAI-compatible); falls back to `auth`. */
provider?: ProviderTarget;
system: string; system: string;
user: string; user: string;
images?: string[]; images?: string[];
@ -67,6 +70,7 @@ type JsonStepResult<T> = {
async function runJsonStep<T>({ async function runJsonStep<T>({
model, model,
auth, auth,
provider,
system, system,
user, user,
images, images,
@ -85,7 +89,15 @@ async function runJsonStep<T>({
let lastError = ""; let lastError = "";
for (let attempt = 1; attempt <= 2; attempt++) { for (let attempt = 1; attempt <= 2; attempt++) {
const result = await ollamaChat({ const result = provider
? await providerChatJson({
target: provider,
model,
messages,
signal,
temperature: temperature ?? 0.4,
})
: await ollamaChat({
model, model,
auth, auth,
messages, messages,
@ -151,6 +163,8 @@ export async function extractNotes(input: {
/** Model used to restore the language if the transcription was translated. */ /** Model used to restore the language if the transcription was translated. */
repairModel: string; repairModel: string;
auth: OllamaAuth; auth: OllamaAuth;
/** Cloud provider override (OpenAI-compatible); falls back to `auth`. */
provider?: ProviderTarget;
images: string[]; images: string[];
text?: string; text?: string;
signal?: AbortSignal; signal?: AbortSignal;
@ -163,6 +177,7 @@ export async function extractNotes(input: {
const { value, repaired } = await runJsonStep<ExtractedNotes>({ const { value, repaired } = await runJsonStep<ExtractedNotes>({
model: input.model, model: input.model,
auth: input.auth, auth: input.auth,
provider: input.provider,
system: VISION_SYSTEM_PROMPT, system: VISION_SYSTEM_PROMPT,
user, user,
images: input.images, images: input.images,
@ -187,6 +202,7 @@ export async function extractNotes(input: {
const restored = await restoreLanguage({ const restored = await restoreLanguage({
model: input.repairModel, model: input.repairModel,
auth: input.auth, auth: input.auth,
provider: input.provider,
language: value.language ?? "the original language of the notes", language: value.language ?? "the original language of the notes",
extraction: value, extraction: value,
signal: input.signal, signal: input.signal,
@ -218,6 +234,8 @@ Rules:
export async function restoreLanguage(input: { export async function restoreLanguage(input: {
model: string; model: string;
auth: OllamaAuth; auth: OllamaAuth;
/** Cloud provider override (OpenAI-compatible); falls back to `auth`. */
provider?: ProviderTarget;
language: string; language: string;
extraction: ExtractedNotes; extraction: ExtractedNotes;
signal?: AbortSignal; signal?: AbortSignal;
@ -225,6 +243,7 @@ export async function restoreLanguage(input: {
const value = await runJsonStep<ExtractedNotes>({ const value = await runJsonStep<ExtractedNotes>({
model: input.model, model: input.model,
auth: input.auth, auth: input.auth,
provider: input.provider,
system: RESTORE_SYSTEM_PROMPT, system: RESTORE_SYSTEM_PROMPT,
user: [ user: [
`ORIGINAL LANGUAGE: ${input.language}`, `ORIGINAL LANGUAGE: ${input.language}`,
@ -338,6 +357,8 @@ function validateDeck(parsed: unknown, minimum: number, requested: number) {
export async function generateActivities(input: { export async function generateActivities(input: {
model: string; model: string;
auth: OllamaAuth; auth: OllamaAuth;
/** Cloud provider override (OpenAI-compatible); falls back to `auth`. */
provider?: ProviderTarget;
notes: string; notes: string;
difficulty: Difficulty; difficulty: Difficulty;
count: number; count: number;
@ -360,6 +381,7 @@ export async function generateActivities(input: {
}>({ }>({
model: input.model, model: input.model,
auth: input.auth, auth: input.auth,
provider: input.provider,
system: GENERATION_SYSTEM_PROMPT, system: GENERATION_SYSTEM_PROMPT,
user: buildGenerationUserPrompt({ user: buildGenerationUserPrompt({
notes: input.notes, notes: input.notes,
@ -388,6 +410,8 @@ const GRADE_SHAPE_HINT = `{"correct":true,"score":85,"feedback":"short feedback"
export async function gradeShortAnswer(input: { export async function gradeShortAnswer(input: {
model: string; model: string;
auth: OllamaAuth; auth: OllamaAuth;
/** Cloud provider override (OpenAI-compatible); falls back to `auth`. */
provider?: ProviderTarget;
question: string; question: string;
referenceAnswer: string; referenceAnswer: string;
keyPoints: string[]; keyPoints: string[];
@ -398,6 +422,7 @@ export async function gradeShortAnswer(input: {
const { value } = await runJsonStep<GradeResult>({ const { value } = await runJsonStep<GradeResult>({
model: input.model, model: input.model,
auth: input.auth, auth: input.auth,
provider: input.provider,
system: GRADING_SYSTEM_PROMPT, system: GRADING_SYSTEM_PROMPT,
user: buildGradingUserPrompt(input), user: buildGradingUserPrompt(input),
validate: (parsed) => { validate: (parsed) => {
@ -425,6 +450,8 @@ const HOMEWORK_MAKE_SHAPE_HINT = `{"title":"...","language":"English name of the
export async function makeHomework(input: { export async function makeHomework(input: {
model: string; model: string;
auth: OllamaAuth; auth: OllamaAuth;
/** Cloud provider override (OpenAI-compatible); falls back to `auth`. */
provider?: ProviderTarget;
assignment: string; assignment: string;
language?: string; language?: string;
signal?: AbortSignal; signal?: AbortSignal;
@ -432,6 +459,7 @@ export async function makeHomework(input: {
const { value } = await runJsonStep<HomeworkMake>({ const { value } = await runJsonStep<HomeworkMake>({
model: input.model, model: input.model,
auth: input.auth, auth: input.auth,
provider: input.provider,
system: HOMEWORK_MAKE_SYSTEM_PROMPT, system: HOMEWORK_MAKE_SYSTEM_PROMPT,
user: buildHomeworkMakeUserPrompt({ assignment: input.assignment, language: input.language }), user: buildHomeworkMakeUserPrompt({ assignment: input.assignment, language: input.language }),
validate: (parsed) => { validate: (parsed) => {
@ -458,6 +486,8 @@ const HOMEWORK_GUIDE_SHAPE_HINT = `{"title":"...","language":"English name of th
export async function buildHomeworkGuide(input: { export async function buildHomeworkGuide(input: {
model: string; model: string;
auth: OllamaAuth; auth: OllamaAuth;
/** Cloud provider override (OpenAI-compatible); falls back to `auth`. */
provider?: ProviderTarget;
assignment: string; assignment: string;
language?: string; language?: string;
signal?: AbortSignal; signal?: AbortSignal;
@ -465,6 +495,7 @@ export async function buildHomeworkGuide(input: {
const { value } = await runJsonStep<HomeworkGuide>({ const { value } = await runJsonStep<HomeworkGuide>({
model: input.model, model: input.model,
auth: input.auth, auth: input.auth,
provider: input.provider,
system: HOMEWORK_GUIDE_SYSTEM_PROMPT, system: HOMEWORK_GUIDE_SYSTEM_PROMPT,
user: buildHomeworkGuideUserPrompt({ assignment: input.assignment, language: input.language }), user: buildHomeworkGuideUserPrompt({ assignment: input.assignment, language: input.language }),
validate: (parsed) => { validate: (parsed) => {
@ -526,6 +557,8 @@ const HOMEWORK_CHECK_SHAPE_HINT = `{"title":"...","language":"English name of th
export async function checkHomework(input: { export async function checkHomework(input: {
model: string; model: string;
auth: OllamaAuth; auth: OllamaAuth;
/** Cloud provider override (OpenAI-compatible); falls back to `auth`. */
provider?: ProviderTarget;
assignment: string; assignment: string;
solution: string; solution: string;
language?: string; language?: string;
@ -534,6 +567,7 @@ export async function checkHomework(input: {
const { value } = await runJsonStep<HomeworkCheck>({ const { value } = await runJsonStep<HomeworkCheck>({
model: input.model, model: input.model,
auth: input.auth, auth: input.auth,
provider: input.provider,
system: HOMEWORK_CHECK_SYSTEM_PROMPT, system: HOMEWORK_CHECK_SYSTEM_PROMPT,
user: buildHomeworkCheckUserPrompt({ user: buildHomeworkCheckUserPrompt({
assignment: input.assignment, assignment: input.assignment,
@ -558,18 +592,14 @@ export async function checkHomework(input: {
export async function refineHomeworkText(input: { export async function refineHomeworkText(input: {
model: string; model: string;
auth: OllamaAuth; auth: OllamaAuth;
/** Cloud provider override (OpenAI-compatible); falls back to `auth`. */
provider?: ProviderTarget;
instruction: "shorter" | "more" | "explain"; instruction: "shorter" | "more" | "explain";
text: string; text: string;
context?: string; context?: string;
signal?: AbortSignal; signal?: AbortSignal;
}): Promise<string> { }): Promise<string> {
const result = await ollamaChat({ const messages: OllamaMessage[] = [
model: input.model,
auth: input.auth,
json: false,
signal: input.signal,
temperature: 0.4,
messages: [
{ role: "system", content: REFINE_SYSTEM_PROMPT }, { role: "system", content: REFINE_SYSTEM_PROMPT },
{ {
role: "user", role: "user",
@ -579,8 +609,10 @@ export async function refineHomeworkText(input: {
context: input.context, context: input.context,
}), }),
}, },
], ];
}); const result = input.provider
? await providerChatJson({ target: input.provider, model: input.model, messages, signal: input.signal, temperature: 0.4 })
: await ollamaChat({ model: input.model, auth: input.auth, json: false, signal: input.signal, temperature: 0.4, messages });
return result.content.trim(); return result.content.trim();
} }

View file

@ -3,6 +3,7 @@
import { ApiError, consumeNdjson } from "./client-api"; import { ApiError, consumeNdjson } from "./client-api";
import { localeHeader, t } from "./i18n"; import { localeHeader, t } from "./i18n";
import { authHeaders } from "./settings"; import { authHeaders } from "./settings";
import { providerHeaders, providersStore } from "./providers-client";
import type { ChatSource } from "./chat"; import type { ChatSource } from "./chat";
/** /**
@ -19,6 +20,8 @@ export type ChatStreamEvent =
export type StreamChatInput = { export type StreamChatInput = {
model?: string; model?: string;
/** A cloud provider the model belongs to (Ollama when omitted). */
provider?: string;
messages: { role: "user" | "assistant"; content: string }[]; messages: { role: "user" | "assistant"; content: string }[];
context: (ChatSource & { text: string })[]; context: (ChatSource & { text: string })[];
signal?: AbortSignal; signal?: AbortSignal;
@ -32,7 +35,7 @@ export async function streamChat(input: StreamChatInput): Promise<void> {
try { try {
response = await fetch("/api/chat", { response = await fetch("/api/chat", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() }, headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(input.provider), ...localeHeader() },
body: JSON.stringify({ body: JSON.stringify({
model: input.model || undefined, model: input.model || undefined,
messages: input.messages, messages: input.messages,
@ -82,12 +85,14 @@ export async function extractChatAttachment(
input: { images?: string[]; text?: string }, input: { images?: string[]; text?: string },
options: { signal?: AbortSignal } = {}, options: { signal?: AbortSignal } = {},
): Promise<string> { ): Promise<string> {
// Attachment transcription follows the study-generation provider, if set.
const generation = providersStore.getSnapshot().generation;
let response: Response; let response: Response;
try { try {
response = await fetch("/api/chat/extract", { response = await fetch("/api/chat/extract", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() }, headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
body: JSON.stringify(input), body: JSON.stringify(generation ? { ...input, model: generation.visionModel || generation.textModel } : input),
signal: options.signal, signal: options.signal,
}); });
} catch (error) { } catch (error) {

View file

@ -16,10 +16,13 @@ export type ChatModelChoice = {
name: string; name: string;
badge: "text" | "vision" | "both"; badge: "text" | "vision" | "both";
available: boolean | null; available: boolean | null;
/** Cloud provider the model belongs to (Ollama when omitted). */
provider?: string;
}; };
/** Last model choice, remembered across visits. */ /** Last model choice, remembered across visits. */
const MODEL_KEY = "freezmo.chat.model.v1"; const MODEL_KEY = "freezmo.chat.model.v1";
const MODEL_PROVIDER_KEY = "freezmo.chat.provider.v1";
export function readStoredChatModel(): string | null { export function readStoredChatModel(): string | null {
try { try {
@ -38,6 +41,23 @@ export function storeChatModel(model: string): void {
} }
} }
/** The provider that owns the remembered model (Ollama unless a cloud one). */
export function readStoredChatProvider(): string {
try {
return window.localStorage.getItem(MODEL_PROVIDER_KEY) ?? "ollama";
} catch {
return "ollama";
}
}
export function storeChatProvider(provider: string): void {
try {
window.localStorage.setItem(MODEL_PROVIDER_KEY, provider);
} catch {
/* private mode */
}
}
export function chatMessageId(prefix: string): string { export function chatMessageId(prefix: string): string {
const rand = const rand =
typeof crypto !== "undefined" && "randomUUID" in crypto typeof crypto !== "undefined" && "randomUUID" in crypto

View file

@ -3,6 +3,7 @@
import { localeHeader, t } from "./i18n"; import { localeHeader, t } from "./i18n";
import type { Activity, Difficulty, HomeworkMode, HomeworkStep } from "./schemas"; import type { Activity, Difficulty, HomeworkMode, HomeworkStep } from "./schemas";
import { API_KEY_HEADER, authHeaders } from "./settings"; import { API_KEY_HEADER, authHeaders } from "./settings";
import { providerHeaders, providersStore } from "./providers-client";
export type GenerateInput = { export type GenerateInput = {
images?: string[]; images?: string[];
@ -119,6 +120,9 @@ export async function generateDeck(
input: GenerateInput, input: GenerateInput,
options: { onProgress?: (progress: GenerateProgress) => void; signal?: AbortSignal } = {}, options: { onProgress?: (progress: GenerateProgress) => void; signal?: AbortSignal } = {},
): Promise<{ deck: StreamedDeck; meta: Record<string, unknown> }> { ): Promise<{ deck: StreamedDeck; meta: Record<string, unknown> }> {
// A provider picked in Settings → Providers runs the generation instead of
// the server's Ollama; its key travels with the request only.
const generation = providersStore.getSnapshot().generation;
// A run that goes quiet is worse than one that fails: the wizard used to sit // A run that goes quiet is worse than one that fails: the wizard used to sit
// on its loading screen until the server's own 2x deadline (ten minutes by // on its loading screen until the server's own 2x deadline (ten minutes by
// default) when the model hung. The caller's signal is forwarded, and a // default) when the model hung. The caller's signal is forwarded, and a
@ -138,8 +142,10 @@ export async function generateDeck(
try { try {
response = await fetch("/api/generate", { response = await fetch("/api/generate", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() }, headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
body: JSON.stringify(input), body: JSON.stringify(
generation ? { ...input, textModel: generation.textModel, visionModel: generation.visionModel } : input,
),
signal: controller.signal, signal: controller.signal,
}); });
} catch { } catch {
@ -242,6 +248,7 @@ export async function generateHomework(
input: HomeworkInput, input: HomeworkInput,
options: { onProgress?: (progress: HomeworkProgress) => void; signal?: AbortSignal } = {}, options: { onProgress?: (progress: HomeworkProgress) => void; signal?: AbortSignal } = {},
): Promise<{ homework: StreamedHomework; meta: Record<string, unknown> }> { ): Promise<{ homework: StreamedHomework; meta: Record<string, unknown> }> {
const generation = providersStore.getSnapshot().generation;
// Same guards as generateDeck: no infinite loading screen on a hung model. // Same guards as generateDeck: no infinite loading screen on a hung model.
const controller = new AbortController(); const controller = new AbortController();
const forwarded = options.signal; const forwarded = options.signal;
@ -258,8 +265,10 @@ export async function generateHomework(
try { try {
response = await fetch("/api/homework", { response = await fetch("/api/homework", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() }, headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
body: JSON.stringify(input), body: JSON.stringify(
generation ? { ...input, textModel: generation.textModel, visionModel: generation.visionModel } : input,
),
signal: controller.signal, signal: controller.signal,
}); });
} catch { } catch {
@ -329,12 +338,17 @@ export async function refineHomework(input: {
context?: string; context?: string;
signal?: AbortSignal; signal?: AbortSignal;
}): Promise<string> { }): Promise<string> {
const generation = providersStore.getSnapshot().generation;
let response: Response; let response: Response;
try { try {
response = await fetch("/api/homework/refine", { response = await fetch("/api/homework/refine", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() }, headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
body: JSON.stringify({ instruction: input.instruction, text: input.text, context: input.context }), body: JSON.stringify(
generation
? { instruction: input.instruction, text: input.text, context: input.context, model: generation.textModel }
: { instruction: input.instruction, text: input.text, context: input.context },
),
signal: input.signal, signal: input.signal,
}); });
} catch (error) { } catch (error) {
@ -359,12 +373,13 @@ export async function gradeAnswer(input: {
language?: string; language?: string;
signal?: AbortSignal; signal?: AbortSignal;
}): Promise<{ correct: boolean; score: number; feedback: string; missing: string[] }> { }): Promise<{ correct: boolean; score: number; feedback: string; missing: string[] }> {
const generation = providersStore.getSnapshot().generation;
let response: Response; let response: Response;
try { try {
response = await fetch("/api/grade", { response = await fetch("/api/grade", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() }, headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
body: JSON.stringify(input), body: JSON.stringify(generation ? { ...input, model: generation.textModel } : input),
signal: input.signal, signal: input.signal,
}); });
} catch (error) { } catch (error) {

View file

@ -739,6 +739,7 @@ const en: Record<string, string> = {
/* server-side messages (API routes) -------------------------------- */ /* server-side messages (API routes) -------------------------------- */
"server.bodyNotJson": "Request body was not valid JSON.", "server.bodyNotJson": "Request body was not valid JSON.",
"server.invalidRequest": "Invalid request: {detail}", "server.invalidRequest": "Invalid request: {detail}",
"server.providerModelNeeded": "Pick the generation model in Settings → Providers.",
"server.needContent": "Add at least one photo of your notes or paste some text to build a deck.", "server.needContent": "Add at least one photo of your notes or paste some text to build a deck.",
"server.needAssignment": "server.needAssignment":
"Add the assignment first — paste its text, photograph it, or add a document.", "Add the assignment first — paste its text, photograph it, or add a document.",
@ -1017,6 +1018,23 @@ const en: Record<string, string> = {
"consent.ok": "Got it", "consent.ok": "Got it",
"settings.version": "Version", "settings.version": "Version",
"settings.chatNotes": "Keep notes text for chat", "settings.chatNotes": "Keep notes text for chat",
"providers.title": "Cloud providers",
"providers.saveKey": "Save key",
"providers.note": "Add your own API keys to use more models. Keys stay in this browser — the server forwards them with your requests and never stores them.",
"providers.key": "API key",
"providers.keySaved": "Key saved — paste a new one to replace",
"providers.getKey": "Get a key",
"providers.test": "Test and list models",
"providers.remove": "Remove the key",
"providers.testFailed": "Could not reach the provider. Check the key (and the base URL for custom endpoints).",
"providers.modelCount": "{n} models",
"providers.generation": "Study generation",
"providers.generationNote": "Generate decks, homework and grading on a provider model instead of the server's Ollama.",
"providers.serverDefault": "Server default (Ollama)",
"providers.textModel": "Text model",
"providers.visionModel": "Vision model",
"providers.baseUrl": "OpenAI-compatible base URL",
"providers.baseUrlPlaceholder": "https://host/v1",
"settings.reduceMotion": "Reduce motion", "settings.reduceMotion": "Reduce motion",
"settings.reduceMotionNote": "Calms the scene and UI animations. Useful when motion feels distracting.", "settings.reduceMotionNote": "Calms the scene and UI animations. Useful when motion feels distracting.",
"settings.chatNotesNote": "Chat answers from the notes you added (stored with each deck). Turn off to use only the generated study content.", "settings.chatNotesNote": "Chat answers from the notes you added (stored with each deck). Turn off to use only the generated study content.",
@ -1702,6 +1720,7 @@ const cs: Record<string, string> = {
/* server-side messages (API routes) -------------------------------- */ /* server-side messages (API routes) -------------------------------- */
"server.bodyNotJson": "Tělo požadavku nebylo platné JSON.", "server.bodyNotJson": "Tělo požadavku nebylo platné JSON.",
"server.invalidRequest": "Neplatný požadavek: {detail}", "server.invalidRequest": "Neplatný požadavek: {detail}",
"server.providerModelNeeded": "Vyberte model pro generování v Nastavení → Poskytovatelé.",
"server.needContent": "Přidej aspoň jednu fotku poznámek nebo vlož text, ze kterého sada vznikne.", "server.needContent": "Přidej aspoň jednu fotku poznámek nebo vlož text, ze kterého sada vznikne.",
"server.needAssignment": "server.needAssignment":
"Nejdřív přidej zadání — vlož jeho text, vyfoť ho nebo přidej dokument.", "Nejdřív přidej zadání — vlož jeho text, vyfoť ho nebo přidej dokument.",
@ -1980,6 +1999,23 @@ const cs: Record<string, string> = {
"consent.ok": "Rozumím", "consent.ok": "Rozumím",
"settings.version": "Verze", "settings.version": "Verze",
"settings.chatNotes": "Uchovávat text poznámek pro chat", "settings.chatNotes": "Uchovávat text poznámek pro chat",
"providers.title": "Cloudoví poskytovatelé",
"providers.saveKey": "Uložit klíč",
"providers.note": "Přidejte si vlastní API klíče a používejte více modelů. Klíče zůstávají v tomto prohlížeči — server je posílá jen s vašimi požadavky a nikdy je neukládá.",
"providers.key": "API klíč",
"providers.keySaved": "Klíč uložen — vložte nový pro nahrazení",
"providers.getKey": "Získat klíč",
"providers.test": "Otestovat a vypsat modely",
"providers.remove": "Odstranit klíč",
"providers.testFailed": "Nepodařilo se připojit k poskytovateli. Zkontrolujte klíč (a u vlastního endpointu i URL).",
"providers.modelCount": "{n} modelů",
"providers.generation": "Generování studia",
"providers.generationNote": "Generovat sady, úkoly a hodnocení na modelu poskytovatele místo serverového Ollama.",
"providers.serverDefault": "Serverové výchozí (Ollama)",
"providers.textModel": "Textový model",
"providers.visionModel": "Model pro obrázky",
"providers.baseUrl": "OpenAI kompatibilní základní URL",
"providers.baseUrlPlaceholder": "https://host/v1",
"settings.reduceMotion": "Omezit pohyb", "settings.reduceMotion": "Omezit pohyb",
"settings.reduceMotionNote": "Zklidní scénu i animace rozhraní. Hodí se, když je pohyb rušivý.", "settings.reduceMotionNote": "Zklidní scénu i animace rozhraní. Hodí se, když je pohyb rušivý.",
"settings.chatNotesNote": "Chat odpovídá z poznámek, které jsi přidal (jsou uložené u každé sady). Vypnutím bude používat jen vygenerovaný obsah.", "settings.chatNotesNote": "Chat odpovídá z poznámek, které jsi přidal (jsou uložené u každé sady). Vypnutím bude používat jen vygenerovaný obsah.",

View file

@ -37,9 +37,9 @@ const PRIVACY_EN: LegalDocument = {
], ],
}, },
{ {
heading: "Your Ollama API key", heading: "Your API keys",
paragraphs: [ paragraphs: [
"The key is kept in this browser only. It is sent to this Freezmo server for the single model call you asked for, forwarded to Ollama, and forgotten: never written to disk, never logged, never part of a backup, sync payload or share link.", "Every model key — the Ollama one and any cloud provider keys you add under Settings → Providers — is kept in this browser only. A key is sent to this Freezmo server for the single model call you asked for, forwarded to that provider, and forgotten: never written to disk, never logged, never part of a backup, sync payload or share link.",
], ],
}, },
{ {
@ -60,7 +60,7 @@ const PRIVACY_EN: LegalDocument = {
{ {
heading: "Third parties", heading: "Third parties",
paragraphs: [ paragraphs: [
"Model calls go to the Ollama endpoint this instance is configured for, using your key — Ollama's own privacy terms apply to them. Reminders travel through your browser's push service (Google, Mozilla or Apple). There is nothing else: no ads, no analytics, no embeds.", "Model calls go to the Ollama endpoint this instance is configured for, or — when you pick a cloud model — to that provider (OpenAI, Anthropic, DeepSeek, Groq, OpenRouter or your own endpoint) with the key from your browser. The provider's own privacy terms apply to those calls. Reminders travel through your browser's push service (Google, Mozilla or Apple). There is nothing else: no ads, no analytics, no embeds.",
], ],
}, },
{ {
@ -87,9 +87,9 @@ const PRIVACY_CS: LegalDocument = {
], ],
}, },
{ {
heading: "Tvůj API klíč Ollama", heading: "Tvoje API klíče",
paragraphs: [ paragraphs: [
"Klíč je uložený jen v tomto prohlížeči. Posílá se tomuto serveru Freezmo pro jeden požadovaný modelový dotaz, server ho předá Ollamě a zapomene ho: nikdy se neukládá na disk, neloguje se a není součástí zálohy, synchronizace ani sdíleného odkazu.", "Každý klíč k modelu — ten ollamový i klíče cloudových poskytovatelů z Nastavení → Poskytovatelé — je uložený jen v tomto prohlížeči. Posílá se tomuto serveru Freezmo pro jeden požadovaný modelový dotaz, server ho předá danému poskytovateli a zapomene ho: nikdy se neukládá na disk, neloguje se a není součástí zálohy, synchronizace ani sdíleného odkazu.",
], ],
}, },
{ {
@ -110,7 +110,7 @@ const PRIVACY_CS: LegalDocument = {
{ {
heading: "Třetí strany", heading: "Třetí strany",
paragraphs: [ paragraphs: [
"Modelové dotazy jdou na Ollama endpoint, který je tato instance nastavená používat, a to s tvým klíčem — platí na ně vlastní podmínky Ollamy. Připomínky putují přes push službu tvého prohlížeče (Google, Mozilla nebo Apple). Nic jiného tu není: žádné reklamy, analytika ani vložený obsah.", "Modelové dotazy jdou na Ollama endpoint, který je tato instance nastavená používat, nebo — když zvolíš cloudový model — danému poskytovateli (OpenAI, Anthropic, DeepSeek, Groq, OpenRouter nebo vlastní endpoint) s klíčem z tvého prohlížeče; na tato volání platí podmínky daného poskytovatele. Připomínky putují přes push službu tvého prohlížeče (Google, Mozilla nebo Apple). Nic jiného tu není: žádné reklamy, analytika ani vložený obsah.",
], ],
}, },
{ {
@ -138,7 +138,7 @@ const TERMS_EN: LegalDocument = {
{ {
heading: "Your key, your usage", heading: "Your key, your usage",
paragraphs: [ paragraphs: [
"You bring your own Ollama API key. Limits, costs and account standing are between you and Ollama. Never paste a key you do not own, and keep your own key to yourself.", "You bring your own API keys. Limits, costs and account standing are between you and the provider (Ollama or a cloud one you added). Never paste a key you do not own, and keep your own key to yourself.",
], ],
}, },
{ {
@ -184,7 +184,7 @@ const TERMS_CS: LegalDocument = {
{ {
heading: "Tvůj klíč, tvoje spotřeba", heading: "Tvůj klíč, tvoje spotřeba",
paragraphs: [ paragraphs: [
"Používáš vlastní API klíč Ollama. Limity, náklady a stav účtu jsou mezi tebou a Ollamou. Nikdy nevkládej klíč, který ti nepatří, a ten svůj nikomu nedávej.", "Používáš vlastní API klíče. Limity, náklady a stav účtu jsou mezi tebou a daným poskytovatelem (Ollamou nebo cloudovým, kterého jsi přidal). Nikdy nevkládej klíč, který ti nepatří, a ten svůj nikomu nedávej.",
], ],
}, },
{ {

View file

@ -66,6 +66,8 @@ export function friendlyErrorMessage(
locale: Locale = DEFAULT_LOCALE, locale: Locale = DEFAULT_LOCALE,
): string { ): string {
if (error instanceof OllamaError) { if (error instanceof OllamaError) {
// Provider failures (lib/openai-compat) carry their own copy.
if ((error as { provider?: string }).provider) return error.message;
switch (error.kind) { switch (error.kind) {
case "unreachable": case "unreachable":
return translate(locale, "server.ollamaUnreachable", { return translate(locale, "server.ollamaUnreachable", {

259
lib/openai-compat.ts Normal file
View file

@ -0,0 +1,259 @@
/**
* One adapter for every OpenAI-compatible chat-completions provider
* (OpenAI, Anthropic's compatibility layer, DeepSeek, Groq, OpenRouter and
* any custom endpoint). The user's key travels with each request and is never
* stored server-side.
*/
import { OllamaError, type OllamaMessage } from "./ollama";
/** Where a provider call goes: an OpenAI-compatible base URL plus the key. */
export type ProviderTarget = {
baseUrl: string;
apiKey: string;
/** Shown in error copy ("OpenAI refused the key…"). */
label: string;
};
/**
* Extends OllamaError on purpose: every route and error card already reads
* `.kind` off that class, and friendlyErrorMessage() returns this message
* verbatim when `provider` is set (the copy already names the provider).
*/
export class ProviderError extends OllamaError {
provider: string;
constructor(kind: string, message: string, provider: string, options?: { status?: number }) {
super(kind as never, message, options);
this.name = "ProviderError";
this.provider = provider;
}
}
function trimBase(baseUrl: string): string {
return baseUrl.replace(/\/+$/, "");
}
function requestHeaders(target: ProviderTarget): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (target.apiKey) {
headers.Authorization = `Bearer ${target.apiKey}`;
// Anthropic's own endpoints also accept (and tooling often expects) the
// native header; harmless for the OpenAI-compatible layer.
if (target.baseUrl.includes("anthropic.com")) {
headers["x-api-key"] = target.apiKey;
headers["anthropic-version"] = "2023-06-01";
}
}
return headers;
}
function failure(status: number, body: string, model: string, target: ProviderTarget): ProviderError {
const detail = body.slice(0, 400);
const hint = detail && /error/i.test(detail) ? ` (${detail.replace(/\s+/g, " ").slice(0, 200)})` : "";
if (status === 401 || status === 403) {
return new ProviderError("auth", `${target.label} refused the API key. Check it in Settings → Providers.${hint}`, target.label, { status });
}
if (status === 402) {
return new ProviderError("usage_limit", `Your ${target.label} account has no credits left for "${model}".${hint}`, target.label, { status });
}
if (status === 404) {
return new ProviderError("model_missing", `${target.label} does not offer the model "${model}". Pick another one in the model list.${hint}`, target.label, { status });
}
if (status === 429) {
if (/quota|credit|insufficient|balance/i.test(detail)) {
return new ProviderError("usage_limit", `Your ${target.label} account reached its usage limit.${hint}`, target.label, { status });
}
return new ProviderError("throttled", `${target.label} is rate limiting requests right now. Try again in a moment.`, target.label, { status });
}
if (status === 400 && /model/i.test(detail) && /(not found|unknown|invalid|does not exist)/i.test(detail)) {
return new ProviderError("model_missing", `${target.label} does not recognise "${model}".${hint}`, target.label, { status });
}
return new ProviderError("http", `${target.label} responded with HTTP ${status}.${hint}`, target.label, { status });
}
async function runFetch(
url: string,
init: RequestInit,
model: string,
target: ProviderTarget,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const forwarded = init.signal;
const onAbort = () => controller.abort();
if (forwarded) {
if (forwarded.aborted) controller.abort();
else forwarded.addEventListener("abort", onAbort, { once: true });
}
try {
return await fetch(url, { ...init, signal: controller.signal });
} catch (error) {
if (forwarded?.aborted) throw error;
if (error instanceof Error && error.name === "AbortError") {
throw new ProviderError("timeout", `${target.label} took too long to answer.`, target.label);
}
throw new ProviderError("unreachable", `Could not reach ${target.label}. Check your connection.`, target.label);
} finally {
clearTimeout(timer);
forwarded?.removeEventListener("abort", onAbort);
}
}
/** Every model the provider offers to this key. */
export async function providerListModels(target: ProviderTarget, timeoutMs = 12_000): Promise<string[]> {
const response = await runFetch(`${trimBase(target.baseUrl)}/models`, { method: "GET", headers: requestHeaders(target), cache: "no-store" }, "", target, timeoutMs);
const text = await response.text().catch(() => "");
if (!response.ok) throw failure(response.status, text, "*", target);
try {
const payload = JSON.parse(text) as { data?: Array<{ id?: string }>; models?: Array<{ name?: string }> };
const ids = (payload.data ?? []).map((entry) => entry.id).filter((id): id is string => Boolean(id));
if (ids.length > 0) return ids.slice(0, 400);
const names = (payload.models ?? []).map((entry) => entry.name).filter((id): id is string => Boolean(id));
return names.slice(0, 400);
} catch {
throw new ProviderError("http", `${target.label} sent a model list Freezmo could not read.`, target.label);
}
}
type ChatPart = { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } };
function contentFor(user: string, images?: string[]): string | ChatPart[] {
if (!images || images.length === 0) return user;
const parts: ChatPart[] = [{ type: "text", text: user }];
for (const image of images.slice(0, 8)) {
parts.push({ type: "image_url", image_url: { url: image } });
}
return parts;
}
/** Non-streaming completion (generation, extraction, grading). */
export async function providerChatJson(input: {
target: ProviderTarget;
model: string;
messages: OllamaMessage[];
temperature?: number;
signal?: AbortSignal;
timeoutMs?: number;
}): Promise<{ content: string; model: string }> {
const body = {
model: input.model,
messages: input.messages.map((message) => ({
role: message.role,
content: message.images && message.images.length > 0 ? contentFor(message.content, message.images) : message.content,
})),
temperature: typeof input.temperature === "number" ? input.temperature : 0.3,
stream: false,
};
const response = await runFetch(
`${trimBase(input.target.baseUrl)}/chat/completions`,
{ method: "POST", headers: requestHeaders(input.target), body: JSON.stringify(body), signal: input.signal, cache: "no-store" },
input.model,
input.target,
input.timeoutMs ?? 300_000,
);
const text = await response.text().catch(() => "");
if (!response.ok) throw failure(response.status, text, input.model, input.target);
try {
const payload = JSON.parse(text) as {
model?: string;
choices?: Array<{ message?: { content?: unknown } }>;
};
const content = payload.choices?.[0]?.message?.content;
const flat = typeof content === "string" ? content : Array.isArray(content) ? content.map((part) => (typeof part === "object" && part && "text" in part ? String((part as { text: unknown }).text) : "")).join("") : "";
if (!flat) throw new Error("empty");
return { content: flat, model: payload.model ?? input.model };
} catch {
throw new ProviderError("http", `${input.target.label} sent an answer Freezmo could not read.`, input.target.label);
}
}
/** Streaming chat: parses the provider's SSE deltas. */
export async function providerChatStream(input: {
target: ProviderTarget;
model: string;
messages: OllamaMessage[];
signal?: AbortSignal;
onToken?: (token: string) => void;
onThinking?: (token: string) => void;
}): Promise<{ model: string }> {
const body = {
model: input.model,
messages: input.messages.map((message) => ({
role: message.role,
content: message.images && message.images.length > 0 ? contentFor(message.content, message.images) : message.content,
})),
stream: true,
};
const response = await runFetch(
`${trimBase(input.target.baseUrl)}/chat/completions`,
{ method: "POST", headers: requestHeaders(input.target), body: JSON.stringify(body), signal: input.signal, cache: "no-store" },
input.model,
input.target,
300_000,
);
if (!response.ok) {
const text = await response.text().catch(() => "");
throw failure(response.status, text, input.model, input.target);
}
if (!response.body) {
throw new ProviderError("http", `${input.target.label} sent an empty stream.`, input.target.label);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let model = input.model;
let sawDone = false;
const handleLine = (line: string) => {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return;
const data = trimmed.slice(5).trim();
if (!data || data === "[DONE]") {
if (data === "[DONE]") sawDone = true;
return;
}
let event: {
model?: string;
choices?: Array<{ delta?: { content?: unknown; reasoning?: unknown; reasoning_content?: unknown } }>;
error?: { message?: string };
};
try {
event = JSON.parse(data);
} catch {
return;
}
if (event.error?.message) throw new ProviderError("http", `${input.target.label}: ${event.error.message}`, input.target.label);
if (event.model) model = event.model;
const delta = event.choices?.[0]?.delta;
if (!delta) return;
const thinking = delta.reasoning_content ?? delta.reasoning;
if (typeof thinking === "string" && thinking) input.onThinking?.(thinking);
if (typeof delta.content === "string" && delta.content) input.onToken?.(delta.content);
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline = buffer.indexOf("\n");
while (newline !== -1) {
handleLine(buffer.slice(0, newline));
buffer = buffer.slice(newline + 1);
newline = buffer.indexOf("\n");
}
}
handleLine(buffer);
if (!sawDone) {
// Some gateways close the stream without [DONE]; tokens already arrived,
// so this is only worth a debug note.
console.warn(`[freezmo] ${input.target.label} stream ended without [DONE]`);
}
return { model };
}

163
lib/providers-client.ts Normal file
View file

@ -0,0 +1,163 @@
"use client";
import { useSyncExternalStore } from "react";
import { PROVIDER_BASE_HEADER, PROVIDER_HEADER, PROVIDER_KEY_HEADER, PROVIDERS } from "./providers";
/**
* Per-provider API keys, kept in this browser only — like the Ollama key,
* they are never stored on the server, never exported in a backup and never
* part of the sync payload. They travel with each model call so a self-hosted
* Freezmo can reach a cloud provider on the user's behalf.
*/
const STORAGE_KEY = "freezmo.providers.v1";
export type GenerationChoice = {
provider: string;
/** Model for decks, homework and grading. */
textModel: string;
/** Model for reading photos of notes (falls back to textModel). */
visionModel: string;
};
export type ProvidersState = {
/** provider id -> API key */
keys: Record<string, string>;
/** provider id -> last successfully listed models */
models: Record<string, string[]>;
/** Base URL for the "custom" OpenAI-compatible provider. */
customBaseUrl: string;
/** Provider used to generate decks and homework (null = server default). */
generation: GenerationChoice | null;
};
const EMPTY: ProvidersState = { keys: {}, models: {}, customBaseUrl: "", generation: null };
let cached: ProvidersState = EMPTY;
let hydrated = false;
const listeners = new Set<() => void>();
function notify() {
cached = { ...cached };
listeners.forEach((listener) => listener());
}
function read(): ProvidersState {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return EMPTY;
const parsed = JSON.parse(raw) as Partial<ProvidersState>;
const keys: Record<string, string> = {};
for (const [id, value] of Object.entries(parsed.keys ?? {})) {
if (typeof value === "string" && value) keys[id] = value.slice(0, 512);
}
const models: Record<string, string[]> = {};
for (const [id, list] of Object.entries(parsed.models ?? {})) {
if (Array.isArray(list)) models[id] = list.filter((entry) => typeof entry === "string").slice(0, 400);
}
return {
keys,
models,
customBaseUrl: typeof parsed.customBaseUrl === "string" ? parsed.customBaseUrl.slice(0, 300) : "",
generation: generationOrNull(parsed.generation),
};
} catch {
return EMPTY;
}
}
function generationOrNull(value: unknown): GenerationChoice | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (typeof record.provider !== "string" || typeof record.textModel !== "string") return null;
return {
provider: record.provider,
textModel: record.textModel,
visionModel: typeof record.visionModel === "string" && record.visionModel ? record.visionModel : record.textModel,
};
}
function persist(state: ProvidersState) {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
/* private mode */
}
}
export const providersStore = {
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
getSnapshot: () => {
if (!hydrated && typeof window !== "undefined") {
cached = read();
hydrated = true;
}
return cached;
},
getServerSnapshot: () => EMPTY,
};
function update(patch: Partial<ProvidersState>) {
cached = { ...providersStore.getSnapshot(), ...patch };
hydrated = true;
persist(cached);
notify();
}
export function useProviders(): ProvidersState {
return useSyncExternalStore(providersStore.subscribe, providersStore.getSnapshot, providersStore.getServerSnapshot);
}
export function saveProviderKey(id: string, key: string) {
update({ keys: { ...providersStore.getSnapshot().keys, [id]: key.trim() } });
}
export function clearProviderKey(id: string) {
const keys = { ...providersStore.getSnapshot().keys };
delete keys[id];
const models = { ...providersStore.getSnapshot().models };
delete models[id];
update({ keys, models });
}
export function setProviderModels(id: string, models: string[]) {
update({ models: { ...providersStore.getSnapshot().models, [id]: models } });
}
export function setCustomBaseUrl(url: string) {
update({ customBaseUrl: url.trim() });
}
export function setGeneration(choice: GenerationChoice | null) {
update({ generation: choice });
}
/** Providers with a saved key, in registry order. */
export function enabledProviders(state: ProvidersState): string[] {
return PROVIDERS.filter((provider) => Boolean(state.keys[provider.id])).map((provider) => provider.id);
}
/**
* The headers that route a model call to a provider instead of the server's
* Ollama. Empty for Ollama targets, so existing calls stay untouched.
*/
export function providerHeaders(provider: string | undefined): Record<string, string> {
if (!provider || provider === "ollama") return {};
const state = providersStore.getSnapshot();
const key = state.keys[provider];
if (!key) return {};
const headers: Record<string, string> = { [PROVIDER_HEADER]: provider, [PROVIDER_KEY_HEADER]: key };
if (provider === "custom" && state.customBaseUrl) headers[PROVIDER_BASE_HEADER] = state.customBaseUrl;
return headers;
}
/** The models a provider offers: its live list, or the built-in seeds. */
export function modelsFor(state: ProvidersState, provider: string): string[] {
const listed = state.models[provider];
if (listed && listed.length > 0) return listed;
return PROVIDERS.find((entry) => entry.id === provider)?.models ?? [];
}

88
lib/providers-server.ts Normal file
View file

@ -0,0 +1,88 @@
/**
* Resolves where a model call should go: the server's Ollama (as before) or a
* cloud provider the browser sent a key for. Shared by the model routes so
* every flow (chat, decks, homework, grading) can run on either backend.
*/
import { authErrorMessage, authErrorKind, resolveAuth, type OllamaAuth, type ServerConfig } from "./config";
import { sanitiseApiKey } from "./http-guard";
import { PROVIDER_BASE_HEADER, PROVIDER_HEADER, PROVIDER_KEY_HEADER, providerInfo } from "./providers";
import type { ProviderTarget } from "./openai-compat";
import type { Locale } from "./i18n-dict";
export type ModelTarget =
| { kind: "ollama"; auth: OllamaAuth }
| { kind: "provider"; provider: string; label: string; target: ProviderTarget };
export type TargetResolution =
| { target: ModelTarget }
| { error: { message: string; kind: string; status: number } };
/**
* Inert credentials for provider runs: the ai.ts helpers still take an
* `auth`, but it is never used once a provider target is set.
*/
export const INERT_OLLAMA_AUTH: OllamaAuth = { baseUrl: "", apiKey: "", directCloud: false, authMode: "local" };
function safeBase(raw: string): string | null {
const value = raw.trim().slice(0, 300);
if (!value) return null;
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (!url.hostname) return null;
return value.replace(/\/+$/, "");
} catch {
return null;
}
}
export function resolveModelTarget(config: ServerConfig, request: Request, locale: Locale): TargetResolution {
const requested = (request.headers.get(PROVIDER_HEADER) ?? "ollama").trim().toLowerCase();
if (!requested || requested === "ollama") {
const auth = resolveAuth(config, request);
if ("error" in auth) {
const kind = authErrorKind(auth.error);
return { error: { message: authErrorMessage(locale, auth.error), kind, status: kind === "needs_key" ? 401 : 500 } };
}
return { target: { kind: "ollama", auth: auth.auth } };
}
const info = providerInfo(requested);
if (!info) {
return { error: { message: `Unknown model provider "${requested}".`, kind: "bad_provider", status: 400 } };
}
// A browser-saved key is the point of providers: the server never holds it.
const apiKey = sanitiseApiKey(request.headers.get(PROVIDER_KEY_HEADER) ?? "");
if (!apiKey) {
return {
error: {
message: `Add your ${info.label} API key in Settings → Providers.`,
kind: "needs_key",
status: 401,
},
};
}
const baseUrl = requested === "custom" ? safeBase(request.headers.get(PROVIDER_BASE_HEADER) ?? "") : info.baseUrl;
if (!baseUrl) {
return {
error: {
message: `Add the base URL for your custom OpenAI-compatible endpoint in Settings → Providers.`,
kind: "bad_provider",
status: 400,
},
};
}
return {
target: {
kind: "provider",
provider: requested,
label: info.label,
target: { baseUrl, apiKey, label: info.label },
},
};
}

89
lib/providers.ts Normal file
View file

@ -0,0 +1,89 @@
/**
* Cloud model providers the user can bring their own key for. Every one of
* them speaks the OpenAI Chat Completions shape (Anthropic ships an official
* compatibility layer at the same path), so one adapter covers them all —
* plus a "custom" entry for any other OpenAI-compatible endpoint.
*
* Shared by the client (key inputs, pickers) and the server (routing): no
* secrets live here.
*/
export type ProviderInfo = {
id: string;
label: string;
/** OpenAI-compatible API base. Empty for the custom provider. */
baseUrl: string;
/** Placeholder shown in the key input. */
keyHint: string;
/** Where to create a key. */
docs: string;
/**
* Models offered before the live listing arrives (and on providers whose
* /models needs a paid tier). Free-tier ids first where they exist.
*/
models: string[];
};
export const PROVIDERS: ProviderInfo[] = [
{
id: "openai",
label: "OpenAI",
baseUrl: "https://api.openai.com/v1",
keyHint: "sk-…",
docs: "https://platform.openai.com/api-keys",
models: ["gpt-4o-mini", "gpt-4o", "o4-mini"],
},
{
id: "anthropic",
label: "Anthropic (Claude)",
baseUrl: "https://api.anthropic.com/v1",
keyHint: "sk-ant-…",
docs: "https://console.anthropic.com/settings/keys",
models: ["claude-sonnet-4-5", "claude-haiku-4-5", "claude-3-5-haiku-latest"],
},
{
id: "deepseek",
label: "DeepSeek",
baseUrl: "https://api.deepseek.com/v1",
keyHint: "sk-…",
docs: "https://platform.deepseek.com/api_keys",
models: ["deepseek-chat", "deepseek-reasoner"],
},
{
id: "groq",
label: "Groq",
baseUrl: "https://api.groq.com/openai/v1",
keyHint: "gsk_…",
docs: "https://console.groq.com/keys",
models: ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "openai/gpt-oss-120b"],
},
{
id: "openrouter",
label: "OpenRouter",
baseUrl: "https://openrouter.ai/api/v1",
keyHint: "sk-or-…",
docs: "https://openrouter.ai/keys",
models: [
"meta-llama/llama-3.3-70b-instruct:free",
"deepseek/deepseek-chat-v3-0324:free",
"qwen/qwen3-235b-a22b:free",
],
},
{
id: "custom",
label: "Custom (OpenAI-compatible)",
baseUrl: "",
keyHint: "key…",
docs: "",
models: [],
},
];
export function providerInfo(id: string): ProviderInfo | undefined {
return PROVIDERS.find((provider) => provider.id === id);
}
/** Headers the browser sends so the server can reach the picked provider. */
export const PROVIDER_HEADER = "x-freezmo-provider";
export const PROVIDER_KEY_HEADER = "x-freezmo-provider-key";
export const PROVIDER_BASE_HEADER = "x-freezmo-provider-base";

View file

@ -1,6 +1,6 @@
{ {
"name": "freezmo", "name": "freezmo",
"version": "0.4.6", "version": "0.4.7",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {

View file

@ -9,7 +9,7 @@
* *
* Bumping CACHE_VERSION is how a new deploy invalidates old assets. * Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/ */
const CACHE_VERSION = "freezmo-v71"; const CACHE_VERSION = "freezmo-v72";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -147,15 +147,15 @@ for (const locale of ["en", "cs"]) {
check(`${name} >= 44px`, bounds.width >= 43.5 && bounds.height >= 43.5, `${Math.round(bounds.width)}x${Math.round(bounds.height)}`); check(`${name} >= 44px`, bounds.width >= 43.5 && bounds.height >= 43.5, `${Math.round(bounds.width)}x${Math.round(bounds.height)}`);
} }
check("Save hidden until the key is edited", (await dialog.getByRole("button", { name: "Uložit" }).count()) === 0); check("Save hidden until the key is edited", (await dialog.getByRole("button", { name: "Uložit", exact: true }).count()) === 0);
const keyInput = dialog.locator("#ollama-api-key-phone"); const keyInput = dialog.locator("#ollama-api-key-phone");
await keyInput.fill("test-key2"); await keyInput.fill("test-key2");
await page.waitForTimeout(200); await page.waitForTimeout(200);
const save = dialog.getByRole("button", { name: "Uložit" }); const save = dialog.getByRole("button", { name: "Uložit", exact: true });
check("Save appears after editing, >= 44px", (await save.isVisible()) && (await box(save)).height >= 43.5); check("Save appears after editing, >= 44px", (await save.isVisible()) && (await box(save)).height >= 43.5);
await keyInput.fill("test-key"); await keyInput.fill("test-key");
await page.waitForTimeout(200); await page.waitForTimeout(200);
check("Save hides again when unchanged", (await dialog.getByRole("button", { name: "Uložit" }).count()) === 0); check("Save hides again when unchanged", (await dialog.getByRole("button", { name: "Uložit", exact: true }).count()) === 0);
const tip = dialog.locator("details").first(); const tip = dialog.locator("details").first();
check("privacy note starts collapsed", !(await tip.evaluate((el) => el.open))); check("privacy note starts collapsed", !(await tip.evaluate((el) => el.open)));

234
tests/providers-test.mjs Normal file
View file

@ -0,0 +1,234 @@
/**
* Cloud providers: bring-your-own keys (OpenAI-compatible endpoints).
*
* One stub server speaks both protocols: the Ollama endpoints the app's shell
* needs (/api/tags) and the OpenAI-compatible endpoints a custom provider uses
* (/v1/models, /v1/chat/completions, streaming and not). The app runs with
* OLLAMA_AUTH=local against the stub, so the server side needs no key while
* the provider side exercises the browser-held key end to end.
*/
import http from "node:http";
import { DECK, launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
const STUB_PORT = 11998;
const APP_PORT = 3213;
const APP = `http://127.0.0.1:${APP_PORT}`;
const PROVIDER_BASE = `http://127.0.0.1:${STUB_PORT}/v1`;
const PROVIDER_KEY = "test-provider-key";
const DECK_REPLY = {
title: "Provider deck",
subject: "Physics",
summary: "Generated on a provider model.",
language: "English",
activities: [
{ type: "multiple_choice", topic: "Sky", explanation: "Rayleigh scattering.", question: "Why is the sky blue?", options: ["Scattering", "Paint", "Wind", "Salt"], correctIndex: 0 },
{ type: "true_false", topic: "Sky", explanation: "It scatters.", statement: "Short wavelengths scatter more.", answer: true },
{ type: "flashcard", topic: "Sky", explanation: "Basics.", front: "Rayleigh", back: "Scattering by small molecules" },
{ type: "fill_blank", topic: "Sky", explanation: "The blank is 'blue'.", sentence: "The clear sky looks ____.", answer: "blue" },
{ type: "matching", topic: "Sky", explanation: "Terms and meanings.", instruction: "Match the terms.", pairs: [{ term: "Rayleigh", definition: "Short wavelengths" }, { term: "Molecule", definition: "Small particle" }, { term: "Sky", definition: "Above us" }] },
],
};
let lastProviderRequest = null;
const seen = { models: 0, completions: 0 };
const stub = http.createServer((request, response) => {
const url = request.url ?? "";
// ---- Ollama side (the app's shell needs a healthy daemon) ----
if (url.startsWith("/api/tags")) {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ models: [{ name: "stub-text" }, { name: "stub-vision" }] }));
return;
}
if (url.startsWith("/api/chat")) {
response.writeHead(200, { "Content-Type": "application/x-ndjson" });
response.end(JSON.stringify({ message: { content: "ollama" }, model: "stub-text", done: true }) + "\n");
return;
}
// ---- OpenAI-compatible side (the provider under test) ----
let raw = "";
request.on("data", (chunk) => (raw += chunk));
request.on("end", () => {
lastProviderRequest = { url, headers: request.headers, body: raw ? JSON.parse(raw) : {} };
if (!request.headers.authorization?.includes(PROVIDER_KEY)) {
response.writeHead(401, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: { message: "missing key" } }));
return;
}
if (url.startsWith("/v1/models")) {
seen.models += 1;
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ data: [{ id: "free-model-a" }, { id: "free-model-b" }] }));
return;
}
if (url.startsWith("/v1/chat/completions")) {
seen.completions += 1;
const stream = Boolean(lastProviderRequest.body?.stream);
if (stream) {
response.writeHead(200, { "Content-Type": "text/event-stream" });
response.write(`data: ${JSON.stringify({ model: "free-model-a", choices: [{ delta: { content: "PROVIDER-" } }] })}\n\n`);
response.write(`data: ${JSON.stringify({ choices: [{ delta: { content: "REPLY-MARKER" } }] })}\n\n`);
response.end("data: [DONE]\n\n");
return;
}
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
JSON.stringify({
model: "free-model-a",
choices: [{ message: { content: JSON.stringify(DECK_REPLY) } }],
}),
);
return;
}
response.writeHead(404, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: { message: "not found" } }));
});
});
await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve));
check("stub app port is free", !(await portInUse(APP_PORT)));
const app = spawnApp(APP_PORT, {
OLLAMA_BASE_URL: `http://127.0.0.1:${STUB_PORT}`,
OLLAMA_AUTH: "local",
OLLAMA_VERIFY_KEY: "false",
CHAT_MODELS: "stub-text,stub-vision",
});
await waitForHttp(`${APP}/api/healthz`);
const providerSeed = {
keys: { custom: PROVIDER_KEY },
models: { custom: ["free-model-a"] },
customBaseUrl: PROVIDER_BASE,
generation: { provider: "custom", textModel: "free-model-a", visionModel: "free-model-a" },
};
try {
/* 1. API: the listing route forwards the key and returns the models ---- */
{
const missing = await fetch(`${APP}/api/providers`, { headers: { "x-freezmo-provider": "custom", "x-freezmo-provider-base": PROVIDER_BASE } });
const missingBody = await missing.json();
check(
"api: listing without a key explains where to add it",
missing.status === 401 && /Settings/.test(String(missingBody.error)),
`${missing.status} ${missingBody.error}`,
);
const listed = await fetch(`${APP}/api/providers`, {
headers: {
"x-freezmo-provider": "custom",
"x-freezmo-provider-key": PROVIDER_KEY,
"x-freezmo-provider-base": PROVIDER_BASE,
},
});
const body = await listed.json();
check(
"api: listing returns the provider's models",
listed.status === 200 && body.models?.includes("free-model-a") && body.models?.includes("free-model-b"),
JSON.stringify(body).slice(0, 160),
);
}
/* 2. Chat on a provider model ----------------------------------------- */
{
const { context, page } = await makePage(browser, { width: 1280, height: 800 });
await seed(page, { decks: [DECK], extra: { "freezmo.providers.v1": JSON.stringify(providerSeed) } });
await page.goto(`${APP}/chat`, { waitUntil: "networkidle" });
await page.waitForTimeout(1400);
await page.locator("[data-picker-trigger]").nth(1).click();
await page.waitForTimeout(500);
check("chat: the picker lists the provider model", (await page.getByText("free-model-a").count()) >= 1);
await page.getByRole("button", { name: /free-model-a/ }).first().click();
await page.waitForTimeout(300);
check(
"chat: the chip labels the provider",
/Custom/.test(await page.locator("[data-picker-trigger]").nth(1).innerText()),
await page.locator("[data-picker-trigger]").nth(1).innerText(),
);
await page.getByRole("textbox").fill("Why is the sky blue?");
await page.getByRole("button", { name: "Send", exact: true }).click();
await page.waitForTimeout(2000);
check("chat: the provider reply streams into the bubble", await page.getByText("PROVIDER-REPLY-MARKER").isVisible());
// The provider only ever sees the bearer key (the app keeps the routing
// header to itself), so this asserts the key, the model and the endpoint.
check(
"chat: the provider got the browser's key and the picked model",
String(lastProviderRequest?.headers.authorization).includes(PROVIDER_KEY) &&
lastProviderRequest?.body?.model === "free-model-a" &&
lastProviderRequest?.url === "/v1/chat/completions",
JSON.stringify({ auth: String(lastProviderRequest?.headers.authorization).slice(0, 24), model: lastProviderRequest?.body?.model }),
);
await context.close();
}
/* 3. Study generation on the provider ---------------------------------- */
{
const { context, page } = await makePage(browser, { width: 390, height: 844 });
await seed(page, { decks: [], extra: { "freezmo.providers.v1": JSON.stringify(providerSeed) } });
await page.goto(`${APP}/create`, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
await page.getByRole("button", { name: "Paste text instead" }).click();
await page.locator("textarea").first().fill("Rayleigh scattering makes the sky blue.");
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Freeze my deck" }).click();
await page.waitForTimeout(2500);
if (!page.url().includes("/deck/")) {
const text = await page.evaluate(() => document.body.innerText.slice(0, 600).replace(/\n+/g, " | "));
console.log("DEBUG generation page:", text);
console.log("DEBUG last provider request:", JSON.stringify(lastProviderRequest).slice(0, 300));
}
check("generation: the deck lands on the study page", page.url().includes("/deck/"), page.url());
check("generation: the deck came from the provider", await page.getByText("Provider deck").first().isVisible());
check(
"generation: the request carried the provider model",
lastProviderRequest?.body?.model === "free-model-a" && lastProviderRequest?.body?.stream === false,
JSON.stringify({ model: lastProviderRequest?.body?.model, stream: lastProviderRequest?.body?.stream }),
);
await context.close();
}
/* 4. Settings: saving a key lists the models --------------------------- */
{
const { context, page } = await makePage(browser, { width: 390, height: 844 });
await seed(page, { decks: [], extra: { "freezmo.providers.v1": JSON.stringify({ ...providerSeed, keys: {}, models: {} }) } });
await page.goto(`${APP}/`, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
const row = page.locator('input[aria-label="Custom (OpenAI-compatible) API key"]');
await row.scrollIntoViewIfNeeded();
await row.fill(PROVIDER_KEY);
await row.locator("xpath=..").getByRole("button", { name: "Save key", exact: true }).click();
await page.waitForTimeout(1600);
check("settings: saving the key lists the models", (await page.getByText("2 models").count()) >= 1);
check(
"settings: the key is stored in this browser only",
(await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.providers.v1") ?? "{}").keys?.custom)) === PROVIDER_KEY,
);
await context.close();
}
check("stub saw both endpoints", seen.models >= 1 && seen.completions >= 1, JSON.stringify(seen));
} finally {
app.stop();
stub.close();
await browser.close();
}
finish();

View file

@ -24,6 +24,7 @@ const SUITES = [
"phase3-test", "phase3-test",
"security-test", "security-test",
"chat-test", "chat-test",
"providers-test",
"review-test", "review-test",
]; ];