SkillPixel LMS · change walkthrough

SP-746: Tenant-wide AI provider selection

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.

Status as of 23 Sep 2026

BranchSP-746/tenant-ai-providers pushed
Commits9 on top of origin/main
Pull requestNo PR yet
JiraSP-746 In Progress
BetaNot on beta
ProductionNot released

1Background

Five features call an AI model today. Before this branch, all of them called Google Gemini.

DOCX / quiz import

Reads a DOCX, PDF, TXT or pasted text and turns it into quiz questions.

AI tutor

Answers a learner inside a lesson, with suggested follow-up questions.

Study Buddy

Chat and suggestion prompts for a learner's study session.

Course content refresh

Reviews a lesson against current sources (Google Search) and edits slide images.

Demo chat

The anonymous chat on the public demo page.

Where we started: SP-742 / SP-743

These tickets (already merged) gave each tenant a key mode:

  • managed: the tenant uses the SkillPixel Gemini key from Infisical.
  • own_key: the tenant uses its own Gemini key. It never falls back to the platform key.

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.

What this replaces

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.

2What changed, at a glance

Before

  • Own key = Gemini only.
  • DOCX import had a separate override with 4 providers.
  • Two places to set a key, with different rules.
GeminiOpenAIAnthropicCompatible
Tutor, Study Buddy, refresh, demo chat: Gemini only

After

  • Own key = pick a provider, one choice per tenant.
  • Import, tutor, Study Buddy and demo chat use it.
  • One settings card, one set of rules.
GeminiOpenAIAnthropicCompatible
Course refresh: still Gemini only
Figure 1. Own-key tenants go from a Gemini-only key to a provider choice that covers every AI feature except course refresh.
Unchanged

Managed mode still uses the platform Gemini key. Nothing changes for managed tenants. There is no per-feature provider choice.

3The model: modes and providers

The key mode decides whose key is used. The provider decides which AI service is called. Only own_key has a provider choice.

Mode / providerAPI keyModelBase URLDefault model hint
managedPlatform Gemini key (Infisical)Platform settinggemini-3.8-flash
own_key · geminiRequiredNot asked; uses the platform settinggemini-3.8-flash
own_key · openaiRequiredRequiredgpt-5.6-luna
own_key · anthropicRequiredRequiredclaude-sonnet-5
own_key · openai_compatibleRequiredRequiredRequired, http or httpsmeta-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.

Invariant

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).

4How a request flows

Every moved feature follows the same path. The example below is a tutor reply for a tenant in own_key mode on OpenAI.

1
Learner asks the tutor

The tutor opens a ledger context for the action. This part did not change.

action = tutor.replykey_source = tenant
2
Credentials resolver

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.

provider = openaimodel = gpt-5.6-lunabase_url = noneapi_key = (hidden, not in logs)
3
Provider adapter

The feature builds an adapter from the credentials and asks for JSON. It passes its own operation name and the conversation's session id.

OpenAIAdaptergenerate_json(prompt, tutor schema)operation = tutor.replysession_id = conversation id
4
Provider SDK call

The OpenAI SDK sends a chat completion. How JSON is requested depends on the provider (see the table below the figure).

response_format = json_schemastrict = false
5a
PostHog

One AI generation event, same span name and session as before.

span = tutor.replysession = conversation id
5b
Usage ledger

One row with tokens and a provider-prefixed model, so it gets the OpenAI price.

action = tutor.replymodel = openai/gpt-5.6-lunakey_source = tenant
Figure 2. A tutor reply on an OpenAI own-key tenant. The action and span name stay tutor.reply, so usage reports and dashboards do not change.
ProviderHow the adapter gets JSONPDF import
GeminiJSON response with the schema (as before)Native file input
OpenAIjson_schema response format, strict: falseNative file input
AnthropicA forced tool call whose input is the schemaNative document input
OpenAI-compatiblejson_object plus the schema written into the promptFalls back to extracted text

Table 2. Each provider asks for structured output in its own way. The feature code does not see the difference.

Decision

The JSON schemas now use lowercase JSON Schema types (object, string) instead of Gemini's uppercase ones, so every provider accepts the same schema.

Error behaviour kept

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.

5Feature coverage

FeatureUses tenant provider?Ledger action (unchanged)Adapter callNotes
DOCX / quiz importYesquiz_import.previewJSON, or file parse for PDFInner operations quiz_import.text / quiz_import.pdf. No key: 412 ai_api_key_missing.
AI tutorYestutor.replyJSONKeeps the conversation session id.
Study BuddyYesstudy_buddy.reply, study_buddy.suggestionsJSONInner operation study_buddy.generate.
Demo chatYesdemo_chat.replyPlain textTemperature 0.2 and session id kept.
Course content refreshGemini onlycourse_refresh.review, course_refresh.regenerate_slideDirect Gemini clientNeeds Google Search grounding and Gemini image editing.

Table 3. Four features move to the adapter. Course refresh stays on Gemini.

What a non-Gemini own-key tenant sees in course refresh

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.

Interpretation

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).

6Settings and storage

Everything lives in the existing tenants.ai_settings JSON column. The change is additive and needs no migration.

FieldMeaningRead by old backend?
gemini_key_modemanaged or own_key. Missing means managed.Yes
gemini_api_key_encryptedEncrypted key when the provider is Gemini.Yes
providergemini, openai, anthropic or openai_compatible. Missing or unknown means Gemini.No
api_key_encryptedEncrypted key for a non-Gemini provider.No
modelRequired for non-Gemini providers. Never stored for Gemini.No
base_urlOnly 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.

Example rows per provider

CasemodeproviderKey fieldmodelbase_url
Gemini (new write)own_keygeminigemini_api_key_encrypted
Gemini (legacy row, still valid)own_keymissinggemini_api_key_encrypted
OpenAIown_keyopenaiapi_key_encryptedgpt-5.6-lunanull
Anthropicown_keyanthropicapi_key_encryptedclaude-sonnet-5null
OpenAI-compatibleown_keyopenai_compatibleapi_key_encryptedmeta-llama/llama-3.1-70b-instructhttps://openrouter.ai/api/v1
Managed, dormant stored keymanagedopenaiapi_key_encryptedgpt-5.6-lunanull

Table 5. What is stored for each case. In the last row the mode wins: the stored OpenAI key is kept but not used.

Save and clear rules

New request body

provider, api_key, model, base_url. Leaving the key blank keeps the current key, if the provider did not change.

Legacy body still works

gemini_api_key alone means "save a Gemini key". An older platform-admin build keeps working.

Switching provider

Needs a new key. Without one the API returns 422, so an OpenAI key is never sent to Anthropic.

Saving one provider

Removes the other provider's fields, so no old key stays behind.

Clearing

Legacy gemini_api_key: null removes all credential fields and sets the mode to managed.

Mode

Saving never changes the mode. Only the separate key-mode endpoint (platform admin) does.

Backward compatibility (observed in code and report)
  • Gemini rows work on the previous backend.
  • An own_key row on another provider looks like "no Gemini key" to the previous backend. It fails closed with no platform fallback. This is safe after a rollback.
Edge case

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.

Gotcha for support

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.

7Usage and cost

Key usedModel stored in the ledgerPriced?
Managed or own-key Geminigemini-3.8-flash (no prefix, as before)Yes
OpenAIopenai/gpt-5.6-lunaYes (new)
Anthropicanthropic/claude-sonnet-5Yes (new)
OpenAI-compatible gatewayopenai_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.

New prices (checked 23 Sep 2026)

ModelInputCached inputCache writeOutputSource
openai/gpt-5.6-luna$0.20$0.02$0.25$1.20developers.openai.com/api/docs/pricing (Standard, short context)
anthropic/claude-sonnet-5$2.00$0.20$2.50 (5 min)$10.00platform.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.

Known gap

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.

Fix: Anthropic cache writes billed twice

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.

8What admins see

There are no screenshots for this report. The UI was not checked by hand yet (see section 11).

Tenant admin: Admin → AI settings

One "AI provider" card with:

  • A provider dropdown (Google Gemini, OpenAI, Anthropic, OpenAI-compatible).
  • An API key field with a Configured / Not configured badge and a masked prefix of the saved key.
  • A required Model field for non-Gemini providers, with the default model as placeholder. Gemini shows a note that it uses the platform model.
  • A required Base URL field, only for OpenAI-compatible.
  • A note that course refresh always uses Gemini.
  • "Changing provider requires a new API key" when the admin picks a new provider. Save stays disabled until the needed fields are filled.
  • "Remove key" with a confirm dialog: the space goes back to the managed key.

Platform admin: tenant AI settings card

The same fields and rules as the tenant page, plus the key mode switch. Mode labels now read:

  • "Managed — SkillPixel Gemini key"
  • "Own key — tenant's provider"

The separate quiz-import settings card and its API route are removed.

Other copy

  • The paste-quiz modal says "AI" instead of "Gemini AI".
  • The no-key import error says the import "needs an AI provider".
  • The Vietnamese text keeps API key, Base URL, model, own-key and managed in English.
  • Three usage labels for actions the ledger never records were removed.

9Decisions and trade-offs

Locked before implementation

#DecisionWhy
1Two modes stay. Managed is always Gemini; only own_key picks a provider.Keeps the platform key and billing simple.
2One provider per tenant, no per-feature choice.One setting is easier to explain and support.
3Store in ai_settings, additive, old field names kept.No migration; the previous backend can still run (rollback).
4own_key never falls back to the platform key.A tenant must not spend SkillPixel's key by accident.
5One resolver for provider, key, model and key source.The ledger's key source always matches the key really used.
6Each feature keeps its operation name.Usage reports and PostHog dashboards stay the same.
7Course refresh stays Gemini-only, no fallback.Needs Search grounding and image editing.
8Add a price only if it can be checked on the official page.No guessed prices in cost reports.
9Provider switch needs a new key (422 otherwise).An old provider's key is never reused silently.

Table 8. Decisions set in the spec.

Made during verification

412 code renamed: gemini_api_key_missing → ai_api_key_missing

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.

Compatible gateways get their own ledger prefix

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.

session_id and attempt kept in the adapter interface

The spec's rough interface did not list them. They were kept so the tutor and demo chat keep their PostHog session linking.

Three usage labels removed

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.

10Verification

All results are on the final commit 868f1d51.

AreaCommand (app)Result
Backend lintyarn lint (backend)PASS
Backend formatyarn format (backend)PASS no changes
Backend typecheckyarn typecheck (backend)1439 errors vs 1436 on main
Backend unit testsyarn test:unit (backend)PASS 1758 passed, 0 failed
Backend DB tests (touched files)pytest, db marker, 8 test filesPASS 193 passed
OpenAPI exportoffline export to JSONOK
Frontend client regenyarn openapi:generate (offline)No diff
Platform-admin client regennpm run openapi:generate (offline)No diff
Frontend i18n typesyarn i18n:interfacePASS no diff
Frontend lintyarn lint (frontend)PASS 0 errors, 12 old warnings
Frontend testsyarn test (frontend)PASS 56 files, 424 tests
Frontend buildyarn build (frontend)PASS
Platform-admin lintnpm run lintPASS 139 files
Platform-admin buildnpm run buildPASS

Table 9. Verification results from the report.

Typecheck note

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).

Unrelated, reported only
  • 1436 existing typecheck errors on main.
  • 12 existing frontend lint warnings in files this branch does not touch.
  • No test failures anywhere. Integration tests were not run (not asked).

11What to test on beta, and what is open

The OpenAI, Anthropic and gateway adapters are only tested with mocked SDKs. Nothing has called a real non-Gemini provider yet.

Beta checklist

Still open