AI & LLM Integration
Master multi-provider LLM support, dynamic model routing, budget enforcement, streaming responses, and cost tracking in Experience plugins.
📋Learning Objectives
- Understand the LLM Provider Abstraction architecture
- Configure and use multiple LLM providers (OpenAI, Anthropic, Azure, Ollama)
- Learn how dynamic model routing selects cost-effective, policy-compliant models
- Implement budget enforcement with soft and hard limits
- Make streaming and non-streaming LLM requests from plugins
- Track costs and understand pricing models
🏗️LLM Provider Architecture
The BFF provides a unified /api/llm/chat/completions endpoint that abstracts multiple providers with built-in authorization, budget controls, and failover.
(Redis)"] end PDP["PDP
(Policy Check)"] subgraph PROVIDERS["LLM Providers"] OAI["OpenAI
gpt-4o-mini, gpt-4.1"] ANT["Anthropic
claude-3-5-sonnet"] AZ["Azure OpenAI
deployments"] OL["Ollama
llama3.2, mistral"] end P1 --> EP P2 --> EP EP --> ENF ENF --> PDP ENF --> BUDGET ENF --> ROUTE ROUTE --> OAI ROUTE --> ANT ROUTE --> AZ ROUTE --> OL style EP fill:#a855f722,stroke:#a855f7 style PDP fill:#f43f5e22,stroke:#f43f5e style BUDGET fill:#10b98122,stroke:#10b981
Key Features
🔀 Multi-Provider
OpenAI, Anthropic, Azure OpenAI, Ollama — all through one API
💰 Budget Control
Per-user/team spending limits with Redis-backed tracking
🔄 Auto-Failover
Circuit breakers detect failures, route to healthy providers
📊 Cost Tracking
Real-time token counting with per-model pricing
🛡️ Policy Enforcement
PDP controls which models users can access
🌊 Streaming
Server-sent events for real-time token delivery
🤖Supported Providers
| Provider | Models | Input $/1M | Output $/1M | Features |
|---|---|---|---|---|
| OpenAI | gpt-4o-mini, gpt-4.1, gpt-5-mini | $0.50 - $5.00 | $1.50 - $15.00 | chat, stream, tools, vision, reasoning |
| Anthropic | claude-3-5-sonnet, claude-3-haiku | $0.25 - $3.00 | $1.25 - $15.00 | chat, stream, tools, vision |
| Azure OpenAI | Same as OpenAI (deployments) | Same as OpenAI | Same as OpenAI | chat, stream, tools |
| Ollama | llama3.2, mistral, codellama | $0.00 (local) | $0.00 (local) | chat, stream |
For development/testing, use Ollama (free, local) or gpt-4o-mini ($0.50/1M input). Reserve gpt-4.1 or claude-3-5-sonnet for production workloads requiring higher quality.
🔀Dynamic Model Routing
When a requested model is blocked by policy or over budget, the BFF automatically finds an alternative using a weighted score:
- 70% Cost — cheaper models ranked higher
- 20% Latency — faster response times preferred
- 10% Error Rate — more reliable providers preferred
gpt-4.1?"} CHK -->|Yes| BUDG{"Within budget?"} CHK -->|No| TYPE{"Reason?"} BUDG -->|Yes| USE["✅ Use gpt-4.1"] BUDG -->|No| TYPE TYPE -->|Policy Only| DENY["❌ 403 Denied"] TYPE -->|Budget/Combined| CAND["Get alternatives"] CAND --> SORT["Sort by cost score"] SORT --> TRY["Try each until
one passes policy+budget"] TRY --> SEL["✅ Use gpt-4o-mini
(rerouted)"] style DENY fill:#f43f5e33,stroke:#f43f5e style USE fill:#10b98133,stroke:#10b981 style SEL fill:#f59e0b33,stroke:#f59e0b
Response Headers
| Header | Description | Example |
|---|---|---|
x-aria-model-selected |
The model actually used | gpt-4o-mini |
x-aria-model-rerouted |
Whether a different model was selected | true |
x-aria-budget-remaining |
Remaining budget in cents | 1523 |
x-aria-decision-id |
PDP decision ID for audit | dec-abc123 |
💰Budget Enforcement
Budget limits prevent runaway costs. The BFF tracks spending in Redis and checks PDP policies.
Budget Configuration
# PDP policy with budget constraints
rules:
- effect: permit
resource: "llm:openai:chat"
action: "invoke"
on_permit:
constraints:
model:
allow: ["gpt-4o-mini", "gpt-4.1"]
tokens:
max_output: 2000
max_stream: 4096
spend_budget:
scope: "user" # user | team | org
period: "monthly" # daily | weekly | monthly
limit_usd: 25.0 # Hard limit in USD
Budget Response Codes
| Code | Meaning | Action |
|---|---|---|
200 |
Success (within budget) | Proceed normally |
402 |
Budget exceeded | Show upgrade prompt or wait for reset |
403 |
Policy denied (model not allowed) | Request different model or escalate |
The BFF places a budget hold before calling the LLM (estimated cost × 1.2). After the response, actual cost is calculated and the difference is released. This prevents overspending during concurrent requests.
🔧Plugin SDK Integration
Non-Streaming Request
// src/hooks/useLlm.ts
const sdk = (globalThis as any).pluginSdk;
interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
interface ChatOptions {
model?: string;
max_tokens?: number;
temperature?: number;
}
export async function chat(
messages: ChatMessage[],
options: ChatOptions = {}
): Promise<string> {
const response = await sdk.api.fetch('/api/llm/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: options.model || 'gpt-4o-mini',
messages,
max_tokens: options.max_tokens || 1000,
temperature: options.temperature || 0.7,
stream: false
})
});
if (!response.ok) {
if (response.status === 402) {
throw new Error('Budget exceeded. Please contact your administrator.');
}
if (response.status === 403) {
throw new Error('You do not have permission to use this model.');
}
throw new Error(`LLM request failed: ${response.status}`);
}
const data = await response.json();
// Log rerouting info
const rerouted = response.headers.get('x-aria-model-rerouted');
const selected = response.headers.get('x-aria-model-selected');
if (rerouted === 'true') {
console.log(`Model rerouted to: ${selected}`);
}
return data.choices[0].message.content;
}
Streaming Request
// Streaming for real-time UI updates
export async function streamChat(
messages: ChatMessage[],
onToken: (token: string) => void,
options: ChatOptions = {}
): Promise<void> {
const response = await sdk.api.fetch('/api/llm/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: options.model || 'gpt-4o-mini',
messages,
max_tokens: options.max_tokens || 1000,
stream: true
})
});
if (!response.ok) {
throw new Error(`LLM stream failed: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error('No response body');
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n').filter(line => line.startsWith('data: '));
for (const line of lines) {
const data = line.slice(6); // Remove 'data: ' prefix
if (data === '[DONE]') return;
try {
const parsed = JSON.parse(data);
const token = parsed.choices?.[0]?.delta?.content;
if (token) {
onToken(token);
}
} catch (e) {
// Skip malformed chunks
}
}
}
}
Usage in Component
// src/components/AiAssistant.tsx
const React = (globalThis as any).React;
const { useState } = React;
export function AiAssistant() {
const [input, setInput] = useState('');
const [response, setResponse] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
setLoading(true);
setResponse('');
try {
// Use streaming for better UX
await streamChat(
[
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: input }
],
(token) => setResponse(prev => prev + token)
);
} catch (err: any) {
setResponse(`Error: ${err.message}`);
} finally {
setLoading(false);
}
};
return (
<div>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask me anything..."
/>
<button onClick={handleSubmit} disabled={loading}>
{loading ? 'Thinking...' : 'Send'}
</button>
<div className="response">{response}</div>
</div>
);
}
permissions:
api:
- method: POST
path: /api/llm/chat/completions
⚙️Provider Configuration
LLM providers are configured in config/providers.yaml. This file defines endpoints, credentials, pricing, circuit breakers, and fallback chains.
Complete Provider Schema
# config/providers.yaml
providers:
- id: openai-primary # Unique identifier
kind: openai # Provider type: openai, anthropic, ollama
name: "OpenAI Primary" # Human-readable name
enabled: true # Enable/disable this provider
base_url: https://api.openai.com/v1
api_key: ${OPENAI_API_KEY} # Environment variable substitution
default_model: gpt-4o-mini
supported_features: # Capabilities
- chat
- stream
- tools
- vision
- reasoning
egress: # Allowed outbound hosts
allow:
- api.openai.com
pricing: # USD per token
gpt-4o-mini:
input: 0.0000005 # $0.50 per 1M tokens
output: 0.0000015 # $1.50 per 1M tokens
gpt-4.1:
input: 0.000005
output: 0.000015
circuit_breaker: # Failover control
threshold: 5 # Consecutive failures before opening
timeout: 60 # Seconds before half-open state
timeouts:
default: 30.0 # Non-streaming timeout
streaming: 120.0 # Streaming timeout
settings:
health_check_interval: 30
config_reload_interval: 300
fallback_chains: # Automatic failover
gpt:
- openai-primary
- anthropic-primary
- ollama-local
claude:
- anthropic-primary
- openai-primary
default:
- openai-primary
- anthropic-primary
Circuit Breaker States
Fallback Chain Behavior
# Request: model="gpt-4o-mini"
# Fallback chain: [openai-primary, anthropic-primary, ollama-local]
# Try openai-primary → Circuit open ❌
# Try anthropic-primary → Success ✅
# Response headers:
# x-aria-provider: anthropic-primary
# x-aria-model-rerouted: true
🛡️Classifier Guard
The Classifier Guard analyzes prompts before they reach the LLM, detecting and blocking risky content.
Blocked Categories
| Label | Description | Min Confidence |
|---|---|---|
prompt_injection |
Attempts to override system instructions | 65% |
jailbreak |
Attempts to bypass safety measures | 75% |
secrets |
Requests for credentials, API keys, passwords | 60% |
exfiltration |
Data extraction attempts | 75% |
malware |
Code generation for malicious purposes | 75% |
Per-Category Actions
# config/classifier.yaml
guard:
enabled: true
block_labels:
- secrets
- prompt_injection
- jailbreak
min_conf: 0.75
per_label_thresholds:
secrets: 0.60
prompt_injection: 0.65
actions:
high_cost_request:
cap_tokens: 2048
route_model: gpt-4o-mini
budget:
hold_multiplier: 1.5
max_cents: 5000
tools_egress_required:
disallow_stream: true
cap_tokens: 1024
Classifier adds ~50-100ms to each request. For high-throughput, low-risk internal tools, consider disabling with CLASSIFIER_GUARD_ENABLED=false.
🔄Hot-Reload Configuration
Update provider configuration without restarting the BFF:
# 1. Edit the providers file
vim config/providers.yaml
# 2. Trigger reload (no restart needed)
curl -X POST http://bff:8000/api/admin/providers/reload \
-H "Authorization: Bearer $ADMIN_TOKEN"
# 3. Verify changes
curl http://bff:8000/health/providers | jq
What Happens on Reload
- New providers are added instantly
- Modified providers are replaced atomically
- Removed providers are cleaned up
- In-flight requests complete normally
- Circuit breaker states are preserved
When using ConfigMaps, update the config and trigger reload:
kubectl edit configmap bff-providers-config
kubectl exec deployment/bff -- curl -X POST localhost:8000/api/admin/providers/reload
🔍Troubleshooting
402 Payment Required (Budget Exceeded)
Symptom: {"error": "Budget exceeded for scope user:xyz"}
Fixes:
- Wait for budget reset (daily/weekly/monthly)
- Request budget increase from admin
- Use a cheaper model (e.g.,
gpt-4o-miniinstead ofgpt-4.1)
403 Forbidden (Model Not Allowed)
Symptom: {"error": "Model gpt-4.1 not allowed by policy"}
Debug:
# Check PDP policy directly
curl -X POST https://pdp.self.empowernow.ai/access/v1/evaluation \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"subject": { "type": "user", "id": "current" },
"resource": { "type": "llm", "id": "openai:chat" },
"action": { "name": "invoke" },
"context": {
"model": "gpt-4.1",
"reason_admin": true
}
}'
Response Rerouted Unexpectedly
Symptom: Requested gpt-4.1 but got gpt-4o-mini
Check:
- Review
x-aria-model-reroutedheader - Check
x-aria-budget-remaining— may be near limit - Check
x-aria-decision-idand look up in receipts
Streaming Not Working
Symptom: Response comes all at once, not streamed
Fixes:
- Ensure
stream: truein request body - Don't use
await response.json()— useresponse.body.getReader() - Some models/providers don't support streaming (check features)
❓Knowledge Check
The BFF attempts dynamic model routing: it finds cheaper alternatives (sorted by cost score) that the user is allowed to use and has budget for. If found, the request proceeds with the alternative model and x-aria-model-rerouted: true is returned. If no alternative is available, a 402 Payment Required is returned.
402 Payment Required = Budget exceeded. The user is allowed to use the model but has run out of spending budget. 403 Forbidden = Policy denied. The user is not allowed to use the requested model at all, regardless of budget.
Holds prevent overspending during concurrent requests. Before calling the LLM, the BFF reserves estimated_cost × 1.2 from the budget. After the response, actual cost is calculated and the difference is released. Without holds, 10 concurrent requests could each think they have budget and collectively exceed the limit.
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// Parse SSE "data: {...}" lines
}
x-aria-model-rerouted — returns true if dynamic routing selected a different model. Check x-aria-model-selected to see which model was actually used.
📝Day 22 Checkpoint
- Understand LLM Provider Abstraction architecture
- Know the supported providers and their pricing
- Understand how dynamic model routing works
- Know the difference between 402 and 403 errors
- Can make non-streaming LLM requests from plugins
- Can make streaming LLM requests from plugins
- Understand budget enforcement and holds