Files
indie/PLAN-byok.md
2026-07-06 14:49:44 +05:45

12 KiB

Bring Your Own Key (BYOK) — Execution Plan

Goal

Allow users to provide their own API keys for LLM providers (Gemini, OpenAI) instead of relying on globally configured environment variables. Users can store keys for multiple providers and select which one to use during site generation. Remove all default/global API key configuration.


Scope

What will be changed

Area Files
User entity User.java — add Map<String, String> llmApiKeys (JSON column, encrypted values)
Encryption EncryptionService.java — AES-GCM encrypt/decrypt for stored keys
LLM providers GeminiProvider.java, OpenAIProvider.java — remove @Component, remove @Value apiKey, accept all params in constructor
Provider creation ProviderFactory.java (new) — creates provider instances per-request with user's key
LLM factory LLMFactory.java — delete, replaced by ProviderFactory
LLM service LLMService.java — accept provider+apiKey params, use ProviderFactory, remove fallback logic
LLM controller LLMController.java — extract provider+apiKey from request; fall back to user's stored keys
User settings API UserSettingsController.java (new) — PUT/GET/DELETE /api/user/llm-config
DTOs LLMConfigRequest.java (new) — {provider, apiKey}
Config application.yml — remove active-provider, fallback-provider, api-key entries
Frontend types llm.ts — add provider field to requests; add LLMKeyStatus type
Frontend service llm-config.service.ts (new) — API calls for key CRUD
Frontend settings settings.component.ts — add LLM API keys card (provider list + key input)
Frontend workspace llm-workspace.component.ts — load key status, pass provider in generate/refine calls
Tests All modified files + new tests for EncryptionService, ProviderFactory, UserSettingsController

What will NOT be changed

  • Storage keys (S3/MinIO) — infrastructure concern
  • Git remote tokens — separate feature
  • JWT signing keys — internal auth
  • Rate limiting — kept as-is
  • Site.java, GitRemote.java, SelfHostedConfig.java — unrelated
  • GenerationVersion.java — no need to track which key was used
  • docker-compose.yml — unrelated

Architecture Decisions

1. Multiple provider keys stored per user

User.java gains a JSON column llmApiKeys mapping provider names to encrypted API keys:

{"gemini": "AES-encrypted-value", "openai": "AES-encrypted-value"}

Stored as a Map<String, String> with Hibernate 6 @JdbcTypeCode(SqlTypes.JSON). Each value is encrypted individually via EncryptionService.

2. No global/default keys

The env-var-based API keys (GEMINI_API_KEY, OPENAI_API_KEY) are removed. Every user must configure their own key(s). If no key is available for the requested provider, the API returns a clear error: "No API key configured for {provider}. Add one in Settings."

3. Per-request provider instances

LLMProvider implementations are no longer Spring beans. A ProviderFactory creates a fresh instance per request with the user's key. This keeps the LLMProvider interface clean (no apiKey parameter on generate()).

4. Provider selected per request

Each generation/refine request includes a provider field. The user's stored key for that provider is used. The request body can optionally include an apiKey to override the stored key on-the-fly.

5. Encryption

AES-256-GCM via EncryptionService. Master key from env var INDIE_ENCRYPTION_KEY. If unset, a key is generated and persisted to disk (same pattern as JWT keys in JwtConfig).


Implementation Plan

Step 1: Backend — Encryption service

New file: backend/src/main/java/com/krrishg/config/EncryptionService.java

  • AES-256-GCM encrypt/decrypt
  • Master key loaded from @Value("${indie.encryption.master-key:}")
  • If empty, generate random key and save to config/encryption.key
  • Methods: encrypt(String plaintext) → String, decrypt(String ciphertext) → String

Step 2: Backend — Add LLM API keys to User entity

Modify: backend/src/main/java/com/krrishg/model/User.java

@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "TEXT")
private Map<String, String> llmApiKeys;

Import java.util.Map, org.hibernate.type.SqlTypes, jakarta.persistence.Column.

Step 3: Backend — LLM config DTO and controller

New file: backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java

public record LLMConfigRequest(String provider, String apiKey) {}

New file: backend/src/main/java/com/krrishg/controller/UserSettingsController.java

  • @RestController @RequestMapping("/api/user")
  • PUT /llm-config — Accept LLMConfigRequest, encrypt the key, store in User.llmApiKeys
  • GET /llm-config — Return map of {provider: boolean} indicating which keys are configured (never return actual keys)
  • DELETE /llm-config/{provider} — Remove the key for that provider

Step 4: Backend — Refactor LLM providers

Modify: backend/src/main/java/com/krrishg/service/llm/GeminiProvider.java

  • Remove @Component
  • Constructor: GeminiProvider(String apiKey, String model, String baseUrl, RestTemplate restTemplate)
  • Remove all @Value annotations

Modify: backend/src/main/java/com/krrishg/service/llm/OpenAIProvider.java

  • Same refactoring as GeminiProvider

Step 5: Backend — Create ProviderFactory

New file: backend/src/main/java/com/krrishg/service/llm/ProviderFactory.java

@Component
public class ProviderFactory {
    // Injected via @Value: model names, base URLs from application.yml
    // Methods:
    //   createProvider("gemini", apiKey) → new GeminiProvider(...)
    //   createProvider("openai", apiKey) → new OpenAIProvider(...)
    //   getAvailableProviders() → ["gemini", "openai"]
}

Delete: backend/src/main/java/com/krrishg/service/llm/LLMFactory.java

Step 6: Backend — Update LLMService

Modify: backend/src/main/java/com/krrishg/service/LLMService.java

  • Remove LLMFactory, inject ProviderFactory, UserRepository, EncryptionService
  • generateSite(UUID userId, UUID siteId, String prompt, List<Reference> references, String provider, String apiKey) — look up key from user if not provided; call callLLM()
  • refineSite(...) — same treatment
  • callLLM() now takes provider + apiKey:
    private LLMResult callLLM(..., String providerName, String apiKey) {
        if (apiKey == null || apiKey.isBlank()) {
            throw new IllegalStateException("No API key configured for " + providerName);
        }
        LLMProvider provider = providerFactory.createProvider(providerName, apiKey);
        return provider.generate(systemPrompt, userPrompt, references, options);
    }
    
  • Remove the old active/fallback provider logic entirely

Step 7: Backend — Update LLMController

Modify: backend/src/main/java/com/krrishg/controller/LLMController.java

  • In /generate and /refine: extract provider (required) and optional apiKey from request body
  • If apiKey not provided, load from user's stored keys via UserRepository + EncryptionService.decrypt()
  • Pass provider + apiKey to LLMService
  • Add validation: provider must be one of the supported values

Step 8: Backend — Update application.yml

Modify: backend/src/main/resources/application.yml

  • Remove indie.llm.active-provider and indie.llm.fallback-provider
  • Remove indie.llm.providers.gemini.api-key and indie.llm.providers.openai.api-key
  • Keep indie.llm.providers.gemini.model, gemini.url, openai.model, openai.url
  • Add indie.encryption.master-key: ${INDIE_ENCRYPTION_KEY:}

Step 9: Frontend — Update LLM types

Modify: frontend/src/app/core/models/llm.ts

export interface LLMGenerateRequest {
  prompt: string;
  siteId: string;
  provider: string;
  apiKey?: string;
  references?: Reference[];
}

export interface LLMRefineRequest {
  prompt: string;
  siteId: string;
  provider: string;
  currentStructure: SiteStructure;
  apiKey?: string;
  references?: Reference[];
}

export interface LLMKeyStatus {
  gemini: boolean;
  openai: boolean;
}

Step 10: Frontend — LLM config service

New file: frontend/src/app/core/services/llm-config.service.ts

  • getStatus(): Observable<LLMKeyStatus> — GET /api/user/llm-config
  • saveKey(provider: string, apiKey: string): Observable<void> — PUT /api/user/llm-config

Step 11: Frontend — Add LLM keys UI to settings

Modify: frontend/src/app/llm-workspace/settings/settings.component.ts

Add a new card "LLM API Keys":

  • For each provider (Gemini, OpenAI): show a row with provider name, status indicator (configured/not configured), and expandable input for the API key
  • "Save" button per provider
  • On init, load LLMKeyStatus from backend
  • On save, call llm-config.service.saveKey()

Step 12: Frontend — Update workspace

Modify: frontend/src/app/llm-workspace/llm-workspace.component.ts

  • On init, load LLMKeyStatus via llm-config.service
  • If no keys configured, show a notice: "Configure an API key in Settings to generate sites"
  • If only one provider has a key, auto-select it
  • If multiple, add a dropdown to select provider before generating
  • Pass provider (and optionally apiKey) in generate/refine requests

Step 13: Backend — Tests

Test file What to test
EncryptionServiceTest.java (new) Encrypt → decrypt round-trip; different keys produce different ciphertexts
ProviderFactoryTest.java (new) Creates correct provider type for each name; throws for unknown provider
LLMControllerTest.java (update) Request with/without provider, with/without apiKey, missing key returns 400
LLMServiceTest.java (update) Uses ProviderFactory instead of LLMFactory; passes key correctly; no fallback logic
UserSettingsControllerTest.java (new) Save key, get status, delete key

Step 14: Frontend — Tests (optional)

Add/tests for:

  • llm-config.service.ts
  • New settings UI code

Validation

Test scenarios

# Scenario Expected Behavior
1 User has no keys configured Workspace shows notice; API returns 400 with clear message
2 User saves Gemini key PUT /api/user/llm-config returns success; GET /api/user/llm-config shows gemini: true
3 User saves OpenAI key Same as above
4 User saves both keys GET returns both as configured
5 Generate with stored Gemini key Request with provider: "gemini" succeeds
6 Generate with stored OpenAI key Request with provider: "openai" succeeds
7 Generate with per-request key override Passing apiKey in request body uses that key instead of stored
8 Generate with unknown provider 400 error
9 Generate with provider that has no key stored 400 error: "No API key configured for {provider}"
10 Delete a stored key DELETE clears it; subsequent generate for that provider fails
11 Encryption round-trip decrypt(encrypt("key")) == "key"
12 Provider factory creates correct instances GeminiProvider for "gemini", OpenAIProvider for "openai"

Risks and mitigations

Risk Mitigation
Encryption master key changes → stored keys unreadable Generate + persist key to file on first run (like JWT keys); document INDIE_ENCRYPTION_KEY as critical env var
Existing users have no key → immediate errors Clear error messages in API responses and UI prompts
Per-request provider creation overhead RestTemplate shared via injected RestTemplateBuilder; negligible cost
Hibernate JSON column compatibility Use @JdbcTypeCode(SqlTypes.JSON) (Hibernate 6); fallback to columnDefinition = "TEXT" with manual JSON serialization if needed

Success criteria

  • Users can configure API keys for Gemini and/or OpenAI via settings UI
  • Users can select which provider to use during site generation
  • Keys are encrypted at rest in the database
  • No global/default API key is required to run the application
  • All existing tests pass with appropriate modifications