Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles
Building a Multi-Tenant LLM Platform: Isolating Contexts, Enforcing Usage Quotas, and Managing Costs Per Customer

Building a Multi-Tenant LLM Platform: Isolating Contexts, Enforcing Usage Quotas, and Managing Costs Per Customer

AI & Machine Learning🔥 Expert30 min readJul 21, 2026Updated Jul 21, 2026
Table of Contents
  • Introduction
  • Prerequisites
  • Understanding the Multi-Tenant Problem for LLMs
  • Designing the Tenant Data Model
  • Building Context Isolation
  • Session Isolation with Redis
  • Prompt Construction with Injection Resistance
  • Implementing Token Quota Enforcement
  • The Quota Manager
  • Cost Attribution and Token Pricing
  • The Request Pipeline: Putting It All Together
  • Building the Usage Reporting Layer
  • Handling Per-Tenant LLM Credentials
  • Advanced: Semantic Caching Across Tenants
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Summary & Next Steps
  • Building a Multi-Tenant LLM Platform: Isolating Contexts, Enforcing Usage Quotas, and Managing Costs Per Customer

    Introduction

    You've built something that works. Your internal LLM-powered tool performs beautifully — it summarizes contracts, answers customer questions, generates reports. Now leadership wants to productize it. You'll serve thirty enterprise customers, each with their own data, their own users, their own expectations about privacy, and their own budget ceilings. Congratulations: you're now building a multi-tenant LLM platform, and the architecture decisions you make in the next few weeks will either scale gracefully to a hundred customers or quietly collapse under the weight of the second one.

    The naive approach is to stand up one API endpoint, share one system prompt, and send everyone's requests through the same pipeline. This breaks almost immediately. Tenant A's conversation history starts bleeding context into Tenant B's completions. A single customer running a batch job eats the entire organization's rate limit. Your monthly OpenAI bill arrives and you have no idea which customer cost you $4,000 and which cost $12. Multi-tenancy for LLM platforms is a genuinely hard systems problem, and most of the existing literature treats it as an afterthought or assumes you're already running Kubernetes at scale. This lesson meets you in the middle: production-grade patterns, real code, and honest discussion of trade-offs.

    By the end of this lesson, you'll be able to design and implement a multi-tenant LLM platform that isolates tenant context at the prompt and session level, enforces per-tenant token quotas with graceful degradation, tracks costs with enough granularity to produce customer-facing invoices, and handles the failure modes that kill multi-tenant systems in production.

    What you'll learn:

    • How LLM context isolation differs from traditional database row-level security, and why naive approaches fail
    • How to design a tenant-aware request routing layer that enforces quotas before tokens are spent
    • How to implement token accounting with accurate pre-flight estimation and post-flight reconciliation
    • How to build a cost attribution system that maps raw API spend to individual tenants, with enough detail for billing and chargeback
    • How to handle the edge cases: burst traffic, context window overflows, and tenants who try to game the system

    Prerequisites

    This lesson assumes you are comfortable with:

    • Python at an intermediate-to-advanced level (async patterns, dataclasses, context managers)
    • REST API concepts and HTTP middleware
    • Basic familiarity with an LLM API (OpenAI, Anthropic, or similar) — you've made API calls and understand tokens, prompts, and completions
    • Redis or a similar in-memory store (you understand key expiry and atomic operations)
    • PostgreSQL or a relational database for persistent storage

    You do not need prior experience with multi-tenancy specifically. That's the point of this lesson.

    Understanding the Multi-Tenant Problem for LLMs

    Multi-tenancy in a traditional web application is largely a data isolation problem. You add a tenant_id column to your tables, filter every query, and you're most of the way there. LLMs introduce three dimensions that make this substantially harder.

    First, the context is stateful in ways databases aren't. A database row is inert. A conversation context is cumulative and deterministic in its influence — every token in the prompt window shapes the model's output. If Tenant A's system prompt or conversation history leaks into a request made on behalf of Tenant B, the contamination is semantic, not just a data exposure. Tenant B's outputs become wrong in ways that may not be immediately obvious.

    Second, consumption is non-deterministic and bursty. You cannot predict in advance how many tokens a user's query will consume because the model's response length is probabilistic. A customer asking a simple question might get a one-sentence answer or, if something in the prompt encourages verbosity, a five-paragraph essay. This makes quota enforcement fundamentally different from, say, rate-limiting API calls, where you know exactly how much resource each call consumes.

    Third, the cost model is external and you pay it before you can bill for it. You get charged by your LLM provider immediately when you make an API call. If your quota enforcement has a race condition and lets ten concurrent requests through simultaneously for a customer who's hit their limit, you eat those costs. The billing problem is also attribution: a single API call might serve a workflow that spans multiple customers' data (think: a summarization job that touches a shared knowledge base), and allocating that cost requires deliberate design.

    Let's build a system that handles all three dimensions cleanly.

    Designing the Tenant Data Model

    Before writing any request-routing code, get the data model right. You need to represent four things: tenants, their quota configuration, their usage state, and their cost records.

    # models.py
    from dataclasses import dataclass, field
    from datetime import datetime
    from decimal import Decimal
    from enum import Enum
    from typing import Optional
    import uuid
    
    
    class QuotaPeriod(Enum):
        HOURLY = "hourly"
        DAILY = "daily"
        MONTHLY = "monthly"
    
    
    class QuotaAction(Enum):
        REJECT = "reject"          # Hard stop — return 429
        THROTTLE = "throttle"      # Slow requests via queue
        NOTIFY = "notify"          # Allow but fire an alert webhook
        ALLOW = "allow"            # Soft limit — log only
    
    
    @dataclass
    class TenantQuotaPolicy:
        """
        Defines what a tenant is allowed to consume and what happens
        when they exceed it. A tenant can have multiple policies
        (e.g., a daily soft limit and a monthly hard limit).
        """
        tenant_id: str
        period: QuotaPeriod
        token_limit: int                    # Total input + output tokens
        action_on_exceed: QuotaAction
        # Cost-based limits are a second enforcement layer
        cost_limit_usd: Optional[Decimal] = None
        # Per-request limits prevent a single runaway query
        max_tokens_per_request: int = 4096
        # Which models this policy applies to (None = all)
        model_filter: Optional[list[str]] = None
    
    
    @dataclass
    class Tenant:
        tenant_id: str
        name: str
        created_at: datetime
        # System prompt that gets injected for all requests from this tenant
        system_prompt_template: str
        # The tenant's own API key for authenticating with your platform
        platform_api_key: str
        # Which LLM provider credentials to use (enables per-tenant API keys)
        llm_credential_key: str = "default"
        quota_policies: list[TenantQuotaPolicy] = field(default_factory=list)
        # Metadata for billing
        billing_tier: str = "standard"
        cost_center: Optional[str] = None
    
    
    @dataclass
    class UsageRecord:
        """
        Immutable record of a single LLM API call.
        This is your audit trail and billing source of truth.
        """
        record_id: str
        tenant_id: str
        session_id: str
        request_timestamp: datetime
        model: str
        input_tokens: int
        output_tokens: int
        total_tokens: int
        estimated_cost_usd: Decimal
        # The request that triggered this usage
        user_id: Optional[str] = None
        workflow_id: Optional[str] = None
        # Whether this was a cache hit (reduces billable cost)
        from_cache: bool = False
        # Raw provider response metadata for reconciliation
        provider_request_id: Optional[str] = None
    

    Notice a few intentional decisions here. TenantQuotaPolicy is separated from Tenant because quota policies change frequently (upgrades, downgrades, temporary overrides) while the tenant itself is relatively stable. Having multiple policies per tenant — with different periods and actions — lets you express nuanced limits like "warn at 80% of daily budget, hard-stop at 100% of monthly budget." The QuotaAction enum gives you a spectrum from graceful degradation to hard rejection, which matters for enterprise customers who will write SLA language around your behavior at their limits.

    Building Context Isolation

    Context isolation has two levels: session-level isolation (ensuring one tenant's conversation doesn't influence another's) and prompt-level isolation (ensuring the tenant's system prompt is correctly constructed and cannot be overridden by user input).

    Session Isolation with Redis

    Each tenant's active conversations need to live in a namespace that cannot be accessed or contaminated by other tenants. The session store is the first line of defense.

    # session_store.py
    import json
    import hashlib
    from datetime import timedelta
    from typing import Optional
    import redis.asyncio as redis
    from models import Tenant
    
    
    class TenantSessionStore:
        """
        Manages conversation context per tenant using Redis.
        All keys are namespaced by tenant_id, making cross-tenant
        access structurally impossible, not just policy-enforced.
        """
        
        def __init__(self, redis_client: redis.Redis, ttl_hours: int = 24):
            self.redis = redis_client
            self.ttl = timedelta(hours=ttl_hours)
        
        def _session_key(self, tenant_id: str, session_id: str) -> str:
            """
            Construct an opaque Redis key that embeds the tenant_id.
            We hash the session_id to prevent key injection attacks
            where a malicious user crafts a session_id that navigates
            outside their tenant's namespace.
            """
            session_hash = hashlib.sha256(session_id.encode()).hexdigest()[:16]
            return f"session:{tenant_id}:{session_hash}"
        
        async def get_messages(
            self, 
            tenant_id: str, 
            session_id: str
        ) -> list[dict]:
            key = self._session_key(tenant_id, session_id)
            raw = await self.redis.get(key)
            if raw is None:
                return []
            return json.loads(raw)
        
        async def append_turn(
            self,
            tenant_id: str,
            session_id: str,
            user_message: str,
            assistant_message: str,
            max_turns: int = 20
        ) -> list[dict]:
            """
            Append a conversation turn and enforce a maximum history depth.
            Truncating from the front (oldest messages first) is the right
            default — it preserves recent context over ancient context.
            """
            key = self._session_key(tenant_id, session_id)
            
            messages = await self.get_messages(tenant_id, session_id)
            messages.append({"role": "user", "content": user_message})
            messages.append({"role": "assistant", "content": assistant_message})
            
            # Keep only the most recent max_turns pairs
            if len(messages) > max_turns * 2:
                messages = messages[-(max_turns * 2):]
            
            await self.redis.setex(
                key, 
                self.ttl, 
                json.dumps(messages)
            )
            return messages
        
        async def clear_session(self, tenant_id: str, session_id: str) -> None:
            key = self._session_key(tenant_id, session_id)
            await self.redis.delete(key)
        
        async def get_session_token_estimate(
            self, 
            tenant_id: str, 
            session_id: str
        ) -> int:
            """
            Rough token estimate for the stored history.
            Uses the 4-chars-per-token heuristic, which is
            conservative enough for quota checking purposes.
            """
            messages = await self.get_messages(tenant_id, session_id)
            total_chars = sum(len(m["content"]) for m in messages)
            return total_chars // 4
    

    The key insight here is the _session_key method. By hashing the session ID and embedding the tenant ID structurally in the Redis key, you make cross-tenant access impossible at the storage layer. A bug in your routing logic can't accidentally serve Tenant A's history to Tenant B, because Tenant B's lookup key for the same session ID would produce a completely different Redis key. Defense in depth is not paranoia; it's engineering.

    Prompt Construction with Injection Resistance

    Prompt injection is the multi-tenant LLM platform's SQL injection. A user who figures out that your system prompt ends with ---User message below--- can craft a message that exits the user turn and injects new instructions. Here's a robust prompt construction layer:

    # prompt_builder.py
    import re
    from dataclasses import dataclass
    from typing import Optional
    from models import Tenant
    
    
    # Sentinel tokens that are structurally unusual enough to be
    # unlikely in legitimate user content. Don't rely on these alone.
    INJECTION_PATTERNS = [
        r"(?i)ignore\s+(all\s+)?previous\s+instructions",
        r"(?i)you\s+are\s+now\s+a",
        r"(?i)new\s+system\s+prompt",
        r"(?i)disregard\s+(your\s+)?instructions",
        r"(?i)act\s+as\s+if\s+you\s+(are|were)",
    ]
    
    COMPILED_PATTERNS = [re.compile(p) for p in INJECTION_PATTERNS]
    
    
    @dataclass
    class BuiltPrompt:
        system_message: str
        messages: list[dict]
        estimated_input_tokens: int
    
    
    def build_tenant_prompt(
        tenant: Tenant,
        user_message: str,
        conversation_history: list[dict],
        context_variables: Optional[dict] = None,
        sanitize_input: bool = True
    ) -> BuiltPrompt:
        """
        Construct the full prompt for a tenant request.
        
        This is deliberately not "smart" — it applies the tenant's
        system prompt rigidly and treats the user message as data,
        not instructions. That's the correct security posture.
        """
        
        if sanitize_input:
            user_message = _sanitize_user_input(user_message)
        
        # Render the tenant's system prompt template with any
        # platform-controlled variables. Tenants cannot inject
        # into these variables — they come from your database.
        system_content = _render_system_prompt(
            tenant.system_prompt_template,
            context_variables or {}
        )
        
        # Critical: wrap the user message in a structural boundary
        # that the system prompt can reference. This creates a clear
        # semantic boundary even if the model doesn't honor it perfectly.
        wrapped_user_message = (
            f"[USER INPUT START]\n{user_message}\n[USER INPUT END]"
        )
        
        # Build the messages array: history first, then current turn
        messages = list(conversation_history)
        messages.append({"role": "user", "content": wrapped_user_message})
        
        # Rough token estimation for quota pre-flight
        system_chars = len(system_content)
        history_chars = sum(len(m["content"]) for m in conversation_history)
        current_chars = len(wrapped_user_message)
        estimated_tokens = (system_chars + history_chars + current_chars) // 4
        
        return BuiltPrompt(
            system_message=system_content,
            messages=messages,
            estimated_input_tokens=estimated_tokens
        )
    
    
    def _sanitize_user_input(text: str) -> str:
        """
        Flag (but don't necessarily block) known injection patterns.
        Blocking them entirely can cause false positives for legitimate
        questions about AI, instructions, or prompt engineering itself.
        The right response is to log these and review, not silently drop.
        """
        for pattern in COMPILED_PATTERNS:
            if pattern.search(text):
                # In production, log this with the tenant/user info
                # Here we annotate rather than strip
                text = f"[FLAGGED CONTENT] {text}"
                break
        return text
    
    
    def _render_system_prompt(template: str, variables: dict) -> str:
        """
        Safe template rendering. We use explicit variable substitution
        rather than f-strings or format() to prevent code execution.
        """
        result = template
        for key, value in variables.items():
            # Only substitute known, validated variable names
            if re.match(r'^[a-z_][a-z0-9_]*$', key):
                result = result.replace(f"{{{{{key}}}}}", str(value))
        return result
    

    Warning: No amount of input sanitization fully prevents prompt injection against current LLMs. Defense in depth matters: sanitize input, structure your prompts with clear boundaries, monitor outputs for anomalies, and design your system prompts to be robust to manipulation. Treat prompt injection as a first-class threat model, not a corner case.

    Implementing Token Quota Enforcement

    The quota system has two phases: pre-flight (before the API call) and post-flight (after you have the actual token counts). Pre-flight prevents overspending; post-flight gives you accurate accounting.

    The Quota Manager

    # quota_manager.py
    import time
    from decimal import Decimal
    from typing import Optional, Tuple
    import redis.asyncio as redis
    from models import (
        Tenant, TenantQuotaPolicy, QuotaPeriod, 
        QuotaAction, UsageRecord
    )
    
    
    class QuotaExceededError(Exception):
        def __init__(self, tenant_id: str, policy: TenantQuotaPolicy, 
                     current_usage: int):
            self.tenant_id = tenant_id
            self.policy = policy
            self.current_usage = current_usage
            super().__init__(
                f"Tenant {tenant_id} has exceeded {policy.period.value} quota. "
                f"Used {current_usage}/{policy.token_limit} tokens."
            )
    
    
    class QuotaManager:
        """
        Enforces token quotas using Redis atomic operations.
        
        The core primitive is INCRBY + EXPIREAT on a quota window key.
        This gives us atomic increment (preventing race conditions) and
        automatic cleanup (the key expires at the end of the window).
        """
        
        def __init__(self, redis_client: redis.Redis):
            self.redis = redis_client
        
        def _quota_key(self, tenant_id: str, period: QuotaPeriod) -> str:
            """Generate a time-windowed Redis key for quota tracking."""
            window = self._current_window(period)
            return f"quota:{tenant_id}:{period.value}:{window}"
        
        def _current_window(self, period: QuotaPeriod) -> str:
            """Return a string representing the current quota window."""
            import datetime
            now = datetime.datetime.utcnow()
            if period == QuotaPeriod.HOURLY:
                return now.strftime("%Y%m%d%H")
            elif period == QuotaPeriod.DAILY:
                return now.strftime("%Y%m%d")
            elif period == QuotaPeriod.MONTHLY:
                return now.strftime("%Y%m")
        
        def _window_expiry_seconds(self, period: QuotaPeriod) -> int:
            """How long until the current window expires, in seconds."""
            import datetime
            now = datetime.datetime.utcnow()
            if period == QuotaPeriod.HOURLY:
                next_window = (now + datetime.timedelta(hours=1)).replace(
                    minute=0, second=0, microsecond=0
                )
            elif period == QuotaPeriod.DAILY:
                next_window = (now + datetime.timedelta(days=1)).replace(
                    hour=0, minute=0, second=0, microsecond=0
                )
            elif period == QuotaPeriod.MONTHLY:
                if now.month == 12:
                    next_window = now.replace(
                        year=now.year + 1, month=1, day=1,
                        hour=0, minute=0, second=0, microsecond=0
                    )
                else:
                    next_window = now.replace(
                        month=now.month + 1, day=1,
                        hour=0, minute=0, second=0, microsecond=0
                    )
            return int((next_window - now).total_seconds()) + 60  # +60s buffer
        
        async def get_current_usage(
            self, 
            tenant_id: str, 
            period: QuotaPeriod
        ) -> int:
            key = self._quota_key(tenant_id, period)
            value = await self.redis.get(key)
            return int(value) if value else 0
        
        async def check_and_reserve(
            self,
            tenant_id: str,
            policies: list[TenantQuotaPolicy],
            estimated_tokens: int,
            model: str
        ) -> Tuple[bool, Optional[TenantQuotaPolicy], Optional[QuotaAction]]:
            """
            Pre-flight quota check. Uses a reservation model:
            we tentatively add the estimated tokens, check if we're
            over the limit, and roll back if we need to reject.
            
            Returns: (allowed, violated_policy, action_to_take)
            """
            applicable_policies = [
                p for p in policies
                if p.model_filter is None or model in p.model_filter
            ]
            
            for policy in applicable_policies:
                # Check per-request limit first — this is cheap
                if estimated_tokens > policy.max_tokens_per_request:
                    return False, policy, QuotaAction.REJECT
                
                key = self._quota_key(tenant_id, policy.period)
                expiry = self._window_expiry_seconds(policy.period)
                
                # Atomic reservation: increment and check
                # Using a Lua script ensures atomicity
                lua_script = """
                local current = redis.call('INCRBY', KEYS[1], ARGV[1])
                if current == tonumber(ARGV[1]) then
                    -- First increment, set expiry
                    redis.call('EXPIRE', KEYS[1], ARGV[2])
                end
                return current
                """
                
                new_total = await self.redis.eval(
                    lua_script, 1, key, 
                    str(estimated_tokens), str(expiry)
                )
                new_total = int(new_total)
                
                if new_total > policy.token_limit:
                    if policy.action_on_exceed == QuotaAction.REJECT:
                        # Roll back the reservation
                        await self.redis.decrby(key, estimated_tokens)
                        return False, policy, QuotaAction.REJECT
                    elif policy.action_on_exceed == QuotaAction.THROTTLE:
                        return False, policy, QuotaAction.THROTTLE
                    # NOTIFY and ALLOW fall through — we let the request proceed
                    # but the caller should fire an alert for NOTIFY
            
            return True, None, None
        
        async def reconcile_usage(
            self,
            tenant_id: str,
            policies: list[TenantQuotaPolicy],
            estimated_tokens: int,
            actual_tokens: int,
            model: str
        ) -> None:
            """
            After a successful API call, adjust the quota accounting
            from the estimated token count to the actual count.
            
            This matters because estimates can be off by 20-40%
            for responses with variable length (code, structured data).
            """
            delta = actual_tokens - estimated_tokens
            if delta == 0:
                return
            
            applicable_policies = [
                p for p in policies
                if p.model_filter is None or model in p.model_filter
            ]
            
            for policy in applicable_policies:
                key = self._quota_key(tenant_id, policy.period)
                if delta > 0:
                    await self.redis.incrby(key, delta)
                else:
                    await self.redis.decrby(key, abs(delta))
    

    The Lua script in check_and_reserve is doing real work: it increments the quota counter and sets the expiry in a single atomic operation. Without atomicity here, you have a race condition where two concurrent requests both read a value under the limit, both decide to proceed, and both push the tenant over quota. This is the "thundering herd at quota boundary" problem, and it's the reason multi-tenant quota systems are hard.

    Tip: Redis's EVAL for Lua scripts runs on a single thread and is guaranteed atomic. It's the right tool here. If you're using Redis Cluster, be aware that Lua scripts can only operate on keys that hash to the same slot — structure your quota keys to use hash tags ({tenant_id}) to ensure this.

    Cost Attribution and Token Pricing

    Token counts from your provider don't translate to dollars without a pricing table, and pricing tables change. Build a cost calculation layer that's separate from your quota logic so you can update prices without touching enforcement code.

    # cost_calculator.py
    from dataclasses import dataclass
    from decimal import Decimal, ROUND_HALF_UP
    from typing import Optional
    import datetime
    
    
    @dataclass
    class ModelPricing:
        model_id: str
        input_cost_per_million_tokens: Decimal
        output_cost_per_million_tokens: Decimal
        # Some models have tiered pricing above a token threshold
        high_volume_threshold_tokens: Optional[int] = None
        high_volume_input_discount: Decimal = Decimal("0")
        high_volume_output_discount: Decimal = Decimal("0")
        effective_from: datetime.date = datetime.date(2024, 1, 1)
    
    
    # Pricing as of late 2024 — update this from your provider's API
    # or a configuration store in production
    MODEL_PRICING: dict[str, ModelPricing] = {
        "gpt-4o": ModelPricing(
            model_id="gpt-4o",
            input_cost_per_million_tokens=Decimal("2.50"),
            output_cost_per_million_tokens=Decimal("10.00"),
        ),
        "gpt-4o-mini": ModelPricing(
            model_id="gpt-4o-mini",
            input_cost_per_million_tokens=Decimal("0.15"),
            output_cost_per_million_tokens=Decimal("0.60"),
        ),
        "claude-3-5-sonnet-20241022": ModelPricing(
            model_id="claude-3-5-sonnet-20241022",
            input_cost_per_million_tokens=Decimal("3.00"),
            output_cost_per_million_tokens=Decimal("15.00"),
        ),
        "claude-3-haiku-20240307": ModelPricing(
            model_id="claude-3-haiku-20240307",
            input_cost_per_million_tokens=Decimal("0.25"),
            output_cost_per_million_tokens=Decimal("1.25"),
        ),
    }
    
    
    def calculate_request_cost(
        model: str,
        input_tokens: int,
        output_tokens: int,
        markup_multiplier: Decimal = Decimal("1.0")
    ) -> Decimal:
        """
        Calculate the cost of a single LLM API call.
        
        markup_multiplier lets you add a margin for resale.
        A value of 1.20 represents a 20% markup over provider cost.
        
        Uses Decimal throughout to avoid floating-point errors
        that compound across thousands of records into billing discrepancies.
        """
        pricing = MODEL_PRICING.get(model)
        if pricing is None:
            raise ValueError(f"No pricing data for model: {model}")
        
        input_cost = (
            Decimal(input_tokens) / Decimal("1_000_000")
        ) * pricing.input_cost_per_million_tokens
        
        output_cost = (
            Decimal(output_tokens) / Decimal("1_000_000")
        ) * pricing.output_cost_per_million_tokens
        
        total_cost = (input_cost + output_cost) * markup_multiplier
        
        # Round to 8 decimal places — enough precision for micro-transactions
        # but not so many that you confuse your accounting system
        return total_cost.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
    
    
    @dataclass
    class TenantCostSummary:
        tenant_id: str
        period_start: datetime.date
        period_end: datetime.date
        total_input_tokens: int
        total_output_tokens: int
        total_tokens: int
        total_cost_usd: Decimal
        cost_by_model: dict[str, Decimal]
        cost_by_workflow: dict[str, Decimal]
        request_count: int
        average_cost_per_request: Decimal
    

    Using Decimal everywhere in cost calculations is not pedantic — it's necessary. Over thousands of API calls, floating-point rounding errors accumulate into real dollar discrepancies. If you're using these numbers for billing, a penny discrepancy per call becomes $10 per thousand calls and $10,000 per million calls. Your finance team will notice, and they won't be pleased.

    The Request Pipeline: Putting It All Together

    Now we assemble the components into a request pipeline. This is the function your API endpoint calls for every LLM request.

    # llm_pipeline.py
    import uuid
    import asyncio
    from datetime import datetime
    from decimal import Decimal
    from typing import Optional, AsyncGenerator
    import openai
    
    from models import Tenant, UsageRecord, QuotaAction
    from session_store import TenantSessionStore
    from prompt_builder import build_tenant_prompt
    from quota_manager import QuotaManager, QuotaExceededError
    from cost_calculator import calculate_request_cost
    
    
    class LLMPipeline:
        
        def __init__(
            self,
            session_store: TenantSessionStore,
            quota_manager: QuotaManager,
            usage_repository,        # Your database DAO
            alert_service,           # Webhook/notification service
            openai_client: openai.AsyncOpenAI,
            markup_multiplier: Decimal = Decimal("1.15")
        ):
            self.sessions = session_store
            self.quota = quota_manager
            self.usage_repo = usage_repository
            self.alerts = alert_service
            self.openai = openai_client
            self.markup = markup_multiplier
        
        async def complete(
            self,
            tenant: Tenant,
            session_id: str,
            user_message: str,
            model: str = "gpt-4o-mini",
            user_id: Optional[str] = None,
            workflow_id: Optional[str] = None,
            context_variables: Optional[dict] = None,
            max_completion_tokens: Optional[int] = None
        ) -> dict:
            """
            Execute a complete LLM request with quota checking,
            context injection, and cost attribution.
            """
            
            # 1. Load conversation history for this tenant/session
            history = await self.sessions.get_messages(tenant.tenant_id, session_id)
            
            # 2. Build the prompt with tenant context
            built_prompt = build_tenant_prompt(
                tenant=tenant,
                user_message=user_message,
                conversation_history=history,
                context_variables=context_variables
            )
            
            # 3. Estimate total tokens for quota pre-flight
            # We add max_completion_tokens as a buffer for output tokens
            completion_buffer = max_completion_tokens or 1024
            estimated_total = built_prompt.estimated_input_tokens + completion_buffer
            
            # 4. Check quota before making the API call
            allowed, violated_policy, action = await self.quota.check_and_reserve(
                tenant_id=tenant.tenant_id,
                policies=tenant.quota_policies,
                estimated_tokens=estimated_total,
                model=model
            )
            
            if not allowed:
                if action == QuotaAction.REJECT:
                    raise QuotaExceededError(
                        tenant.tenant_id, violated_policy, 
                        await self.quota.get_current_usage(
                            tenant.tenant_id, violated_policy.period
                        )
                    )
                elif action == QuotaAction.THROTTLE:
                    # In production, push to a priority queue instead of sleeping
                    await asyncio.sleep(2.0)
            
            if action == QuotaAction.NOTIFY:
                await self.alerts.send_quota_alert(
                    tenant_id=tenant.tenant_id,
                    policy=violated_policy,
                    current_usage=await self.quota.get_current_usage(
                        tenant.tenant_id, violated_policy.period
                    )
                )
            
            # 5. Make the API call
            request_start = datetime.utcnow()
            
            try:
                response = await self.openai.chat.completions.create(
                    model=model,
                    messages=[
                        {"role": "system", "content": built_prompt.system_message},
                        *built_prompt.messages
                    ],
                    max_tokens=max_completion_tokens,
                    # Tag each request with tenant metadata for provider-side
                    # cost analysis (OpenAI supports this via metadata)
                    metadata={
                        "tenant_id": tenant.tenant_id,
                        "session_id": session_id,
                        "workflow_id": workflow_id or "direct"
                    }
                )
            except openai.RateLimitError as e:
                # Roll back the quota reservation if the provider rejects us
                # This is important: we reserved tokens but consumed none
                await self.quota.reconcile_usage(
                    tenant.tenant_id, tenant.quota_policies,
                    estimated_total, 0, model
                )
                raise
            
            # 6. Extract actual token usage from the response
            actual_input_tokens = response.usage.prompt_tokens
            actual_output_tokens = response.usage.completion_tokens
            actual_total_tokens = response.usage.total_tokens
            assistant_message = response.choices[0].message.content
            
            # 7. Reconcile quota: adjust from estimate to actual
            await self.quota.reconcile_usage(
                tenant_id=tenant.tenant_id,
                policies=tenant.quota_policies,
                estimated_tokens=estimated_total,
                actual_tokens=actual_total_tokens,
                model=model
            )
            
            # 8. Calculate cost and write the usage record
            cost = calculate_request_cost(
                model=model,
                input_tokens=actual_input_tokens,
                output_tokens=actual_output_tokens,
                markup_multiplier=self.markup
            )
            
            usage_record = UsageRecord(
                record_id=str(uuid.uuid4()),
                tenant_id=tenant.tenant_id,
                session_id=session_id,
                request_timestamp=request_start,
                model=model,
                input_tokens=actual_input_tokens,
                output_tokens=actual_output_tokens,
                total_tokens=actual_total_tokens,
                estimated_cost_usd=cost,
                user_id=user_id,
                workflow_id=workflow_id,
                provider_request_id=response.id
            )
            
            # Write to DB asynchronously — don't block the response
            asyncio.create_task(
                self.usage_repo.insert_usage_record(usage_record)
            )
            
            # 9. Update session history
            await self.sessions.append_turn(
                tenant_id=tenant.tenant_id,
                session_id=session_id,
                user_message=user_message,
                assistant_message=assistant_message
            )
            
            return {
                "content": assistant_message,
                "usage": {
                    "input_tokens": actual_input_tokens,
                    "output_tokens": actual_output_tokens,
                    "total_tokens": actual_total_tokens,
                    "estimated_cost_usd": float(cost)
                },
                "session_id": session_id,
                "record_id": usage_record.record_id
            }
    

    Notice the error handling in step 6. When the provider returns a rate limit error (which is their rate limit, not yours), you need to roll back the quota reservation. The tenant shouldn't be penalized for a failure on your provider's side. This distinction — your quota limits vs. provider rate limits — matters operationally, and you need to handle both failure modes distinctly.

    Building the Usage Reporting Layer

    Quota enforcement keeps you from overspending. Usage reporting tells you who spent what, so you can bill accurately and make product decisions.

    # usage_repository.py
    from decimal import Decimal
    from datetime import date, datetime
    from typing import Optional
    import asyncpg
    
    from models import UsageRecord, TenantCostSummary
    from cost_calculator import calculate_request_cost
    
    
    class UsageRepository:
        
        def __init__(self, db_pool: asyncpg.Pool):
            self.db = db_pool
        
        async def insert_usage_record(self, record: UsageRecord) -> None:
            async with self.db.acquire() as conn:
                await conn.execute("""
                    INSERT INTO llm_usage_records (
                        record_id, tenant_id, session_id, request_timestamp,
                        model, input_tokens, output_tokens, total_tokens,
                        estimated_cost_usd, user_id, workflow_id, 
                        from_cache, provider_request_id
                    ) VALUES (
                        $1, $2, $3, $4, $5, $6, $7, $8, 
                        $9, $10, $11, $12, $13
                    )
                """,
                    record.record_id, record.tenant_id, record.session_id,
                    record.request_timestamp, record.model,
                    record.input_tokens, record.output_tokens, record.total_tokens,
                    record.estimated_cost_usd, record.user_id, record.workflow_id,
                    record.from_cache, record.provider_request_id
                )
        
        async def get_tenant_cost_summary(
            self,
            tenant_id: str,
            period_start: date,
            period_end: date
        ) -> TenantCostSummary:
            """
            Generate a billing-grade cost summary for a tenant.
            This query is the foundation of your invoice generation.
            """
            async with self.db.acquire() as conn:
                # Main aggregation
                totals = await conn.fetchrow("""
                    SELECT 
                        COUNT(*) as request_count,
                        SUM(input_tokens) as total_input_tokens,
                        SUM(output_tokens) as total_output_tokens,
                        SUM(total_tokens) as total_tokens,
                        SUM(estimated_cost_usd) as total_cost_usd
                    FROM llm_usage_records
                    WHERE tenant_id = $1
                        AND request_timestamp >= $2
                        AND request_timestamp < $3
                        AND from_cache = false
                """, tenant_id, 
                    datetime.combine(period_start, datetime.min.time()),
                    datetime.combine(period_end, datetime.min.time()))
                
                # Cost breakdown by model
                model_rows = await conn.fetch("""
                    SELECT 
                        model,
                        SUM(estimated_cost_usd) as cost_usd
                    FROM llm_usage_records
                    WHERE tenant_id = $1
                        AND request_timestamp >= $2
                        AND request_timestamp < $3
                        AND from_cache = false
                    GROUP BY model
                    ORDER BY cost_usd DESC
                """, tenant_id,
                    datetime.combine(period_start, datetime.min.time()),
                    datetime.combine(period_end, datetime.min.time()))
                
                # Cost breakdown by workflow
                workflow_rows = await conn.fetch("""
                    SELECT 
                        COALESCE(workflow_id, 'direct') as workflow_id,
                        SUM(estimated_cost_usd) as cost_usd
                    FROM llm_usage_records
                    WHERE tenant_id = $1
                        AND request_timestamp >= $2
                        AND request_timestamp < $3
                        AND from_cache = false
                    GROUP BY workflow_id
                    ORDER BY cost_usd DESC
                """, tenant_id,
                    datetime.combine(period_start, datetime.min.time()),
                    datetime.combine(period_end, datetime.min.time()))
                
                total_cost = totals["total_cost_usd"] or Decimal("0")
                request_count = totals["request_count"] or 0
                
                return TenantCostSummary(
                    tenant_id=tenant_id,
                    period_start=period_start,
                    period_end=period_end,
                    total_input_tokens=totals["total_input_tokens"] or 0,
                    total_output_tokens=totals["total_output_tokens"] or 0,
                    total_tokens=totals["total_tokens"] or 0,
                    total_cost_usd=total_cost,
                    cost_by_model={
                        r["model"]: r["cost_usd"] for r in model_rows
                    },
                    cost_by_workflow={
                        r["workflow_id"]: r["cost_usd"] for r in workflow_rows
                    },
                    request_count=request_count,
                    average_cost_per_request=(
                        total_cost / request_count 
                        if request_count > 0 else Decimal("0")
                    )
                )
    

    The PostgreSQL schema that backs this needs two indexes to perform well at scale: one on (tenant_id, request_timestamp) for tenant-scoped time range queries, and one on (request_timestamp) for global analytics. Partition the table by month once you're above a few million rows — monthly usage reports against a multi-year unpartitioned table will slow to a crawl.

    -- Schema for reference
    CREATE TABLE llm_usage_records (
        record_id UUID PRIMARY KEY,
        tenant_id VARCHAR(64) NOT NULL,
        session_id VARCHAR(128) NOT NULL,
        request_timestamp TIMESTAMPTZ NOT NULL,
        model VARCHAR(128) NOT NULL,
        input_tokens INTEGER NOT NULL,
        output_tokens INTEGER NOT NULL,
        total_tokens INTEGER NOT NULL,
        estimated_cost_usd NUMERIC(16, 8) NOT NULL,
        user_id VARCHAR(128),
        workflow_id VARCHAR(128),
        from_cache BOOLEAN NOT NULL DEFAULT FALSE,
        provider_request_id VARCHAR(256)
    ) PARTITION BY RANGE (request_timestamp);
    
    CREATE INDEX idx_usage_tenant_time 
        ON llm_usage_records (tenant_id, request_timestamp);
    CREATE INDEX idx_usage_time 
        ON llm_usage_records (request_timestamp);
    

    Handling Per-Tenant LLM Credentials

    Some enterprise customers will want to bring their own API keys — either for compliance reasons (their data must never touch a shared API key) or because they've negotiated enterprise pricing directly with OpenAI or Anthropic. Your credential manager needs to handle this cleanly.

    # credential_manager.py
    import os
    from typing import Optional
    import openai
    from cryptography.fernet import Fernet
    
    
    class CredentialManager:
        """
        Manages LLM API credentials per tenant.
        Credentials are encrypted at rest using Fernet symmetric encryption.
        In production, the encryption key should come from a KMS,
        not an environment variable.
        """
        
        def __init__(self, encryption_key: bytes, credential_store: dict):
            self.fernet = Fernet(encryption_key)
            # In production: encrypted records in your database, not a dict
            self._store = credential_store
        
        def store_credential(
            self, 
            credential_key: str, 
            api_key: str
        ) -> None:
            encrypted = self.fernet.encrypt(api_key.encode())
            self._store[credential_key] = encrypted
        
        def get_openai_client(
            self, 
            credential_key: str = "default"
        ) -> openai.AsyncOpenAI:
            if credential_key == "default":
                return openai.AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"])
            
            encrypted = self._store.get(credential_key)
            if not encrypted:
                raise ValueError(f"No credentials found for key: {credential_key}")
            
            api_key = self.fernet.decrypt(encrypted).decode()
            return openai.AsyncOpenAI(api_key=api_key)
    

    Warning: Bring-your-own-key (BYOK) customers introduce a support complication: when their API key is invalid, revoked, or rate-limited, requests fail in ways that look identical to other failures from your platform's perspective. Build explicit credential validation on ingestion and a clear error path that distinguishes credential failures from capacity failures.

    Advanced: Semantic Caching Across Tenants

    One cost optimization that requires careful thought in a multi-tenant context is semantic caching: if two different users (even from different tenants) ask semantically identical questions, you return a cached response instead of making a new API call.

    The multi-tenant wrinkle is that cache keys must include tenant context. A cached response to "What's our refund policy?" from Tenant A (a software company) is completely wrong for Tenant B (a clothing retailer). The cache key must incorporate the tenant's system prompt — or at least a hash of it.

    # semantic_cache.py
    import hashlib
    import json
    from typing import Optional
    import redis.asyncio as redis
    
    
    class TenantAwareSemanticCache:
        """
        A simple exact-match cache with tenant isolation.
        True semantic caching requires embedding similarity search
        (e.g., using pgvector or a vector database), but starts here.
        """
        
        def __init__(self, redis_client: redis.Redis, ttl_seconds: int = 3600):
            self.redis = redis_client
            self.ttl = ttl_seconds
        
        def _cache_key(
            self, 
            tenant_id: str, 
            system_prompt: str,
            user_message: str,
            model: str
        ) -> str:
            """
            Cache key must incorporate both the tenant's system prompt
            and the user message. The system prompt is the context that
            makes the same question have different correct answers across tenants.
            """
            content = json.dumps({
                "tenant_id": tenant_id,
                "system_prompt_hash": hashlib.sha256(
                    system_prompt.encode()
                ).hexdigest(),
                "user_message": user_message.strip().lower(),
                "model": model
            }, sort_keys=True)
            return f"cache:{hashlib.sha256(content.encode()).hexdigest()}"
        
        async def get(
            self,
            tenant_id: str,
            system_prompt: str,
            user_message: str,
            model: str
        ) -> Optional[str]:
            key = self._cache_key(tenant_id, system_prompt, user_message, model)
            return await self.redis.get(key)
        
        async def set(
            self,
            tenant_id: str,
            system_prompt: str,
            user_message: str,
            model: str,
            response: str
        ) -> None:
            key = self._cache_key(tenant_id, system_prompt, user_message, model)
            await self.redis.setex(key, self.ttl, response)
    

    For cache hits, you still need to log a usage record — but with from_cache=True and zero tokens — so your reporting accurately reflects when cost savings occurred. This also lets you calculate your cache hit rate by tenant, which is valuable data for optimizing your caching strategy.

    Hands-On Exercise

    Build a minimal but complete multi-tenant LLM API server using FastAPI that demonstrates all the concepts from this lesson. The exercise has four stages.

    Stage 1: Scaffold the system Create the SQLite + Redis data layer for two tenants: "Acme Corp" (a customer service chatbot for a software company) and "Brightline Health" (a medical information assistant). Give them different system prompts, different quota limits (Acme: 100,000 tokens/day, Brightline: 50,000 tokens/day), and different quota actions (Acme: THROTTLE at limit, Brightline: REJECT at limit — you can imagine why a medical assistant might need stricter controls).

    Stage 2: Build the request endpoint Create a POST /v1/chat/{tenant_id} endpoint that:

    • Authenticates the request using the tenant's platform_api_key in the Authorization header
    • Runs the full LLMPipeline.complete() flow
    • Returns appropriate HTTP status codes (429 for quota exceeded, 400 for invalid requests, 503 for provider errors)

    Stage 3: Test quota enforcement Write a script that fires 200 concurrent requests for Acme Corp's tenant. Observe: (a) Do any requests succeed after the quota is exhausted? (b) Is the token count in Redis accurate after the burst? (c) Are all 200 usage records present in the database?

    Stage 4: Generate a cost report Write the SQL query (or use the UsageRepository) to generate a monthly cost summary for both tenants, broken down by workflow. Add a 15% markup to the provider cost and calculate what you'd invoice each customer.

    Evaluation criteria: The quota counter in Redis should never allow more than 10% overage above the limit during a burst (due to estimation lag). Every API call, whether successful or not, should have a usage record. The cost summary should reconcile exactly with the sum of individual estimated_cost_usd fields.

    Common Mistakes & Troubleshooting

    The Race Condition at Quota Reset

    When a quota window resets (midnight for a daily quota), you'll often see a burst of requests that were queued waiting for the reset. If your reset logic is clock-based and slightly inaccurate across instances, you can get a window where multiple workers all believe the quota has reset and flood through. The fix: use the Redis key expiry as the source of truth, not application-layer clock logic. If the key exists, you're in the window. If it doesn't, the window has reset.

    Prompt Leakage Through System Prompt Templates

    The most common context isolation bug: a developer puts tenant-specific data (like a customer list or internal pricing) directly into the system prompt template, which gets cached in Redis as part of the session. When the system prompt changes (new pricing, updated policy), the cached sessions still carry the old system prompt. Fix: don't cache the system prompt in the session store. Rebuild it fresh from the tenant record on every request. The session store should contain only the conversation turns.

    Estimation Errors on Structured Output

    If a tenant's workflow requests structured JSON output (e.g., "respond only in valid JSON"), the output token count can be significantly higher than free-form prose for equivalent information, because JSON structure tokens (brackets, quotes, commas) all count. The 4-chars-per-token heuristic is calibrated for prose. For tenants using structured output, use a larger completion_buffer in your pre-flight estimation, or instrument your system to measure average output tokens for each workflow type and use those empirical values.

    The "Ghost Tenant" Cost Problem

    You cancel a tenant's account, but their sessions remain active in Redis (TTL hasn't expired) and their usage records reference a now-deleted tenant record. On re-signup, a new tenant might get a session ID that collides with a ghost session. Fix: on tenant deactivation, actively scan and delete their session keys using SCAN with the session:{tenant_id}:* pattern. Don't rely on TTL expiry for security-sensitive cleanup.

    Provider Rate Limits vs. Your Rate Limits

    Your quota system controls how many tokens a tenant can spend on your platform. Your LLM provider has separate rate limits on your API key (tokens per minute, requests per minute). Under burst load from a single tenant, you can hit the provider's RPM limit before you hit the tenant's token quota. These errors look like provider failures to the tenant, but they're actually architectural: you need to either spread requests across time (rate-limiting at the request queue level) or use multiple provider API keys with load balancing.

    Database Write Backpressure

    The asyncio.create_task() call for writing usage records is a fire-and-forget pattern that can hide failures. If your database is under load, those background tasks can pile up in memory and fail silently. In production, use a proper async job queue (Celery, ARQ, or a message broker) for the usage record writes, with dead-letter handling for failures. The usage record is your billing record — losing it means losing revenue.

    Tip: Add a Prometheus counter that tracks "usage records written" vs. "usage records attempted" and alert when the gap exceeds 0.01%. Silent write failures are the insidious billing bug that only surfaces at month-end invoice reconciliation.

    Summary & Next Steps

    You've built the core of a production multi-tenant LLM platform. The architecture we've built provides structural isolation (Redis key namespacing, hashed session IDs), semantic isolation (tenant-scoped prompt construction), pre-flight quota enforcement using atomic Redis operations, post-flight reconciliation to handle estimation error, cost attribution at the model, workflow, and tenant levels, and a per-tenant credential system for BYOK requirements.

    The patterns here compose well. The LLMPipeline class is the integration point — everything else is independently replaceable. You can swap Redis for DynamoDB, PostgreSQL for BigQuery, or OpenAI for Anthropic without touching your quota logic.

    Where to go from here:

    • Streaming responses and quota enforcement: The architecture above works for synchronous completions. Streaming (stream=True) requires a different quota model where you count tokens as they arrive and can cut off mid-stream when a limit is hit. This is architecturally significant because you can't do a pre-flight estimate that's reliable enough for streaming without a per-token counter.

    • Semantic caching at scale: Replace the exact-match cache with embedding-based similarity search using pgvector. Cache hit rates of 30-40% are achievable for support chatbot workloads, which directly reduces per-tenant costs.

    • Context window management: The max_turns parameter in the session store is a crude context window manager. A more sophisticated approach uses token-accurate history truncation that preserves the most semantically important turns (not just the most recent), using techniques like conversation summarization.

    • Tenant isolation for RAG pipelines: If tenants each have their own document corpora, the isolation problem extends to your vector database. Namespaced collections per tenant is the standard approach, but it has scaling implications when you have hundreds of tenants with small corpora.

    • Audit logging for compliance: Enterprise customers in regulated industries (healthcare, finance) need an immutable audit log of every request and response. This is different from your usage records — it needs to capture the full content, be tamper-evident, and potentially be exportable on demand.

    Learning Path: Building with LLMs

    Previous

    Implementing LLM Observability: Tracing, Logging, and Monitoring Requests in Production

    Related Articles

    AI & Machine Learning🔥 Expert

    Multi-Agent Orchestration: Building RAG Systems Where Specialized Agents Collaborate Across Data Sources

    27 min
    AI & Machine Learning🔥 Expert

    Prompt Versioning and Iteration Management: How to Track, Test, and Systematically Improve Prompts Across Enterprise AI Deployments

    29 min
    AI & Machine Learning⚡ Practitioner

    Metadata Filtering in RAG: Using Structured Attributes to Narrow Retrieval Before Vector Search

    22 min

    On this page

    • Introduction
    • Prerequisites
    • Understanding the Multi-Tenant Problem for LLMs
    • Designing the Tenant Data Model
    • Building Context Isolation
    • Session Isolation with Redis
    • Prompt Construction with Injection Resistance
    • Implementing Token Quota Enforcement
    • The Quota Manager
    • Cost Attribution and Token Pricing
    • The Request Pipeline: Putting It All Together
    • Building the Usage Reporting Layer
    • Handling Per-Tenant LLM Credentials
    • Advanced: Semantic Caching Across Tenants
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Summary & Next Steps