feat: per-campaign AI providers — a campaign brings its own ASR and LLM, behind an instance switch (#128) #501
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/128-campaign-byo-ai"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #128 (the per-campaign BYO-AI half; the adapter half landed in #496, the usage counters in #494).
One instance, several groups, each bringing their own API key and paying for their own transcription and summarisation — with the instance's providers as the fallback and an admin switch above it all.
What
d8e9fa0b1c2d(afterc7d8e9fa0b1c, one head, up/down/up verified twice):campaign_ai_settings— per campaign, each side's endpoint, key (encrypted with the existing AES-256-GCM/HKDF scheme via newcrypto.encrypt_secret/decrypt_secret), model, provider, window;updated_by.allow_campaign_ai_overrides(app_settings, default off) on the bot-settings API and as one checkbox in Admin → Bot Settings.get_llm_config(db, campaign_id=None),get_whisper_config(db, campaign_id=None)), documented next to the four not-configured policies: no campaign → instance; no row or blank endpoint for that side → instance; switch off → instance, row untouched; otherwise the campaign's, each side independently. An override that is in effect never silently falls back: a campaign URL rejected by policy is a clear error naming the campaign's settings, and an undecryptable campaign key becomes an empty credential with a warn-once error — substituting the admin's provider would send a group's audio somewhere they did not choose.process_audio(resolved before the capability gaps, slots and usage row), the session-scoped tasks, the@llm_taskdecorator (so all four converted tasks and any future one get it for free), planning tasks, the Workbench generators,/ask, lore and beat services,describe_*. Admin test buttons,/bot/settingsat bot startup, and the monthly canary stay instance-level, each with a comment. An AST guard test walks the origin sites so a call site written next month cannot forget the campaign.GET|PUT|DELETE /api/campaigns/{id}/ai-settingsandPOST …/test-llm|test-asr(GM; 403 with a message naming the admin when the switch is off); URLs through the same SSRF guard as instance settings; providers validated against the registries; responses carry*_api_key_setand*_using_instance_default, never a key; blank endpoint returns that side to the instance default; keys preserve on blank and clear on an explicit flag. Test buttons refuse (400) when that side has no override rather than probing the instance's endpoint for a non-admin.GET /api/settings/instance(any authenticated user) gates the frontend.CampaignAiSettingson the campaign settings page for GMs when the switch is on — LLM and ASR blocks, "using instance default" indicators, test and clear; key never rendered.One behaviour change to note:
/askresolves the LLM after the campaign is known, so a guild with no summaries and no LLM now gets the "no summaries yet" 200 rather than a 503; same response schema, no bot contract change.Rebased over #352 and #353/#354 (changelog and OPERATIONS conflicts, both sides kept; the rebase surfaced and fixed a vacuous assertion of this branch's own — a coroutine compared to
None).Verification
alembic heads→d8e9fa0b1c2d; ciphertext-at-rest, SSRF, precedence, masking and GM/player/other-GM authorisation tests; version sync OK.🤖 Generated with Claude Code
GET/PUT/DELETE /api/campaigns/{id}/ai-settings, plus test-llm and test-asr, all GM-only and all 403 while allow_campaign_ai_overrides is off — including the read, so a GM cannot make this instance call an arbitrary host by finding the route. The switch itself rides on the existing admin bot-settings GET/PUT, and is written only when the field is present, like the VAD trio: an older client must not disable every campaign's override by omission. A key goes in and never comes out. The response carries llm_api_key_set / asr_api_key_set and nothing else about it — no ciphertext, no prefix, no length — and the audit entry records that the settings changed, not what to. Endpoint URLs, models, providers and the window are replaced by what is sent, deliberately unlike the admin endpoint where blank preserves: here the endpoint URL *is* the on/off switch for its side, so blank has to mean "back to the instance default" or an override could be turned on and never off. The two keys keep the familiar convention — blank preserves, clear_llm_key / clear_asr_key delete — because a form that cannot show you your key cannot ask you to retype it on every save. Every URL goes through normalize_service_url with the same allowed internal hosts and the same private-range rule an admin's does. That guard is what makes handing this form to a non-admin defensible at all. Provider names are validated against the adapter registry, and the ASR model rule is imported from the admin router rather than restated — two copies of "which model does this gateway serve" is how they stop agreeing. The Test buttons probe the campaign's own endpoint or refuse. Falling through to the instance's would put its latency and, via a transport error string, its URL in front of a non-admin, and would make one button mean two different things depending on state. The ASR probe moves to ai_probe_service so the admin's button and a GM's make the identical call — a Test button that drifts from the path it claims to test is exactly the bug #488 and #128's ASR half both were. GET /api/settings/instance is new and deliberately tiny: the capabilities any signed-in user may know about, so the campaign page can decide whether to render the panel instead of interpreting a 403 that equally means "you are not the GM". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>