--- since: 1.2.2 --- # Local Development Bring-Your-Own-Key (BYOK) Depends on: nothing code-blocking; conceptually reuses the provider registry pattern from free-model-catalog-and-selection. Does NOT cover: production use — see production-byok-session-only.md. ## 1. Goal When a developer runs DjangoPlay locally, let them use their own paid AI provider API key (OpenAI, xAI, OpenRouter paid tier, or any OpenAI-compatible endpoint) without editing tracked settings files or committing secrets. This is local-machine-only, single-user, file-based — no browser, no session/tab isolation problem, no encrypted-in-transit design needed (that's the production-BYOK doc). ## 2. Decisions - No interactive `manage.py` scaffolding command — a short dedicated doc plus a `README.md` pointer is enough for v1. - Support switching between **two** personal API-key configurations, not just one, via named profiles in the same secrets file. ## 3. Design ### 3.1 Two layers, on purpose 1. **Baseline (already existed, generic):** `paystream.app_settings.common.get_decrypted_value()` already falls back to `~/.dplay/.secrets` for *any* unset setting when that value is absent from `os.environ`/`.env` — that's how a single personal key (`AI_CUSTOM_API_KEY` / `AI_CUSTOM_BASE_URL` / `AI_CUSTOM_MODEL`) already worked before this feature (documented in `README.md` §6.1). This layer is lowest-priority fallback, one flat value per setting, and has no opinion about dev vs. prod — it's shared plumbing used for every secret in the app (DB password, JWT signing key, etc.). 2. **New, narrower layer** (`paystream/app_settings/local_ai_dev.py`): lets a developer keep **two named AI provider profiles** side by side in the same `~/.dplay/.secrets` file and flip between them by changing one line — `AI_LOCAL_ACTIVE_PROFILE`. Unlike the baseline layer, these values **win over** `.env`/`os.environ` for the AI_* keys the active profile sets (a true override, not a lowest-priority fallback). ### 3.2 Gating: `DJANGO_SETTINGS_MODULE`, not `DEBUG` This module is imported from `paystream/app_settings/ai.py`, which loads *before* `paystream/settings/base.py` assigns `DEBUG`. So `DEBUG` isn't available yet at the point this code runs, and reading `django.conf.settings.DEBUG` here would recurse into Django's own settings loading before it's finished. `DJANGO_SETTINGS_MODULE` is already resolved into `os.environ` before any settings module loads (via `paystream.settings.bootstrap.resolve_settings_module()`, called from `manage.py`/`wsgi.py`/`celery.py`), so checking for the literal string `"paystream.settings.dev"` is a safe, import-order-independent proxy for "this is the settings module that sets `DEBUG = True`" — today the *only* one that does (`staging.py` and `prod.py` both hardcode `DEBUG = False`). This means: **if the file exists but `DJANGO_SETTINGS_MODULE` isn't `paystream.settings.dev`, nothing in this module is read — not even to check existence.** ### 3.3 File format ``` AI_LOCAL_ACTIVE_PROFILE=personal AI_LOCAL_PROFILE_personal__AI_PROVIDER=custom AI_LOCAL_PROFILE_personal__AI_CUSTOM_BASE_URL=https://openrouter.ai/api/v1 AI_LOCAL_PROFILE_personal__AI_CUSTOM_API_KEY=sk-or-v1-xxxxxxxx AI_LOCAL_PROFILE_personal__AI_CUSTOM_MODEL=nvidia/nemotron-3.5-lightning:free AI_LOCAL_PROFILE_work__AI_PROVIDER=openai AI_LOCAL_PROFILE_work__AI_OPENAI_API_KEY=sk-xxxx AI_LOCAL_PROFILE_work__AI_OPENAI_MODEL=gpt-4.1-mini ``` - `AI_LOCAL_ACTIVE_PROFILE` picks which block is live. Change this one line and restart the dev server to switch — nothing else touched. - Each block is `AI_LOCAL_PROFILE___`. `` can be anything except containing `__` or `=`. - Only a **fixed allowlist** of AI_* keys can be set this way — provider, base URL, API key, model, for each of the four provider slots (`AI_PROVIDER`, `AI_XAI_*`, `AI_OPENAI_*`, `AI_OPENROUTER_*`, `AI_CUSTOM_*`). Numeric/limit settings (`AI_MAX_TOKENS`, rate limits, token budget) are deliberately **not** overridable this way — this mechanism is about switching *which provider/key*, not tuning behavior per profile. - Unrecognized keys under a profile are ignored, logged as a warning (key name only, never a value). - Same tolerant parsing as the baseline secrets-file loader: blank lines and lines without `=` are silently skipped. ### 3.4 Loading (`load_local_ai_dev_overrides`) - Returns `{}` immediately if `_is_local_dev()` is false — no read, no log line referencing the file's existence, nothing. - Parses the file, resolves the active profile's prefix, and returns only the recognized-key overrides for that profile. - `_warn_if_permissive()` — best-effort nudge (not a hard requirement) if the file is group/other readable, suggesting `chmod 600`. - Logs (INFO) which profile was applied and how many settings were overridden — **never** a raw value. Warns (not errors) if the active profile has no matching keys at all. ## 4. Acceptance criteria - [x] With no `~/.dplay/.secrets` file, behavior is unchanged from before this feature. - [x] With the file present and running under `paystream.settings.dev`, the active profile's `AI_*` values override `.env`/environment defaults for the keys it sets. - [x] With the file present under any non-dev settings module (staging, prod), the file is never read by this module. - [x] No secret value from this file is ever logged. - [x] Switching between two profiles is a one-line edit (`AI_LOCAL_ACTIVE_PROFILE`), no other file changes needed. - [x] Unknown/unsupported override keys are ignored with a warning, not a hard failure. ## 5. Documentation - `docs/system/aicore/local-ai-byok.md` — full file-format reference and the reasoning above, written for a developer setting this up for the first time; explicitly notes that a single personal key doesn't need any of this (plain `AI_CUSTOM_*` lines already work via the baseline layer). - Referenced from `README.md`. ## 6. Related Files - `paystream/app_settings/local_ai_dev.py` — new. - `paystream/app_settings/ai.py` — applies `load_local_ai_dev_overrides()` on top of the normal setting resolution. - `docs/system/aicore/local-ai-byok.md` — new. - `README.md` — pointer to the new doc.