Pattern Proxy
Fournissez un substitut ou un placeholder pour un autre objet afin de contrôler l'accès à celui-ci.
Le Problème
Vous appelez une API LLM depuis votre app. Chaque appel coûte de l'argent et prend du temps. La même question posée deux fois ne devrait pas coûter deux fois. Les utilisateurs spamment le bouton. Et quand le fournisseur principal tombe, votre app plante.
L'approche naïve :
async function askLLM(question: string): Promise<string> {
const response = await fetch("https://api.openai.com/v1/chat", {
method: "POST",
body: JSON.stringify({ prompt: question }),
});
return response.json(); // $0.003 every single time
}
// "What is the capital of France?" asked 10 times = 10 API calls = $0.03
// Provider goes down? App crashes.
// User spams? You burn through your rate limit.
Pas de cache. Pas de rate limit. Pas de fallback. Chaque appel frappe l'API, brûle de l'argent et prie pour que le fournisseur reste debout.
La Solution
Enveloppez la fonction avec des couches de proxy. Même interface, trois couches de protection :
import { memoize, throttle } from "@pithos/core/arkhe";
import { withFallback } from "@pithos/core/eidos/strategy/strategy";
// 1. Cache: same question = instant response, $0.000
const cachedAsk = memoize(askLLM);
// 2. Rate limit: max 1 call per second
const rateLimitedAsk = throttle(cachedAsk, 1000);
// 3. Fallback: if primary fails, try backup silently
const resilientAsk = withFallback(rateLimitedAsk, askBackupLLM);
// Consumer code is identical — just a function call
const answer = await resilientAsk("What is the capital of France?");
// First call: 1.2s, $0.003 — cache miss
// Second call: 2ms, $0.000 — cache hit ⚡
Trois utilitaires Pithos, trois couches de proxy. Le consommateur ne sait rien du cache, du rate limiting ou du failover.
Démo
Posez des questions à un LLM simulé et observez le proxy en action : cache hits, rate limits et failover de fournisseur.
- llmProxy.ts
- Usage
/**
* LLM Proxy demo: memoize from Pithos for caching,
* manual rate-limit, and fallback provider.
*/
import { memoize } from "@pithos/core/eidos/proxy/proxy";
import type { ProxyLogEntry, ProxyStats } from "./types";
import { RESPONSES, DEFAULT_RESPONSE, PRIMARY_COST, FALLBACK_COST, RATE_LIMIT_WINDOW } from "./data";
// Re-export for consumers
export type { ProxyLogEntry, ProxyStats } from "./types";
export { PRESET_QUESTIONS } from "./data";
async function simulateCall(question: string, delayMs: number): Promise<string> {
await new Promise((r) => setTimeout(r, delayMs));
return RESPONSES[question] ?? DEFAULT_RESPONSE;
}
export function createLLMProxy() {
let simulateFailure = false;
let lastCallTime = 0;
let logId = 0;
const cachedPrimaryCall = memoize(
(question: string) => simulateCall(question, 800 + Math.random() * 600),
);
const cachedBackupCall = memoize(
(question: string) => simulateCall(question, 1200 + Math.random() * 800),
);
const stats: ProxyStats = {
logs: [],
totalCost: 0,
totalSaved: 0,
cacheHits: 0,
rateLimitHits: 0,
fallbackHits: 0,
};
function addEntry(entry: ProxyLogEntry) {
stats.logs = [entry, ...stats.logs];
}
async function ask(question: string): Promise<{ entry: ProxyLogEntry; stats: ProxyStats }> {
const now = Date.now();
const elapsed = now - lastCallTime;
// Rate limit
if (lastCallTime > 0 && elapsed < RATE_LIMIT_WINDOW) {
const entry: ProxyLogEntry = {
id: ++logId, question, type: "rate-limited", provider: null,
duration: 0, cost: 0,
response: `Rate limited. Try again in ${Math.ceil((RATE_LIMIT_WINDOW - elapsed) / 1000)}s.`,
timestamp: now,
};
stats.rateLimitHits++;
addEntry(entry);
return { entry, stats: { ...stats } };
}
lastCallTime = now;
const start = performance.now();
// Primary or fallback
const useFallback = simulateFailure;
const call = useFallback ? cachedBackupCall : cachedPrimaryCall;
const cost = useFallback ? FALLBACK_COST : PRIMARY_COST;
const response = await call(question);
const duration = Math.round(performance.now() - start);
const isCacheHit = duration < 50;
const entry: ProxyLogEntry = {
id: ++logId, question,
type: isCacheHit ? "cache-hit" : useFallback ? "fallback" : "cache-miss",
provider: isCacheHit ? null : useFallback ? "backup" : "primary",
duration, cost: isCacheHit ? 0 : cost, response, timestamp: now,
};
if (isCacheHit) {
stats.cacheHits++;
stats.totalSaved += cost;
} else {
stats.totalCost += cost;
if (useFallback) stats.fallbackHits++;
}
addEntry(entry);
return { entry, stats: { ...stats } };
}
return {
ask,
setSimulateFailure: (v: boolean) => { simulateFailure = v; },
getSimulateFailure: () => simulateFailure,
reset: () => {
Object.assign(stats, { logs: [], totalCost: 0, totalSaved: 0, cacheHits: 0, rateLimitHits: 0, fallbackHits: 0 });
lastCallTime = 0;
logId = 0;
},
stats,
};
}
import { useState } from "react";
import { RotateCcw, Send, Zap, AlertTriangle, Terminal, X } from "lucide-react";
import { PRESET_QUESTIONS } from "@/lib/llmProxy";
import { useLLMProxy } from "@/hooks/useLLMProxy";
import { StatsPanel } from "./StatsPanel";
import { TinyLLMLogo } from "./TinyLLMLogo";
import { ProxyLog } from "./ProxyLog";
export function LLMProxy() {
const {
stats, input, setInput, loading, lastResponse, simulateFailure, inputRef,
handleAsk, handleReset, toggleFailure, handleSpam,
} = useLLMProxy();
const [logOpen, setLogOpen] = useState(false);
return (
<div className="h-screen flex flex-col bg-[#09090f] text-white overflow-hidden relative">
{/* Glow effects */}
<div className="absolute top-0 left-1/4 w-96 h-96 bg-violet-600/10 rounded-full blur-3xl pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-indigo-600/8 rounded-full blur-3xl pointer-events-none" />
{/* Header */}
<div className="shrink-0 border-b border-white/[0.06] relative z-10">
<div className="max-w-5xl mx-auto px-3 sm:px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<TinyLLMLogo size={18} />
<span className="text-sm font-bold tracking-tight bg-gradient-to-r from-violet-300 to-indigo-300 bg-clip-text text-transparent">TinyLLM</span>
</div>
<div className="hidden sm:flex items-center gap-2 ml-3 text-[10px] text-white/25">
<span>Proxy pattern</span>
<span>·</span>
<span>cache, rate-limit, fallback</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setLogOpen(!logOpen)}
className="md:hidden p-1.5 rounded-md bg-white/[0.04] border border-white/[0.06] relative"
>
{logOpen ? <X className="w-3.5 h-3.5 text-white/50" /> : <Terminal className="w-3.5 h-3.5 text-violet-400" />}
{!logOpen && stats.logs.length > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-violet-500 rounded-full text-[8px] font-bold text-white flex items-center justify-center">
{stats.logs.length}
</span>
)}
</button>
<button onClick={handleReset} className="p-1.5 rounded-md text-white/30 hover:text-white/60 hover:bg-white/[0.04] transition-colors" title="Reset">
<RotateCcw className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-auto md:overflow-hidden relative z-10">
{/* Mobile: log overlay */}
{logOpen && (
<div className="md:hidden h-full p-3 flex flex-col">
<ProxyLog logs={stats.logs} className="flex-1" />
</div>
)}
<div className={`max-w-5xl mx-auto px-3 sm:px-4 py-4 h-full ${logOpen ? "hidden md:block" : ""}`}>
<div className="grid md:grid-cols-2 gap-4 h-full">
{/* Left column */}
<div className="space-y-3">
{/* Controls */}
<div className="flex gap-2">
<button
onClick={toggleFailure}
className={`flex-1 px-3 py-2 text-xs font-medium rounded-lg transition-all flex items-center justify-center gap-1.5 border ${
simulateFailure
? "bg-red-500/10 text-red-400 border-red-500/20 hover:bg-red-500/20"
: "bg-white/[0.03] text-white/40 border-white/[0.06] hover:bg-white/[0.06]"
}`}
>
<AlertTriangle className="w-3 h-3" />
{simulateFailure ? "Failure ON" : "Simulate failure"}
</button>
<button
onClick={handleSpam}
disabled={loading}
className="px-3 py-2 bg-amber-500/10 text-amber-400 border border-amber-500/20 hover:bg-amber-500/20 rounded-lg text-xs font-medium transition-all flex items-center gap-1.5 disabled:opacity-30"
>
<Zap className="w-3 h-3" /> ×5
</button>
</div>
<div className="h-px bg-white/[0.06]" />
{/* Quick questions */}
<div className="space-y-1.5">
<div className="text-[10px] text-white/25 font-medium uppercase tracking-wider">Quick questions</div>
{PRESET_QUESTIONS.map((q) => (
<button
key={q}
onClick={() => handleAsk(q)}
disabled={loading}
className="w-full text-left px-3 py-2 text-xs bg-white/[0.03] hover:bg-white/[0.06] border border-white/[0.04] rounded-lg transition-colors disabled:opacity-30 truncate text-white/50"
>
{q}
</button>
))}
</div>
{/* Ask input */}
<form onSubmit={(e) => { e.preventDefault(); handleAsk(input); }} className="flex gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Or type your own question..."
className="flex-1 px-3 py-2.5 bg-white/[0.04] border border-white/[0.08] rounded-xl text-sm text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-violet-500/30 focus:border-violet-500/30 transition-all"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !input.trim()}
className="px-4 py-2.5 bg-gradient-to-r from-violet-600 to-indigo-600 text-white rounded-xl text-sm font-medium hover:from-violet-500 hover:to-indigo-500 disabled:opacity-30 disabled:cursor-not-allowed transition-all shadow-lg shadow-violet-600/20 flex items-center gap-1.5"
>
<Send className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Ask</span>
</button>
</form>
{/* Response */}
{(loading || lastResponse) && (
<div className="bg-white/[0.03] rounded-xl border border-white/[0.06] p-3">
{loading ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-violet-500 border-t-transparent" />
<span className="text-xs text-white/30">Thinking...</span>
</div>
) : (
<p className="text-sm text-white/70 leading-relaxed">{lastResponse}</p>
)}
</div>
)}
<div className="h-px bg-white/[0.06]" />
{/* Status + Stats */}
<div className="flex gap-2">
<div className={`flex-1 px-2.5 py-2 rounded-lg border text-[10px] transition-all ${
simulateFailure
? "bg-red-500/10 border-red-500/20 text-red-400"
: "bg-emerald-500/10 border-emerald-500/20 text-emerald-400"
}`}>
<span className="font-medium">Primary</span> · {simulateFailure ? "⛔ Down" : "✓ Online"}
</div>
<div className={`flex-1 px-2.5 py-2 rounded-lg border text-[10px] transition-all ${
simulateFailure
? "bg-amber-500/10 border-amber-500/20 text-amber-400"
: "bg-white/[0.03] border-white/[0.06] text-white/25"
}`}>
<span className="font-medium">Backup</span> · {simulateFailure ? "⚡ Active" : "Standby"}
</div>
</div>
<StatsPanel stats={stats} />
</div>
{/* Right: Proxy Log (desktop only) */}
<div className="hidden md:flex md:flex-col md:min-h-0">
<ProxyLog logs={stats.logs} className="flex-1 min-h-0" />
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="shrink-0 border-t border-white/[0.04] py-1.5 text-center text-[10px] text-white/15 relative z-10">
<code className="text-white/20">memoize()</code> · Proxy pattern · cache, rate-limit, fallback
</div>
</div>
);
}
Analogie
Une carte de crédit est un proxy pour votre compte bancaire. Elle fournit la même interface "payer des choses", mais ajoute du contrôle d'accès (plafond de crédit), du logging (historique des transactions) et peut fonctionner hors ligne (transactions par signature).
Quand l'Utiliser
- Mettre en cache des calculs coûteux ou des appels API
- Limiter le débit d'accès aux services externes
- Ajouter une logique de fallback/retry sans changer le code consommateur
- Ajouter du logging ou des métriques de manière transparente
- Charger des ressources en lazy à la première utilisation
Quand NE PAS l'Utiliser
Si la fonction est peu coûteuse, rapide et fiable, un proxy ajoute de l'overhead sans bénéfice. Ne mettez pas en cache une fonction qui retourne Date.now() et n'enveloppez pas un calcul synchrone pur dans un rate limiter.
Variantes de Proxy dans Arkhe
Arkhe fournit plusieurs utilitaires de proxy :
import { memoize, once, throttle, debounce, lazy, guarded } from "@pithos/core/arkhe";
// Caching proxy — cache results by arguments
const cached = memoize(expensiveCalculation);
// Single-execution proxy — run only once
const initialize = once(loadConfig);
// Rate-limiting proxies
const throttled = throttle(saveToServer, 1000); // max once per second
const debounced = debounce(search, 300); // wait for pause in calls
// Lazy initialization proxy
const config = lazy(() => loadExpensiveConfig());
// Conditional execution proxy
const adminOnly = guarded(deleteUser, (user) => user.isAdmin);
API
Ces fonctions viennent d'Arkhe et sont ré-exportées par Eidos :
- memoize — Mettre en cache les résultats de fonctions
- once — Exécuter uniquement au premier appel
- throttle — Limiter la fréquence d'appel
- debounce — Retarder jusqu'à l'arrêt des appels
- lazy — Différer l'initialisation
- guarded — Exécution conditionnelle
- withFallback — Chaîner une fonction principale avec un backup (depuis Strategy)