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

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

View file

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

View file

@ -16,10 +16,13 @@ export type ChatModelChoice = {
name: string;
badge: "text" | "vision" | "both";
available: boolean | null;
/** Cloud provider the model belongs to (Ollama when omitted). */
provider?: string;
};
/** Last model choice, remembered across visits. */
const MODEL_KEY = "freezmo.chat.model.v1";
const MODEL_PROVIDER_KEY = "freezmo.chat.provider.v1";
export function readStoredChatModel(): string | null {
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 {
const rand =
typeof crypto !== "undefined" && "randomUUID" in crypto

View file

@ -3,6 +3,7 @@
import { localeHeader, t } from "./i18n";
import type { Activity, Difficulty, HomeworkMode, HomeworkStep } from "./schemas";
import { API_KEY_HEADER, authHeaders } from "./settings";
import { providerHeaders, providersStore } from "./providers-client";
export type GenerateInput = {
images?: string[];
@ -119,6 +120,9 @@ export async function generateDeck(
input: GenerateInput,
options: { onProgress?: (progress: GenerateProgress) => void; signal?: AbortSignal } = {},
): 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
// 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
@ -138,8 +142,10 @@ export async function generateDeck(
try {
response = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
body: JSON.stringify(input),
headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
body: JSON.stringify(
generation ? { ...input, textModel: generation.textModel, visionModel: generation.visionModel } : input,
),
signal: controller.signal,
});
} catch {
@ -242,6 +248,7 @@ export async function generateHomework(
input: HomeworkInput,
options: { onProgress?: (progress: HomeworkProgress) => void; signal?: AbortSignal } = {},
): Promise<{ homework: StreamedHomework; meta: Record<string, unknown> }> {
const generation = providersStore.getSnapshot().generation;
// Same guards as generateDeck: no infinite loading screen on a hung model.
const controller = new AbortController();
const forwarded = options.signal;
@ -258,8 +265,10 @@ export async function generateHomework(
try {
response = await fetch("/api/homework", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
body: JSON.stringify(input),
headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
body: JSON.stringify(
generation ? { ...input, textModel: generation.textModel, visionModel: generation.visionModel } : input,
),
signal: controller.signal,
});
} catch {
@ -329,12 +338,17 @@ export async function refineHomework(input: {
context?: string;
signal?: AbortSignal;
}): Promise<string> {
const generation = providersStore.getSnapshot().generation;
let response: Response;
try {
response = await fetch("/api/homework/refine", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
body: JSON.stringify({ instruction: input.instruction, text: input.text, context: input.context }),
headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
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,
});
} catch (error) {
@ -359,12 +373,13 @@ export async function gradeAnswer(input: {
language?: string;
signal?: AbortSignal;
}): Promise<{ correct: boolean; score: number; feedback: string; missing: string[] }> {
const generation = providersStore.getSnapshot().generation;
let response: Response;
try {
response = await fetch("/api/grade", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
body: JSON.stringify(input),
headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
body: JSON.stringify(generation ? { ...input, model: generation.textModel } : input),
signal: input.signal,
});
} catch (error) {

View file

@ -739,6 +739,7 @@ const en: Record<string, string> = {
/* server-side messages (API routes) -------------------------------- */
"server.bodyNotJson": "Request body was not valid JSON.",
"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.needAssignment":
"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",
"settings.version": "Version",
"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.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.",
@ -1702,6 +1720,7 @@ const cs: Record<string, string> = {
/* server-side messages (API routes) -------------------------------- */
"server.bodyNotJson": "Tělo požadavku nebylo platné JSON.",
"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.needAssignment":
"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",
"settings.version": "Verze",
"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.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.",

View file

@ -37,9 +37,9 @@ const PRIVACY_EN: LegalDocument = {
],
},
{
heading: "Your Ollama API key",
heading: "Your API keys",
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",
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: [
"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",
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",
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",
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,
): string {
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) {
case "unreachable":
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";