Design and Architecture

LLM Settings and Secret Storage

Architecture for organization LLM configuration, encrypted tenant keys, and default LLM clients.

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:

FieldMeaning
ProviderProvider name such as Fireworks, OpenAI, or Anthropic.
ModelProvider model identifier.
KeyIdOptional 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:

PathUse
Default credentialsApp-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 credentialsOrganization-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:

ProviderEnvironmentPurpose
data-protectionDevelopment onlyLocal persisted key ring for developer machines.
cloud-kmsProductionGoogle 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:

EventKMS use
Create managed keyEncrypt once.
Rotate managed keyEncrypt once with the currently active provider.
First decrypt after cache missDecrypt 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:

ProviderClient path
FireworksOpenAI-compatible endpoint https://api.fireworks.ai/inference/v1.
OpenAIOpenAI SDK default endpoint.
AnthropicUI 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:

EndpointPurpose
GET /api/v1/orgs/{orgId}/llm-settingsReads tier settings and key metadata for managers; non-admin members receive a notice-only response.
PUT /api/v1/orgs/{orgId}/llm-settingsSaves all three tier selections after validating managed-key references.
POST /api/v1/orgs/{orgId}/llm-settings/testRuns a bounded provider/model/key access test with sanitized output.
POST /api/v1/orgs/{orgId}/llm-settings/keysCreates a tenant-managed encrypted key.
PATCH /api/v1/orgs/{orgId}/llm-settings/keys/{keyId}/nameRenames key metadata.
PUT /api/v1/orgs/{orgId}/llm-settings/keys/{keyId}/rotateReplaces 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.