LLM connections use a UUID-based Vault path design where environment swaps are pure database operations with zero Vault I/O. Each connection stores its credentials in Vault once at a fixed path (secret/llm/connections/{platform}/{vault_uuid}), and switching which connection is "production" vs "testing" only updates the environment column in PostgreSQL.
flowchart TD
subgraph GUI["GUI (React)"]
A[User clicks Swap/Edit]
end
subgraph Ruuter["Ruuter Private DSL"]
B[POST /llm-connections/edit]
C[Demote old production → testing]
D[Clear LLM cache]
E[Promote connection → production]
end
subgraph Resql["Resql (PostgreSQL)"]
F[(rag_search.llm_connections)]
end
subgraph LLM["LLM Orchestration Service"]
G[POST /cache/clear]
H[ConnectionIdFetcher]
I[POST /orchestrate/stream]
J[_initialize_llm_manager]
K[ConfigurationLoader]
L[SecretResolver]
end
subgraph Vault["HashiCorp Vault"]
M[secret/llm/connections/platform/uuid]
N[secret/embeddings/connections/platform/uuid]
end
A --> B
B --> C
C --> F
C --> D
D --> G
G --> H
B --> E
E --> F
I --> J
J --> H
H --> F
J --> K
K --> L
L --> M
L --> N
-- Table: rag_search.llm_connections
CREATE TABLE rag_search.llm_connections (
id SERIAL PRIMARY KEY,
vault_uuid UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
connection_name TEXT,
llm_platform TEXT, -- e.g., "azure_openai", "aws_bedrock"
llm_model TEXT,
embedding_platform TEXT,
embedding_model TEXT,
environment TEXT, -- "production" or "testing"
connection_status TEXT, -- "active" or "inactive"
-- ... budget fields, timestamps, etc.
);Key point: vault_uuid is immutable and assigned at row creation. The environment column is the only thing that changes during a swap.
secret/
├── llm/
│ └── connections/
│ ├── azure_openai/
│ │ ├── <vault_uuid_1> ← credentials for connection 1
│ │ └── <vault_uuid_2> ← credentials for connection 2
│ └── aws_bedrock/
│ └── <vault_uuid_3>
└── embeddings/
└── connections/
├── azure_openai/
│ └── <vault_uuid_1>
└── aws_bedrock/
└── <vault_uuid_3>
Environment is NOT part of the Vault path. This is the core design decision — it means swapping environments never touches Vault.
The user edits a "testing" connection and changes its environment to "production" via the GUI. This triggers POST /llm-connections/edit.
# Step 1: Check if promoting testing → production
check_deployment_environment:
condition: environment == "production" && existing.environment == "testing"
next: get_existing_production_connection
# Step 2: Find current production connection
get_existing_production_connection:
call: Resql → get-production-connection
next: update_existing_production_to_testing
# Step 3: Demote current production to testing (DB only)
update_production_connection:
call: Resql → update-llm-connection-environment
body: { connection_id: <old_prod_id>, environment: "testing" }
next: clear_llm_cache
# Step 4: Invalidate LLM service cache
clear_llm_cache:
call: POST http://llm-orchestration-service:8100/cache/clear
next: update_llm_connection
# Step 5: Update the edited connection to production (DB only)
update_llm_connection:
call: Resql → update-llm-connection
body: { connection_id: <new_prod_id>, environment: "production", ... }Total Vault operations: 0 — only PostgreSQL updates and a cache invalidation.
The LLM orchestration service caches vault_uuid and connection_id in memory (via ConnectionIdFetcher). Without invalidation, it would keep using the old production connection's vault_uuid.
# src/llm_orchestration_service_api.py
@app.post("/cache/clear")
async def clear_connection_cache():
fetcher = get_connection_id_fetcher()
fetcher.clear_cache() # Clears all cached connection_id + vault_uuid entries
return {"status": "ok"}On the next /orchestrate/stream request:
Request arrives → _initialize_llm_manager(environment="production", connection_id=None)
↓
ConnectionIdFetcher.fetch_vault_uuid_sync("production")
↓ (cache is empty after clear)
POST Resql → get-production-connection → returns new production row with vaultUuid
↓
vault_uuid cached in memory for subsequent requests
↓
ConfigurationLoader(environment="production", connection_id=<vault_uuid>)
↓
SecretResolver._build_vault_path() → "llm/connections/{provider}/{vault_uuid}"
↓
VaultAgentClient.get_secret() → fetches credentials from Vault
↓
LLM initialized with new credentials
sequenceDiagram
participant Client
participant API as FastAPI (/orchestrate/stream)
participant OrcSvc as OrchestrationService
participant Fetcher as ConnectionIdFetcher
participant Resql as Resql (PostgreSQL)
participant Loader as ConfigurationLoader
participant Resolver as SecretResolver
participant Vault as HashiCorp Vault
Client->>API: POST /orchestrate/stream
API->>OrcSvc: orchestrate(request)
OrcSvc->>OrcSvc: _initialize_llm_manager("production", None)
Note over OrcSvc,Fetcher: Auto-resolve vault_uuid for production
OrcSvc->>Fetcher: fetch_vault_uuid_sync("production")
alt Cache hit
Fetcher-->>OrcSvc: cached vault_uuid
else Cache miss
Fetcher->>Resql: POST /get-production-connection
Resql-->>Fetcher: [{id, vaultUuid, llmPlatform, ...}]
Note over Fetcher: Resql converts snake_case → camelCase
Fetcher-->>OrcSvc: vault_uuid (cached for next time)
end
OrcSvc->>Loader: load(environment, connection_id=vault_uuid)
Loader->>Resolver: get_secret_for_model(provider, env, "", vault_uuid)
Resolver->>Resolver: _build_vault_path → "llm/connections/{provider}/{vault_uuid}"
Resolver->>Vault: GET secret/llm/connections/{provider}/{vault_uuid}
Vault-->>Resolver: {api_key, endpoint, model, ...}
Resolver-->>Loader: AzureOpenAISecret / AWSBedrockSecret
Loader-->>OrcSvc: config with resolved secrets
OrcSvc-->>API: LLM response (streamed)
| Operation | File | Triggers /cache/clear? |
|---|---|---|
| Add new production connection (demotes existing) | add.yml |
Yes |
| Edit connection to promote testing → production | edit.yml |
Yes |
| Delete a connection | delete.yml |
No (not needed — deleted connections aren't cached) |
| Edit without environment change | edit.yml |
No (no swap happening) |
# Cache keys:
# "production_connection_id" → int (DB row ID)
# "production_vault_uuid" → str (UUID for Vault path)
# "testing_connection_id" → int
# "testing_vault_uuid" → str
_connection_cache: Dict[str, int | str] = {}- Populated on: First request after service start or cache clear
- Cleared by:
POST /cache/clear(called by Ruuter after swap) - Thread-safe: Uses
threading.Lock
# Cache key: vault_path string → CachedSecret (data + expires_at)
# TTL: 5 minutes (configurable)
_cache: Dict[str, CachedSecret] = {}- Populated on: Successful Vault read
- Evicted after: 5-minute TTL
- Background refresh: Expired entries are refreshed asynchronously
- Not cleared by
/cache/clear— addressed by TTL expiry
- ConnectionIdFetcher cache — Avoids repeated DB calls to resolve which connection is currently "production". Cleared instantly on swap.
- SecretResolver cache — Avoids repeated Vault reads for the same credentials. Not cleared on swap because the vault_uuid changes, so the new path is a cache miss anyway.
| Aspect | Production | Testing |
|---|---|---|
connection_id in request |
Optional (auto-resolved from DB) | Required (must be provided) |
| DB lookup | get-production-connection SQL |
Not needed (UUID in request) |
| Vault path | llm/connections/{provider}/{auto_resolved_uuid} |
llm/connections/{provider}/{provided_uuid} |
| Caching | Cached in ConnectionIdFetcher | Not cached (provided per-request) |
Vault is only written to during connection creation, not during swaps:
sequenceDiagram
participant GUI
participant Ruuter
participant Resql as Resql (DB)
participant CronMgr as CronManager
participant Vault
GUI->>Ruuter: POST /llm-connections/add
Ruuter->>Resql: INSERT into llm_connections (generates vault_uuid)
Resql-->>Ruuter: {id, vaultUuid, ...}
Note over Ruuter: If production: demote old, clear cache
Ruuter->>CronMgr: POST /vault/secret/create {vaultUuid, platform, credentials}
CronMgr->>CronMgr: build_vault_path → "secret/llm/connections/{platform}/{vaultUuid}"
CronMgr->>Vault: Write credentials to path
Vault-->>CronMgr: OK
CronMgr-->>Ruuter: Success
- No environment in Vault paths — Swapping is instantaneous (DB UPDATE only), no secret migration needed.
- UUID generated by PostgreSQL —
gen_random_uuid()DEFAULT ensures it's assigned atomically at INSERT. - Cache invalidation via HTTP — Ruuter calls
/cache/clearafter any swap to ensure the orchestration service picks up the new production connection on the next request. - Resql auto-converts snake_case to camelCase —
vault_uuidin PostgreSQL becomesvaultUuidin all JSON responses. All DSL/code must usevaultUuid. - Graceful degradation — If Vault is unavailable,
SecretResolverfalls back to last-known-good cached credentials.
| Symptom | Likely Cause | Fix |
|---|---|---|
| Old connection still used after swap | Cache not cleared | Verify clear_llm_cache step fires in Ruuter DSL; manually call POST /cache/clear |
vault_uuid is null in Ruuter |
Using vault_uuid instead of vaultUuid |
Resql converts to camelCase — always use vaultUuid in DSL |
| "No production connection found" | DB has no row with environment = 'production' |
Create a production connection via GUI |
| Vault secret not found | Vault path mismatch | Verify CronManager store_secrets_in_vault.sh used same vaultUuid as DB |
| Stale credentials after 5+ minutes | SecretResolver TTL expired but Vault is down | Check Vault connectivity; fallback cache serves last-known-good |