Model Catalog
The model catalog is Candela’s centralized registry of LLM models, their pricing, and metadata. Every cost calculation, budget check, and dashboard query resolves model pricing through the catalog. You can run it as a static config file (zero infrastructure) or as a dynamic Firestore-backed store with an admin API.
Catalog Backends
Section titled “Catalog Backends”Candela supports two catalog backends, configured via catalog.backend in your config.yaml:
| Backend | Mutability | Best For |
|---|---|---|
config |
Read-only | Single-team setups, static pricing, no Firestore dependency |
firestore |
Read/write | Multi-team orgs, dynamic pricing, admin UI editing |
Config Backend (default)
Section titled “Config Backend (default)”The config backend loads model pricing from compiled-in defaults and optional YAML overrides. It requires no external services.
catalog: backend: "config"
# Optional: override built-in pricing for specific modelspricing: discount_percent: 0.0 # global discount (0.15 = 15% off) models: - provider: openai model: gpt-4o input_per_million: 2.00 # negotiated rate output_per_million: 8.00 - provider: google model: gemini-2.5-pro input_per_million: 1.00 output_per_million: 8.00 discount_percent: 0.10 # additional 10% off this modelThe config backend always returns ErrReadOnly for create/update/delete operations. Use the pricing.models section to override individual model prices.
Firestore Backend
Section titled “Firestore Backend”The firestore backend stores catalog entries in a Cloud Firestore collection, enabling runtime updates via the admin API or the Candela UI.
catalog: backend: "firestore" firestore: collection: "model_catalog" # Firestore collection name (default) project_id: "" # GCP project (defaults to server's project_id)Document IDs are deterministic: {provider}_{model_id} (URL-encoded to handle slashes in model names like meta-llama/Llama-3).
Catalog Entry
Section titled “Catalog Entry”Every model in the catalog is represented by an Entry with the following fields:
| Field | Type | Description |
|---|---|---|
model_id |
string | Canonical model identifier (e.g. gemini-2.5-pro) |
provider |
string | Provider name (e.g. openai, anthropic, google) |
display_name |
string | Human-friendly label (e.g. “Gemini 2.5 Pro”) |
input_per_million |
float | Base tier: USD per 1M input tokens |
output_per_million |
float | Base tier: USD per 1M output tokens |
enabled |
bool | Whether the model is available for routing |
category |
string | Grouping label (e.g. flagship, lite, reasoning) |
context_window |
int | Maximum context length in tokens |
input_per_million_high |
float | High tier: USD per 1M input tokens (optional) |
output_per_million_high |
float | High tier: USD per 1M output tokens (optional) |
tier_threshold_tokens |
int | Token count threshold for high-tier pricing |
provider_model_id |
string | Upstream provider-specific model ID (e.g. claude-opus-5-5 on Vertex AI) |
region |
string | Vertex AI region override (e.g. global, us-central1, us-east5) |
aliases |
string[] | Alternative model names that resolve to this entry |
required_access |
string[] | Access tags required to use this model (empty = unrestricted). See Model Access Control |
allowed_tenants |
string[] | Tenant IDs allowed to access this model (empty = all tenants). See Model Access Control |
discount_percent |
float | Model-specific discount (0.0–1.0) |
updated_at |
timestamp | Last modification time (server-set for Firestore) |
Tiered Pricing
Section titled “Tiered Pricing”Some models (notably Gemini) have tiered pricing where costs increase after a token threshold. When tier_threshold_tokens is set, the cost engine uses the _high rates for the portion of input tokens exceeding the threshold.
Access Control Fields
Section titled “Access Control Fields”The required_access and allowed_tenants fields enable tag-based model gating. When set, Candela enforces access checks as a pre-flight gate before proxying the request:
required_access: A list of access tag strings. The requesting user must have at least one matching tag in theiraccess_tagsto use the model. If empty, the model is open to everyone. Admin users bypass this check.allowed_tenants: A list of tenant ID strings. The request’sX-Candela-Tenant-Idmust match one of the listed tenants. If empty, all tenants can access the model.
Both gates are fail-closed — errors in the catalog or user store result in a 500, never silent pass-through. See Model Access Control for full documentation.
Seeding the Catalog
Section titled “Seeding the Catalog”The seed-catalog CLI populates a Firestore catalog collection with either Candela’s built-in default pricing or an external declarative YAML/JSON file (--from). This supports both initial bootstrapping and GitOps catalog synchronization in CI/CD.
# Preview what would be written (safe — no Firestore writes)go run ./cmd/seed-catalog/ \ --project-id=your-gcp-project \ --dry-run
# Seed from built-in defaults (insert-only via --merge)go run ./cmd/seed-catalog/ \ --project-id=your-gcp-project \ --database-id=candela \ --collection=model_catalog \ --merge
# Declarative GitOps sync from a checked-in YAML file (upserts all listed entries)go run ./cmd/seed-catalog/ \ --project-id=your-gcp-project \ --database-id=candela \ --collection=model_catalog \ --from=catalog/models.yaml| Flag | Default | Description |
|---|---|---|
--project-id |
(required) | GCP project ID for Firestore |
--database-id |
(default) |
Firestore database ID (e.g. candela, candela-stg) |
--collection |
model_catalog |
Firestore collection name |
--from |
"" |
Path to an external YAML/JSON catalog file (uses embedded defaults when omitted) |
--merge |
false |
Insert-only mode: skip entries that already exist in Firestore instead of overwriting |
--dry-run |
false |
Print entries without writing to Firestore |
When --from is omitted, the seeder pulls pricing from the built-in costcalc.Calculator.Defaults() table — the same data that powers the config backend. Each entry is written via FirestoreStore.Update(), which performs a full document Set (upsert) unless --merge is specified.
Admin API
Section titled “Admin API”When the catalog backend is firestore, admins can manage entries via the ConnectRPC ModelCatalogService. All mutation endpoints require admin role — non-admin callers receive PERMISSION_DENIED.
ListModelCatalog
Section titled “ListModelCatalog”Returns all models in the catalog. Non-admin callers only see enabled models regardless of include_disabled.
buf curl --protocol connect \ https://candela.example.com/candela.v1.ModelCatalogService/ListModelCatalog \ -d '{"include_disabled": true}'Response includes source (e.g. "firestore") and admin_editable (true for writable backends).
UpdateModelCatalogEntry
Section titled “UpdateModelCatalogEntry”Creates or updates a single model entry. Supports field masks for partial updates — only the specified fields are modified, preventing accidental data loss.
# Create a new entrybuf curl --protocol connect \ https://candela.example.com/candela.v1.ModelCatalogService/UpdateModelCatalogEntry \ -d '{ "entry": { "provider": "openai", "model_id": "gpt-4.1-nano", "display_name": "GPT-4.1 Nano", "input_per_million": 0.10, "output_per_million": 0.40, "enabled": true, "category": "lite" } }'
# Partial update — only change pricingbuf curl --protocol connect \ https://candela.example.com/candela.v1.ModelCatalogService/UpdateModelCatalogEntry \ -d '{ "entry": { "provider": "openai", "model_id": "gpt-4.1-nano", "input_per_million": 0.08, "output_per_million": 0.35 }, "updateMask": "inputPerMillion,outputPerMillion" }'Supported field mask paths: enabled, displayName, inputPerMillion, outputPerMillion, inputPerMillionHigh, outputPerMillionHigh, tierThresholdTokens, discountPercent, category, contextWindow, aliases, requiredAccess, allowedTenants.
GetModelCatalogEntry
Section titled “GetModelCatalogEntry”Retrieve a single entry by provider and model ID:
buf curl --protocol connect \ https://candela.example.com/candela.v1.ModelCatalogService/GetModelCatalogEntry \ -d '{"provider": "openai", "model_id": "gpt-4.1-nano"}'DeleteModelCatalogEntry
Section titled “DeleteModelCatalogEntry”Remove a model from the catalog:
buf curl --protocol connect \ https://candela.example.com/candela.v1.ModelCatalogService/DeleteModelCatalogEntry \ -d '{"provider": "openai", "model_id": "gpt-4.1-nano"}'Returns NOT_FOUND if the entry doesn’t exist. Returns UNIMPLEMENTED if the catalog backend is read-only (config).
Pricing Flow
Section titled “Pricing Flow”The catalog feeds directly into Candela’s cost engine. Here’s how pricing resolves for each proxied LLM request:
LLM response received (with token counts) │ ▼┌──────────────────┐│ Catalog Lookup │ Get(provider, model_id)│ (config or │ → Entry with pricing│ firestore) │└────────┬─────────┘ │ ▼┌──────────────────┐│ Cost Engine │ Applies tiered pricing,│ (costcalc) │ model discount, global│ │ discount└────────┬─────────┘ │ ▼┌──────────────────┐│ Budget Deduct │ cost → user budget│ & Record Span │ cost → span record└──────────────────┘The degradation chain for pricing resolution:
- Catalog entry — exact match by provider + model ID
- Alias match — check
aliasesfield for alternative names - Built-in defaults — compiled-in pricing table
- Zero cost — if no pricing found, cost is $0.00 (logged as warning)
Configuration Reference
Section titled “Configuration Reference”# Full catalog configuration blockcatalog: # Backend: "config" (default) or "firestore" # Override at runtime: CANDELA_CATALOG_BACKEND env var backend: "config"
# Firestore settings (only used when backend: "firestore") firestore: collection: "model_catalog" # Firestore collection name project_id: "" # GCP project (defaults to server project)
# Pricing overrides (used by config backend)pricing: discount_percent: 0.0 # Global discount (0.0–1.0) models: # Per-model overrides - provider: openai model: gpt-4o input_per_million: 2.00 output_per_million: 8.00