Skip to content

Cloud AI providers: Gemini, Vertex AI, OpenAI

Local AI / Ollama needs a daemon running somewhere OLLAMA_BASE_URL can reach it — fine for local development or a self-hosted server you control, but not an option on a typical hosted platform (Render's own deploy guide defaults to AI_PROVIDER=none for exactly this reason — see docs/internal/render-deployment.md). AI_PROVIDER also accepts gemini, vertex, and openai (issue #315) for a deploy that needs real AI features without operating its own model host.

All three implement the same AIProvider port Ollama does — mnemonic suggestions, vocabulary extraction/enrichment, the conversation tutor, learning paths, and the companion coach all work identically regardless of which one is configured, and the companion coach's evidence/forbidden-claim validation (validate_generated_content) applies to every provider's output the same way, not just Ollama's.

Verification status — read this before choosing one for production

Every adapter's request construction, JSON-mode handling, and error-to-AIProviderUnavailableError mapping is covered by unit tests against a mocked transport (tests/test_google_ai_providers.py, tests/test_openai_provider.py) — no test in this codebase has made a real network call to Gemini, Vertex AI, or OpenAI, because no credentials for any of the three were available in the environment this was built in.

This is a materially different verification level than Ollama's own integration tests in tests/test_ollama_provider.py, which run for real against a local daemon whenever one happens to be reachable (see docs/reference/ai-model-verification.md for a dated log of an actual such run). No equivalent live-model pass exists yet for Gemini, Vertex AI, or OpenAI. Concretely, unverified against a real account:

  • that the request shape this adapter sends is accepted end-to-end by the real API (only the SDK's own request construction was inspected, not a live response from Google/OpenAI's servers);
  • real-world latency, JSON-mode compliance rate, and output quality for the prompts this codebase uses (the same category of finding docs/reference/ai-model-verification.md recorded for Ollama);
  • that GOOGLE_APPLICATION_CREDENTIALS/workload identity resolves the way this doc describes in an actual Render/Docker deployment.

Treat a first production rollout of any of these three as needing its own verification pass, the same way Ollama's did, before relying on it for real learners.

Gemini

The Gemini Developer API, authenticated with a single API key.

bash
AI_PROVIDER=gemini
GEMINI_API_KEY=your-key-from-aistudio.google.com
GEMINI_MODEL=gemini-2.5-flash

Get a key at aistudio.google.com/apikey. GEMINI_MODEL defaults to gemini-2.5-flash — Google's fast/economical tier, a deliberate choice for a feature that runs on every learner action rather than the top-of-line reasoning model.

Vertex AI

The same underlying Gemini models, reached through Google Cloud's Vertex AI API instead — for a deployment that already lives inside a GCP project and wants billing/quota/IAM to go through that project rather than a standalone API key.

bash
AI_PROVIDER=vertex
VERTEX_PROJECT_ID=your-gcp-project-id
VERTEX_LOCATION=us-central1
VERTEX_MODEL=gemini-2.5-flash

Vertex authenticates differently from Gemini: there is no VERTEX_API_KEY field. The google-genai SDK resolves Application Default Credentials on its own — a service-account key file referenced by the GOOGLE_APPLICATION_CREDENTIALS environment variable, or workload identity when running on GCP compute. That resolution happens entirely inside the SDK; this backend does not read, parse, or validate a credentials file itself. Set GOOGLE_APPLICATION_CREDENTIALS (and mount the key file it points at) in the deploy environment alongside VERTEX_PROJECT_ID — neither Docker Compose nor apps/backend/.env.example does this for you automatically, since where the key file lives is deployment-specific.

OpenAI

bash
AI_PROVIDER=openai
OPENAI_API_KEY=your-key-from-platform.openai.com
OPENAI_MODEL=gpt-5.6-luna

Get a key at platform.openai.com/api-keys. OPENAI_MODEL defaults to the current general-purpose model as confirmed against OpenAI's own API documentation while this adapter was built — model names churn faster than most dependencies, so re-check the live model list before trusting this indefinitely on a new deploy.

Startup validation

Same "fail at startup, not at generation" posture Ollama's own AI_PROVIDER validation already has (see local-ai-ollama.md): setting AI_PROVIDER to a cloud provider without its one required field — GEMINI_API_KEY/VERTEX_PROJECT_ID/OPENAI_API_KEY — stops the app at startup with a message naming exactly what's missing, rather than a confusing failure on someone's first suggestion request. The admin AI-settings API (PUT /api/v1/ai-settings) enforces the same check immediately on save, before the new configuration is even persisted.

Admin AI-settings API

GET/PUT /api/v1/ai-settings (admin-only, same as Ollama's) carry every provider's fields at once, not just the currently selected one — so switching providers through the API doesn't require a separate schema. GET never echoes a configured gemini_api_key/openai_api_key back; instead it reports gemini_api_key_set/openai_api_key_set booleans, the same "is this credential configured" shape many admin APIs use for a stored secret. Submitting a blank key on PUT leaves the previously stored one alone rather than clearing it, so updating just a model name or the selected provider doesn't require resending the secret.

GET /api/v1/ai-settings/probe behaves differently depending on the configured provider. For Ollama (or AI switched off), it is the original reachability + model-list check against the local daemon. For a cloud provider, it deliberately does not make a real generation call — that would be a paid API request fired on every admin page load — and instead reports whether the required credential looks configured (live_check_performed: false on the response marks this explicitly, so a caller does not mistake it for a verified live connection the way Ollama's own reachable: true is).

Bring Your Own Key (BYOK)

Everything above is deployment-wide: one AI_PROVIDER an administrator configures, used for every learner's requests. A hosted deployment with no billing/credits system cannot pay for everyone's usage that way forever — so a signed-in user can instead supply their own Gemini, OpenAI, or Vertex AI credential, on the Settings page, used automatically for their own requests. No admin opt-in is required to enable this per user.

Precedence. A user with no stored credential of their own is unaffected — every request still goes through the deployment's own AI_PROVIDER exactly as described above. A user with exactly one stored credential has it used regardless of what the deployment is configured with. A user who has stored credentials for more than one provider gets whichever one matches the deployment's own AI_PROVIDER, if any; if none matches, there is no principled way to guess which of two personal keys they meant, so it falls back to the deployment default rather than guessing. See resolve_ai_provider_for_user in app/api/deps.py for the exact policy and its own worked-through reasoning.

A broken personal credential is reported, not silently absorbed. If a user's own key stops working — revoked at the provider, or the deployment's master encryption key was rotated — their requests fail with the same "AI provider is not reachable" response any other provider failure produces. They deliberately do not fall back to the deployment's own key: the entire point of BYOK is that a deployment with no billing system does not pay for a user's usage, and silently spending its budget because a user's own key broke would undermine that.

Storage and encryption. Each credential is encrypted at rest with application-level authenticated encryption (cryptography.fernet.Fernet) under one master key, AI_CREDENTIAL_ENCRYPTION_KEY — not a cloud KMS or HashiCorp Vault, to avoid adding a second service to run and back up on top of this project's self-hosted-first Docker/Render/SQLite posture. Generate one with:

bash
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

Leaving it unset does not silently disable BYOK insecurely — every write to a credential fails with a clear 503 until it is configured, the same "fail loudly, not by degrading to something worse" posture the rest of this codebase's AI configuration already has.

API: GET/PUT/DELETE /api/v1/me/ai-credentials[/{provider}], user-scoped (any signed-in account, not admin-only). GET never returns a stored secret — only which providers are configured and each one's non-secret fields (Vertex's project_id/location; nothing for Gemini/OpenAI, whose only field is the key itself). PUT validates the payload against that provider's own schema (app/domain/services/ai_credentials.py — the extensibility point for a future provider: one new schema class, nothing else in this stack changes) before encrypting and storing it. Writes are rate-limited separately from AI generation itself (RATE_LIMIT_AI_CREDENTIAL_WRITES).

Verification status. Schema validation, the encrypt/decrypt round trip, the API's never-leak-a-secret contract, cross-user isolation, and every branch of the precedence/fallback policy above are covered by unit tests against a mocked transport — the same offline-only standard the deployment-wide adapters above are held to, and for the same reason: no real Gemini/OpenAI/Vertex AI credentials were available while building this. This is genuinely new territory for this codebase — the first reversibly-encrypted secret it has ever stored (every other credential here, a password or an OAuth token, is one-way hashed) — and handles real financial-risk credentials if it is wrong. Treat it as needing a human security review before it is relied on for real users' keys, not as self-certified safe by these tests passing.

Released under the MIT License. No tagged release exists yet — see the Trust section.