First-run gate offers free Ollama vs paid providers; add Google Gemini

The gate (user-key installs) now headlines Ollama with a "Free" badge and
adds an "or - Use a paid provider instead" option that opens the same
provider rows as Settings, framed as billed by the provider directly.
Saving a provider key there lists its models and switches study
generation to it automatically, so the gate steps aside with no Ollama
key at all.

Gemini joins the registry via its OpenAI-compatible endpoint
(generativelanguage.googleapis.com/v1beta/openai); the privacy policy
names it (en+cs).

Providers suite grows to 20 checks: the gate scenario runs against a
second user-key-mode app instance (free badge, paid option, provider
rows, pass-through, generation switch). All other gates green:
contrast 0 findings, mobile 126/126, chat 102/102, wizard 95/95,
bubbles 34/34, regression 44/44, i18n 13/13, native 33/33.
Version 0.4.9, SW -> freezmo-v73.
This commit is contained in:
Martin Dvořák 2026-10-14 07:09:05 +00:00
commit 8651674cf5
8 changed files with 132 additions and 8 deletions

View file

@ -1411,9 +1411,18 @@ Things I chose while building, with the reasoning:
pre-blurred wallpaper — the scene is clearly visible through the glass, and the
pixel-contrast scan still passes.
82. **First run offers the choice: free Ollama or a paid provider.** The gate (user-key installs)
now shows Ollama with a "Free" badge as the headline option, and below it an "or · Use a paid
provider instead" button that opens the same provider rows as Settings — with the framing that
those are billed by the provider directly (Freezmo never sees a card) and that keys stay in the
browser. Saving a provider key on that screen lists its models and switches study generation to
it automatically, so the gate steps aside without an Ollama key at all — pinned end to end in
the providers suite with a second, user-key-mode app instance.
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 —
keys for OpenAI, Anthropic (their official OpenAI-compatibility layer), Google Gemini (its
OpenAI-compatible endpoint), 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

View file

@ -17,6 +17,9 @@ import {
import { Spinner } from "@/components/ui";
import { LOCALES, useI18n } from "@/lib/i18n";
import { verifyOllamaKey } from "@/lib/client-api";
import { ProvidersSection } from "@/components/ProvidersSection";
import { PROVIDERS } from "@/lib/providers";
import { modelsFor, setGeneration, useProviders } from "@/lib/providers-client";
import { saveSettings, settingsStore } from "@/lib/settings";
import { useHealth } from "@/lib/use-health";
import { cn } from "@/lib/utils";
@ -40,13 +43,26 @@ export function ApiKeyGate({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { health } = useHealth();
const providers = useProviders();
// Server-key and local-daemon installs never need a browser key.
const needsKeyMode = health?.authMode !== "local" && health?.authMode !== "server";
// A paid provider is a valid first-run choice too. As soon as a key is saved
// the study generation switches to that provider — Ollama stays untouched —
// and the gate steps aside (the provider's models were listed on save).
const firstEnabled = PROVIDERS.find((provider) => providers.keys[provider.id]);
useEffect(() => {
if (!firstEnabled || providers.generation) return;
const models = modelsFor(providers, firstEnabled.id);
if (models.length === 0) return;
setGeneration({ provider: firstEnabled.id, textModel: models[0], visionModel: models[0] });
}, [firstEnabled, providers]);
const providerReady = Boolean(providers.generation && providers.keys[providers.generation.provider]);
// Until hydration finishes, render the page as usual so a key holder never
// sees the gate flash. After that, a missing key replaces the app.
if (!mounted || storedKey || !needsKeyMode) return <>{children}</>;
if (!mounted || storedKey || providerReady || !needsKeyMode) return <>{children}</>;
return <GateScreen />;
}
@ -85,6 +101,7 @@ function GateScreen() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<GateError>(null);
const [helpOpen, setHelpOpen] = useState(false);
const [providersOpen, setProvidersOpen] = useState(false);
const [keyboardOpen, setKeyboardOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
@ -262,7 +279,14 @@ function GateScreen() {
{t("gate.subtitle")}
</p>
<form className="mt-6" onSubmit={submit} noValidate>
<div className="mt-6 flex items-center justify-center gap-2">
<span className="text-[13px] font-semibold text-[var(--text)]">{t("gate.ollamaLabel")}</span>
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-700 dark:text-emerald-300">
{t("gate.free")}
</span>
</div>
<form className="mt-2.5" onSubmit={submit} noValidate>
<div className="relative">
<label className="sr-only" htmlFor="gate-api-key">
{t("gate.apiKeyLabel")}
@ -341,6 +365,28 @@ function GateScreen() {
</button>
</div>
<div className="mt-5 flex items-center gap-3" aria-hidden>
<span className="h-px flex-1 bg-[var(--hairline-soft)]" />
<span className="text-[11px] font-medium uppercase tracking-wide text-[var(--text-subtle)]">{t("gate.or")}</span>
<span className="h-px flex-1 bg-[var(--hairline-soft)]" />
</div>
<button
type="button"
onClick={() => setProvidersOpen((open) => !open)}
aria-expanded={providersOpen}
className="btn-secondary mt-3 h-11 w-full rounded-[13px] text-[13.5px]"
>
{t("gate.paidOption")}
</button>
{providersOpen ? (
<div className="mt-3 space-y-2 text-left">
<p className="text-[11.5px] leading-snug text-[var(--text-subtle)]">{t("gate.paidNote")}</p>
<ProvidersSection />
</div>
) : null}
<p className="mx-auto mt-4 max-w-[320px] text-balance text-[12px] leading-relaxed text-[var(--text-subtle)]">
{t("gate.stays")}
</p>

View file

@ -561,6 +561,11 @@ const en: Record<string, string> = {
"results.false": "False",
/* gate ------------------------------------------------------------ */
"gate.free": "Free",
"gate.ollamaLabel": "Ollama",
"gate.or": "or",
"gate.paidOption": "Use a paid provider instead",
"gate.paidNote": "OpenAI, Anthropic, Gemini, DeepSeek, Groq, OpenRouter… Billed by the provider directly — Freezmo never sees a card. Your keys stay in this browser, and picking one switches study generation to it.",
"gate.title": "Connect your Ollama key",
"gate.subtitle": "One step to freeze your first deck.",
"gate.apiKeyLabel": "Ollama API key",
@ -1542,6 +1547,11 @@ const cs: Record<string, string> = {
"results.false": "Nepravda",
/* gate ------------------------------------------------------------ */
"gate.free": "Zdarma",
"gate.ollamaLabel": "Ollama",
"gate.or": "nebo",
"gate.paidOption": "Použít placeného poskytovatele",
"gate.paidNote": "OpenAI, Anthropic, Gemini, DeepSeek, Groq, OpenRouter… Účtuje je přímo poskytovatel — Freezmo nikdy nevidí kartu. Klíče zůstávají v tomto prohlížeči a výběrem se na něj přesune generování studia.",
"gate.title": "Připoj svůj klíč Ollama",
"gate.subtitle": "Jeden krok k tvé první zmrazené sadě.",
"gate.apiKeyLabel": "API klíč Ollama",

View file

@ -60,7 +60,7 @@ const PRIVACY_EN: LegalDocument = {
{
heading: "Third parties",
paragraphs: [
"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.",
"Model calls go to the Ollama endpoint this instance is configured for, or — when you pick a cloud model — to that provider (OpenAI, Anthropic, Google Gemini, 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.",
],
},
{
@ -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, 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.",
"Modelové dotazy jdou na Ollama endpoint, který je tato instance nastavená používat, nebo — když zvolíš cloudový model — danému poskytovateli (OpenAI, Anthropic, Google Gemini, 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.",
],
},
{

View file

@ -69,6 +69,14 @@ export const PROVIDERS: ProviderInfo[] = [
"qwen/qwen3-235b-a22b:free",
],
},
{
id: "gemini",
label: "Google Gemini",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
keyHint: "AIza…",
docs: "https://aistudio.google.com/app/apikey",
models: ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"],
},
{
id: "custom",
label: "Custom (OpenAI-compatible)",

View file

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

View file

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

View file

@ -113,6 +113,7 @@ const providerSeed = {
generation: { provider: "custom", textModel: "free-model-a", visionModel: "free-model-a" },
};
let gateApp = null;
try {
/* 1. API: the listing route forwards the key and returns the models ---- */
{
@ -211,6 +212,7 @@ try {
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
check("settings: Google Gemini is offered", (await page.locator('input[aria-label="Google Gemini API key"]').count()) === 1);
const row = page.locator('input[aria-label="Custom (OpenAI-compatible) API key"]');
await row.scrollIntoViewIfNeeded();
await row.fill(PROVIDER_KEY);
@ -224,8 +226,57 @@ try {
await context.close();
}
/* 5. First run: the gate offers the free Ollama path and paid providers - */
{
const GATE_PORT = 3214;
check("gate app port is free", !(await portInUse(GATE_PORT)));
// No OLLAMA_AUTH: the default user-key mode, so the first-run gate shows.
gateApp = spawnApp(GATE_PORT, {
OLLAMA_BASE_URL: `http://127.0.0.1:${STUB_PORT}`,
OLLAMA_VERIFY_KEY: "false",
});
await waitForHttp(`http://127.0.0.1:${GATE_PORT}/api/healthz`);
const { context, page } = await makePage(browser, { width: 390, height: 844 });
await seed(page, {
decks: [],
key: "",
extra: {
"freezmo.providers.v1": JSON.stringify({ keys: {}, models: {}, customBaseUrl: PROVIDER_BASE, generation: null }),
},
});
await page.goto(`http://127.0.0.1:${GATE_PORT}/`, { waitUntil: "networkidle" });
await page.waitForTimeout(1400);
check("gate: the free Ollama path is the headline", (await page.getByText("Free").count()) >= 1);
const paid = page.getByRole("button", { name: "Use a paid provider instead" });
check("gate: the paid option is visible on first run", await paid.isVisible());
await paid.click();
await page.waitForTimeout(500);
check("gate: the provider rows open", (await page.locator('input[aria-label="OpenAI API key"]').count()) === 1);
const gateKey = page.locator('input[aria-label="Custom (OpenAI-compatible) API key"]');
await gateKey.scrollIntoViewIfNeeded();
await gateKey.fill(PROVIDER_KEY);
await gateKey.locator("xpath=..").getByRole("button", { name: "Save key", exact: true }).click();
await page.waitForTimeout(2200);
check(
"gate: a saved provider key lets the app through",
await page.getByRole("heading", { name: "Your icebox" }).isVisible(),
);
check(
"gate: the provider became the study-generation model",
(await page.evaluate(() => {
const state = JSON.parse(localStorage.getItem("freezmo.providers.v1") ?? "{}");
return state.generation?.provider === "custom" && state.generation?.textModel === "free-model-a";
})),
);
await context.close();
}
check("stub saw both endpoints", seen.models >= 1 && seen.completions >= 1, JSON.stringify(seen));
} finally {
gateApp?.stop();
app.stop();
stub.close();
await browser.close();