From bac8580f6b346a8b1ec725554d5a6ff891fa3022 Mon Sep 17 00:00:00 2001 From: daTechGuy Date: Sat, 3 Oct 2026 16:52:35 -0500 Subject: [PATCH 1/2] Add Google Gemini as a course-content provider; gate on the active provider's key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini joins Claude and Ollama Cloud as a course-content provider. Backend (src/services/llm/gemini.ts) - streamGenerateContent over SSE, called straight from the browser: Google answers CORS preflights for the x-goog-api-key header from any origin (verified with a keyless preflight), so no proxy is needed. The key is sent in a header, never the URL. - Maps Anthropic-style messages to Gemini contents/systemInstruction; requests thought summaries only with a thinking budget and retries once without them on a 400 that complains about thinking; leaves output-token headroom for thinking (capped at 65,536). - Streams text and thought summaries; reassembles SSE events split across network chunks, CRLF delimiters and a final event with no blank line; explains blocked prompts/responses; returns partial text on MAX_TOKENS like the Anthropic path; errors keep the HTTP code (so 429 backoff works) and carry .status. - Router: resolveProvider swaps in the Gemini key/model and ignores the claude-* model ids every call site passes (same rule now shared with Ollama). Store / UI / CLI - apiStore: geminiApiKey, geminiModel (default gemini-3.8-flash, free-text editable), validation state; new selectActiveLlm() = "the key that matters right now". - Setup keys dialog: third provider pill, Gemini key + model fields; Verify GETs the chosen model (proves key AND model id; 404 = unknown model). Free-tier privacy note. - canvas-course CLI: --provider gemini, --gemini-model, GEMINI_API_KEY. Fixes a latent bug that also affected Ollama - Syllabus auto-start, Export learning outcomes, outline upload and the Research gate required a *Claude* key regardless of provider, silently blocking anyone using Ollama or Gemini without one. They now use selectActiveLlm(). - friendlyError understands Google's wording: "API key not valid" (a 400), unknown model (404 → "check the model name"), RECITATION/BLOCKLIST blocks. Tests (+75): backend contract/streaming/errors, router key+model resolution, store and persistence upgrade, Setup dialog flows, per-provider gates (verified to fail against the old gating), error wording. tools/smoke-gemini.ts (npm run smoke:gemini, key from GEMINI_API_KEY) exercises the live API; its bad-key check ran against Google for real. Docs: README, CLAUDE.md. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 9 +- README.md | 19 +- package.json | 3 +- scripts/canvas-course.ts | 49 ++- src/components/setup/CourseOutlineUpload.tsx | 12 +- src/pages/ExportPage.tsx | 11 +- src/pages/ResearchPage.tsx | 7 +- src/pages/SetupPage.tsx | 108 +++++- src/pages/SyllabusPage.tsx | 8 +- src/services/claude/client.ts | 9 + src/services/llm/gemini.ts | 218 ++++++++++++ src/services/llm/index.ts | 40 ++- src/services/llm/types.ts | 8 +- src/services/template/generateChapter.ts | 13 +- src/store/apiStore.ts | 71 +++- src/utils/errors.ts | 12 +- tests/api-store.test.ts | 99 ++++++ tests/components/SetupPage.gemini.test.tsx | 203 +++++++++++ tests/components/provider-gates.test.tsx | 184 ++++++++++ tests/llm-gemini.test.ts | 338 +++++++++++++++++++ tests/llm-router.test.ts | 111 ++++++ tests/utils-errors.test.ts | 45 +++ tools/smoke-gemini.ts | 128 +++++++ 23 files changed, 1629 insertions(+), 76 deletions(-) create mode 100644 src/services/llm/gemini.ts create mode 100644 tests/api-store.test.ts create mode 100644 tests/components/SetupPage.gemini.test.tsx create mode 100644 tests/components/provider-gates.test.tsx create mode 100644 tests/llm-gemini.test.ts create mode 100644 tests/llm-router.test.ts create mode 100644 tools/smoke-gemini.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3355689..0698936 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ npm run build # tsc -b + vite build (tsc only type-checks src/, NOT te npm run lint # ESLint over everything (7 known react-hooks/exhaustive-deps warnings, all pre-existing upstream) npm test # vitest (happy-dom). CI runs `test:coverage` npm run validate:imscc -- file.imscc # offline structural check of a Canvas cartridge +GEMINI_API_KEY=... npm run smoke:gemini # live check of the Gemini backend (key never printed) # Canvas-importable course from a topic + a Canvas template, headless npx tsx scripts/canvas-course.ts --topic "..." --template ./template.imscc --chapters 8 --output ./out @@ -56,7 +57,7 @@ src/ ├── types/ # course.ts (all data interfaces), template.ts, outline.ts, generation.ts ├── store/ # Zustand │ ├── courseStore.ts # course state (persisted to IndexedDB via idbStorage) -│ ├── apiStore.ts # API keys, provider, research backend, advancedMode (persisted) +│ ├── apiStore.ts # API keys, provider, research backend, advancedMode (persisted); selectActiveLlm() │ ├── templateStore.ts # uploaded Canvas templates (parsed + raw .imscc blob) │ └── uiStore.ts # transient UI/generation state, in-flight map ├── pages/ # Landing, Setup, Syllabus, Research, Build, Export, TemplatePreview @@ -66,7 +67,7 @@ src/ │ ├── setup/, syllabus/, export/, layout/, shared/ ├── prompts/ # prompt builders (syllabus, chapter, quizzes, slides, templateChapter…) ├── services/ -│ ├── llm/ # provider router: anthropic.ts, ollama.ts, index.ts (the real streaming layer) +│ ├── llm/ # provider router: anthropic.ts, gemini.ts, ollama.ts, index.ts (the real streaming layer) │ ├── claude/ # SDK client, model ids, thinking budgets; streaming.ts is a thin facade over llm/ │ ├── research/ # backends: anthropic (web search), tavily, wikipedia; runResearch() dispatch │ ├── academic/ # Crossref / Semantic Scholar / Unpaywall source enrichment @@ -85,7 +86,9 @@ tests/ # vitest; fixtures/ holds real Canvas-made .imsc - `tsconfig.app.json` has `erasableSyntaxOnly: true` — no parameter properties; use class field declarations. `noUnusedLocals` / `noUnusedParameters` are on — always run `npm run build`. - **`tsc -b` only checks `src/`.** Test files are transpiled by vitest but not type-checked; keep them type-correct anyway. -- **Providers (Claude / Ollama Cloud) go through `src/services/llm/`.** `apiStore.provider` picks the backend. Call sites pass Anthropic model ids (`MODELS.opus` …); `resolveProvider` **ignores any `claude-*` model id on the Ollama path** and uses `apiStore.ollamaModel`. Never forward a Claude model id to Ollama. +- **Providers (Claude / Gemini / Ollama Cloud) go through `src/services/llm/`.** `apiStore.provider` picks the backend. Call sites pass Anthropic model ids (`MODELS.opus` …) and `claudeApiKey`; `resolveProvider` **ignores any `claude-*` model id on the Gemini/Ollama paths** and swaps in that provider's own key and model. Never forward a Claude model id to another provider. +- **Never gate on `claudeApiKey`.** "Can we generate?" is `selectActiveLlm(useApiStore()).hasKey` (also `.label` / `.keyValid`). Gating on the Claude key silently blocked Ollama/Gemini users (the syllabus never auto-started). Adding a provider = a backend in `services/llm/`, fields in `apiStore`, a pill + key in the Setup keys dialog, a case in `selectActiveLlm` and `resolveProvider`, CLI flags, and tests. +- **Gemini** (`services/llm/gemini.ts`) is called directly from the browser — Google answers CORS preflights for the `x-goog-api-key` header, so no proxy (unlike Ollama). Key goes in a header, never the URL. It ignores Anthropic server tools, so "Claude web search" research still needs a Claude key; Tavily/Wikipedia work. Thought summaries are requested only with a thinking budget and retried once without on a 400 that mentions thinking. Model ids change often — the model is a free-text setting. Unit tests mock `fetch`; `npm run smoke:gemini` (key from `GEMINI_API_KEY`) checks the live API. - **Opus 4.8** uses adaptive thinking (`thinking: {type:'adaptive'}` + `output_config.effort`); manual `budget_tokens` is rejected on Opus ≥4.7. Sonnet/Haiku still use manual budgets. Pass `signal` for cancellation and don't retry an aborted call. - **`advancedMode`** (apiStore) is "Canvas-focused" (Reading / Practice / Quizzes / Discussion) vs "Everything" (+ Challenge / Activities / Audio / Slides). It gates the visible tabs **and** batch / "Generate all" generation **and** the sidebar's N-of-M count — keep them in step. - **Don't put an inline `onClose` in an effect dependency list.** `CodexModal` once re-focused the dialog on every keystroke because its effect depended on the caller's inline arrow; it now holds `onClose` in a ref. Same trap anywhere an effect side-effect (focus, scroll) follows a changing callback. diff --git a/README.md b/README.md index aadad46..1643862 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ A fork of [ClassBuild](https://github.com/jtangen/classbuild) by Jason Tangen, r | Batch generation: "Generate All Canvas Modules" for the whole course at once | Build header | | Export an `.imscc` that mirrors the template's structure: verbatim modules carry through untouched, pattern modules replaced by generated content, LTI links + web_resources passed through | "Export for Canvas (.imscc)" button | | Upload a course-outline `.docx` and have an LLM extract title / description / course information / course materials → populates Canvas's Syllabus tab body at export time | Setup page | -| Pluggable LLM provider — **Anthropic (Claude)** or **Ollama Cloud** for course-content generation | Setup → Course-content provider | +| Pluggable LLM provider — **Anthropic (Claude)**, **Google Gemini** or **Ollama Cloud** for course-content generation | Setup → Course-content provider | | Pluggable research backend — **Claude web search**, **Tavily**, or **Wikipedia** | Setup → Research backend | | Advanced-mode toggle — hides multimedia outputs (slides, audio, infographic, weekly challenge, activities) by default to keep the Canvas-focused happy path tight | Setup page bottom | @@ -48,6 +48,7 @@ Open [localhost:5173](http://localhost:5173). | Provider | Required when… | What it does | |---|---|---| | Anthropic Claude | LLM provider = Anthropic, **OR** research backend = Claude web search | Course-content generation; Claude's built-in web search for the Research stage | +| Google Gemini | LLM provider = Gemini | Course-content generation, called straight from the browser (Google allows CORS — no proxy). Get a key at aistudio.google.com/apikey. The model id is editable (default `gemini-3.8-flash`). Doesn't run Claude's web-search tool, so pair it with Tavily or Wikipedia research. On Google's free tier prompts may be used to improve its products — check your plan | | Ollama Cloud | LLM provider = Ollama Cloud | Course-content generation. Free tier doesn't include cloud models — see ollama.com/settings/keys | | Tavily | Research backend = Tavily | Web search for the Research stage. Free tier covers ~1,000 searches/month | | OpenAI | Advanced mode + you want slide images / chapter figures | Image generation (gpt-image-2, optional) | @@ -154,12 +155,21 @@ Flags: | `--length` | `standard` | `concise` / `standard` / `comprehensive` | | `--cohort` | `60` | Class size | | `--notes` | — | Additional learner context | -| `--provider` | `anthropic` | LLM provider — `anthropic` or `ollama`. With `ollama`, set `OLLAMA_API_KEY`; with `anthropic`, set `ANTHROPIC_API_KEY` | +| `--provider` | `anthropic` | LLM provider — `anthropic`, `gemini` or `ollama`. Set `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` or `OLLAMA_API_KEY` to match | | `--ollama-model` | `gpt-oss:120b-cloud` | Ollama model used when `--provider ollama` | +| `--gemini-model` | `gemini-3.8-flash` | Gemini model used when `--provider gemini` | | `--concurrency` | `3` | Parallel chapters during Canvas Module generation | | `--syllabus` | — | Path to existing `syllabus.json` (skip regeneration) | | `--skip-canvas-module` | `false` | Skip per-chapter content generation (for fast manifest-only test runs) | +Gemini example (`GEMINI_API_KEY` must be set in your environment): + +```bash +npx tsx scripts/canvas-course.ts \n --provider gemini \n --topic "Statistics for Data Science" \n --template ./template.imscc \n --chapters 8 \n --output ./output/stats +``` + +Before relying on a Gemini model, run `npm run smoke:gemini` (reads `GEMINI_API_KEY` from your environment): it exercises plain text, streaming, thinking, structured-JSON quiz output and the error paths against the live API. + Ollama example: ```bash @@ -172,7 +182,7 @@ OLLAMA_API_KEY=... npx tsx scripts/canvas-course.ts \ --output ./output/stats ``` -If both `OLLAMA_API_KEY` and `ANTHROPIC_API_KEY` are set, outline-DOCX extraction always uses Claude (its instruction-following on the small JSON-extraction task is more reliable) regardless of `--provider`. +If `ANTHROPIC_API_KEY` is set alongside `--provider gemini` or `ollama`, outline-DOCX extraction always uses Claude (its instruction-following on the small JSON-extraction task is more reliable) regardless of `--provider`. ### Original ClassBuild CLI ([`scripts/generate-course.ts`](scripts/generate-course.ts)) @@ -195,6 +205,7 @@ src/ llm/ anthropic.ts # Anthropic SDK streaming ollama.ts # Ollama Cloud NDJSON streaming + gemini.ts # Google Gemini SSE streaming (direct from the browser) store/ templateStore.ts # uploaded templates (parsed + raw .imscc Blob) pages/ @@ -209,7 +220,7 @@ src/ ## Built with -React 19 · Vite 7 · TypeScript 5.9 · Tailwind CSS 4 · Zustand · JSZip · mammoth.js · Claude (Opus 4.8 / Sonnet 4.6 / Haiku 4.5) · Ollama Cloud · Tavily · OpenAI gpt-image-2 · ElevenLabs +React 19 · Vite 7 · TypeScript 5.9 · Tailwind CSS 4 · Zustand · JSZip · mammoth.js · Claude (Opus 4.8 / Sonnet 4.6 / Haiku 4.5) · Google Gemini · Ollama Cloud · Tavily · OpenAI gpt-image-2 · ElevenLabs ## Contributing diff --git a/package.json b/package.json index e6c998e..8cd0c30 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "validate:imscc": "tsx tools/validate-imscc.ts" + "validate:imscc": "tsx tools/validate-imscc.ts", + "smoke:gemini": "tsx tools/smoke-gemini.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.74.0", diff --git a/scripts/canvas-course.ts b/scripts/canvas-course.ts index 78a6454..a388d75 100644 --- a/scripts/canvas-course.ts +++ b/scripts/canvas-course.ts @@ -63,8 +63,9 @@ const { values } = parseArgs({ concurrency: { type: 'string', default: '3' }, syllabus: { type: 'string' }, // path to existing syllabus.json (skip regeneration) 'skip-canvas-module': { type: 'boolean', default: false }, - provider: { type: 'string', default: 'anthropic' }, // anthropic | ollama + provider: { type: 'string', default: 'anthropic' }, // anthropic | ollama | gemini 'ollama-model': { type: 'string', default: 'gpt-oss:120b-cloud' }, + 'gemini-model': { type: 'string', default: 'gemini-3.8-flash' }, }, strict: true, }); @@ -80,13 +81,15 @@ if (!values.template) { process.exit(1); } -type Provider = 'anthropic' | 'ollama'; +type Provider = 'anthropic' | 'ollama' | 'gemini'; const PROVIDER: Provider = - values.provider === 'ollama' ? 'ollama' : 'anthropic'; + values.provider === 'ollama' ? 'ollama' : values.provider === 'gemini' ? 'gemini' : 'anthropic'; const OLLAMA_MODEL = values['ollama-model']!; +const GEMINI_MODEL = values['gemini-model']!; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY; +const GEMINI_API_KEY = process.env.GEMINI_API_KEY; if (PROVIDER === 'anthropic' && !ANTHROPIC_API_KEY) { console.error('Error: ANTHROPIC_API_KEY environment variable is required for --provider anthropic'); @@ -96,12 +99,28 @@ if (PROVIDER === 'ollama' && !OLLAMA_API_KEY) { console.error('Error: OLLAMA_API_KEY environment variable is required for --provider ollama'); process.exit(1); } +if (PROVIDER === 'gemini' && !GEMINI_API_KEY) { + console.error('Error: GEMINI_API_KEY environment variable is required for --provider gemini'); + process.exit(1); +} + +/** Per-call provider overrides for the LLM router (the CLI never touches the browser store). */ +function providerOptions(p: Provider) { + return { + provider: p, + ollamaApiKey: p === 'ollama' ? OLLAMA_API_KEY : undefined, + ollamaModel: p === 'ollama' ? OLLAMA_MODEL : undefined, + geminiApiKey: p === 'gemini' ? GEMINI_API_KEY : undefined, + geminiModel: p === 'gemini' ? GEMINI_MODEL : undefined, + }; +} // The outline-DOCX extraction step always runs on Claude when available // (it's a one-shot JSON extraction that benefits from instruction-following -// quality). If Anthropic isn't configured, fall back to Ollama. -const OUTLINE_KEY = ANTHROPIC_API_KEY ?? OLLAMA_API_KEY!; -const OUTLINE_PROVIDER: Provider = ANTHROPIC_API_KEY ? 'anthropic' : 'ollama'; +// quality). If Anthropic isn't configured, fall back to the chosen provider +// (its key was validated above). +const OUTLINE_PROVIDER: Provider = ANTHROPIC_API_KEY ? 'anthropic' : PROVIDER; +const OUTLINE_KEY = ANTHROPIC_API_KEY ?? (PROVIDER === 'gemini' ? GEMINI_API_KEY! : OLLAMA_API_KEY!); const setup: CourseSetup = { topic: values.topic, @@ -136,11 +155,11 @@ function printUsage() { ' [--level advanced-undergrad] \\\n' + ' [--length concise|standard|comprehensive] \\\n' + ' [--notes "Additional learner context"] \\\n' + - ' [--provider anthropic|ollama] \\\n' + - ' [--ollama-model gpt-oss:120b-cloud] \\\n' + + ' [--provider anthropic|ollama|gemini] \\\n' + + ' [--ollama-model gpt-oss:120b-cloud] [--gemini-model gemini-3.8-flash] \\\n' + ' [--concurrency 3] \\\n' + ' [--syllabus ./existing-syllabus.json]\n' + - '\nFor Ollama: set OLLAMA_API_KEY (and optionally ANTHROPIC_API_KEY for\noutline extraction quality if you have it).', + '\nFor Ollama: set OLLAMA_API_KEY; for Gemini: set GEMINI_API_KEY (optionally also ANTHROPIC_API_KEY for\noutline extraction quality if you have it).', ); } @@ -218,9 +237,7 @@ async function main() { const result = await extractOutlineFields({ apiKey: OUTLINE_KEY, rawText, - provider: OUTLINE_PROVIDER, - ollamaApiKey: OUTLINE_PROVIDER === 'ollama' ? OLLAMA_API_KEY ?? undefined : undefined, - ollamaModel: OUTLINE_PROVIDER === 'ollama' ? OLLAMA_MODEL : undefined, + ...providerOptions(OUTLINE_PROVIDER), }); outlineFields = result.fields; const found = Object.keys(outlineFields).filter( @@ -248,9 +265,7 @@ async function main() { messages: [{ role: 'user', content: userMessage }], thinkingBudget: 'high', maxTokens: 16000, - provider: PROVIDER, - ollamaApiKey: PROVIDER === 'ollama' ? OLLAMA_API_KEY! : undefined, - ollamaModel: PROVIDER === 'ollama' ? OLLAMA_MODEL : undefined, + ...providerOptions(PROVIDER), }, {}, ); @@ -282,9 +297,7 @@ async function main() { courseTitle: syllabus.courseTitle, courseOverview: syllabus.courseOverview, examplePatternContent: template.examplePatternContent, - provider: PROVIDER, - ollamaApiKey: PROVIDER === 'ollama' ? OLLAMA_API_KEY! : undefined, - ollamaModel: PROVIDER === 'ollama' ? OLLAMA_MODEL : undefined, + ...providerOptions(PROVIDER), }); const target = chapters.find((c) => c.number === ch.number); if (target) target.templateContent = content; diff --git a/src/components/setup/CourseOutlineUpload.tsx b/src/components/setup/CourseOutlineUpload.tsx index 50f81ae..b047d27 100644 --- a/src/components/setup/CourseOutlineUpload.tsx +++ b/src/components/setup/CourseOutlineUpload.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from 'react'; import { useCourseStore } from '../../store/courseStore'; -import { useApiStore } from '../../store/apiStore'; +import { useApiStore, selectActiveLlm } from '../../store/apiStore'; import { docxToText, extractOutlineFields } from '../../services/template/parseOutlineDocx'; import { Button } from '../shared/Button'; import { friendlyError } from '../../utils/errors'; @@ -22,17 +22,15 @@ const FIELD_HINTS: Record = { export function CourseOutlineUpload() { const { outlineFields, outlineRawText, setOutlineFields, setOutlineRawText } = useCourseStore(); - const { provider, claudeApiKey, ollamaApiKey } = useApiStore(); + const llm = selectActiveLlm(useApiStore()); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [open, setOpen] = useState(false); const fileInputRef = useRef(null); - const llmKey = provider === 'ollama' ? ollamaApiKey : claudeApiKey; - const handleFile = async (file: File) => { - if (!llmKey.trim()) { - setError('Add an API key (Claude or Ollama) before uploading — the outline is parsed by the active LLM provider.'); + if (!llm.hasKey) { + setError(`Add your ${llm.label} API key before uploading — the outline is parsed by the active LLM provider.`); return; } setBusy(true); @@ -40,7 +38,7 @@ export function CourseOutlineUpload() { try { const rawText = await docxToText(file); setOutlineRawText(rawText); - const result = await extractOutlineFields({ apiKey: llmKey, rawText }); + const result = await extractOutlineFields({ apiKey: llm.apiKey, rawText }); setOutlineFields(result.fields); setOpen(true); } catch (err) { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index b598770..fca4851 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,7 +1,7 @@ import { useState, useCallback, useMemo, useRef } from 'react'; import { motion } from 'framer-motion'; import { useCourseStore } from '../store/courseStore'; -import { useApiStore } from '../store/apiStore'; +import { useApiStore, selectActiveLlm } from '../store/apiStore'; import { useUiStore } from '../store/uiStore'; import { streamMessage } from '../services/claude/streaming'; import { MODELS } from '../services/claude/client'; @@ -89,7 +89,9 @@ function buildClassReadme( export function ExportPage() { const { syllabus, chapters, researchDossiers, addChapter, updateChapter, setSlideImage, setup, updateSetup, curriculumMap, setCurriculumMap, outlineFields } = useCourseStore(); const currentThemeId = (setup.themeId as ChapterThemeId | undefined) ?? DEFAULT_CHAPTER_THEME_ID; - const { claudeApiKey, openaiApiKey } = useApiStore(); + const apiState = useApiStore(); + const { claudeApiKey, openaiApiKey } = apiState; + const llm = selectActiveLlm(apiState); const { isGenerating, setIsGenerating, setError, error } = useUiStore(); const [generatingChapter, setGeneratingChapter] = useState(null); const [isPublishing, setIsPublishing] = useState(false); @@ -524,7 +526,7 @@ export function ExportPage() { curriculumMap.syllabusHash !== currentSyllabusHash; const generateLearningOutcomes = useCallback(async () => { - if (!syllabus || !claudeApiKey || isGeneratingOutcomes) return; + if (!syllabus || !llm.hasKey || isGeneratingOutcomes) return; setIsGeneratingOutcomes(true); setOutcomesError(null); outcomesStreamRef.current = ''; @@ -564,6 +566,7 @@ export function ExportPage() { }, [ syllabus, claudeApiKey, + llm.hasKey, isGeneratingOutcomes, currentSyllabusHash, setCurriculumMap, @@ -1406,7 +1409,7 @@ export function ExportPage() { onGenerate={() => void generateLearningOutcomes()} onDownloadHtml={handleDownloadOutcomesHtml} onDownloadCsv={handleDownloadOutcomesCsv} - canGenerate={!!claudeApiKey && !isGenerating} + canGenerate={llm.hasKey && !isGenerating} />
— 05 —
diff --git a/src/pages/ResearchPage.tsx b/src/pages/ResearchPage.tsx index d5426d6..c76c011 100644 --- a/src/pages/ResearchPage.tsx +++ b/src/pages/ResearchPage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useCourseStore } from '../store/courseStore'; -import { useApiStore } from '../store/apiStore'; +import { useApiStore, selectActiveLlm } from '../store/apiStore'; import { useUiStore } from '../store/uiStore'; import type { WebSearchResult } from '../services/llm/types'; import { runResearch } from '../services/research'; @@ -54,11 +54,12 @@ export function ResearchPage() { setStage, completeStage, } = useCourseStore(); - const { claudeApiKey, ollamaApiKey, tavilyApiKey, provider, researchBackend } = useApiStore(); + const apiState = useApiStore(); + const { claudeApiKey, tavilyApiKey, researchBackend } = apiState; // Active LLM key — used by the tavily/wikipedia backends for query generation // and synthesis. The anthropic backend is always Claude (only Claude exposes // the web_search tool). - const llmApiKey = provider === 'ollama' ? ollamaApiKey : claudeApiKey; + const llmApiKey = selectActiveLlm(apiState).apiKey; const { setActiveTab } = useUiStore(); const [currentChapter, setCurrentChapter] = useState(0); const [researchingSet, setResearchingSet] = useState>(new Set()); diff --git a/src/pages/SetupPage.tsx b/src/pages/SetupPage.tsx index 3ef8166..df6ab92 100644 --- a/src/pages/SetupPage.tsx +++ b/src/pages/SetupPage.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useCourseStore } from '../store/courseStore'; -import { useApiStore } from '../store/apiStore'; +import { useApiStore, selectActiveLlm } from '../store/apiStore'; import { useUiStore } from '../store/uiStore'; -import { MODELS } from '../services/claude/client'; +import { MODELS, DEFAULT_GEMINI_MODEL } from '../services/claude/client'; import { TemplatePicker } from '../components/setup/TemplatePicker'; import { CourseOutlineUpload } from '../components/setup/CourseOutlineUpload'; import { STAGES } from '../types/course'; @@ -110,7 +110,7 @@ const STARTER_BRIEFS: StarterBrief[] = [ export function SetupPage() { const navigate = useNavigate(); const { setup, updateSetup, setStage, completeStage, resetDownstream, syllabus, chapters, currentStage } = useCourseStore(); - const { provider, claudeApiKey, claudeKeyValid, ollamaApiKey, ollamaKeyValid } = useApiStore(); + const llm = selectActiveLlm(useApiStore()); const { openKeysOnNextSetupVisit, setOpenKeysOnNextSetupVisit, @@ -164,10 +164,9 @@ export function SetupPage() { const hasTopic = setup.topic.trim().length > 10; // The key that gates "Begin" is whichever course-content provider is active. - const providerLabel = provider === 'ollama' ? 'Ollama' : 'Anthropic'; - const activeKey = provider === 'ollama' ? ollamaApiKey : claudeApiKey; - const activeKeyValid = provider === 'ollama' ? ollamaKeyValid : claudeKeyValid; - const hasApiKey = activeKey.trim().length > 0; + const providerLabel = llm.label; + const activeKeyValid = llm.keyValid; + const hasApiKey = llm.hasKey; const canProceed = hasTopic && hasApiKey; // Anything downstream that "Begin" would wipe. const hasDownstream = !!syllabus || chapters.length > 0; @@ -1180,17 +1179,25 @@ function ApiKeysModal({ open, onClose }: { open: boolean; onClose: () => void }) setAdvancedMode, ollamaApiKey, ollamaModel, + geminiApiKey, + geminiModel, tavilyApiKey, ollamaKeyValid, + geminiKeyValid, tavilyKeyValid, isValidatingOllama, + isValidatingGemini, isValidatingTavily, setOllamaApiKey, setOllamaModel, + setGeminiApiKey, + setGeminiModel, setTavilyApiKey, setOllamaKeyValid, + setGeminiKeyValid, setTavilyKeyValid, setIsValidatingOllama, + setIsValidatingGemini, setIsValidatingTavily, claudeApiKey, openaiApiKey, @@ -1290,6 +1297,26 @@ function ApiKeysModal({ open, onClose }: { open: boolean; onClose: () => void }) } }, [ollamaApiKey, ollamaModel, setOllamaKeyValid, setIsValidatingOllama]); + const validateGemini = useCallback(async () => { + if (!geminiApiKey.trim()) return; + setIsValidatingGemini(true); + try { + // Fetching the chosen model proves BOTH the key and the model id: 200 = ok, + // 404 = unknown model, 400/403 = bad key. Google answers CORS, so this is a + // direct browser call (no proxy), with the key in a header rather than the URL. + const model = geminiModel.trim() || DEFAULT_GEMINI_MODEL; + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}`, + { headers: { 'x-goog-api-key': geminiApiKey.trim() } }, + ); + setGeminiKeyValid(res.ok); + } catch { + setGeminiKeyValid(false); + } finally { + setIsValidatingGemini(false); + } + }, [geminiApiKey, geminiModel, setGeminiKeyValid, setIsValidatingGemini]); + const validateTavily = useCallback(async () => { if (!tavilyApiKey.trim()) return; setIsValidatingTavily(true); @@ -1317,6 +1344,7 @@ function ApiKeysModal({ open, onClose }: { open: boolean; onClose: () => void }) validatedRef.current = true; if (claudeApiKey.trim() && claudeKeyValid === null) void validateClaude(); if (ollamaApiKey.trim() && ollamaKeyValid === null) void validateOllama(); + if (geminiApiKey.trim() && geminiKeyValid === null) void validateGemini(); if (tavilyApiKey.trim() && tavilyKeyValid === null) void validateTavily(); if (openaiApiKey.trim() && openaiKeyValid === null) void validateOpenai(); if (elevenLabsApiKey.trim() && elevenLabsKeyValid === null) { @@ -1325,10 +1353,13 @@ function ApiKeysModal({ open, onClose }: { open: boolean; onClose: () => void }) }, [ open, ollamaApiKey, + geminiApiKey, tavilyApiKey, ollamaKeyValid, + geminiKeyValid, tavilyKeyValid, validateOllama, + validateGemini, validateTavily, claudeApiKey, openaiApiKey, @@ -1364,12 +1395,14 @@ function ApiKeysModal({ open, onClose }: { open: boolean; onClose: () => void }) elevenLabsApiKey, ); const ollamaStatus = statusFor(isValidatingOllama, ollamaKeyValid, ollamaApiKey); + const geminiStatus = statusFor(isValidatingGemini, geminiKeyValid, geminiApiKey); const tavilyStatus = statusFor(isValidatingTavily, tavilyKeyValid, tavilyApiKey); const anyValidating = isValidatingClaude || isValidatingOpenai || isValidatingElevenLabs || isValidatingOllama || + isValidatingGemini || isValidatingTavily; const linkStyle: React.CSSProperties = { @@ -1404,6 +1437,7 @@ function ApiKeysModal({ open, onClose }: { open: boolean; onClose: () => void }) onClick={() => { if (claudeApiKey.trim()) void validateClaude(); if (ollamaApiKey.trim()) void validateOllama(); + if (geminiApiKey.trim()) void validateGemini(); if (tavilyApiKey.trim()) void validateTavily(); if (openaiApiKey.trim()) void validateOpenai(); if (elevenLabsApiKey.trim()) void validateElevenLabs(); @@ -1411,6 +1445,7 @@ function ApiKeysModal({ open, onClose }: { open: boolean; onClose: () => void }) disabled={ (!claudeApiKey.trim() && !ollamaApiKey.trim() && + !geminiApiKey.trim() && !tavilyApiKey.trim() && !openaiApiKey.trim() && !elevenLabsApiKey.trim()) || @@ -1430,18 +1465,24 @@ function ApiKeysModal({ open, onClose }: { open: boolean; onClose: () => void }) > Course-content provider -
+
setProvider('anthropic')} label="Claude" sub="Best quality" /> + setProvider('gemini')} + label="Gemini" + sub="Google" + /> setProvider('ollama')} label="Ollama Cloud" - sub="Open-weight alternative" + sub="Open-weight" />
@@ -1563,6 +1604,55 @@ function ApiKeysModal({ open, onClose }: { open: boolean; onClose: () => void }) } /> )} + setGeminiApiKey(e.target.value)} + onBlur={() => { + if (geminiApiKey.trim() && geminiKeyValid === null) void validateGemini(); + }} + placeholder="AIza…" + suffix={ + geminiStatus ? ( + {geminiStatus.text} + ) : undefined + } + hint={ + <> + + Where do Gemini keys come from? ↗ + + + {' '} + · On Google's free tier, prompts may be used to improve its products — check your plan. + + + } + /> + {provider === 'gemini' && ( + setGeminiModel(e.target.value)} + placeholder={DEFAULT_GEMINI_MODEL} + hint={ + + Model ids change often — see Google's list ↗ + + } + /> + )} { - if (!syllabus && !isGenerating && claudeApiKey && !generationStarted.current) { + if (!syllabus && !isGenerating && llm.hasKey && !generationStarted.current) { generationStarted.current = true; void generateSyllabus(); } diff --git a/src/services/claude/client.ts b/src/services/claude/client.ts index 7a8bedc..d805624 100644 --- a/src/services/claude/client.ts +++ b/src/services/claude/client.ts @@ -27,6 +27,15 @@ export const OLLAMA_MODELS = { export const DEFAULT_OLLAMA_MODEL = OLLAMA_MODELS.default; +// Gemini ids change often — the Setup page lets the user type any model id; these are +// just the defaults (https://ai.google.dev/gemini-api/docs/models). +export const GEMINI_MODELS = { + default: 'gemini-3.8-flash', + pro: 'gemini-3.1-pro-preview', +} as const; + +export const DEFAULT_GEMINI_MODEL = GEMINI_MODELS.default; + export type ThinkingBudget = 'max' | 'high' | 'medium' | 'low'; const BUDGET_TOKENS: Record = { diff --git a/src/services/llm/gemini.ts b/src/services/llm/gemini.ts new file mode 100644 index 0000000..19acf07 --- /dev/null +++ b/src/services/llm/gemini.ts @@ -0,0 +1,218 @@ +import type Anthropic from '@anthropic-ai/sdk'; +import { getThinkingTokens } from '../claude/client'; +import type { StreamCallbacks, StreamOptions } from './types'; + +/** + * Google Gemini backend (generateContent / streamGenerateContent, REST + SSE). + * + * Unlike Ollama Cloud, Google answers CORS preflights (including for the + * `x-goog-api-key` header) from any origin, so the browser calls it directly — no + * proxy. The key goes in a header rather than the `?key=` query string so it never + * lands in URLs, logs or referrers. + * + * Like the Ollama backend this ignores Anthropic server tools (web search), so the + * "Claude web search" research backend still needs a Claude key; Tavily and + * Wikipedia research work with Gemini as the synthesising model. + */ +const GEMINI_BASE = 'https://generativelanguage.googleapis.com/v1beta'; + +/** Output ceiling for the current Flash/Pro models. */ +const GEMINI_MAX_OUTPUT_TOKENS = 65536; + +interface GeminiContent { + role: 'user' | 'model'; + parts: Array<{ text: string }>; +} + +interface GeminiChunk { + candidates?: Array<{ + content?: { parts?: Array<{ text?: string; thought?: boolean }> }; + finishReason?: string; + }>; + promptFeedback?: { blockReason?: string }; + error?: { code?: number; message?: string; status?: string }; +} + +/** Anthropic content blocks → plain text (tool blocks are dropped, as for Ollama). */ +function flattenContent(content: Anthropic.MessageParam['content']): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .map((block) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b = block as any; + return b.type === 'text' && typeof b.text === 'string' ? b.text : ''; + }) + .filter(Boolean) + .join('\n'); +} + +function toGeminiContents(messages: Anthropic.MessageParam[]): GeminiContent[] { + return messages + .map((m) => ({ + role: (m.role === 'assistant' ? 'model' : 'user') as 'user' | 'model', + parts: [{ text: flattenContent(m.content) }], + })) + .filter((c) => c.parts[0].text.length > 0); +} + +/** + * Carries the HTTP status so `friendlyError` (utils/errors.ts) can map it, as it does for + * the SDK errors from the other providers. (No parameter properties: erasableSyntaxOnly.) + */ +class GeminiHttpError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.name = 'GeminiHttpError'; + this.status = status; + } +} + +/** Google's error envelope → one readable line that keeps the HTTP code (retry logic keys off "429"). */ +async function errorFromResponse(res: Response): Promise { + const raw = await res.text().catch(() => ''); + let message = ''; + let status = ''; + try { + const j = JSON.parse(raw) as GeminiChunk; + message = j.error?.message ?? ''; + status = j.error?.status ?? ''; + } catch { + message = raw; + } + return new GeminiHttpError(`Gemini ${res.status}${status ? ` ${status}` : ''}: ${message || res.statusText}`, res.status); +} + +const BLOCK_REASONS = new Set(['SAFETY', 'RECITATION', 'PROHIBITED_CONTENT', 'BLOCKLIST', 'SPII', 'IMAGE_SAFETY']); + +export async function streamMessageGemini( + options: StreamOptions, + callbacks: StreamCallbacks, +): Promise { + const { apiKey, model, system, messages, thinkingBudget, maxTokens = 16000, signal } = options; + + const fail = (message: string): never => { + const err = new Error(message); + callbacks.onError?.(err); + throw err; + }; + if (!model) fail('Gemini provider requires a model name (e.g. gemini-3.8-flash).'); + if (!apiKey) fail('Gemini API key is missing — paste it on the Setup page.'); + + const contents = toGeminiContents(messages); + if (contents.length === 0) fail('Gemini was given no message content.'); + + // Thinking tokens count toward maxOutputTokens (a hard cutoff), so leave headroom above + // the caller's intended output — same reasoning as the Opus path in anthropic.ts. + const maxOutputTokens = Math.min( + GEMINI_MAX_OUTPUT_TOKENS, + maxTokens + (thinkingBudget ? getThinkingTokens(thinkingBudget) * 2 : 0), + ); + + const url = `${GEMINI_BASE}/models/${encodeURIComponent(model!)}:streamGenerateContent?alt=sse`; + + const request = (withThoughts: boolean) => + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey }, + body: JSON.stringify({ + ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}), + contents, + generationConfig: { + maxOutputTokens, + // Ask for thought *summaries* so the UI's "thinking" trace works. Not every model + // accepts this, so a 400 about it is retried once without. + ...(withThoughts && thinkingBudget ? { thinkingConfig: { includeThoughts: true } } : {}), + }, + }), + signal, + }); + + let fullText = ''; + + try { + let res = await request(true); + if (res.status === 400 && thinkingBudget) { + const err = await errorFromResponse(res); + if (!/think|thought/i.test(err.message)) throw err; + res = await request(false); + } + if (!res.ok || !res.body) throw await errorFromResponse(res); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let finishReason: string | undefined; + let blockReason: string | undefined; + + const handle = (payload: string) => { + let chunk: GeminiChunk; + try { + chunk = JSON.parse(payload); + } catch { + return; // partial/keep-alive + } + if (chunk.error) { + throw new GeminiHttpError( + `Gemini ${chunk.error.code ?? ''} ${chunk.error.status ?? ''}: ${chunk.error.message ?? 'stream error'}`.replace(/\s+/g, ' '), + chunk.error.code ?? 500, + ); + } + if (chunk.promptFeedback?.blockReason) blockReason = chunk.promptFeedback.blockReason; + const cand = chunk.candidates?.[0]; + if (cand?.finishReason) finishReason = cand.finishReason; + for (const part of cand?.content?.parts ?? []) { + if (!part.text) continue; + if (part.thought) { + callbacks.onThinking?.(part.text); + } else { + fullText += part.text; + callbacks.onText?.(part.text); + } + } + }; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // SSE: events are separated by a blank line; each has one or more `data:` lines. + let sep = buffer.search(/\r?\n\r?\n/); + while (sep !== -1) { + const event = buffer.slice(0, sep); + buffer = buffer.slice(sep).replace(/^\r?\n\r?\n/, ''); + sep = buffer.search(/\r?\n\r?\n/); + const data = event + .split(/\r?\n/) + .filter((l) => l.startsWith('data:')) + .map((l) => l.slice(5).trim()) + .join(''); + if (data) handle(data); + } + } + if (buffer.trim().startsWith('data:')) handle(buffer.trim().slice(5).trim()); // stream ended without a blank line + + if (!fullText) { + const reason = blockReason ?? finishReason; + if (reason && BLOCK_REASONS.has(reason)) { + throw new Error( + `Gemini blocked the response (${reason}). Reword the topic, or switch to another provider in the API keys dialog.`, + ); + } + throw new Error(`Gemini returned no text${reason ? ` (finish reason: ${reason})` : ''}.`); + } + if (finishReason === 'MAX_TOKENS') { + // Same as the Anthropic path: hand back what we have; callers' JSON parsing will surface a truncated material. + console.warn('Gemini stopped at the output token limit (MAX_TOKENS); the response may be truncated.'); + } + + callbacks.onDone?.(fullText); + return fullText; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + callbacks.onError?.(err); + throw err; + } +} diff --git a/src/services/llm/index.ts b/src/services/llm/index.ts index d2c65f7..5f9d678 100644 --- a/src/services/llm/index.ts +++ b/src/services/llm/index.ts @@ -2,38 +2,47 @@ import type Anthropic from '@anthropic-ai/sdk'; import { useApiStore } from '../../store/apiStore'; import { streamMessageAnthropic, sendMessageAnthropic } from './anthropic'; import { streamMessageOllama } from './ollama'; +import { streamMessageGemini } from './gemini'; import type { StreamCallbacks, StreamOptions, LlmProvider } from './types'; export type { LlmProvider, StreamCallbacks, StreamOptions, WebSearchResult, ThinkingBudget } from './types'; +/** + * Call sites pass Anthropic model ids (MODELS.opus …). Those mean nothing to a + * non-Claude provider, so only an explicit non-Claude override is honoured. + */ +function explicitNonClaudeModel(model?: string): string | undefined { + return model && !model.startsWith('claude-') ? model : undefined; +} + /** * Resolve the provider + concrete (apiKey, model) pair for this call. An * explicit `provider` in options wins; otherwise we read the active provider - * from apiStore. On Ollama, the apiKey/model passed in (which call sites - * derive from the legacy Anthropic store fields) is overridden with the - * Ollama-specific store fields. + * from apiStore. On Ollama/Gemini, the apiKey/model passed in (which call sites + * derive from the legacy Anthropic store fields) is overridden with that + * provider's own store fields. */ function resolveProvider(options: StreamOptions): { provider: LlmProvider; apiKey: string; model?: string; } { - const explicit = options.provider; const state = useApiStore.getState(); - const provider = explicit ?? state.provider; + const provider = options.provider ?? state.provider; + // Explicit overrides win (the CLI doesn't touch the IndexedDB-backed apiStore); + // otherwise fall back to the store, as in the browser flow. if (provider === 'ollama') { - // Prefer explicit overrides (used by the CLI, which doesn't touch - // the IndexedDB-backed apiStore). Fall back to store state in the - // browser flow. return { provider, apiKey: options.ollamaApiKey || state.ollamaApiKey, - // Call sites pass Anthropic model ids (MODELS.opus etc.); those mean - // nothing to Ollama, so only honour an explicit non-Claude override. - model: - (options.model && !options.model.startsWith('claude-') ? options.model : undefined) || - options.ollamaModel || - state.ollamaModel, + model: explicitNonClaudeModel(options.model) || options.ollamaModel || state.ollamaModel, + }; + } + if (provider === 'gemini') { + return { + provider, + apiKey: options.geminiApiKey || state.geminiApiKey, + model: explicitNonClaudeModel(options.model) || options.geminiModel || state.geminiModel, }; } return { provider, apiKey: options.apiKey, model: options.model }; @@ -48,6 +57,9 @@ export async function streamMessage( if (provider === 'ollama') { return streamMessageOllama(resolved, callbacks); } + if (provider === 'gemini') { + return streamMessageGemini(resolved, callbacks); + } return streamMessageAnthropic(resolved, callbacks); } diff --git a/src/services/llm/types.ts b/src/services/llm/types.ts index 3f56a3c..4341cbc 100644 --- a/src/services/llm/types.ts +++ b/src/services/llm/types.ts @@ -1,6 +1,6 @@ import type Anthropic from '@anthropic-ai/sdk'; -export type LlmProvider = 'anthropic' | 'ollama'; +export type LlmProvider = 'anthropic' | 'ollama' | 'gemini'; export type ThinkingBudget = 'max' | 'high' | 'medium' | 'low'; @@ -30,7 +30,7 @@ export interface StreamOptions { system?: string; messages: Anthropic.MessageParam[]; thinkingBudget?: ThinkingBudget; - // Custom and server-side tools (web_search, etc.). Ollama silently ignores. + // Custom and server-side tools (web_search, etc.). Ollama and Gemini silently ignore. // eslint-disable-next-line @typescript-eslint/no-explicit-any tools?: any[]; maxTokens?: number; @@ -47,4 +47,8 @@ export interface StreamOptions { ollamaApiKey?: string; /** Ollama model override. Falls back to apiStore.ollamaModel when omitted. */ ollamaModel?: string; + /** Gemini API key, used only when provider==='gemini' (same Node/CLI rationale as ollamaApiKey). */ + geminiApiKey?: string; + /** Gemini model override. Falls back to apiStore.geminiModel when omitted. */ + geminiModel?: string; } diff --git a/src/services/template/generateChapter.ts b/src/services/template/generateChapter.ts index f0b56ca..bd055ad 100644 --- a/src/services/template/generateChapter.ts +++ b/src/services/template/generateChapter.ts @@ -26,6 +26,9 @@ export interface GenerateTemplateChapterInput { /** Ollama-specific overrides for non-browser callers. */ ollamaApiKey?: string; ollamaModel?: string; + /** Gemini overrides for non-browser callers (CLI). */ + geminiApiKey?: string; + geminiModel?: string; /** Optional cancellation — aborting rejects with the SDK's abort error. */ signal?: AbortSignal; onText?: (text: string) => void; @@ -45,7 +48,7 @@ export interface GenerateTemplateChapterResult { export async function generateTemplateChapter( input: GenerateTemplateChapterInput, ): Promise { - const { apiKey, setup, chapter, courseTitle, courseOverview, examplePatternContent, provider, ollamaApiKey, ollamaModel, signal, onText, onError } = input; + const { apiKey, setup, chapter, courseTitle, courseOverview, examplePatternContent, provider, ollamaApiKey, ollamaModel, geminiApiKey, geminiModel, signal, onText, onError } = input; const { systemPrompt, userMessage } = buildTemplateChapterPrompt({ setup, @@ -60,9 +63,9 @@ export async function generateTemplateChapter( const rawText = await streamWithRetry( { apiKey, - // Don't force MODELS.sonnet when the caller picked Ollama — the - // Ollama backend would 404 trying to find a Claude model name. - model: provider === 'ollama' ? undefined : MODELS.sonnet, + // Don't force MODELS.sonnet when the caller picked a non-Claude provider — + // it would 404 trying to find a Claude model name. + model: provider && provider !== 'anthropic' ? undefined : MODELS.sonnet, system: systemPrompt, messages, thinkingBudget: 'medium', @@ -70,6 +73,8 @@ export async function generateTemplateChapter( provider, ollamaApiKey, ollamaModel, + geminiApiKey, + geminiModel, signal, }, { diff --git a/src/store/apiStore.ts b/src/store/apiStore.ts index b32eb45..e10ad53 100644 --- a/src/store/apiStore.ts +++ b/src/store/apiStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { DEFAULT_OLLAMA_MODEL } from '../services/claude/client'; +import { DEFAULT_OLLAMA_MODEL, DEFAULT_GEMINI_MODEL } from '../services/claude/client'; import type { LlmProvider } from '../services/llm/types'; import type { ResearchBackend } from '../services/research/types'; @@ -13,6 +13,10 @@ interface ApiState { claudeApiKey: string; ollamaApiKey: string; ollamaModel: string; + geminiApiKey: string; + geminiModel: string; + geminiKeyValid: boolean | null; + isValidatingGemini: boolean; tavilyApiKey: string; claudeKeyValid: boolean | null; @@ -35,6 +39,10 @@ interface ApiState { setClaudeApiKey: (key: string) => void; setOllamaApiKey: (key: string) => void; setOllamaModel: (m: string) => void; + setGeminiApiKey: (key: string) => void; + setGeminiModel: (m: string) => void; + setGeminiKeyValid: (valid: boolean | null) => void; + setIsValidatingGemini: (v: boolean) => void; setTavilyApiKey: (key: string) => void; setClaudeKeyValid: (valid: boolean | null) => void; setOllamaKeyValid: (valid: boolean | null) => void; @@ -59,6 +67,10 @@ export const useApiStore = create()( claudeApiKey: '', ollamaApiKey: '', ollamaModel: DEFAULT_OLLAMA_MODEL, + geminiApiKey: '', + geminiModel: DEFAULT_GEMINI_MODEL, + geminiKeyValid: null, + isValidatingGemini: false, tavilyApiKey: '', claudeKeyValid: null, ollamaKeyValid: null, @@ -79,6 +91,11 @@ export const useApiStore = create()( setClaudeApiKey: (key) => set({ claudeApiKey: key, claudeKeyValid: null }), setOllamaApiKey: (key) => set({ ollamaApiKey: key, ollamaKeyValid: null }), setOllamaModel: (m) => set({ ollamaModel: m }), + setGeminiApiKey: (key) => set({ geminiApiKey: key, geminiKeyValid: null }), + // A different model id invalidates a previous check (the check is per key+model). + setGeminiModel: (m) => set({ geminiModel: m, geminiKeyValid: null }), + setGeminiKeyValid: (valid) => set({ geminiKeyValid: valid }), + setIsValidatingGemini: (v) => set({ isValidatingGemini: v }), setTavilyApiKey: (key) => set({ tavilyApiKey: key, tavilyKeyValid: null }), setClaudeKeyValid: (valid) => set({ claudeKeyValid: valid }), setOllamaKeyValid: (valid) => set({ ollamaKeyValid: valid }), @@ -96,7 +113,9 @@ export const useApiStore = create()( }), { name: 'classbuild-api-keys', - version: 4, + // v5: added geminiApiKey/geminiModel. New fields default via the initial state + // (persist shallow-merges), so no migration step is needed. + version: 5, migrate(persisted, version) { const state = persisted as Record; // v0/v1 → v2: drop the retired geminiApiKey. @@ -123,6 +142,8 @@ export const useApiStore = create()( claudeApiKey: state.claudeApiKey, ollamaApiKey: state.ollamaApiKey, ollamaModel: state.ollamaModel, + geminiApiKey: state.geminiApiKey, + geminiModel: state.geminiModel, tavilyApiKey: state.tavilyApiKey, openaiApiKey: state.openaiApiKey, elevenLabsApiKey: state.elevenLabsApiKey, @@ -130,3 +151,49 @@ export const useApiStore = create()( }, ), ); + +// ── Active course-content provider ── +// +// Many places need "the key that matters right now" (is generation possible? which +// banner to show?). Gating on `claudeApiKey` alone silently breaks anyone using Ollama +// or Gemini without a Claude key, so everything asks this instead. + +export interface ActiveLlm { + provider: LlmProvider; + /** Human label for messages: "Anthropic" | "Ollama" | "Gemini". */ + label: string; + apiKey: string; + /** true/false after a check, null if not checked yet. */ + keyValid: boolean | null; + /** True when a non-blank key is set for the active provider. */ + hasKey: boolean; +} + +type LlmKeyState = Pick< + ApiState, + | 'provider' + | 'claudeApiKey' + | 'claudeKeyValid' + | 'ollamaApiKey' + | 'ollamaKeyValid' + | 'geminiApiKey' + | 'geminiKeyValid' +>; + +export function selectActiveLlm(s: LlmKeyState): ActiveLlm { + const pick = (label: string, apiKey: string, keyValid: boolean | null): ActiveLlm => ({ + provider: s.provider, + label, + apiKey, + keyValid, + hasKey: apiKey.trim().length > 0, + }); + switch (s.provider) { + case 'ollama': + return pick('Ollama', s.ollamaApiKey, s.ollamaKeyValid); + case 'gemini': + return pick('Gemini', s.geminiApiKey, s.geminiKeyValid); + default: + return pick('Anthropic', s.claudeApiKey, s.claudeKeyValid); + } +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index e0c5b52..abbcf34 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -21,7 +21,11 @@ export function friendlyError(err: unknown, fallback = 'Something went wrong. Tr const status = getStatus(err); // Auth / billing - if (status === 401 || /invalid[_\s-]?api[_\s-]?key|unauthori[sz]ed|authentication/i.test(message)) { + // (Google words a bad key as "API key not valid" / "API key expired" / API_KEY_INVALID.) + if ( + status === 401 || + /invalid[_\s-]?api[_\s-]?key|api[_\s-]?key[_\s-]*(not valid|invalid|expired)|unauthori[sz]ed|authentication/i.test(message) + ) { return 'API key rejected. Check your key in Setup.'; } if (status === 403 || /permission_denied|forbidden/i.test(message)) { @@ -40,9 +44,13 @@ export function friendlyError(err: unknown, fallback = 'Something went wrong. Tr } // Input / content - if (/content[_\s]?filter|prohibited[_\s]?content|safety/i.test(message)) { + if (/content[_\s]?filter|prohibited[_\s]?content|safety|blocked the response/i.test(message)) { return 'The model declined to respond to that prompt. Try softer wording or a different angle.'; } + // Typo'd / retired model id (Gemini, Ollama and Anthropic all 404 on an unknown model). + if (status === 404 || /not_found|is not found for api version|model .*not found/i.test(message)) { + return 'That model was not found. Check the model name in Setup → API keys.'; + } if (status === 400 || /invalid_request|bad request/i.test(message)) { return 'The request was rejected. Try regenerating — if it keeps failing, refine your topic.'; } diff --git a/tests/api-store.test.ts b/tests/api-store.test.ts new file mode 100644 index 0000000..a61b081 --- /dev/null +++ b/tests/api-store.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useApiStore, selectActiveLlm } from '../src/store/apiStore'; +import { DEFAULT_GEMINI_MODEL } from '../src/services/claude/client'; + +const state = () => useApiStore.getState(); + +describe('selectActiveLlm — the key that matters for the active provider', () => { + beforeEach(() => { + useApiStore.setState({ + provider: 'anthropic', + claudeApiKey: '', + claudeKeyValid: null, + ollamaApiKey: '', + ollamaKeyValid: null, + geminiApiKey: '', + geminiKeyValid: null, + }); + }); + + it('follows the selected provider, with the label used in messages', () => { + useApiStore.setState({ claudeApiKey: 'c', ollamaApiKey: 'o', geminiApiKey: 'g' }); + + useApiStore.setState({ provider: 'anthropic' }); + expect(selectActiveLlm(state())).toMatchObject({ provider: 'anthropic', label: 'Anthropic', apiKey: 'c' }); + useApiStore.setState({ provider: 'ollama' }); + expect(selectActiveLlm(state())).toMatchObject({ provider: 'ollama', label: 'Ollama', apiKey: 'o' }); + useApiStore.setState({ provider: 'gemini' }); + expect(selectActiveLlm(state())).toMatchObject({ provider: 'gemini', label: 'Gemini', apiKey: 'g' }); + }); + + it('hasKey ignores other providers\' keys and whitespace-only keys', () => { + // A Claude-only key must not satisfy a Gemini user (and vice versa) — the old gates got this wrong. + useApiStore.setState({ provider: 'gemini', claudeApiKey: 'sk-ant', geminiApiKey: ' ' }); + expect(selectActiveLlm(state()).hasKey).toBe(false); + + useApiStore.setState({ geminiApiKey: 'AIza' }); + expect(selectActiveLlm(state()).hasKey).toBe(true); + + useApiStore.setState({ provider: 'ollama' }); + expect(selectActiveLlm(state()).hasKey).toBe(false); + }); + + it('reports each provider\'s own validation result', () => { + useApiStore.setState({ claudeKeyValid: true, ollamaKeyValid: false, geminiKeyValid: null }); + useApiStore.setState({ provider: 'anthropic' }); + expect(selectActiveLlm(state()).keyValid).toBe(true); + useApiStore.setState({ provider: 'ollama' }); + expect(selectActiveLlm(state()).keyValid).toBe(false); + useApiStore.setState({ provider: 'gemini' }); + expect(selectActiveLlm(state()).keyValid).toBeNull(); + }); +}); + +describe('Gemini fields in the API store', () => { + it('defaults to the default model and no key', () => { + // A fresh persisted-state merge (e.g. someone upgrading from v4) falls back to these. + const fresh = useApiStore.getInitialState(); + expect(fresh.geminiApiKey).toBe(''); + expect(fresh.geminiModel).toBe(DEFAULT_GEMINI_MODEL); + expect(fresh.geminiKeyValid).toBeNull(); + expect(fresh.isValidatingGemini).toBe(false); + }); + + it('changing the key or the model invalidates a previous verification', () => { + useApiStore.setState({ geminiKeyValid: true }); + state().setGeminiApiKey('AIza-new'); + expect(state().geminiKeyValid).toBeNull(); + + useApiStore.setState({ geminiKeyValid: true }); + state().setGeminiModel('gemini-other'); + expect(state().geminiKeyValid).toBeNull(); + expect(state().geminiModel).toBe('gemini-other'); + }); + + it('persists the key and model (like the other provider settings)', () => { + const opts = useApiStore.persist.getOptions(); + const persisted = opts.partialize!(state()) as Record; + expect(persisted).toHaveProperty('geminiApiKey'); + expect(persisted).toHaveProperty('geminiModel'); + // …but not transient validation state. + expect(persisted).not.toHaveProperty('geminiKeyValid'); + expect(persisted).not.toHaveProperty('isValidatingGemini'); + }); + + it('upgrading from an older persisted state keeps its keys and fills in Gemini defaults', async () => { + const opts = useApiStore.persist.getOptions(); + expect(opts.version).toBeGreaterThanOrEqual(5); + // What a v4 user has in storage: no gemini fields at all. + const v4 = { provider: 'ollama', claudeApiKey: 'sk-old', ollamaApiKey: 'o', ollamaModel: 'm', researchBackend: 'wikipedia', advancedMode: true }; + const migrated = (await opts.migrate!(v4, 4)) as Record; + // The migration must not drop anything the user had… + expect(migrated).toMatchObject(v4); + // …and persist's shallow merge over the initial state supplies the new fields. + const merged = { ...useApiStore.getInitialState(), ...migrated } as ReturnType; + expect(merged.geminiModel).toBe(DEFAULT_GEMINI_MODEL); + expect(merged.geminiApiKey).toBe(''); + expect(merged.provider).toBe('ollama'); + }); +}); diff --git a/tests/components/SetupPage.gemini.test.tsx b/tests/components/SetupPage.gemini.test.tsx new file mode 100644 index 0000000..63cd628 --- /dev/null +++ b/tests/components/SetupPage.gemini.test.tsx @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, within, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { SetupPage } from '../../src/pages/SetupPage'; +import { useCourseStore } from '../../src/store/courseStore'; +import { useApiStore } from '../../src/store/apiStore'; +import { useUiStore } from '../../src/store/uiStore'; +import { DEFAULT_GEMINI_MODEL } from '../../src/services/claude/client'; + +const navigateMock = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => navigateMock }; +}); + +const TOPIC = 'Cognitive Load Theory and how working memory shapes good teaching.'; + +function renderPage() { + return render( + + + , + ); +} + +function resetStores() { + navigateMock.mockClear(); + useCourseStore.getState().reset(); + useUiStore.setState({ openKeysOnNextSetupVisit: false }); + useApiStore.setState({ + provider: 'anthropic', + researchBackend: 'anthropic', + advancedMode: false, + claudeApiKey: '', + claudeKeyValid: null, + ollamaApiKey: '', + ollamaKeyValid: null, + geminiApiKey: '', + geminiModel: DEFAULT_GEMINI_MODEL, + geminiKeyValid: null, + tavilyApiKey: '', + }); +} + +describe(' — Gemini as a course-content provider', () => { + beforeEach(resetStores); + afterEach(() => vi.unstubAllGlobals()); + + describe('Begin gating follows the ACTIVE provider\'s key', () => { + it('a Claude key does not satisfy a Gemini user, and the message names Gemini', () => { + useCourseStore.getState().updateSetup({ topic: TOPIC }); + useApiStore.setState({ provider: 'gemini', claudeApiKey: 'sk-ant-x', geminiApiKey: '' }); + renderPage(); + + expect(screen.getByText(/add your gemini key to continue/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /begin · syllabus/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /add gemini key/i })).toBeInTheDocument(); + }); + + it('a Gemini key alone is enough, with no Claude key at all', () => { + useCourseStore.getState().updateSetup({ topic: TOPIC }); + useApiStore.setState({ provider: 'gemini', claudeApiKey: '', geminiApiKey: 'AIza-x' }); + renderPage(); + + expect(screen.getByRole('button', { name: /begin · syllabus/i })).toBeEnabled(); + }); + + it('warns when the Gemini key failed verification', () => { + useCourseStore.getState().updateSetup({ topic: TOPIC }); + useApiStore.setState({ provider: 'gemini', geminiApiKey: 'AIza-bad', geminiKeyValid: false }); + renderPage(); + + expect(screen.getByText(/your gemini key failed verification/i)).toBeInTheDocument(); + }); + }); + + describe('API keys dialog', () => { + async function openModal() { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 403 }))); + const user = userEvent.setup(); + renderPage(); + await user.click(screen.getByRole('button', { name: /set keys|manage keys/i })); + const dialog = await screen.findByRole('dialog'); + return { user, dialog }; + } + + it('offers Gemini as a third provider and selecting it switches the store', async () => { + const { user, dialog } = await openModal(); + // ("Claude" also prefixes the "Claude search" research pill, hence getAll.) + expect(within(dialog).getAllByRole('button', { name: /^claude/i }).length).toBeGreaterThanOrEqual(1); + expect(within(dialog).getByRole('button', { name: /^ollama cloud/i })).toBeInTheDocument(); + + await user.click(within(dialog).getByRole('button', { name: /^gemini/i })); + + expect(useApiStore.getState().provider).toBe('gemini'); + }); + + it('shows the model field only for the Gemini provider, defaulting to the default model', async () => { + const { user, dialog } = await openModal(); + expect(within(dialog).queryByLabelText(/gemini model/i)).not.toBeInTheDocument(); + + await user.click(within(dialog).getByRole('button', { name: /^gemini/i })); + + expect(within(dialog).getByLabelText(/gemini model/i)).toHaveValue(DEFAULT_GEMINI_MODEL); + }); + + it('labels the Gemini key required only when Gemini is the provider', async () => { + const { user, dialog } = await openModal(); + expect(within(dialog).getByLabelText(/gemini · optional/i)).toBeInTheDocument(); + + await user.click(within(dialog).getByRole('button', { name: /^gemini/i })); + + expect(within(dialog).getByLabelText(/gemini · required/i)).toBeInTheDocument(); + }); + + it('keeps focus while typing the key and a custom model (the modal must not steal focus)', async () => { + const { user, dialog } = await openModal(); + await user.click(within(dialog).getByRole('button', { name: /^gemini/i })); + + await user.type(within(dialog).getByLabelText(/gemini · required/i), 'AIza-typed-key'); + const model = within(dialog).getByLabelText(/gemini model/i); + await user.clear(model); + await user.type(model, 'gemini-3.1-pro-preview'); + + expect(useApiStore.getState().geminiApiKey).toBe('AIza-typed-key'); + expect(useApiStore.getState().geminiModel).toBe('gemini-3.1-pro-preview'); + }); + + it('Verify checks the chosen model with the key in a header, not the URL, and shows ✓ on success', async () => { + const fetchMock = vi.fn(async () => ({ ok: true, status: 200 })); + vi.stubGlobal('fetch', fetchMock); + useApiStore.setState({ provider: 'gemini', geminiApiKey: 'AIza-good', geminiModel: 'gemini-test-model' }); + const user = userEvent.setup(); + renderPage(); + await user.click(screen.getByRole('button', { name: /manage keys/i })); + const dialog = await screen.findByRole('dialog'); + fetchMock.mockClear(); // ignore the automatic check on open + + await user.click(within(dialog).getByRole('button', { name: /^verify$/i })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, { headers: Record }]; + expect(url).toBe('https://generativelanguage.googleapis.com/v1beta/models/gemini-test-model'); + expect(url).not.toContain('AIza-good'); + expect(init.headers['x-goog-api-key']).toBe('AIza-good'); + await waitFor(() => expect(useApiStore.getState().geminiKeyValid).toBe(true)); + expect(await within(dialog).findByText(/✓ verified/i)).toBeInTheDocument(); + }); + + it('marks the key failed when Google rejects it (bad key or unknown model)', async () => { + const fetchMock = vi.fn(async () => ({ ok: false, status: 404 })); + vi.stubGlobal('fetch', fetchMock); + useApiStore.setState({ provider: 'gemini', geminiApiKey: 'AIza-x', geminiModel: 'no-such-model' }); + const user = userEvent.setup(); + renderPage(); + await user.click(screen.getByRole('button', { name: /manage keys/i })); + const dialog = await screen.findByRole('dialog'); + + await user.click(within(dialog).getByRole('button', { name: /^verify$/i })); + + await waitFor(() => expect(useApiStore.getState().geminiKeyValid).toBe(false)); + expect(await within(dialog).findByText(/✗ failed/i)).toBeInTheDocument(); + }); + + it('treats a network failure as failed, not as a crash', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); })); + useApiStore.setState({ provider: 'gemini', geminiApiKey: 'AIza-x' }); + const user = userEvent.setup(); + renderPage(); + await user.click(screen.getByRole('button', { name: /manage keys/i })); + const dialog = await screen.findByRole('dialog'); + + await user.click(within(dialog).getByRole('button', { name: /^verify$/i })); + + await waitFor(() => expect(useApiStore.getState().geminiKeyValid).toBe(false)); + }); + + it('editing the model invalidates an earlier "verified" status', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200 }))); + useApiStore.setState({ provider: 'gemini', geminiApiKey: 'AIza-x', geminiKeyValid: true }); + const user = userEvent.setup(); + renderPage(); + await user.click(screen.getByRole('button', { name: /manage keys/i })); + const dialog = await screen.findByRole('dialog'); + + await user.type(within(dialog).getByLabelText(/gemini model/i), 'x'); + + expect(useApiStore.getState().geminiKeyValid).toBeNull(); + }); + + it('does not disturb the other providers\' fields', async () => { + useApiStore.setState({ claudeApiKey: 'sk-ant-keep', ollamaApiKey: 'ollama-keep' }); + const { user, dialog } = await openModal(); + + await user.click(within(dialog).getByRole('button', { name: /^gemini/i })); + await user.type(within(dialog).getByLabelText(/gemini · required/i), 'AIza-1'); + + expect(useApiStore.getState().claudeApiKey).toBe('sk-ant-keep'); + expect(useApiStore.getState().ollamaApiKey).toBe('ollama-keep'); + }); + }); +}); diff --git a/tests/components/provider-gates.test.tsx b/tests/components/provider-gates.test.tsx new file mode 100644 index 0000000..2dd6c87 --- /dev/null +++ b/tests/components/provider-gates.test.tsx @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { CourseOutlineUpload } from '../../src/components/setup/CourseOutlineUpload'; +import { SyllabusPage } from '../../src/pages/SyllabusPage'; +import { ExportPage } from '../../src/pages/ExportPage'; +import { ResearchPage } from '../../src/pages/ResearchPage'; +import { useCourseStore } from '../../src/store/courseStore'; +import { useApiStore } from '../../src/store/apiStore'; +import { useUiStore } from '../../src/store/uiStore'; +import { streamMessage } from '../../src/services/claude/streaming'; +import { runResearch } from '../../src/services/research'; +import type { Syllabus } from '../../src/types/course'; + +/** + * Several screens used to gate on `claudeApiKey` being present, so anyone on Ollama or + * Gemini WITHOUT a Claude key was silently blocked (e.g. the syllabus never auto-started). + * They now ask the ACTIVE provider's key. These tests pin that for every provider. + */ +vi.mock('../../src/services/claude/streaming', () => ({ + streamMessage: vi.fn(() => new Promise(() => {})), + streamWithRetry: vi.fn(() => new Promise(() => {})), +})); +vi.mock('../../src/services/research', () => ({ + runResearch: vi.fn(() => new Promise(() => {})), +})); +const navigateMock = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => navigateMock }; +}); + +const NO_KEYS = { + claudeApiKey: '', + ollamaApiKey: '', + geminiApiKey: '', + tavilyApiKey: '', + provider: 'anthropic' as const, + researchBackend: 'anthropic' as const, +}; + +function setKeys(partial: Partial & Record) { + useApiStore.setState({ ...NO_KEYS, ...partial }); +} + +function syllabus(n = 2): Syllabus { + return { + courseTitle: 'Intro to Statistics', + courseOverview: 'A first course in stats.', + chapters: Array.from({ length: n }, (_, i) => ({ + number: i + 1, + title: `Topic ${i + 1}`, + narrative: 'A short narrative for the chapter.', + keyConcepts: ['concept'], + widgets: [], + scienceAnnotations: [], + spacingConnections: [], + })), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + navigateMock.mockClear(); + useCourseStore.getState().reset(); + useUiStore.setState({ error: null, isGenerating: false }); + setKeys({}); +}); + +describe('outline upload gate', () => { + async function upload() { + const user = userEvent.setup(); + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(input, new File(['not really a docx'], 'outline.docx')); + } + + it('names the active provider when its key is missing, even if another provider has one', async () => { + setKeys({ provider: 'gemini', claudeApiKey: 'sk-ant-present', geminiApiKey: '' }); + await upload(); + expect(await screen.findByText(/add your gemini api key before uploading/i)).toBeInTheDocument(); + }); + + it('passes the gate with only a Gemini key (the failure is then about the file, not the key)', async () => { + setKeys({ provider: 'gemini', claudeApiKey: '', geminiApiKey: 'AIza-x' }); + await upload(); + await waitFor(() => expect(screen.queryByText(/before uploading/i)).not.toBeInTheDocument()); + expect(await screen.findByText(/could not parse|couldn.t|failed|error/i)).toBeInTheDocument(); + }); +}); + +describe('syllabus auto-start', () => { + const renderSyllabus = () => render(); + + it.each([ + ['gemini', { geminiApiKey: 'AIza-x' }], + ['ollama', { ollamaApiKey: 'ollama-x' }], // was silently blocked before the fix + ['anthropic', { claudeApiKey: 'sk-ant-x' }], + ] as const)('starts drafting with only the %s key set', (provider, keys) => { + setKeys({ provider, ...keys }); + renderSyllabus(); + expect(streamMessage).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['gemini', { claudeApiKey: 'sk-ant-x', ollamaApiKey: 'o' }], + ['ollama', { claudeApiKey: 'sk-ant-x', geminiApiKey: 'g' }], + ['anthropic', { ollamaApiKey: 'o', geminiApiKey: 'g' }], + ] as const)('does NOT start for %s when only OTHER providers have keys', (provider, keys) => { + setKeys({ provider, ...keys }); + renderSyllabus(); + expect(streamMessage).not.toHaveBeenCalled(); + }); +}); + +describe('export: learning outcomes', () => { + const generateButton = () => screen.getByRole('button', { name: /generate learning outcomes/i }); + const renderExport = () => { + useCourseStore.setState({ syllabus: syllabus() }); + return render(); + }; + + it('is enabled with only a Gemini key', () => { + setKeys({ provider: 'gemini', geminiApiKey: 'AIza-x' }); + renderExport(); + expect(generateButton()).toBeEnabled(); + }); + + it('is disabled when only another provider has a key', () => { + setKeys({ provider: 'gemini', claudeApiKey: 'sk-ant-x' }); + renderExport(); + expect(generateButton()).toBeDisabled(); + }); + + it('is enabled with only an Ollama key', () => { + setKeys({ provider: 'ollama', ollamaApiKey: 'ollama-x' }); + renderExport(); + expect(generateButton()).toBeEnabled(); + }); +}); + +describe('research: which key each backend needs', () => { + const renderResearch = () => { + useCourseStore.setState({ syllabus: syllabus() }); + return render(); + }; + + it('Wikipedia research runs on a Gemini-only setup and hands Gemini\'s key to the synthesiser', () => { + setKeys({ provider: 'gemini', researchBackend: 'wikipedia', geminiApiKey: 'AIza-synth' }); + renderResearch(); + + expect(screen.queryByRole('heading', { name: /research backend not configured/i })).not.toBeInTheDocument(); + expect(runResearch).toHaveBeenCalledWith( + 'wikipedia', + expect.objectContaining({ llmApiKey: 'AIza-synth' }), + expect.any(Function), + ); + }); + + it('Tavily research needs the Tavily key AND the active LLM\'s key', () => { + setKeys({ provider: 'gemini', researchBackend: 'tavily', geminiApiKey: 'AIza-x', tavilyApiKey: '' }); + const { unmount } = renderResearch(); + expect(screen.getByText(/tavily backend needs both a tavily key and an llm key/i)).toBeInTheDocument(); + expect(runResearch).not.toHaveBeenCalled(); + unmount(); + + setKeys({ provider: 'gemini', researchBackend: 'tavily', geminiApiKey: 'AIza-x', tavilyApiKey: 'tvly-x' }); + renderResearch(); + expect(runResearch).toHaveBeenCalledWith( + 'tavily', + expect.objectContaining({ llmApiKey: 'AIza-x', tavilyApiKey: 'tvly-x' }), + expect.any(Function), + ); + }); + + it('"Claude web search" still needs a Claude key — Gemini cannot run Anthropic\'s search tool', () => { + setKeys({ provider: 'gemini', researchBackend: 'anthropic', geminiApiKey: 'AIza-x' }); + renderResearch(); + expect(screen.getByRole('heading', { name: /research backend not configured/i })).toBeInTheDocument(); + expect(screen.getByText(/claude web-search backend needs an anthropic api key/i)).toBeInTheDocument(); + expect(runResearch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/llm-gemini.test.ts b/tests/llm-gemini.test.ts new file mode 100644 index 0000000..b0da96a --- /dev/null +++ b/tests/llm-gemini.test.ts @@ -0,0 +1,338 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { streamMessageGemini } from '../src/services/llm/gemini'; +import type { StreamCallbacks, StreamOptions } from '../src/services/llm/types'; + +interface Call { + url: string; + method: string; + headers: Headers; + body: Record; + signal?: AbortSignal | null; +} + +/** Build an SSE response. `wire` is the exact byte segments as they arrive, so tests can split mid-event. */ +function sseResponse(wire: string[], status = 200): Response { + const stream = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + for (const c of wire) controller.enqueue(enc.encode(c)); + controller.close(); + }, + }); + return new Response(stream, { status, headers: { 'Content-Type': 'text/event-stream' } }); +} + +const event = (obj: unknown) => `data: ${JSON.stringify(obj)}\n\n`; +const textChunk = (text: string, extra: Record = {}) => ({ + candidates: [{ content: { role: 'model', parts: [{ text }] }, ...extra }], +}); +const thoughtChunk = (text: string) => ({ + candidates: [{ content: { role: 'model', parts: [{ text, thought: true }] } }], +}); +const errorResponse = (status: number, message: string, statusText: string) => + new Response(JSON.stringify({ error: { code: status, message, status: statusText } }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +function installFetch(handler: (call: Call, n: number) => Response): { calls: Call[]; restore: () => void } { + const calls: Call[] = []; + const original = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const call: Call = { + url: typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url, + method: init?.method ?? 'GET', + headers: new Headers(init?.headers ?? {}), + body: init?.body ? JSON.parse(init.body as string) : {}, + signal: init?.signal, + }; + calls.push(call); + return handler(call, calls.length); + }) as typeof globalThis.fetch; + return { calls, restore: () => { globalThis.fetch = original; } }; +} + +const opts = (o: Partial = {}): StreamOptions => ({ + apiKey: 'AIza-test', + model: 'gemini-3.8-flash', + messages: [{ role: 'user', content: 'Hello' }], + ...o, +}); + +describe('streamMessageGemini', () => { + let restore: (() => void) | null = null; + beforeEach(() => vi.spyOn(console, 'warn').mockImplementation(() => {})); + afterEach(() => { + restore?.(); + restore = null; + vi.restoreAllMocks(); + }); + + describe('request contract', () => { + it('POSTs to the streaming endpoint with the key in a header, never in the URL', async () => { + const f = installFetch(() => sseResponse([event(textChunk('hi'))])); + restore = f.restore; + await streamMessageGemini(opts({ model: 'gemini-3.8-flash' }), {}); + + expect(f.calls).toHaveLength(1); + const c = f.calls[0]; + expect(c.method).toBe('POST'); + expect(c.url).toBe( + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash:streamGenerateContent?alt=sse', + ); + expect(c.url).not.toContain('AIza-test'); + expect(c.headers.get('x-goog-api-key')).toBe('AIza-test'); + expect(c.headers.get('content-type')).toBe('application/json'); + }); + + it('encodes odd model ids and maps system prompt + roles (assistant → model)', async () => { + const f = installFetch(() => sseResponse([event(textChunk('ok'))])); + restore = f.restore; + await streamMessageGemini( + opts({ + model: 'models/x y', + system: 'You are a course architect.', + messages: [ + { role: 'user', content: 'Plan it' }, + { role: 'assistant', content: 'Draft one' }, + { role: 'user', content: 'Refine it' }, + ], + }), + {}, + ); + const body = f.calls[0].body as { + systemInstruction: { parts: { text: string }[] }; + contents: { role: string; parts: { text: string }[] }[]; + }; + expect(f.calls[0].url).toContain('/models/models%2Fx%20y:streamGenerateContent'); + expect(body.systemInstruction.parts[0].text).toBe('You are a course architect.'); + expect(body.contents.map((c) => [c.role, c.parts[0].text])).toEqual([ + ['user', 'Plan it'], + ['model', 'Draft one'], + ['user', 'Refine it'], + ]); + }); + + it('flattens content blocks to text and drops empty/tool-only messages', async () => { + const f = installFetch(() => sseResponse([event(textChunk('ok'))])); + restore = f.restore; + await streamMessageGemini( + opts({ + messages: [ + { role: 'user', content: [{ type: 'text', text: 'part A' }, { type: 'text', text: 'part B' }] }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { role: 'assistant', content: [{ type: 'tool_use', id: 't', name: 'x', input: {} } as any] }, + ], + }), + {}, + ); + const contents = (f.calls[0].body as { contents: { parts: { text: string }[] }[] }).contents; + expect(contents).toHaveLength(1); + expect(contents[0].parts[0].text).toBe('part A\npart B'); + }); + + it('omits systemInstruction when there is no system prompt', async () => { + const f = installFetch(() => sseResponse([event(textChunk('ok'))])); + restore = f.restore; + await streamMessageGemini(opts(), {}); + expect(f.calls[0].body).not.toHaveProperty('systemInstruction'); + }); + + it('leaves headroom above maxTokens for thinking, capped at the 65,536 output ceiling', async () => { + const f = installFetch(() => sseResponse([event(textChunk('ok'))])); + restore = f.restore; + await streamMessageGemini(opts({ maxTokens: 4000 }), {}); + await streamMessageGemini(opts({ maxTokens: 4000, thinkingBudget: 'low' }), {}); + await streamMessageGemini(opts({ maxTokens: 60000, thinkingBudget: 'max' }), {}); + const max = f.calls.map((c) => (c.body.generationConfig as { maxOutputTokens: number }).maxOutputTokens); + expect(max[0]).toBe(4000); // no thinking requested → exactly what the caller asked for + expect(max[1]).toBeGreaterThan(4000); + expect(max[2]).toBe(65536); + }); + + it('asks for thought summaries only when a thinking budget was requested', async () => { + const f = installFetch(() => sseResponse([event(textChunk('ok'))])); + restore = f.restore; + await streamMessageGemini(opts(), {}); + await streamMessageGemini(opts({ thinkingBudget: 'medium' }), {}); + const cfg = f.calls.map((c) => c.body.generationConfig as Record); + expect(cfg[0]).not.toHaveProperty('thinkingConfig'); + expect(cfg[1].thinkingConfig).toEqual({ includeThoughts: true }); + }); + + it('passes the abort signal through to fetch', async () => { + const f = installFetch(() => sseResponse([event(textChunk('ok'))])); + restore = f.restore; + const ac = new AbortController(); + await streamMessageGemini(opts({ signal: ac.signal }), {}); + expect(f.calls[0].signal).toBe(ac.signal); + }); + }); + + describe('streaming', () => { + it('streams text, routes thought summaries to onThinking, and returns the full text', async () => { + const f = installFetch(() => + sseResponse([event(thoughtChunk('planning…')), event(textChunk('Hello ')), event(textChunk('world', { finishReason: 'STOP' }))]), + ); + restore = f.restore; + const cb: StreamCallbacks = { onText: vi.fn(), onThinking: vi.fn(), onDone: vi.fn(), onError: vi.fn() }; + const out = await streamMessageGemini(opts({ thinkingBudget: 'high' }), cb); + + expect(out).toBe('Hello world'); + expect(cb.onThinking).toHaveBeenCalledWith('planning…'); + expect(cb.onText).toHaveBeenNthCalledWith(1, 'Hello '); + expect(cb.onText).toHaveBeenNthCalledWith(2, 'world'); + expect(cb.onDone).toHaveBeenCalledWith('Hello world'); + expect(cb.onError).not.toHaveBeenCalled(); + }); + + it('reassembles events split across arbitrary network chunks (mid-JSON, mid-delimiter)', async () => { + const all = event(textChunk('abc')) + event(textChunk('def')); + // Cut at awkward places: inside the JSON, and between the two newlines of the delimiter. + const cuts = [7, 31, all.indexOf('\n\n') + 1, all.length - 3]; + const wire: string[] = []; + let prev = 0; + for (const c of [...cuts, all.length]) { wire.push(all.slice(prev, c)); prev = c; } + const f = installFetch(() => sseResponse(wire)); + restore = f.restore; + expect(await streamMessageGemini(opts(), {})).toBe('abcdef'); + }); + + it('handles CRLF event delimiters', async () => { + const crlf = (o: unknown) => `data: ${JSON.stringify(o)}\r\n\r\n`; + const f = installFetch(() => sseResponse([crlf(textChunk('one ')), crlf(textChunk('two'))])); + restore = f.restore; + expect(await streamMessageGemini(opts(), {})).toBe('one two'); + }); + + it('accepts a final event that arrives without a trailing blank line', async () => { + const f = installFetch(() => sseResponse([event(textChunk('a')), `data: ${JSON.stringify(textChunk('b'))}`])); + restore = f.restore; + expect(await streamMessageGemini(opts(), {})).toBe('ab'); + }); + + it('ignores non-JSON keep-alives and parts with no text', async () => { + const f = installFetch(() => + sseResponse([ + ': keep-alive\n\n', + 'data: not json\n\n', + event({ candidates: [{ content: { parts: [{}] } }] }), + event(textChunk('real')), + ]), + ); + restore = f.restore; + expect(await streamMessageGemini(opts(), {})).toBe('real'); + }); + + it('returns the partial text on MAX_TOKENS (like the Anthropic path) and warns', async () => { + const f = installFetch(() => sseResponse([event(textChunk('{"half":', { finishReason: 'MAX_TOKENS' }))])); + restore = f.restore; + expect(await streamMessageGemini(opts(), {})).toBe('{"half":'); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('MAX_TOKENS')); + }); + }); + + describe('errors', () => { + it('reports Google\'s error message with the HTTP code and status, and calls onError', async () => { + const f = installFetch(() => errorResponse(400, 'API key not valid. Please pass a valid API key.', 'INVALID_ARGUMENT')); + restore = f.restore; + const cb: StreamCallbacks = { onError: vi.fn() }; + await expect(streamMessageGemini(opts(), cb)).rejects.toThrow( + 'Gemini 400 INVALID_ARGUMENT: API key not valid. Please pass a valid API key.', + ); + expect(cb.onError).toHaveBeenCalledOnce(); + expect(f.calls).toHaveLength(1); // an unrelated 400 is not retried + }); + + it('keeps "429" in the message so streamWithRetry\'s rate-limit backoff recognises it', async () => { + const f = installFetch(() => errorResponse(429, 'Quota exceeded for this model.', 'RESOURCE_EXHAUSTED')); + restore = f.restore; + await expect(streamMessageGemini(opts(), {})).rejects.toThrow(/Gemini 429 RESOURCE_EXHAUSTED/); + }); + + it('attaches the HTTP status to the error so friendlyError can map it', async () => { + const f = installFetch(() => errorResponse(404, 'models/nope is not found', 'NOT_FOUND')); + restore = f.restore; + const err = await streamMessageGemini(opts(), {}).catch((e) => e); + expect((err as { status?: number }).status).toBe(404); + }); + + it('falls back to the raw body / status text when the error is not JSON', async () => { + const f = installFetch(() => new Response('upstream exploded', { status: 502, statusText: 'Bad Gateway' })); + restore = f.restore; + await expect(streamMessageGemini(opts(), {})).rejects.toThrow('Gemini 502: upstream exploded'); + }); + + it('retries once WITHOUT thought summaries when a 400 complains about thinking', async () => { + const f = installFetch((call, n) => + n === 1 + ? errorResponse(400, 'Thinking config is not supported for this model.', 'INVALID_ARGUMENT') + : sseResponse([event(textChunk('fine'))]), + ); + restore = f.restore; + const out = await streamMessageGemini(opts({ thinkingBudget: 'high' }), {}); + + expect(out).toBe('fine'); + expect(f.calls).toHaveLength(2); + expect((f.calls[0].body.generationConfig as Record).thinkingConfig).toBeDefined(); + expect((f.calls[1].body.generationConfig as Record).thinkingConfig).toBeUndefined(); + }); + + it('does not retry a thinking-flavoured 400 when no thinking was requested', async () => { + const f = installFetch(() => errorResponse(400, 'thinking is odd', 'INVALID_ARGUMENT')); + restore = f.restore; + await expect(streamMessageGemini(opts(), {})).rejects.toThrow(/Gemini 400/); + expect(f.calls).toHaveLength(1); + }); + + it('explains a blocked prompt instead of returning an empty string', async () => { + const f = installFetch(() => sseResponse([event({ promptFeedback: { blockReason: 'SAFETY' } })])); + restore = f.restore; + await expect(streamMessageGemini(opts(), {})).rejects.toThrow(/blocked the response \(SAFETY\).*another provider/); + }); + + it('explains a response cut off by the safety filter mid-stream with no text', async () => { + const f = installFetch(() => sseResponse([event({ candidates: [{ finishReason: 'PROHIBITED_CONTENT' }] })])); + restore = f.restore; + await expect(streamMessageGemini(opts(), {})).rejects.toThrow(/blocked the response \(PROHIBITED_CONTENT\)/); + }); + + it('says so when the model returns nothing at all', async () => { + const f = installFetch(() => sseResponse([event({ candidates: [{ finishReason: 'STOP' }] })])); + restore = f.restore; + await expect(streamMessageGemini(opts(), {})).rejects.toThrow('Gemini returned no text (finish reason: STOP).'); + }); + + it('surfaces an error object delivered inside the stream', async () => { + const f = installFetch(() => + sseResponse([event(textChunk('partial')), event({ error: { code: 503, status: 'UNAVAILABLE', message: 'overloaded' } })]), + ); + restore = f.restore; + await expect(streamMessageGemini(opts(), {})).rejects.toThrow('Gemini 503 UNAVAILABLE: overloaded'); + }); + + it('fails fast, without calling the network, when the key or model is missing', async () => { + const f = installFetch(() => sseResponse([event(textChunk('x'))])); + restore = f.restore; + const cb: StreamCallbacks = { onError: vi.fn() }; + await expect(streamMessageGemini(opts({ apiKey: '' }), cb)).rejects.toThrow(/key is missing/); + await expect(streamMessageGemini(opts({ model: undefined }), cb)).rejects.toThrow(/requires a model name/); + expect(f.calls).toHaveLength(0); + expect(cb.onError).toHaveBeenCalledTimes(2); + }); + + it('rejects when there is nothing to send', async () => { + const f = installFetch(() => sseResponse([])); + restore = f.restore; + await expect(streamMessageGemini(opts({ messages: [{ role: 'user', content: '' }] }), {})).rejects.toThrow(/no message content/); + expect(f.calls).toHaveLength(0); + }); + + it('propagates an abort as the original error (callers treat it as a silent cancel)', async () => { + const abort = new DOMException('The operation was aborted.', 'AbortError'); + const f = installFetch(() => { throw abort; }); + restore = f.restore; + await expect(streamMessageGemini(opts(), {})).rejects.toBe(abort); + }); + }); +}); diff --git a/tests/llm-router.test.ts b/tests/llm-router.test.ts new file mode 100644 index 0000000..41e7eb2 --- /dev/null +++ b/tests/llm-router.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// The router's job is deciding WHICH backend gets WHICH key and model. Stub the backends +// and assert on what they receive. +vi.mock('../src/services/llm/anthropic', () => ({ + streamMessageAnthropic: vi.fn(async () => 'from-anthropic'), + sendMessageAnthropic: vi.fn(), +})); +vi.mock('../src/services/llm/ollama', () => ({ streamMessageOllama: vi.fn(async () => 'from-ollama') })); +vi.mock('../src/services/llm/gemini', () => ({ streamMessageGemini: vi.fn(async () => 'from-gemini') })); + +import { streamMessage, streamWithRetry } from '../src/services/llm'; +import { streamMessageAnthropic } from '../src/services/llm/anthropic'; +import { streamMessageOllama } from '../src/services/llm/ollama'; +import { streamMessageGemini } from '../src/services/llm/gemini'; +import { useApiStore } from '../src/store/apiStore'; +import { MODELS, DEFAULT_GEMINI_MODEL } from '../src/services/claude/client'; + +const base = { messages: [{ role: 'user' as const, content: 'hi' }] }; + +describe('LLM router — provider, key and model resolution', () => { + beforeEach(() => { + vi.clearAllMocks(); + useApiStore.setState({ + provider: 'anthropic', + claudeApiKey: 'sk-ant', + ollamaApiKey: 'ollama-key', + ollamaModel: 'gpt-oss:120b-cloud', + geminiApiKey: 'AIza-store', + geminiModel: 'gemini-store-model', + }); + }); + + it('sends Anthropic the key and (Claude) model the call site gave it', async () => { + expect(await streamMessage({ ...base, apiKey: 'sk-call', model: MODELS.opus }, {})).toBe('from-anthropic'); + expect(streamMessageAnthropic).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: 'sk-call', model: MODELS.opus }), + {}, + ); + expect(streamMessageGemini).not.toHaveBeenCalled(); + expect(streamMessageOllama).not.toHaveBeenCalled(); + }); + + it('Gemini: uses the store key + model, ignoring the Claude key and Claude model id call sites pass', async () => { + useApiStore.setState({ provider: 'gemini' }); + // Every page passes claudeApiKey and MODELS.opus/sonnet/haiku regardless of provider. + expect(await streamMessage({ ...base, apiKey: 'sk-ant', model: MODELS.opus }, {})).toBe('from-gemini'); + expect(streamMessageGemini).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: 'AIza-store', model: 'gemini-store-model' }), + {}, + ); + expect(streamMessageAnthropic).not.toHaveBeenCalled(); + }); + + it('Gemini: a Claude model id from a call site never leaks through — the default model is used', async () => { + useApiStore.setState({ provider: 'gemini', geminiModel: DEFAULT_GEMINI_MODEL }); + await streamMessage({ ...base, apiKey: 'x', model: MODELS.haiku }, {}); + expect(streamMessageGemini).toHaveBeenCalledWith(expect.objectContaining({ model: DEFAULT_GEMINI_MODEL }), {}); + }); + + it('Gemini: honours an explicit non-Claude model override', async () => { + useApiStore.setState({ provider: 'gemini' }); + await streamMessage({ ...base, apiKey: 'x', model: 'gemini-custom' }, {}); + expect(streamMessageGemini).toHaveBeenCalledWith(expect.objectContaining({ model: 'gemini-custom' }), {}); + }); + + it('Gemini: explicit CLI overrides win over the browser store (the CLI never touches it)', async () => { + await streamMessage( + { ...base, apiKey: '', provider: 'gemini', geminiApiKey: 'AIza-cli', geminiModel: 'gemini-cli-model' }, + {}, + ); + expect(streamMessageGemini).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: 'AIza-cli', model: 'gemini-cli-model' }), + {}, + ); + }); + + it('an explicit provider option beats the stored provider', async () => { + useApiStore.setState({ provider: 'anthropic' }); + expect(await streamMessage({ ...base, apiKey: '', provider: 'gemini' }, {})).toBe('from-gemini'); + }); + + it('Ollama keeps working the same way (key + model from the store, Claude ids ignored)', async () => { + useApiStore.setState({ provider: 'ollama' }); + await streamMessage({ ...base, apiKey: 'sk-ant', model: MODELS.sonnet }, {}); + expect(streamMessageOllama).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: 'ollama-key', model: 'gpt-oss:120b-cloud' }), + {}, + ); + }); + + it('streamWithRetry backs off on a Gemini 429 and succeeds on the next try', async () => { + useApiStore.setState({ provider: 'gemini' }); + vi.mocked(streamMessageGemini) + .mockRejectedValueOnce(new Error('Gemini 429 RESOURCE_EXHAUSTED: Quota exceeded')) + .mockResolvedValueOnce('recovered'); + vi.useFakeTimers(); + const p = streamWithRetry({ ...base, apiKey: 'x' }, {}); + await vi.advanceTimersByTimeAsync(2000); + expect(await p).toBe('recovered'); + expect(streamMessageGemini).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + + it('streamWithRetry does not retry other Gemini errors', async () => { + useApiStore.setState({ provider: 'gemini' }); + vi.mocked(streamMessageGemini).mockRejectedValueOnce(new Error('Gemini 400 INVALID_ARGUMENT: bad key')); + await expect(streamWithRetry({ ...base, apiKey: 'x' }, {})).rejects.toThrow(/Gemini 400/); + expect(streamMessageGemini).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/utils-errors.test.ts b/tests/utils-errors.test.ts index e42709a..d9baaf0 100644 --- a/tests/utils-errors.test.ts +++ b/tests/utils-errors.test.ts @@ -84,4 +84,49 @@ describe('friendlyError', () => { expect(friendlyError('rate_limit exceeded')).toMatch(/too many requests/i); expect(friendlyError({ message: 'opaque' })).toMatch(/something went wrong/i); }); + + describe('Gemini (Google) error wording', () => { + // Real shapes: our GeminiHttpError message is "Gemini : ". + it("maps Google's \"API key not valid\" (a 400, not a 401) to \"API key rejected\"", () => { + const err = Object.assign( + new Error('Gemini 400 INVALID_ARGUMENT: API key not valid. Please pass a valid API key.'), + { status: 400 }, + ); + expect(friendlyError(err)).toMatch(/api key rejected/i); + }); + + it('also catches API_KEY_INVALID and expired keys', () => { + expect(friendlyError(new Error('API_KEY_INVALID'))).toMatch(/api key rejected/i); + expect(friendlyError(new Error('Gemini 400: API key expired. Please renew the API key.'))).toMatch(/api key rejected/i); + }); + + it('still reads other 400s as a rejected request (the key rule must not swallow them)', () => { + const err = Object.assign(new Error('Gemini 400 INVALID_ARGUMENT: contents is required'), { status: 400 }); + expect(friendlyError(err)).toMatch(/request was rejected/i); + }); + + it('maps an unknown / retired model (404) to a model-name hint', () => { + const err = Object.assign( + new Error('Gemini 404 NOT_FOUND: models/gemini-9 is not found for API version v1beta'), + { status: 404 }, + ); + expect(friendlyError(err)).toMatch(/model was not found.*model name/i); + expect(friendlyError(new Error('models/gemini-9 is not found for API version v1beta'))).toMatch(/model was not found/i); + }); + + it('maps a response Gemini blocked (RECITATION/BLOCKLIST included) to the softer-wording sentence', () => { + expect(friendlyError(new Error('Gemini blocked the response (RECITATION). Reword the topic, or switch…'))).toMatch(/declined to respond/i); + expect(friendlyError(new Error('Gemini blocked the response (BLOCKLIST). Reword the topic'))).toMatch(/declined to respond/i); + }); + + it('maps a 503 UNAVAILABLE to the overloaded sentence and a 403 to access denied', () => { + expect(friendlyError(Object.assign(new Error('Gemini 503 UNAVAILABLE: overloaded'), { status: 503 }))).toMatch(/overloaded/i); + expect(friendlyError(Object.assign(new Error('Gemini 403 PERMISSION_DENIED: no access'), { status: 403 }))).toMatch(/access denied/i); + }); + + it('does not misread unrelated "not found" text as a missing model', () => { + expect(friendlyError(new Error('file not found in archive'), 'fallback')).toBe('fallback'); + }); + }); }); + diff --git a/tools/smoke-gemini.ts b/tools/smoke-gemini.ts new file mode 100644 index 0000000..8cd0d12 --- /dev/null +++ b/tools/smoke-gemini.ts @@ -0,0 +1,128 @@ +/** + * Live smoke test for the Gemini backend (src/services/llm/gemini.ts). Unit tests mock + * `fetch`, so they cannot catch Google changing its API, a model id being retired, or a + * model rejecting a request field. Run this against the real service: + * + * GEMINI_API_KEY=AIza... npm run smoke:gemini + * GEMINI_API_KEY=AIza... GEMINI_MODEL=gemini-3.1-pro-preview npm run smoke:gemini + * + * The key is read from the environment and never printed. Checks that need a key are + * reported SKIPPED without one; the bad-key check always runs (it needs no real key). + * Exit code is 1 if any check that ran failed. + * + * Uses the same code the app uses, so a pass here means the browser path works too — + * Google answers CORS preflights for the x-goog-api-key header from any origin. + */ +import { streamMessageGemini } from '../src/services/llm/gemini'; +import { friendlyError } from '../src/utils/errors'; +import { parseJson } from '../src/utils/format'; +import { DEFAULT_GEMINI_MODEL } from '../src/services/claude/client'; + +const KEY = process.env.GEMINI_API_KEY?.trim() ?? ''; +const MODEL = process.env.GEMINI_MODEL?.trim() || DEFAULT_GEMINI_MODEL; + +type Result = { name: string; status: 'PASS' | 'FAIL' | 'SKIPPED'; detail: string }; +const results: Result[] = []; + +async function check(name: string, needsKey: boolean, fn: () => Promise) { + if (needsKey && !KEY) { + results.push({ name, status: 'SKIPPED', detail: 'set GEMINI_API_KEY to run' }); + return; + } + try { + results.push({ name, status: 'PASS', detail: await fn() }); + } catch (e) { + results.push({ name, status: 'FAIL', detail: (e as Error).message }); + } +} + +function expect(cond: unknown, message: string): asserts cond { + if (!cond) throw new Error(message); +} + +const ask = (prompt: string, extra: Record = {}) => + streamMessageGemini( + { apiKey: KEY, model: MODEL, messages: [{ role: 'user', content: prompt }], maxTokens: 2000, ...extra }, + {}, + ); + +console.log(`Gemini smoke test — model: ${MODEL}, key: ${KEY ? 'provided' : 'NOT set'}\n`); + +await check('plain text', true, async () => { + const out = await ask('Reply with exactly one word: pong'); + expect(/pong/i.test(out), `expected "pong", got ${JSON.stringify(out.slice(0, 80))}`); + return `got ${JSON.stringify(out.trim().slice(0, 40))}`; +}); + +await check('streams in several chunks', true, async () => { + let chunks = 0; + const out = await streamMessageGemini( + { apiKey: KEY, model: MODEL, messages: [{ role: 'user', content: 'Count from 1 to 40, separated by spaces.' }], maxTokens: 2000 }, + { onText: () => { chunks++; } }, + ); + expect(out.includes('40'), 'did not count to 40'); + return `${chunks} text chunk(s)`; +}); + +await check('thinking budget accepted (and summaries, if the model offers them)', true, async () => { + let thoughts = 0; + const out = await streamMessageGemini( + { + apiKey: KEY, + model: MODEL, + messages: [{ role: 'user', content: 'What is 17 * 23? Reply with only the number.' }], + thinkingBudget: 'low', + maxTokens: 2000, + }, + { onThinking: () => { thoughts++; } }, + ); + expect(out.includes('391'), `expected 391, got ${JSON.stringify(out.slice(0, 80))}`); + return `answer ok; ${thoughts ? `${thoughts} thought-summary chunk(s)` : 'no thought summaries (the UI trace stays empty — cosmetic)'}`; +}); + +await check('structured JSON like the app asks for (quiz)', true, async () => { + const out = await ask( + 'Write 3 multiple-choice questions about photosynthesis.', + { + system: + 'You write quizzes. Return ONLY a JSON array, no prose. Each item: {"question": string, "options": string[4], "correctIndex": 0-3, "explanation": string}.', + maxTokens: 4000, + }, + ); + const parsed = parseJson(out) as Array<{ question: string; options: string[]; correctIndex: number }>; + expect(Array.isArray(parsed) && parsed.length === 3, `expected 3 items, got ${Array.isArray(parsed) ? parsed.length : typeof parsed}`); + for (const q of parsed) { + expect(typeof q.question === 'string' && q.options?.length === 4, 'item has the wrong shape'); + expect(Number.isInteger(q.correctIndex) && q.correctIndex >= 0 && q.correctIndex <= 3, 'bad correctIndex'); + } + return 'parsed 3 well-formed questions through the app\'s own parseJson'; +}); + +await check('unknown model id → a clear "model not found" message', true, async () => { + try { + await streamMessageGemini({ apiKey: KEY, model: 'gemini-this-model-does-not-exist', messages: [{ role: 'user', content: 'hi' }] }, {}); + } catch (e) { + const friendly = friendlyError(e); + expect(/model was not found/i.test(friendly), `friendlyError said: ${friendly} (raw: ${(e as Error).message.slice(0, 120)})`); + return `friendly message: "${friendly}"`; + } + throw new Error('expected an error for an unknown model'); +}); + +// Needs no real key: Google must reject it, and we must explain that well. +await check('bad key → "API key rejected" (against Google\'s real error text)', false, async () => { + try { + await streamMessageGemini({ apiKey: 'AIzaThisIsNotARealKey000000000000000000', model: MODEL, messages: [{ role: 'user', content: 'hi' }] }, {}); + } catch (e) { + const raw = (e as Error).message; + const friendly = friendlyError(e); + expect(/api key rejected/i.test(friendly), `friendlyError said: ${friendly} (raw: ${raw.slice(0, 160)})`); + return `raw: "${raw.slice(0, 90)}…" → "${friendly}"`; + } + throw new Error('expected an error for a bad key'); +}); + +for (const r of results) console.log(`${r.status.padEnd(7)} ${r.name}\n ${r.detail}`); +const failed = results.filter((r) => r.status === 'FAIL').length; +console.log(`\n${results.filter((r) => r.status === 'PASS').length} passed, ${failed} failed, ${results.filter((r) => r.status === 'SKIPPED').length} skipped`); +process.exit(failed ? 1 : 0); From fc9dbc8fdbe16f815826a05a83946ff1362a4864 Mon Sep 17 00:00:00 2001 From: daTechGuy Date: Sat, 3 Oct 2026 16:55:33 -0500 Subject: [PATCH 2/2] Fix flaky outline-upload gate test that failed CI with an unhandled rejection The test fed a fake .docx to the real parser, which rejects asynchronously; on CI's timing the rejection escaped the component's try/catch chain and Vitest failed the run ("1 unhandled error") although every test passed. Stub the DOCX parser and LLM extraction instead, and assert what the test is really about: with only a Gemini / Ollama / Anthropic key set, extraction runs and receives THAT provider's key. Co-Authored-By: Claude Sonnet 5.5 --- tests/components/provider-gates.test.tsx | 26 +++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tests/components/provider-gates.test.tsx b/tests/components/provider-gates.test.tsx index 2dd6c87..e981cb4 100644 --- a/tests/components/provider-gates.test.tsx +++ b/tests/components/provider-gates.test.tsx @@ -11,6 +11,7 @@ import { useApiStore } from '../../src/store/apiStore'; import { useUiStore } from '../../src/store/uiStore'; import { streamMessage } from '../../src/services/claude/streaming'; import { runResearch } from '../../src/services/research'; +import { docxToText, extractOutlineFields } from '../../src/services/template/parseOutlineDocx'; import type { Syllabus } from '../../src/types/course'; /** @@ -25,6 +26,12 @@ vi.mock('../../src/services/claude/streaming', () => ({ vi.mock('../../src/services/research', () => ({ runResearch: vi.fn(() => new Promise(() => {})), })); +// Stub the DOCX parser + LLM extraction: the point is which KEY reaches extraction, and feeding a +// fake .docx to the real parser fails asynchronously (an unhandled rejection that fails CI). +vi.mock('../../src/services/template/parseOutlineDocx', () => ({ + docxToText: vi.fn(async () => 'outline text'), + extractOutlineFields: vi.fn(async () => ({ fields: { courseTitle: 'Parsed Title' } })), +})); const navigateMock = vi.fn(); vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom'); @@ -73,20 +80,29 @@ describe('outline upload gate', () => { const user = userEvent.setup(); render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; - await user.upload(input, new File(['not really a docx'], 'outline.docx')); + await user.upload(input, new File(['stub'], 'outline.docx')); } it('names the active provider when its key is missing, even if another provider has one', async () => { setKeys({ provider: 'gemini', claudeApiKey: 'sk-ant-present', geminiApiKey: '' }); await upload(); expect(await screen.findByText(/add your gemini api key before uploading/i)).toBeInTheDocument(); + expect(docxToText).not.toHaveBeenCalled(); + expect(extractOutlineFields).not.toHaveBeenCalled(); }); - it('passes the gate with only a Gemini key (the failure is then about the file, not the key)', async () => { - setKeys({ provider: 'gemini', claudeApiKey: '', geminiApiKey: 'AIza-x' }); + it.each([ + ['gemini', { geminiApiKey: 'AIza-the-key' }, 'AIza-the-key'], + ['ollama', { ollamaApiKey: 'ollama-the-key' }, 'ollama-the-key'], + ['anthropic', { claudeApiKey: 'sk-ant-the-key' }, 'sk-ant-the-key'], + ] as const)('with only a %s key, extraction runs and receives THAT key', async (provider, keys, expectedKey) => { + setKeys({ provider, ...keys }); await upload(); - await waitFor(() => expect(screen.queryByText(/before uploading/i)).not.toBeInTheDocument()); - expect(await screen.findByText(/could not parse|couldn.t|failed|error/i)).toBeInTheDocument(); + + await waitFor(() => expect(extractOutlineFields).toHaveBeenCalledTimes(1)); + expect(extractOutlineFields).toHaveBeenCalledWith(expect.objectContaining({ apiKey: expectedKey, rawText: 'outline text' })); + await waitFor(() => expect(useCourseStore.getState().outlineFields?.courseTitle).toBe('Parsed Title')); + expect(screen.queryByText(/before uploading/i)).not.toBeInTheDocument(); }); });