TDD — BYOK LLM Keys for SDP Consumers¶
status: draft # draft | reviewed | approved
date: 2026-07-06
author: Mark Ly
prd: n/a — standalone cross-cutting design (no parent PRD)
brief: n/a
written-by: TechLead
template-version: 2.2
Written by: TechLead. Reviewed by another senior engineer + the SDP owner before any consumer builds against it. Standalone — this is an architecture/design note, not a release TDD, so PRD cross-refs (
[P-*]) are omitted; requirements are stated inline.
Change History¶
| Date | Author | Change |
|---|---|---|
| 2026-07-06 | Mark Ly | Initial draft |
TL;DR [T-tldr]¶
- Problem: SDP is a shared, multi-tenant engine that makes LLM calls with its own key (
ALTAFORGE_LLM_API_KEYfrom SDP's env/vault). Cost, quotas, rotation, and blast-radius are pooled on one SDP key instead of scoped per consumer. - Target: BYOK — each consuming app (CICI first) owns a LiteLLM gateway (virtual) key in its own vault and passes it to SDP per request; SDP runs that request on the caller's key.
- The library already supports it:
altaforge.llm'sclient(api_key=…)/LLMClient(api_key=…)take a per-call key (env is only the fallback). The work is an SDP app-layer seam, not new LLM plumbing. - Safe transport = server-side BFF only: the key rides consumer backend → SDP in a header over internal TLS; the browser is never in the key path. A Vite SPA calling SDP directly is disqualified for carrying the key.
- SDP responsibilities: read
X-AltaForge-LLM-Key, thread intoLLMClient(api_key=…), redact it from logs + Arize/OTel spans, and authenticate the caller separately (Entra/JWT) — the LLM key is not an auth credential. - Phases: (1) SDP caller-key seam + redaction; (2) CICI consumer contract + BFF forwarding; (3) roll out to remaining consumers, then retire SDP's own-key fallback.
System Overview [T-system]¶
Consumers hold their own LiteLLM virtual key server-side and forward it to SDP per request. SDP uses the caller's key for that request's LLM calls and never persists it. Caller authentication (Entra) is a separate, parallel concern.
consumer org boundary shared engine
┌───────────────────────────────────────────────┐ ┌──────────────────────────────┐
│ browser ──▶ consumer BFF (Next server route │ │ SDP (FastAPI) │
│ OR existing FastAPI backend) │ │ │
│ • loads ici--llm-api-key │ │ • authN caller (Entra/JWT) │
│ from OWN vault (server-only env) │ │ • reads X-AltaForge-LLM-Key │
│ • attaches header, TLS ────────────────▶│ • LLMClient(api_key=<hdr>) │──▶ LiteLLM ──▶ model
│ │ │ • redacts header in logs/ │ gateway
│ (browser never sees the key) │ │ OTel; never stores it │
└───────────────────────────────────────────────┘ └──────────────────────────────┘
Key never leaves the consumer's org boundary at rest (its own vault); it transits, encrypted, only for the duration of a request and is used transiently in SDP memory.
Tech Stack [T-stack]¶
| Layer | Choice | Rationale (only if non-default) |
|---|---|---|
| LLM client lib | altaforge.llm (LLMClient, client()) |
Already supports per-call api_key=; no new dependency |
| Engine | SDP — FastAPI + LangGraph | Existing |
| Consumer BFF | Existing FastAPI backend or Next server route | Either is a valid server-side BFF; Next not required for this design (see [T-decision-7]) |
| Key type | LiteLLM virtual/gateway key | Revocable, rate-limited, per-consumer; not a raw sk-ant-* |
| Secrets | altaforge.envsync + per-consumer Key Vault |
Consumer's key lives in its own vault (icistgcanctrlkv01), not SDP's |
| Caller auth | Entra / JwtAuthBackend (existing) |
Separate from the LLM key |
| Observability | OTel → Arize / Loki (existing) | Must add header redaction (see [T-decision-6]) |
| Transport | internal HTTPS pod FQDNs | Existing; TLS required for the key hop |
Data Model [T-entity-N]¶
n/a in v1. BYOK introduces no persistent entities. The key is transient — held at rest only in the consumer's vault (already managed by envsync), used in-memory per request in SDP, never written to any SDP table, log, or trace. (This "never persist the caller key" property is a hard requirement, enforced in [T-decision-6].)
API Contracts [T-api-N]¶
[T-api-1] SDP LLM-backed endpoints — new optional request header¶
Applies to every SDP endpoint that triggers an LLM call (extraction, assessment, generation).
New header:
X-AltaForge-LLM-Key: <consumer LiteLLM virtual key>
Semantics:
- Precedence: header key wins; if absent, SDP falls back to its own ALTAFORGE_LLM_API_KEY only during migration ([T-decision-5]), then the fallback is removed and a missing header is an error.
- After migration — missing header: 400 { "error": "missing X-AltaForge-LLM-Key" } on LLM-backed routes.
- Caller auth is separate: an unauthenticated caller gets 401 regardless of the LLM header. A valid caller with a bad/rejected LLM key surfaces the upstream LiteLLM error (e.g. 402/429) mapped to a clean 502 { "error": "llm_key_rejected" }.
- Never echoed: the key is never returned in any response body, error, or header.
[T-api-2] Consumer BFF → SDP (server-side)¶
The consumer's server route/proxy attaches the header from its server-only environment (ALTAFORGE_LLM_API_KEY, hydrated by envsync). Browser-originated requests never carry the key; the BFF injects it.
Component / Module Structure [T-files]¶
Distinctive touch-points only (not a full tree):
SDP (altaforge-sdp)
<api layer>/deps.py # extract X-AltaForge-LLM-Key from request -> request-scoped value
llm integration:
config.step_llm(step) # resolve model+mode (existing)
llm.step_client(step) # MUST accept a per-request api_key and build/select client by (auth_mode, key)
# — NOT reuse the process-cached env-key client (see [T-decision-3], [T-q-1])
observability.py # add X-AltaForge-LLM-Key to the redaction/deny list (httpx + logging + OTel)
consumer (e.g. client-canada-ici)
backend/env.secrets.toml # add ALTAFORGE_LLM_API_KEY = { kv = "ici--llm-api-key" }
backend/<bff> # attach header on server-side calls to SDP (BFF)
# OR, if converted, a Next server route does the same
Auth Model [T-auth]¶
- Caller authentication is unchanged and independent: SDP authenticates the calling service/user via Entra /
JwtAuthBackend(existing BFF path). This authorizes the request. - The LLM key authorizes nothing in SDP — it only scopes the downstream LLM spend/quota. It must never be accepted as, or conflated with, a caller credential. (Enforced by treating the two as separate inputs: reject on failed caller-auth before the LLM key is even read.)
Key Design Decisions [T-decision-N]¶
T-decision-1: Pass the key server-side (BFF → SDP) in a header — not from the browser, not seeded into SDP's vault. Decision: the consumer's server tier attaches the key; SDP reads it from the request header. Because: the browser must never see a long-lived key (a Vite SPA calling SDP directly would ship it to every user's network tab). And seeding each consumer's key into SDP's vault would copy credentials across the org boundary — the exact thing the KV standard ("vault = org boundary, never copy a credential across") forbids. Header-over-TLS from a server tier keeps the key in the consumer's boundary at rest and transient everywhere else.
T-decision-2: The key is a LiteLLM virtual/gateway key, never a raw provider key. Decision: consumers seed
ici--llm-api-key= a LiteLLM gateway key. Because: a virtual key is revocable, rate-limited, and scoped per consumer — if it leaks, you rotate one key with bounded blast radius, versus a rawsk-ant-*with full account access.T-decision-3: SDP uses the caller key via the existing
LLMClient(api_key=…)— no new LLM plumbing. Decision: thread the header value into the client the library already exposes. Because:altaforge.llmalready resolvesapi_keykwarg over env (resolved_key = api_key or os.environ.get('ALTAFORGE_LLM_API_KEY')). The seam is request→client wiring, not a new integration.T-decision-4: Caller auth stays separate from the LLM key. Because: conflating them is privilege confusion — a valid LLM key is not proof of a valid caller, and vice-versa. Reject on caller-auth first; only then consume the LLM key.
T-decision-5: Keep SDP's own-key env fallback during migration, remove it after. Decision: header wins; absent header falls back to
ALTAFORGE_LLM_API_KEYuntil all consumers forward a key, then the fallback is deleted and a missing header 400s. Because: lets consumers migrate one at a time without a flag-day; the final removal is what actually enforces BYOK.T-decision-6: Redact
X-AltaForge-LLM-Keyeverywhere, and never persist it. Decision: add the header to the deny/redact list for access logs, the httpx/logging instrumentors, and OTel/Arize span attributes; never write it to a DB row. Because: headers routinely leak into traces and error dumps; an unredacted key in Arize is a real disclosure. "Never persist" keeps the data-model surface at zero ([T-entity-N]).T-decision-7: Do NOT require a Next conversion for this design. Decision: the BFF can be the consumer's existing FastAPI backend; Next is optional. Because: CICI already has a server tier (
canada_ici_client, FastAPI) that can hold the key and forward it — the browser-exposure problem is solved by routing LLM-triggering calls through it. Converting Vite→Next is a separate strategic bet (auth posture / SSR / org parity), not a prerequisite here. If a consumer is on Next, its server route is the BFF instead — same contract.
Phase Decomposition [T-phase-N]¶
[T-phase-1] SDP caller-key seam + redaction¶
Issues:
1. Extract X-AltaForge-LLM-Key into a request-scoped dependency in SDP's API layer.
2. Make llm.step_client(step) accept a per-request api_key and build/select the client by (auth_mode, api_key) rather than reusing the process-cached env-key client ([T-q-1]).
3. Add the header to redaction lists (logging, httpx/OTel instrumentors, Arize span attrs).
4. Keep env-key fallback ([T-decision-5]).
Acceptance:
T-test-1.1: Given a request with
X-AltaForge-LLM-Key: K, when SDP makes its LLM call, then the call authenticates withK(not SDP's env key). Verified via a stubbed LiteLLM asserting the received key.T-test-1.2: Given a request with header
K, when the run completes, thenKappears in no log line, OTel span, Arize trace, or DB row.T-test-1.3: Given two concurrent requests with different keys
K1,K2, when both run, then neither leaks into the other's LLM call (no cross-request key bleed from client caching). Guards[T-q-1].
[T-phase-2] CICI consumer contract + BFF forwarding¶
Issues:
1. Seed ici--llm-api-key (LiteLLM virtual key) into icistgcanctrlkv01 (Dev Manager / DevOps).
2. Add ALTAFORGE_LLM_API_KEY = { kv = "ici--llm-api-key" } to CICI backend/env.secrets.toml; correct the "no secrets" comment.
3. Route LLM-triggering calls through CICI's backend (BFF); attach the header server-side from env.
Acceptance:
T-test-2.1: Given
make env-backendon CICI, when it runs, thenALTAFORGE_LLM_API_KEYis hydrated into.env.localfrom the ICI vault (needskv-secrets-readers-ici).T-test-2.2: Given a browser-initiated action that triggers an LLM call, when the network is inspected, then the key is absent from all browser↔BFF traffic and present only on the BFF→SDP hop.
[T-phase-3] Roll out remaining consumers + retire SDP fallback¶
Issues:
1. Repeat phase-2 for each other SDP consumer.
2. Remove SDP's ALTAFORGE_LLM_API_KEY env fallback; missing header now 400s on LLM routes.
3. Remove SDP's own LLM key from its vault (no longer used for consumer traffic).
Acceptance:
T-test-3.1: Given the fallback is removed, when an LLM-backed SDP endpoint is called without the header, then it returns
400 missing X-AltaForge-LLM-Keyand makes no LLM call.T-test-3.2: Given all consumers migrated, when LLM spend is reviewed in the gateway, then usage is attributable per consumer virtual key.
Deployment [T-deploy]¶
- Env vars (consumer):
ALTAFORGE_LLM_API_KEY(gateway key, server-only, via envsync). Must not be exposed to the browser (noNEXT_PUBLIC_/VITE_prefix). - Env vars (SDP):
ALTAFORGE_LLM_API_KEYretained only through phases 1–2 as fallback; removed in phase 3. - Vault seeding:
ici--llm-api-keyinicistgcanctrlkv01(and equivalents per consumer). Access viakv-secrets-readers-<org>. - Redaction config: ship the header deny-list with phase 1 — before any real key is ever sent.
- Rotation: rotate the virtual key in the gateway → reseed the consumer vault →
make env-backend. SDP needs no redeploy (it uses whatever the caller sends).
Open Questions [T-q-N]¶
T-q-1: SDP's
llm.get_llm_client(auth_mode)caches clients per auth mode (per current docs). BYOK requires the key to vary per request — so the cache key must include the api_key, or the client must be constructed per-request when a caller key is present. Need to confirm the current caching shape and that no key bleeds across requests (covered by[T-test-1.3]). Highest-risk item — verify before phase 1 build.T-q-2: Is the request-letter / any current CICI feature actually LLM-backed, or docx-templated? Decides whether CICI needs the key now or only when LLM features land. (Requires reading the
can-ici-backendbranch of SDP.)T-q-3: Rejected-key UX: how should a
429/402from the gateway surface to the end user vs. an SDP-internal error? Proposed502 llm_key_rejected— confirm with product.T-q-4: Future option (not in scope): per-request short-lived virtual-key minting instead of forwarding a long-lived virtual key — only if a "no long-lived key in transit" requirement appears.
Sign-off¶
| Role | Name | Date |
|---|---|---|
| Author | Mark Ly | |
| Engineering reviewer | ||
| SDP owner | ||
| Approver |
Reviewer's checklist¶
- [ ] TL;DR conveys the engineering shape in ≤6 bullets
- [ ] Every design decision has a because
- [ ] Every phase has ≥1 Given/When/Then acceptance test
- [ ] The key never touches the browser in any documented path (
[T-decision-1],[T-test-2.2]) - [ ] The key is never persisted/logged/traced (
[T-decision-6],[T-test-1.2]) - [ ] Caller auth is separate from the LLM key (
[T-auth],[T-decision-4]) - [ ] Client-cache/key-bleed risk is addressed before build (
[T-q-1],[T-test-1.3]) - [ ] Consumer contract change is scoped (env.secrets.toml + vault seed + BFF forwarding)
- [ ] No Next conversion is assumed as a prerequisite (
[T-decision-7]) - [ ] Sections that don't apply are marked
n/a(data model) ```