An own-key tenant can now run all of its AI features on Gemini, OpenAI, Anthropic or an OpenAI-compatible gateway. Before this, own key meant Gemini only. Course content refresh is the one exception and stays Gemini-only.
SP-746/tenant-ai-providers pushedorigin/mainFive features call an AI model today. Before this branch, all of them called Google Gemini.
Reads a DOCX, PDF, TXT or pasted text and turns it into quiz questions.
Answers a learner inside a lesson, with suggested follow-up questions.
Chat and suggestion prompts for a learner's study session.
Reviews a lesson against current sources (Google Search) and edits slide images.
The anonymous chat on the public demo page.
These tickets (already merged) gave each tenant a key mode:
One resolver picked the key for every feature, and every call wrote a row to a shared usage ledger. The row records the action (for example tutor.reply), the model, the tokens and the key source (platform or tenant). The admin usage page and the cost estimate read this ledger.
An earlier branch added four providers, but only for DOCX import. It was a separate "quiz import override", with its own settings, endpoints and pages. SP-746 starts from that work (first commit on the branch) and turns it into one provider choice for the whole tenant. The override is gone.
Managed mode still uses the platform Gemini key. Nothing changes for managed tenants. There is no per-feature provider choice.
The key mode decides whose key is used. The provider decides which AI service is called. Only own_key has a provider choice.
| Mode / provider | API key | Model | Base URL | Default model hint |
|---|---|---|---|---|
| managed | Platform Gemini key (Infisical) | Platform setting | — | gemini-3.8-flash |
| own_key · gemini | Required | Not asked; uses the platform setting | — | gemini-3.8-flash |
| own_key · openai | Required | Required | — | gpt-5.6-luna |
| own_key · anthropic | Required | Required | — | claude-sonnet-5 |
| own_key · openai_compatible | Required | Required | Required, http or https | meta-llama/llama-3.1-70b-instruct (UI only) |
Table 1. What each mode and provider needs. The default model is only a placeholder in the form; the admin must type a model for every non-Gemini provider.
own_key never falls back to the platform key. A missing or unreadable key makes the feature fail the same way it failed before (for example DOCX import returns 412, the tutor says it is unavailable).
Every moved feature follows the same path. The example below is a tutor reply for a tenant in own_key mode on OpenAI.
The tutor opens a ledger context for the action. This part did not change.
Reads the tenant's ai_settings. Mode is own_key, so it returns the tenant's provider, decrypted key and model. Managed would return the platform Gemini key here.
The feature builds an adapter from the credentials and asks for JSON. It passes its own operation name and the conversation's session id.
The OpenAI SDK sends a chat completion. How JSON is requested depends on the provider (see the table below the figure).
One AI generation event, same span name and session as before.
One row with tokens and a provider-prefixed model, so it gets the OpenAI price.
tutor.reply, so usage reports and dashboards do not change.| Provider | How the adapter gets JSON | PDF import |
|---|---|---|
| Gemini | JSON response with the schema (as before) | Native file input |
| OpenAI | json_schema response format, strict: false | Native file input |
| Anthropic | A forced tool call whose input is the schema | Native document input |
| OpenAI-compatible | json_object plus the schema written into the prompt | Falls back to extracted text |
Table 2. Each provider asks for structured output in its own way. The feature code does not see the difference.
The JSON schemas now use lowercase JSON Schema types (object, string) instead of Gemini's uppercase ones, so every provider accepts the same schema.
When a provider answers with empty or non-JSON output, the tutor and Study Buddy still say the AI "returned an invalid response". Network or auth failures still say the service is "temporarily unavailable". The verification run restored this; the first version had merged the two cases.
| Feature | Uses tenant provider? | Ledger action (unchanged) | Adapter call | Notes |
|---|---|---|---|---|
| DOCX / quiz import | Yes | quiz_import.preview | JSON, or file parse for PDF | Inner operations quiz_import.text / quiz_import.pdf. No key: 412 ai_api_key_missing. |
| AI tutor | Yes | tutor.reply | JSON | Keeps the conversation session id. |
| Study Buddy | Yes | study_buddy.reply, study_buddy.suggestions | JSON | Inner operation study_buddy.generate. |
| Demo chat | Yes | demo_chat.reply | Plain text | Temperature 0.2 and session id kept. |
| Course content refresh | Gemini only | course_refresh.review, course_refresh.regenerate_slide | Direct Gemini client | Needs Google Search grounding and Gemini image editing. |
Table 3. Four features move to the adapter. Course refresh stays on Gemini.
The feature is treated as "not configured". The API returns the same 503 it returns when there is no Gemini key at all. It does not fall back to the platform key. The route description and the AI settings page both say this.
Other providers have no drop-in for Google Search grounding plus image editing. Keeping refresh on Gemini avoids a quality drop that would be hard to spot. A tenant that needs refresh should stay on Gemini (own key or managed).
Everything lives in the existing tenants.ai_settings JSON column. The change is additive and needs no migration.
| Field | Meaning | Read by old backend? |
|---|---|---|
gemini_key_mode | managed or own_key. Missing means managed. | Yes |
gemini_api_key_encrypted | Encrypted key when the provider is Gemini. | Yes |
provider | gemini, openai, anthropic or openai_compatible. Missing or unknown means Gemini. | No |
api_key_encrypted | Encrypted key for a non-Gemini provider. | No |
model | Required for non-Gemini providers. Never stored for Gemini. | No |
base_url | Only for openai_compatible, http or https. Null otherwise. | No |
Table 4. The ai_settings field layout. The first two fields keep their old names and meaning.
| Case | mode | provider | Key field | model | base_url |
|---|---|---|---|---|---|
| Gemini (new write) | own_key | gemini | gemini_api_key_encrypted | — | — |
| Gemini (legacy row, still valid) | own_key | missing | gemini_api_key_encrypted | — | — |
| OpenAI | own_key | openai | api_key_encrypted | gpt-5.6-luna | null |
| Anthropic | own_key | anthropic | api_key_encrypted | claude-sonnet-5 | null |
| OpenAI-compatible | own_key | openai_compatible | api_key_encrypted | meta-llama/llama-3.1-70b-instruct | https://openrouter.ai/api/v1 |
| Managed, dormant stored key | managed | openai | api_key_encrypted | gpt-5.6-luna | null |
Table 5. What is stored for each case. In the last row the mode wins: the stored OpenAI key is kept but not used.
provider, api_key, model, base_url. Leaving the key blank keeps the current key, if the provider did not change.
gemini_api_key alone means "save a Gemini key". An older platform-admin build keeps working.
Needs a new key. Without one the API returns 422, so an OpenAI key is never sent to Anthropic.
Removes the other provider's fields, so no old key stays behind.
Legacy gemini_api_key: null removes all credential fields and sets the mode to managed.
Saving never changes the mode. Only the separate key-mode endpoint (platform admin) does.
If an old platform-admin build saves a Gemini key on a row that already says provider: "openai", the new backend keeps using OpenAI until someone saves again. Only an old platform-admin build can cause this.
A tenant admin can save a provider key while the tenant is still in managed mode. The key is stored but not used until a platform admin switches the tenant to own_key.
| Key used | Model stored in the ledger | Priced? |
|---|---|---|
| Managed or own-key Gemini | gemini-3.8-flash (no prefix, as before) | Yes |
| OpenAI | openai/gpt-5.6-luna | Yes (new) |
| Anthropic | anthropic/claude-sonnet-5 | Yes (new) |
| OpenAI-compatible gateway | openai_compatible/<model> | No, shown as unpriced |
Table 6. Ledger model ids. The prefix stops a gateway model with an OpenAI-like name from getting the OpenAI price.
| Model | Input | Cached input | Cache write | Output | Source |
|---|---|---|---|---|---|
openai/gpt-5.6-luna | $0.20 | $0.02 | $0.25 | $1.20 | developers.openai.com/api/docs/pricing (Standard, short context) |
anthropic/claude-sonnet-5 | $2.00 | $0.20 | $2.50 (5 min) | $10.00 | platform.claude.com/docs/en/about-claude/pricing |
Table 7. USD per 1M tokens. Only the two default models are priced. Other OpenAI or Anthropic models a tenant types in are unpriced until someone adds them.
OpenAI publishes a higher long-context price but no threshold. It is not modelled, so cost for very long OpenAI prompts is a lower bound.
Anthropic cache-write tokens were counted in input and again as cache writes. Now they are taken out of input first. Gemini and OpenAI always record zero cache writes, so their costs do not change. No call uses Anthropic caching today, so this bug never showed up in real data.
There are no screenshots for this report. The UI was not checked by hand yet (see section 11).
One "AI provider" card with:
The same fields and rules as the tenant page, plus the key mode switch. Mode labels now read:
The separate quiz-import settings card and its API route are removed.
| # | Decision | Why |
|---|---|---|
| 1 | Two modes stay. Managed is always Gemini; only own_key picks a provider. | Keeps the platform key and billing simple. |
| 2 | One provider per tenant, no per-feature choice. | One setting is easier to explain and support. |
| 3 | Store in ai_settings, additive, old field names kept. | No migration; the previous backend can still run (rollback). |
| 4 | own_key never falls back to the platform key. | A tenant must not spend SkillPixel's key by accident. |
| 5 | One resolver for provider, key, model and key source. | The ledger's key source always matches the key really used. |
| 6 | Each feature keeps its operation name. | Usage reports and PostHog dashboards stay the same. |
| 7 | Course refresh stays Gemini-only, no fallback. | Needs Search grounding and image editing. |
| 8 | Add a price only if it can be checked on the official page. | No guessed prices in cost reports. |
| 9 | Provider switch needs a new key (422 otherwise). | An old provider's key is never reused silently. |
Table 8. Decisions set in the spec.
The old name would be wrong for an OpenAI or Anthropic tenant. The new frontend accepts both codes, so a backend rollback still shows the right message. Gap: an old frontend on the new backend shows a generic import error for a keyless tenant until the frontend deploys. This is a short window.
At first, gateway calls were stored as openai/.... After the OpenAI price was added, a gateway model named like an OpenAI model would have been priced as OpenAI. Gateway rows are now openai_compatible/<model> and stay unpriced.
The spec's rough interface did not list them. They were kept so the tutor and demo chat keep their PostHog session linking.
study_buddy.generate, course_refresh.review_content and course_refresh.slide_edit exist in the backend only as inner span names. The ledger stores the outer action, so these never appear as usage rows.
All results are on the final commit 868f1d51.
| Area | Command (app) | Result |
|---|---|---|
| Backend lint | yarn lint (backend) | PASS |
| Backend format | yarn format (backend) | PASS no changes |
| Backend typecheck | yarn typecheck (backend) | 1439 errors vs 1436 on main |
| Backend unit tests | yarn test:unit (backend) | PASS 1758 passed, 0 failed |
| Backend DB tests (touched files) | pytest, db marker, 8 test files | PASS 193 passed |
| OpenAPI export | offline export to JSON | OK |
| Frontend client regen | yarn openapi:generate (offline) | No diff |
| Platform-admin client regen | npm run openapi:generate (offline) | No diff |
| Frontend i18n types | yarn i18n:interface | PASS no diff |
| Frontend lint | yarn lint (frontend) | PASS 0 errors, 12 old warnings |
| Frontend tests | yarn test (frontend) | PASS 56 files, 424 tests |
| Frontend build | yarn build (frontend) | PASS |
| Platform-admin lint | npm run lint | PASS 139 files |
| Platform-admin build | npm run build | PASS |
Table 9. Verification results from the report.
Typecheck is not a gate; main already has 1436 errors. The earlier commits kept the total the same (some fixed, some new). The verification run added 3, all in new tests, of the same kinds the nearby tests already have (mock arguments, possibly-None values).
The OpenAI, Anthropic and gateway adapters are only tested with mocked SDKs. Nothing has called a real non-Gemini provider yet.
json_schema with strict: false returns valid quiz JSON.json_object. PDF import should fall back to text.openai/… and anthropic/… with a cost, gateway rows show as unpriced, key source is "tenant".tutor.reply, quiz_import.text, …) and the tutor keeps its session id.provider) keep working with no action.SP-746: …) and get it merged to reach beta.