LLM Settings and Secret Storage
Zeeq stores organization LLM settings separately from API key material. The organization configuration records provider, model, and an optional encrypted key reference for each workload tier; key plaintext is never persisted.
Data Model
Organization.LlmConfiguration is a typed JSON document stored on the organization row. It contains the Fast, High, and Max tier selections.
Each tier stores:
| Field | Meaning |
|---|---|
Provider | Provider name such as Fireworks, OpenAI, or Anthropic. |
Model | Provider model identifier. |
KeyId | Optional encrypted-value ID. null is valid only for Fireworks default-key tiers. |
Tenant-owned API keys are stored in core_encrypted_values as EncryptedValueKind.LlmApiKey rows. Each row includes organization scope, display name, ciphertext, timestamps, disabled state, and the encryption provider that produced the ciphertext.
The encrypted row does not store the LLM provider. Provider selection belongs to tier configuration, not secret metadata.
Credential Paths
Zeeq uses two credential paths:
| Path | Use |
|---|---|
| Default credentials | App-level Fireworks defaults loaded from runtime configuration. These are Zeeq-owned credentials and are not tenant data. The default model is Fireworks GLM 5.2 (accounts/fireworks/models/glm-5p2). |
| Tenant credentials | Organization-owned managed keys encrypted before persistence. These are required for OpenAI and Anthropic selections. |
The default-key path registers scoped keyed IChatClient services for Fast, High, and Max and wraps them in DefaultLlmChatClients. Tenant credentials are resolved at runtime and passed to ILlmClientFactory; they are not registered as singleton or scoped DI services.
Encryption Providers
KeyEncryptionService orchestrates encryption and decryption. The active AppSettings:Llm:EncryptionProvider controls new and rotated rows only.
On decrypt, the service uses the row's stored EncryptionProvider, not the current app setting. This allows an environment to move from local Data Protection to Cloud KMS without breaking older rows.
Supported provider names:
| Provider | Environment | Purpose |
|---|---|---|
data-protection | Development only | Local persisted key ring for developer machines. |
cloud-kms | Production | Google Cloud KMS-backed encryption for tenant secrets. |
Plaintext decrypt results are cached in memory for 30 minutes by organization, encrypted row ID, and row update timestamp. Disabled rows are checked before cache lookup, so disabled keys cannot be served from cache.
Local Data Protection
Local development uses ASP.NET Core Data Protection with a persisted file-system key ring at .secrets/data-protection-keys by default. The directory is created automatically and is ignored by the repository.
This lets encrypted keys survive app restarts without introducing a hand-managed symmetric key. The provider is rejected outside Development.
Cloud Run and KMS
Production uses Cloud KMS because Cloud Run file systems are ephemeral and not shared across instances. The key-encryption key remains in Google Cloud, access is governed by IAM, and key versions can be rotated or disabled centrally.
Zeeq uses direct symmetric Cloud KMS encrypt/decrypt operations for small API key strings. It does not currently use envelope encryption because the plaintext payload is a small secret, not a large document or blob.
KMS cost is bounded by the call pattern:
| Event | KMS use |
|---|---|
| Create managed key | Encrypt once. |
| Rotate managed key | Encrypt once with the currently active provider. |
| First decrypt after cache miss | Decrypt once, then cache briefly server-side. |
Plaintext is never logged, traced, returned to clients, stored in Postgres, or used as a cache key.
Provider Clients and Agents
LlmClientFactory builds Microsoft.Extensions.AI chat clients and Microsoft Agent Framework agents from resolved configuration.
The factory stores only non-secret dependencies. Provider SDK clients receive API keys only when a caller creates a client or agent for a specific resolved credential.
The current implementation supports OpenAI-compatible providers through the OpenAI SDK path:
| Provider | Client path |
|---|---|
| Fireworks | OpenAI-compatible endpoint https://api.fireworks.ai/inference/v1. |
| OpenAI | OpenAI SDK default endpoint. |
| Anthropic | UI cataloged, but not wired to a chat-client adapter yet. |
DeepSeek models are reachable only through Fireworks (e.g. accounts/fireworks/models/deepseek-v4-pro). DeepSeek-direct is no longer a selectable provider and is rejected by the factory as unsupported_provider.
Provider access tests and future runtime consumers must resolve organization, tier, provider, model, key source, and endpoint before calling the factory.
API Surface
LLM settings endpoints are organization-scoped and owner/admin restricted:
| Endpoint | Purpose |
|---|---|
GET /api/v1/orgs/{orgId}/llm-settings | Reads tier settings and key metadata for managers; non-admin members receive a notice-only response. |
PUT /api/v1/orgs/{orgId}/llm-settings | Saves all three tier selections after validating managed-key references. |
POST /api/v1/orgs/{orgId}/llm-settings/test | Runs a bounded provider/model/key access test with sanitized output. |
POST /api/v1/orgs/{orgId}/llm-settings/keys | Creates a tenant-managed encrypted key. |
PATCH /api/v1/orgs/{orgId}/llm-settings/keys/{keyId}/name | Renames key metadata. |
PUT /api/v1/orgs/{orgId}/llm-settings/keys/{keyId}/rotate | Replaces the encrypted value for an existing key ID. |
DELETE /api/v1/orgs/{orgId}/llm-settings/keys/{keyId} | Soft-disables an unreferenced key. |
The API never returns plaintext, masked key text derived from plaintext, API key prefixes or suffixes, raw provider bodies, or generated model text.