Skip to content

Unified AI Proxy

This article describes setting up a unified AI proxy using LiteLLM router that routes requests to different AI models

Original Author: Tuukka Peltomäki
Lastmod: 21.10.2025
Status: In progress

1. Introduction#

  • Unified AI Proxy acts as a centralized interface between models from OpenAI and Anthropic.
  • Uses LiteLLM router
  • Provides a single, unified OpenAI-compatible API surface through which you can:
    • Automatically route requests to different LLM models
    • Use a fallback strategy if the primary model fails
    • Manage user permissions using a MySQL database and Redis cache
    • Use the same OpenAI clients with other models, such as Claude

2. Architecture#

The proxy works like the OpenAI API (/v1/chat/completions), but:

  • Supports provider field to select a model ("claude" or "openai")
  • Automatically handles user permissions (user_id from Redis / MariaDB)
  • Supports non-streaming responses
  • Streaming not tested

3. Main code structure#

  • main.py – Main service
# file: main.py
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from litellm import Router
import os, json, asyncio
import mysql.connector
import redis.asyncio as aioredis

app = FastAPI(title="Unified AI Proxy")

# Loading the model list and fallbacks from ConfigMap (set in Kubernetes with environment variables)
model_list = json.loads(os.getenv("MODELS_JSON", "[]"))
fallbacks = json.loads(os.getenv("FALLBACKS_JSON", "[]"))

# Redis cache (user permissions and caches)
REDIS_HOST = os.getenv("REDIS_HOST", "redis-proxy")
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
redis_client = aioredis.Redis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD, decode_responses=True)

# LiteLLM Router is responsible for routing and fallbacks for different models
router = Router(
    model_list=model_list,
    fallbacks=fallbacks,
    redis_host=REDIS_HOST,
    redis_port=REDIS_PORT,
    redis_password=REDIS_PASSWORD,
    cache_responses=True,
    num_retries=3,
    routing_strategy="simple-shuffle"
)

# User management (checking if the user has access)
async def get_allowed_users():
    # Tarkistetaan ensin Redisistä
    cached = await redis_client.get("allowed_users")
    if cached:
        return set(json.loads(cached))

    # If not cached, fetch from MariaDB and store in Redis cache
    try:
        connection = mysql.connector.connect(
            host=os.getenv("DB_HOST"),
            user=os.getenv("DB_USER"),
            password=os.getenv("DB_PASSWORD"),
            database=os.getenv("DB_NAME")
        )
        cursor = connection.cursor()
        cursor.execute("SELECT user_id FROM allowed_users;")
        users = set(row[0] for row in cursor.fetchall())
        await redis_client.set("allowed_users", json.dumps(list(users)), ex=60)  # TTL 60s
        return users

    except Exception as e:
        print(f"Error connecting to MariaDB: {e}")
        return set()
    finally:
        if 'connection' in locals() and connection.is_connected():
            cursor.close()
            connection.close()

# OpenAI-compatible main endpoint
@app.post("/v1/chat/completions")
async def chat(request: Request):
    """
    A unified endpoint for all ChatCompletion requests.

    Uses the provider parameter to select a template.
    """
    data = await request.json()
    user_id = data.get("user_id")
    provider = data.get("provider")
    messages = data.get("messages", [])
    stream = data.get("stream", False)
    model = data.get("model", "openai-gpt-4o-mini")

    # Model selection if provider given
    if provider == "claude":
        model = "claude-opus-4-1-20250805"
    elif provider == "openai":
        model = "openai-gpt-4o-mini"

    # Checking access rights
    allowed_users = await get_allowed_users()
    if user_id not in allowed_users:
        raise HTTPException(status_code=403, detail="Access denied")

    # Request processing (stream or non-stream)
    try:
        if stream:
            async def stream_response():
                response = await router.acompletion(model=model, messages=messages, stream=True)
                async for chunk in response:
                    yield json.dumps(chunk) + "\n"
                    await asyncio.sleep(0)
            return StreamingResponse(stream_response(), media_type="application/json")
        else:
            response = await router.acompletion(model=model, messages=messages)
            if hasattr(response, "model_dump"):
                response = response.model_dump()
            return JSONResponse(content=response)
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

# Health check and metadata
@app.get("/")
def root():
    return {
        "message": "Unified AI Proxy with Router + Redis",
        "models": [m["model_name"] for m in model_list],
        "redis": REDIS_HOST,
    }

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 5000)))

4. Kubernetes deployment guide#

This guide describes how to install the Unified AI Proxy service on a Kubernetes cluster (Rahti OpenShift).

The service consists of four main components:

  • Redis Proxy – caching and user list storage
  • ConfigMap – template and configuration data
  • Proxy Deployment + Service + Route – the actual FastAPI application
  • Horizontal Pod Autoscaler (HPA) – automatic scaling based on load

Environment variables summary:

Variable Source Description
REDIS_HOST ConfigMap Redis service name (default: redis-proxy)
REDIS_PORT ConfigMap Redis port (default: 6379)
REDIS_PASSWORD ConfigMap / Secret Password if needed
DB_HOST Secret MariaDB host
DB_USER Secret MariaDB username
DB_PASSWORD Secret MariaDB password
DB_NAME Secret MariaDB database name
MODELS_JSON ConfigMap List of supported models
FALLBACKS_JSON ConfigMap Model fallback mapping
OPENAI_API_KEY Secret API key for OpenAI
ANTHROPIC_API_KEY Secret API key for Anthropic

1. Redis Proxy - redis-proxy.yaml#

  • LiteLLM Router cache (cache for responses)
  • user list (allowed_users) storage with 60 seconds TTL
# file: redis-proxy.yaml
apiVersion: v1
kind: Service
metadata:
  name: redis-proxy
spec:
  selector:
    app: redis-proxy
  ports:
    - protocol: TCP
      port: 6379
      targetPort: 6379
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis-proxy
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis-proxy
  template:
    metadata:
      labels:
        app: redis-proxy
    spec:
      containers:
        - name: redis-proxy
          image: redis/redis-stack-server:latest
          ports:
            - containerPort: 6379
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]
            seccompProfile:
              type: RuntimeDefault
          volumeMounts:
            - name: redis-data
              mountPath: /data
      volumes:
        - name: redis-data
          emptyDir: {}
  • redis/redis-stack-server:latest contains Redis + RedisJSON + any additional modules
  • emptyDir: means that the data is only retained for the lifetime of the pod (enough for caching)
  • Redis is an internal service (ClusterIP) that is used by Proxy via the environment variable REDIS_HOST=redis-proxy

2. ConfigMap - proxy-config.yaml#

  • Contains all the environment variables that Proxy needs without sensitive information.
# file: proxy-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: proxy-config
data:
  REDIS_HOST: "redis-proxy"
  REDIS_PORT: "6379"
  REDIS_PASSWORD: ""

  MODELS_JSON: |
    [
      {
        "model_name": "openai-gpt-4o-mini",
        "litellm_params": {"model": "gpt-4o-mini"}
      },
      {
        "model_name": "openai-gpt-4o",
        "litellm_params": {"model": "gpt-4o"}
      },
      {
        "model_name": "claude-opus-4",
        "litellm_params": {"model": "claude-opus-4-1-20250805"}
      },
      {
        "model_name": "claude-sonnet-3.7",
        "litellm_params": {"model": "claude-3-7-sonnet-20250219"}
      }
    ]

  FALLBACKS_JSON: |
    [
      {"claude-opus-4": ["claude-sonnet-3.7"]},
      {"openai-gpt-4o-mini": ["openai-gpt-4o"]}
    ]
  • MODELS_JSON defines the models that LiteLLM Router knows and how they are mapped to LLM services.
  • FALLBACKS_JSON tells which model to use as a fallback model if the primary model fails.
  • The proxy reads these JSONs from environment variables os.getenv("MODELS_JSON") etc.

3. Proxy Deployment ja Route - proxy-deployment.yaml#

  • This is the actual AI Proxy service that provides the /v1/chat/completions interface.
# file: proxy-deployment.yaml
apiVersion: v1
kind: Service
metadata:
  name: proxy-service
spec:
  selector:
    app: proxy
  ports:
    - protocol: TCP
      port: 5000
      targetPort: 5000
  type: ClusterIP
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: proxy
spec:
  replicas: 1
  selector:
    matchLabels:
      app: proxy
  template:
    metadata:
      labels:
        app: proxy
    spec:
      containers:
        - name: prestashop
          image: <IMAGE_URL>:latest
          imagePullPolicy: Always
          ports:
            - containerPort: 5000
          envFrom:
            - secretRef:
                name: ai-api-keys # Sisältää esim. OpenAI- ja Anthropic API-avaimet
            - configMapRef:
                name: proxy-config
            - secretRef:
                name: db-credentials # Sisältää MariaDB yhteystiedot
          resources:
            requests:
              cpu: "100m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]
            seccompProfile:
              type: RuntimeDefault
      imagePullSecrets:
        - name: gitlab-registry-secret
---
apiVersion: route.openshift.io/v1
kind: Route
metadata:
  name: proxy
spec:
  to:
    kind: Service
    name: proxy-service
  port:
    targetPort: 5000
  tls:
    termination: edge
  • Service: creates an internal DNS name proxy-service and port 5000 for the proxy
  • Deployment: runs the Docker image ai-api-proxy:latest
  • envFrom automatically combines ConfigMap and Secrets
  • CPU/memory is limited and protected (allowPrivilegeEscalation: false)
  • Route: OpenShift's external HTTPS route (terminates at the TLS edge)

4. Auto Scaling – proxy-hpa.yaml#

  • This configures automatic horizontal scaling based on CPU load.
# file: proxy-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: proxy-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: proxy
  minReplicas: 1
  maxReplicas: 6
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
  • If the proxy CPU load exceeds 70% on average, new pods are created (max 6)
  • When the load decreases, pods are reduced
  • This ensures scalability even when there are many simultaneous LLM calls

5. Secrets (not included in files)#

The proxy uses two Secret objects:

  • ai-api-keys

Contains API keys for various model services:

kubectl create secret generic ai-api-keys \
  --from-literal=OPENAI_API_KEY='sk-xxxx' \
  --from-literal=ANTHROPIC_API_KEY='sk-ant-xxxx'
  • db-credentials

Contains MariaDB database connection information:

kubectl create secret generic db-credentials \
  --from-literal=DB_HOST=<DB_HOST/SERVER> \
  --from-literal=DB_USER=<DB_USERNAME> \
  --from-literal=DB_PASSWORD=<DB_PASSWORD> \
  --from-literal=DB_NAME=<DATABASE_NAME>

5. Usage examples#

Example 1 — Basic Python request#

  • This simple example shows how to make a direct POST request to the Unified AI Proxy in Python using the requests library.
  • The proxy works like OpenAI's API, but supports additional fields such as provider (e.g. "claude" or "openai") and user_id,
  • which are used for permissions management.
import requests

resp = requests.post("<PROXY_URL>/v1/chat/completions", json={
    "user_id": "<ALLOWED-USERNAME>",
    "provider": "claude",
    "messages": [{"role": "user", "content": "Is this working?"}],
})

print(resp.json())

Example 2 — TypeScript / JavaScript fetch#

  • Same idea as the previous one, but in a JavaScript environment.
  • This works, for example, in Node.js or a browser when you want to call a proxy directly with the fetch function in an OpenAI-compatible way.
const response = await fetch("<PROXY_URL>/v1/chat/completions", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    user_id: "ae5971",
    provider: "claude",
    messages: [{ role: "user", content: "Is this working?" }],
  }),
});
const data = await response.json();
console.log(data.choices[0].message);

Example 3 — Tool calls with external API (PrestaShop integration)#

  • This example shows how to use a proxy with “tool calling” features.
  • The proxy forwards calls to a model (e.g. Claude or OpenAI), which can decide to call an external interface – in this case the PrestaShop REST API.
  • This enables intelligent tools that retrieve data from an external service upon request from the model.

    3.1 – FastAPI connector (PrestaShop API interface)

# prestashop-connector for a test of an actual tools
from fastapi import FastAPI
import requests
import os

app = FastAPI()

PRESTASHOP_URL = "<PRESTASHOP_URL>/api"
PRESTASHOP_KEY = os.getenv("PS_API_KEY")


# Gets resources from PrestaShop based on OpenAI's query
@app.get("/query/{RESOURCE}")
def get_resources(RESOURCE):

    resource_url = f"{PRESTASHOP_URL}/{RESOURCE}?ws_key={PRESTASHOP_KEY}&output_format=JSON&display=full"
    resource_r = requests.get(resource_url)
    resource_r.raise_for_status()
    data = resource_r.json()

    return {"results": data}

3.2 – openai_proxy.py (tool call execution via Unified Proxy)

# file: openai_proxy.py
from openai import OpenAI
import requests
import os
import json

client = OpenAI(
    api_key="proxy-key",
    base_url="<PROXY_URL>"
)

def get_resource(resource):
    url = f"http://localhost:8000/query/{resource}"
    r = requests.get(url)
    r.raise_for_status()
    return r.json()

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_resource",
            "description": "Get information from PrestaShop and from the given resource",
            "parameters": {
                "type": "object",
                "properties": {
                    "resource": {
                        "type": "string",
                        "enum": [
                                    "addresses",
                                    "attachments",
                                    "attachments/file",
                                    "carriers",
                                    "cart_rules",
                                    "carts",
                                    "categories",
                                    "combinations",
                                    "configurations",
                                    "contacts",
                                    "content_management_system",
                                    "countries",
                                    "currencies",
                                    "customer_messages",
                                    "customer_threads",
                                    "customers",
                                    "customizations",
                                    "deliveries",
                                    "employees",
                                    "groups",
                                    "guests",
                                    "image_types",
                                    "images",
                                    "images/general/header",
                                    "images/general/mail",
                                    "images/general/invoice",
                                    "images/general/store_icon",
                                    "images/products",
                                    "images/categories",
                                    "images/manufacturers",
                                    "images/suppliers",
                                    "images/stores",
                                    "images/customizations",
                                    "languages",
                                    "manufacturers",
                                    "messages",
                                    "order_carriers",
                                    "order_details",
                                    "order_histories",
                                    "order_invoices",
                                    "order_payments",
                                    "order_slip",
                                    "order_states",
                                    "orders",
                                    "price_ranges",
                                    "product_customization_fields",
                                    "product_feature_values",
                                    "product_features",
                                    "product_option_values",
                                    "product_options",
                                    "product_suppliers",
                                    "products",
                                    "search",
                                    "shop_groups",
                                    "shop_urls",
                                    "shops",
                                    "specific_price_rules",
                                    "specific_prices",
                                    "states",
                                    "stock_availables",
                                    "stock_movement_reasons",
                                    "stock_movements",
                                    "stocks",
                                    "stores",
                                    "suppliers",
                                    "supply_order_details",
                                    "supply_order_histories",
                                    "supply_order_receipt_histories",
                                    "supply_order_states",
                                    "supply_orders",
                                    "tags",
                                    "tax_rule_groups",
                                    "tax_rules",
                                    "taxes",
                                    "translated_configurations",
                                    "warehouse_product_locations",
                                    "warehouses",
                                    "weight_ranges",
                                    "zones"
                                ],
                        "description": "PrestaShop resource to be fetched."
                    }
                },
                "required": ["resource"]
            }
        }
    }
]

def ask_question(question: str) -> str:
    # Makes a query to AI and fetched information from PrestaShop resource if necessary.
    messages = [
        {"role": "system", "content": "You are an assistant who can search for information in PrestaShop."},
        {"role": "user", "content": question}
    ]

    while True:
        response = client.chat.completions.create(
            model="openai-gpt-4o-mini", # proxy bypasses if provider is given, but required for query
            messages=messages,
            tools=tools,
            tool_choice="auto",
            extra_body={
                "user_id": "<ALLOWED-USERNAME>",
                "provider": "claude"  # "openai" or "claude"
            }
        )

        msg = response.choices[0].message

        if not msg.tool_calls:
            # Return answer as string
            return msg.content

        # Add message to history and handle tools
        messages.append({
            "role": "assistant",
            "content": msg.content,
            "tool_calls": [tc.to_dict() for tc in msg.tool_calls]
        })

        for call in msg.tool_calls:
            if call.function.name == "get_resource":
                args = json.loads(call.function.arguments)
                resource = args["resource"]
                data = get_resource(resource)
                messages.append({
                    "role": "tool",
                    "tool_call_id": call.id,
                    "content": json.dumps(data)
                })

if __name__ == "__main__":
    while True:
        question = input("Question: ")
        answer = ask_question(question)
        print("Answer:", answer)

Example 4 — LangChain integration (ChatOpenAI)

  • This example uses the LangChain library, which allows for building more complex AI agents and chained functions.
  • Since the proxy supports an OpenAI-compatible interface, it can be used directly via the ChatOpenAI class without any additional modifications, even when using Claude
  • Note: LangChain also includes the ChatLiteLLM class, but the current proxy still responds in OpenAI format, so ChatOpenAI works directly without any modifications.
from langchain_openai import ChatOpenAI

# Here ChatOpenAI is used.
# LangChain contains also a ChatLiteLLM, but it requires modifications on the proxy side especially when using Claude
# Currently proxy, when using ChatLiteLLM with Claude, returns information in a form that LiteLLM doesn't accept as is
llm = ChatOpenAI(
    model="openai-gpt-4o-mini", # proxy bypasses if provider is given, but required for query
    api_key="proxy-key",
    base_url="<PROXY_URL>/v1",
    extra_body={"user_id": "<ALLOWED-USERNAME>", "provider": "claude"},
)

response = llm.invoke("Is this working?")
print("Answer:", response.content)

6. Summary#

Unified AI Proxy enables a single, unified interface for different LLM services.

Its benefits include:

  • Compatibility with OpenAI clients and possibility to use claude through that as well
  • Permission and cache management (MariaDB + Redis)
  • Fallback and routing strategies (LiteLLM Router)
  • Scalable and secure Kubernetes implementation

Next steps / further development:

  • Add auditing (who asked what)
  • Use JWT token instead of user_id
  • Add ChatLiteLLM compatibility for LangChain internal support
  • Expand the list of models (e.g. Mistral, Gemini)