Compare commits

...

2 Commits

Author SHA1 Message Date
CTO H3R7Tech
60e12cc4dd feat(HRT-226): Dashboard consommation IA + alertes visuelles
- 3 nouvelles pages HTML servies sous /dashboard/consumption*
- Proxy routes /api/v1/consumption/* -> port 8784 (consumption-tracker)
- Proxy routes /api/v1/ai/usage* -> port 8783 (AI Router)
- consumption_dashboard.html: KPI cards, Chart.js tokens/cost/provider/calls charts, period selector 24h/7j/30j, auto-refresh 30s, alertes visuelles temps reel
- consumption_history.html: Tableau pagine, filtres provider/status/date range, tri colonnes, export CSV
- consumption_alerts.html: CRUD alertes, toggle actif/inactif, seuils visuels badges, modal creation/edition

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-24 11:29:33 +02:00
CTO H3R7Tech
4b766cb908 feat(HRT-200): AI Router — Multi-provider LLM routing with failover
- 4 provider adapters: OpenAI (SDK), Anthropic (SDK), Google (google-genai), Mistral (direct HTTP)
- Core router with automatic failover + exponential backoff
- Flask blueprint with /api/v1/ai/* endpoints
- Auth via token-broker verify endpoint
- DB models for ai_providers, ai_model_mapping, ai_router_log
- /health endpoint (parallel provider check), /usage stats
- 21 unit tests (all passing)
2026-05-24 10:21:36 +02:00
17 changed files with 2368 additions and 0 deletions

4
ai_router/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .router import AIRouter
from .api import ai_router_bp, register_ai_router
__all__ = ["AIRouter", "ai_router_bp", "register_ai_router"]

172
ai_router/api.py Normal file
View File

@@ -0,0 +1,172 @@
"""Flask Blueprint for AI Router — chat, health, models, admin."""
import logging
from datetime import datetime, timezone
from flask import Blueprint, jsonify, request
from .router import AIRouter
from .models import init_db, upsert_provider, upsert_model_mapping
from .utils import require_auth, admin_required
logger = logging.getLogger("ai_router.api")
ai_router_bp = Blueprint("ai_router", __name__, url_prefix="/api/v1/ai")
def register_ai_router(app):
app.register_blueprint(ai_router_bp)
_router = AIRouter()
@ai_router_bp.route("/health", methods=["GET"])
def health():
health_data = _router.check_all_providers_health()
all_ok = all(v["status"] == "ok" for v in health_data.values())
return jsonify({
"status": "ok" if all_ok else "degraded",
"service": "ai-router",
"version": "1.0.0",
"providers": health_data,
"timestamp": datetime.now(timezone.utc).isoformat(),
}), 200 if all_ok else 503
@ai_router_bp.route("/models", methods=["GET"])
def list_models():
models = _router.list_available_models()
return jsonify({"models": models})
@ai_router_bp.route("/chat", methods=["POST"])
@require_auth
def chat():
data = request.get_json(silent=True) or {}
messages = data.get("messages", [])
model = data.get("model", "gpt-4o-mini")
user_id = (request.current_user or {}).get("user_id")
if not messages:
return jsonify({"error": "messages field is required"}), 400
kwargs = {k: data[k] for k in ("temperature", "max_tokens", "top_p", "stream") if k in data}
result = _router.chat(messages=messages, model_alias=model, user_id=user_id, **kwargs)
if result.get("status") == "error":
code = 503 if "All providers failed" in result.get("error", "") else 400
return jsonify(result), code
return jsonify(result), 200
@ai_router_bp.route("/admin/providers", methods=["GET"])
@admin_required
def list_providers():
from .models import get_db
conn = get_db()
try:
rows = conn.execute(
"SELECT id, name, provider_type, base_url, priority, is_active, created_at, updated_at "
"FROM ai_providers ORDER BY priority"
).fetchall()
return jsonify({"providers": [dict(r) for r in rows]})
finally:
conn.close()
@ai_router_bp.route("/admin/providers", methods=["POST"])
@admin_required
def upsert_provider_endpoint():
data = request.get_json(silent=True) or {}
name = data.get("name", "")
provider_type = data.get("provider_type", "")
api_key = data.get("api_key", "")
base_url = data.get("base_url", "")
priority = data.get("priority", 99)
if not name or provider_type not in ("openai", "anthropic", "google", "mistral"):
return jsonify({"error": "Valid name and provider_type required"}), 400
ok = upsert_provider(name, provider_type, api_key, base_url, priority=priority)
if ok:
return jsonify({"status": "ok", "message": f"Provider {name} saved"}), 200
return jsonify({"error": "Failed to save provider"}), 500
@ai_router_bp.route("/admin/model-mappings", methods=["POST"])
@admin_required
def upsert_model_mapping_endpoint():
data = request.get_json(silent=True) or {}
model_alias = data.get("model_alias", "")
provider_id = data.get("provider_id")
real_model_id = data.get("real_model_id", "")
cost = data.get("cost_per_1k_tokens", 0)
if not model_alias or not provider_id or not real_model_id:
return jsonify({"error": "model_alias, provider_id, real_model_id required"}), 400
ok = upsert_model_mapping(model_alias, provider_id, real_model_id, cost)
if ok:
return jsonify({"status": "ok", "message": f"Mapping for {model_alias} saved"}), 200
return jsonify({"error": "Failed to save model mapping"}), 500
@ai_router_bp.route("/admin/providers/<int:provider_id>", methods=["DELETE"])
@admin_required
def delete_provider(provider_id):
from .models import get_db
conn = get_db()
try:
conn.execute("DELETE FROM ai_model_mapping WHERE provider_id = ?", (provider_id,))
conn.execute("DELETE FROM ai_providers WHERE id = ?", (provider_id,))
conn.commit()
return jsonify({"status": "deleted", "provider_id": provider_id})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@ai_router_bp.route("/usage", methods=["GET"])
@admin_required
def usage_stats():
from .models import get_db
conn = get_db()
try:
limit = request.args.get("limit", 50, type=int)
rows = conn.execute(
"SELECT * FROM ai_router_log ORDER BY created_at DESC LIMIT ?", (limit,)
).fetchall()
return jsonify({"usage": [dict(r) for r in rows]})
finally:
conn.close()
@ai_router_bp.route("/usage/summary", methods=["GET"])
@admin_required
def usage_summary():
from .models import get_db
conn = get_db()
try:
agg = conn.execute("""
SELECT provider_used, status, COUNT(*) as count,
SUM(duration_ms) as total_ms, SUM(tokens_in + tokens_out) as total_tokens
FROM ai_router_log
GROUP BY provider_used, status
ORDER BY provider_used
""").fetchall()
totals = conn.execute("""
SELECT COUNT(*) as total_requests,
SUM(CASE WHEN status='success' THEN 1 ELSE 0 END) as success_count,
SUM(tokens_in + tokens_out) as total_tokens
FROM ai_router_log
""").fetchone()
return jsonify({
"by_provider": [dict(r) for r in agg],
"totals": dict(totals) if totals else {},
})
finally:
conn.close()

167
ai_router/models.py Normal file
View File

@@ -0,0 +1,167 @@
import logging
import os
import sqlite3
from datetime import datetime, timezone
logger = logging.getLogger("ai_router.models")
DB_PATH = os.environ.get("AI_ROUTER_DB", "/home/h3r7/turf_saas/ai_router.db")
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db():
conn = get_db()
try:
conn.executescript("""
CREATE TABLE IF NOT EXISTS ai_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
provider_type TEXT NOT NULL CHECK(provider_type IN ('openai','anthropic','google','mistral')),
api_key TEXT NOT NULL DEFAULT '',
base_url TEXT DEFAULT '',
config TEXT DEFAULT '{}',
priority INTEGER NOT NULL DEFAULT 99,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ai_model_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model_alias TEXT NOT NULL UNIQUE,
provider_id INTEGER NOT NULL REFERENCES ai_providers(id),
real_model_id TEXT NOT NULL,
cost_per_1k_tokens REAL NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ai_router_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
user_id INTEGER,
model_alias TEXT NOT NULL,
provider_used TEXT NOT NULL,
tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_out INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL CHECK(status IN ('success','error')),
error_message TEXT DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_ai_router_log_request_id ON ai_router_log(request_id);
CREATE INDEX IF NOT EXISTS idx_ai_router_log_created_at ON ai_router_log(created_at);
CREATE INDEX IF NOT EXISTS idx_ai_model_mapping_alias ON ai_model_mapping(model_alias);
""")
conn.commit()
logger.info("AI Router database tables initialized")
except Exception as e:
logger.error(f"Failed to initialize AI Router DB: {e}")
finally:
conn.close()
def get_providers_from_db():
conn = get_db()
try:
rows = conn.execute("""
SELECT p.id, p.name, p.provider_type, p.api_key, p.base_url, p.config,
p.priority, p.is_active, m.model_alias, m.real_model_id, m.cost_per_1k_tokens
FROM ai_providers p
LEFT JOIN ai_model_mapping m ON m.provider_id = p.id
WHERE p.is_active = 1
""").fetchall()
return [dict(r) for r in rows]
except Exception as e:
logger.warning(f"Could not query providers: {e}")
return []
finally:
conn.close()
def log_router_attempt(request_id, user_id, model_alias, provider_used,
tokens_in, tokens_out, duration_ms, status,
error_message=""):
conn = get_db()
try:
conn.execute(
"""INSERT INTO ai_router_log
(request_id, user_id, model_alias, provider_used,
tokens_in, tokens_out, duration_ms, status, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(request_id, user_id, model_alias, provider_used,
tokens_in, tokens_out, duration_ms, status, error_message),
)
conn.commit()
except Exception as e:
logger.warning(f"Failed to log router attempt: {e}")
finally:
conn.close()
def upsert_provider(name, provider_type, api_key="", base_url="",
config=None, priority=99, is_active=1):
conn = get_db()
try:
existing = conn.execute(
"SELECT id FROM ai_providers WHERE name = ?", (name,)
).fetchone()
if existing:
conn.execute(
"""UPDATE ai_providers SET provider_type=?, api_key=?, base_url=?,
config=?, priority=?, is_active=?, updated_at=datetime('now')
WHERE name=?""",
(provider_type, api_key, base_url,
config or "{}", priority, is_active, name),
)
else:
conn.execute(
"""INSERT INTO ai_providers
(name, provider_type, api_key, base_url, config, priority, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(name, provider_type, api_key, base_url,
config or "{}", priority, is_active),
)
conn.commit()
return True
except Exception as e:
logger.error(f"Failed to upsert provider: {e}")
return False
finally:
conn.close()
def upsert_model_mapping(model_alias, provider_id, real_model_id, cost_per_1k=0):
conn = get_db()
try:
existing = conn.execute(
"SELECT id FROM ai_model_mapping WHERE model_alias = ?", (model_alias,)
).fetchone()
if existing:
conn.execute(
"""UPDATE ai_model_mapping SET provider_id=?, real_model_id=?,
cost_per_1k_tokens=? WHERE model_alias=?""",
(provider_id, real_model_id, cost_per_1k, model_alias),
)
else:
conn.execute(
"""INSERT INTO ai_model_mapping
(model_alias, provider_id, real_model_id, cost_per_1k_tokens)
VALUES (?, ?, ?, ?)""",
(model_alias, provider_id, real_model_id, cost_per_1k),
)
conn.commit()
return True
except Exception as e:
logger.error(f"Failed to upsert model mapping: {e}")
return False
finally:
conn.close()

View File

@@ -0,0 +1,14 @@
from .base import AIProvider
from .openai_adapter import OpenAIAdapter
from .anthropic_adapter import AnthropicAdapter
from .google_adapter import GoogleAdapter
from .mistral_adapter import MistralAdapter
PROVIDER_MAP = {
"openai": OpenAIAdapter,
"anthropic": AnthropicAdapter,
"google": GoogleAdapter,
"mistral": MistralAdapter,
}
__all__ = ["AIProvider", "PROVIDER_MAP", "OpenAIAdapter", "AnthropicAdapter", "GoogleAdapter", "MistralAdapter"]

View File

@@ -0,0 +1,57 @@
import logging
from typing import Optional
from .base import AIProvider
logger = logging.getLogger("ai_router.anthropic")
class AnthropicAdapter(AIProvider):
@property
def name(self) -> str:
return "anthropic"
def chat(self, messages: list, model: str, api_key: Optional[str] = None, **kwargs) -> dict:
from anthropic import Anthropic
key = api_key or self.get_api_key()
client = Anthropic(api_key=key)
system_msg = None
chat_messages = messages
if messages and messages[0].get("role") == "system":
system_msg = messages[0]["content"]
chat_messages = messages[1:]
resp = client.messages.create(
model=model,
system=system_msg,
messages=[{"role": m["role"], "content": m["content"]} for m in chat_messages],
**{k: v for k, v in kwargs.items() if k in ("temperature", "max_tokens", "top_p")},
)
return {
"content": resp.content[0].text if resp.content else "",
"model": resp.model,
"provider": self.name,
"usage": {
"prompt_tokens": resp.usage.input_tokens if resp.usage else 0,
"completion_tokens": resp.usage.output_tokens if resp.usage else 0,
"total_tokens": (resp.usage.input_tokens + resp.usage.output_tokens) if resp.usage else 0,
},
}
def models(self) -> list:
from anthropic import Anthropic
client = Anthropic(api_key=self.get_api_key())
return [m.id for m in client.models.list()]
def check_health(self) -> dict:
try:
from anthropic import Anthropic
client = Anthropic(api_key=self.get_api_key())
client.models.list()
return {"status": "ok", "details": "API reachable"}
except Exception as e:
logger.warning(f"Anthropic health check failed: {e}")
return {"status": "error", "details": str(e)}

View File

@@ -0,0 +1,44 @@
from abc import ABC, abstractmethod
from typing import Optional
class AIProvider(ABC):
@property
@abstractmethod
def name(self) -> str:
"""Provider identifier (openai, anthropic, google, mistral)."""
@abstractmethod
def chat(self, messages: list, model: str, **kwargs) -> dict:
"""Send a chat completion request. Returns dict with at least:
{
"content": str,
"model": str,
"provider": self.name,
"usage": {"prompt_tokens": int, "completion_tokens": int, "total_tokens": int}
}
"""
@abstractmethod
def models(self) -> list:
"""Return list of available models from this provider."""
@abstractmethod
def check_health(self) -> dict:
"""Check provider connectivity. Returns {"status": "ok"|"error", "details": str}"""
def get_api_key(self, db_config: Optional[dict] = None) -> Optional[str]:
"""Resolve API key: DB override > env var."""
provider_env_map = {
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"google": "GOOGLE_API_KEY",
"mistral": "MISTRAL_API_KEY",
}
if db_config and db_config.get("api_key"):
return db_config["api_key"]
import os
env_var = provider_env_map.get(self.name)
if env_var:
return os.environ.get(env_var)
return None

View File

@@ -0,0 +1,57 @@
import logging
from typing import Optional
from .base import AIProvider
logger = logging.getLogger("ai_router.google")
class GoogleAdapter(AIProvider):
@property
def name(self) -> str:
return "google"
def chat(self, messages: list, model: str, api_key: Optional[str] = None, **kwargs) -> dict:
from google import genai
key = api_key or self.get_api_key()
client = genai.Client(api_key=key)
system_instruction = None
chat_messages = messages
if messages and messages[0].get("role") == "system":
system_instruction = messages[0]["content"]
chat_messages = messages[1:]
contents = []
for m in chat_messages:
role = "user" if m["role"] in ("user", "system") else "model"
contents.append({"role": role, "parts": [{"text": m["content"]}]})
resp = client.models.generate_content(
model=model,
contents=contents,
config={"system_instruction": system_instruction} if system_instruction else None,
)
return {
"content": resp.text or "",
"model": model,
"provider": self.name,
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
def models(self) -> list:
from google import genai
client = genai.Client(api_key=self.get_api_key())
return [m.name for m in client.models.list()]
def check_health(self) -> dict:
try:
from google import genai
client = genai.Client(api_key=self.get_api_key())
client.models.list()
return {"status": "ok", "details": "API reachable"}
except Exception as e:
logger.warning(f"Google health check failed: {e}")
return {"status": "error", "details": str(e)}

View File

@@ -0,0 +1,70 @@
import json
import logging
from typing import Optional
import requests
from .base import AIProvider
logger = logging.getLogger("ai_router.mistral")
MISTRAL_API_BASE = "https://api.mistral.ai/v1"
class MistralAdapter(AIProvider):
@property
def name(self) -> str:
return "mistral"
def _headers(self, api_key: Optional[str] = None) -> dict:
key = api_key or self.get_api_key()
return {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
def chat(self, messages: list, model: str, api_key: Optional[str] = None, **kwargs) -> dict:
key = api_key or self.get_api_key()
headers = self._headers(key)
payload = {
"model": model,
"messages": messages,
}
for k in ("temperature", "max_tokens", "top_p", "stream"):
if k in kwargs:
payload[k] = kwargs[k]
resp = requests.post(
f"{MISTRAL_API_BASE}/chat/completions",
headers=headers,
json=payload,
timeout=120,
)
resp.raise_for_status()
data = resp.json()
choice = data["choices"][0]
return {
"content": choice["message"]["content"] or "",
"model": data.get("model", model),
"provider": self.name,
"usage": {
"prompt_tokens": data.get("usage", {}).get("prompt_tokens", 0),
"completion_tokens": data.get("usage", {}).get("completion_tokens", 0),
"total_tokens": data.get("usage", {}).get("total_tokens", 0),
},
}
def models(self) -> list:
resp = requests.get(f"{MISTRAL_API_BASE}/models", headers=self._headers(), timeout=30)
resp.raise_for_status()
return [m["id"] for m in resp.json().get("data", [])]
def check_health(self) -> dict:
try:
resp = requests.get(f"{MISTRAL_API_BASE}/models", headers=self._headers(), timeout=10)
resp.raise_for_status()
return {"status": "ok", "details": "API reachable"}
except Exception as e:
logger.warning(f"Mistral health check failed: {e}")
return {"status": "error", "details": str(e)}

View File

@@ -0,0 +1,50 @@
import logging
from typing import Optional
from .base import AIProvider
logger = logging.getLogger("ai_router.openai")
class OpenAIAdapter(AIProvider):
@property
def name(self) -> str:
return "openai"
def chat(self, messages: list, model: str, api_key: Optional[str] = None, **kwargs) -> dict:
from openai import OpenAI
key = api_key or self.get_api_key()
client = OpenAI(api_key=key)
resp = client.chat.completions.create(
model=model,
messages=messages,
**{k: v for k, v in kwargs.items() if k in ("temperature", "max_tokens", "top_p", "stream")},
)
choice = resp.choices[0]
return {
"content": choice.message.content or "",
"model": resp.model,
"provider": self.name,
"usage": {
"prompt_tokens": resp.usage.prompt_tokens if resp.usage else 0,
"completion_tokens": resp.usage.completion_tokens if resp.usage else 0,
"total_tokens": resp.usage.total_tokens if resp.usage else 0,
},
}
def models(self) -> list:
from openai import OpenAI
client = OpenAI(api_key=self.get_api_key())
return [m.id for m in client.models.list()]
def check_health(self) -> dict:
try:
from openai import OpenAI
client = OpenAI(api_key=self.get_api_key())
client.models.list()
return {"status": "ok", "details": "API reachable"}
except Exception as e:
logger.warning(f"OpenAI health check failed: {e}")
return {"status": "error", "details": str(e)}

174
ai_router/router.py Normal file
View File

@@ -0,0 +1,174 @@
import logging
import time
import uuid
from typing import Optional
from .providers import PROVIDER_MAP, AIProvider
from .models import get_providers_from_db, log_router_attempt
logger = logging.getLogger("ai_router.router")
DEFAULT_MODEL_MAP = {
"gpt-4o": {"provider": "openai", "real_model": "gpt-4o"},
"gpt-4o-mini": {"provider": "openai", "real_model": "gpt-4o-mini"},
"claude-3-opus": {"provider": "anthropic", "real_model": "claude-3-opus-20240229"},
"claude-3-sonnet": {"provider": "anthropic", "real_model": "claude-3-sonnet-20240229"},
"claude-3-haiku": {"provider": "anthropic", "real_model": "claude-3-haiku-20240307"},
"gemini-pro": {"provider": "google", "real_model": "gemini-1.5-pro"},
"gemini-flash": {"provider": "google", "real_model": "gemini-1.5-flash"},
"mistral-large": {"provider": "mistral", "real_model": "mistral-large-latest"},
"mistral-small": {"provider": "mistral", "real_model": "mistral-small-latest"},
}
class AIRouter:
def __init__(self):
self._provider_instances = {}
def get_provider(self, name: str) -> Optional[AIProvider]:
if name not in self._provider_instances:
cls = PROVIDER_MAP.get(name)
if not cls:
return None
self._provider_instances[name] = cls()
return self._provider_instances[name]
def _resolve_model(self, model_alias: str) -> Optional[dict]:
mapping = self._load_model_mappings()
return mapping.get(model_alias)
def _load_model_mappings(self) -> dict:
db_mappings = []
try:
db_mappings = get_providers_from_db()
except Exception as e:
logger.warning(f"Could not load model mappings from DB: {e}")
merged = dict(DEFAULT_MODEL_MAP)
for entry in db_mappings:
alias = entry.get("model_alias")
if alias:
merged[alias] = {
"provider": entry["provider_type"],
"real_model": entry.get("real_model_id", alias),
"cost_per_1k": entry.get("cost_per_1k_tokens", 0),
"db_config": entry,
}
return merged
def _get_prioritized_providers(self):
providers = []
try:
db_providers = get_providers_from_db()
seen_names = set()
for p in sorted(db_providers, key=lambda x: x.get("priority", 99)):
name = p["provider_type"]
if name not in seen_names:
seen_names.add(name)
providers.append((name, p))
except Exception as e:
logger.warning(f"Could not load provider priority from DB: {e}")
if not providers:
default_order = ["openai", "anthropic", "google", "mistral"]
providers = [(n, None) for n in default_order]
return providers
def chat(self, messages: list, model_alias: str, user_id: Optional[int] = None, **kwargs) -> dict:
request_id = str(uuid.uuid4())
start_time = time.time()
model_info = self._resolve_model(model_alias)
if not model_info:
return {"error": f"Unknown model: {model_alias}", "status": "error"}
provider_order = self._get_prioritized_providers()
preferred_provider = model_info["provider"]
real_model = model_info["real_model"]
ordered = []
for name, db_config in provider_order:
if name == preferred_provider:
ordered.insert(0, (name, db_config))
else:
ordered.append((name, db_config))
if preferred_provider not in [p[0] for p in ordered]:
ordered.insert(0, (preferred_provider, None))
last_error = None
for attempt, (provider_name, db_config) in enumerate(ordered):
provider = self.get_provider(provider_name)
if not provider:
continue
try:
if attempt > 0:
backoff = min(2 ** (attempt - 1), 30)
logger.info(f"Failover to {provider_name} after {backoff}s backoff (attempt {attempt})")
time.sleep(backoff)
api_key = provider.get_api_key(db_config)
if not api_key:
logger.warning(f"No API key configured for {provider_name}, skipping")
continue
result = provider.chat(messages, model=real_model, api_key=api_key, **kwargs)
elapsed = int((time.time() - start_time) * 1000)
log_router_attempt(
request_id=request_id,
user_id=user_id,
model_alias=model_alias,
provider_used=provider_name,
tokens_in=result.get("usage", {}).get("prompt_tokens", 0),
tokens_out=result.get("usage", {}).get("completion_tokens", 0),
duration_ms=elapsed,
status="success",
)
result["request_id"] = request_id
result["status"] = "success"
return result
except Exception as e:
last_error = str(e)
elapsed = int((time.time() - start_time) * 1000)
logger.warning(f"Provider {provider_name} failed: {e}")
log_router_attempt(
request_id=request_id,
user_id=user_id,
model_alias=model_alias,
provider_used=provider_name,
tokens_in=0,
tokens_out=0,
duration_ms=elapsed,
status="error",
error_message=last_error,
)
elapsed = int((time.time() - start_time) * 1000)
return {
"error": f"All providers failed. Last error: {last_error}",
"status": "error",
"request_id": request_id,
"duration_ms": elapsed,
}
def check_all_providers_health(self) -> dict:
results = {}
for name in PROVIDER_MAP:
provider = self.get_provider(name)
results[name] = provider.check_health()
return results
def list_available_models(self) -> list:
model_map = self._load_model_mappings()
return [
{
"alias": alias,
"provider": info["provider"],
"real_model": info["real_model"],
"cost_per_1k_tokens": info.get("cost_per_1k", 0),
}
for alias, info in model_map.items()
]

93
ai_router/utils.py Normal file
View File

@@ -0,0 +1,93 @@
import logging
import os
import sys
from functools import wraps
from flask import request, jsonify
logger = logging.getLogger("ai_router")
TOKEN_BROKER_URL = os.environ.get(
"TOKEN_BROKER_URL", "http://localhost:8783"
)
def verify_token_via_broker(token: str) -> dict:
"""Verify an API token via the token-broker /verify endpoint."""
import requests
try:
resp = requests.post(
f"{TOKEN_BROKER_URL}/api/v1/tokens/verify",
json={"token": token},
timeout=10,
)
if resp.status_code == 200:
data = resp.json()
if data.get("valid"):
return data
return {}
except requests.RequestException as e:
logger.warning(f"Token broker unreachable: {e}")
return {}
def require_auth(f):
"""Decorator: validate Bearer or X-API-Key via token-broker."""
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get("Authorization", "")
api_key = request.headers.get("X-API-Key", "")
raw_token = ""
if auth_header.startswith("Bearer "):
raw_token = auth_header.split(" ", 1)[1]
elif api_key:
raw_token = api_key
if not raw_token:
return jsonify({"error": "Authentication required"}), 401
payload = verify_token_via_broker(raw_token)
if not payload or not payload.get("valid"):
return jsonify({"error": "Invalid or expired token"}), 401
request.current_user = {
"user_id": payload.get("user_id"),
"token_id": payload.get("token_id"),
"scopes": payload.get("scopes", []),
}
return f(*args, **kwargs)
return decorated
def admin_required(f):
"""Decorator: require admin scope on the authenticated token."""
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get("Authorization", "")
api_key = request.headers.get("X-API-Key", "")
raw_token = ""
if auth_header.startswith("Bearer "):
raw_token = auth_header.split(" ", 1)[1]
elif api_key:
raw_token = api_key
if not raw_token:
return jsonify({"error": "Authentication required"}), 401
payload = verify_token_via_broker(raw_token)
if not payload or not payload.get("valid"):
return jsonify({"error": "Invalid or expired token"}), 401
scopes = payload.get("scopes", [])
if "admin" not in scopes and "ai_router_admin" not in scopes:
return jsonify({"error": "Admin access required"}), 403
request.current_user = {
"user_id": payload.get("user_id"),
"token_id": payload.get("token_id"),
"scopes": scopes,
}
return f(*args, **kwargs)
return decorated

77
ai_router_api.py Normal file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""
AI Router API — Multi-provider LLM routing with failover
Port: 8783 | DB: SQLite ai_router.db
HRT-200 — AI Router (Multi-provider + failover)
Endpoints:
GET /api/v1/ai/health — Check all providers health
GET /api/v1/ai/models — List available models
POST /api/v1/ai/chat — Chat completion with auto-failover
GET /api/v1/ai/admin/providers — List configured providers
POST /api/v1/ai/admin/providers — Upsert a provider
POST /api/v1/ai/admin/model-mappings— Upsert a model mapping
DELETE /api/v1/ai/admin/providers/:id — Remove a provider
GET /api/v1/ai/usage — Usage logs
GET /api/v1/ai/usage/summary — Aggregated usage stats
"""
import logging
import logging.handlers
import os
import sys
from flask import Flask, jsonify
from flask_cors import CORS
LOG_DIR = os.path.join(os.path.dirname(__file__), "ai_router", "logs")
os.makedirs(LOG_DIR, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] ai-router: %(name)s: %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.handlers.RotatingFileHandler(
os.path.join(LOG_DIR, "ai_router.log"),
maxBytes=5 * 1024 * 1024,
backupCount=3,
),
],
)
logger = logging.getLogger("ai_router")
PORT = int(os.environ.get("AI_ROUTER_PORT", "8783"))
def create_app():
app = Flask(__name__)
CORS(app)
from ai_router.api import register_ai_router
from ai_router.models import init_db
init_db()
register_ai_router(app)
@app.errorhandler(404)
def not_found(e):
return jsonify({"error": "not_found", "message": "Route not found"}), 404
@app.errorhandler(500)
def internal_error(e):
logger.error(f"Internal error: {e}")
return jsonify({"error": "internal_error", "message": "Internal server error"}), 500
return app
if __name__ == "__main__":
logger.info("=" * 60)
logger.info("AI Router API starting...")
logger.info(f"Port: {PORT}")
logger.info("=" * 60)
app = create_app()
debug = os.environ.get("FLASK_ENV", "production") == "development"
app.run(host="0.0.0.0", port=PORT, debug=debug)

335
consumption_alerts.html Normal file
View File

@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Consommation IA — Alertes</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
--gold: #ffd600; --orange: #ff6d00;
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
--radius: 10px; --error: #f85149; --purple: #7c3aed;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; }
a { color: inherit; text-decoration: none; }
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 16px 28px; border-bottom: 1px solid var(--border); background: var(--dark); position: sticky; top: 0; z-index: 10; flex-wrap: wrap; gap: 10px; }
.topbar-title { font-size: 1.1rem; font-weight: 700; display: flex; align-items: center; gap: 10px; }
.topbar-title a { color: var(--muted); font-weight: 400; font-size: .9rem; }
.topbar-title a:hover { color: var(--text); }
.topbar-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; border-radius: 8px; font-size: .85rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
.btn-primary { background: var(--green); color: #000; }
.btn-primary:hover { background: var(--green-d); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover { border-color: var(--muted); }
.btn-danger { background: rgba(248,81,73,.15); color: var(--error); border: 1px solid rgba(248,81,73,.3); }
.btn-danger:hover { background: rgba(248,81,73,.25); }
.btn-sm { padding: 5px 12px; font-size: .8rem; }
.content { padding: 28px; max-width: 1000px; margin: 0 auto; }
.section-title { font-size: 1rem; font-weight: 700; margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; }
.alert-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; margin-bottom: 12px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.alert-card.inactive { opacity: .6; }
.alert-icon { font-size: 1.3rem; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: var(--dark3); border-radius: 8px; flex-shrink: 0; }
.alert-info { flex: 1; min-width: 200px; }
.alert-info .alert-name { font-weight: 700; font-size: .93rem; }
.alert-info .alert-desc { font-size: .82rem; color: var(--muted); margin-top: 2px; }
.alert-actions { display: flex; gap: 8px; flex-shrink: 0; }
.toggle-switch { width: 40px; height: 22px; border-radius: 11px; background: var(--border); cursor: pointer; position: relative; transition: background .2s; flex-shrink: 0; }
.toggle-switch.active { background: var(--green); }
.toggle-switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform .2s; }
.toggle-switch.active::after { transform: translateX(18px); }
.empty-state { text-align: center; padding: 60px 20px; color: var(--muted); }
.empty-state .icon { font-size: 2.5rem; margin-bottom: 12px; }
.empty-state p { font-size: .9rem; margin-bottom: 16px; }
.loading { text-align: center; padding: 60px 20px; color: var(--muted); }
.loading .spinner { display: inline-block; width: 32px; height: 32px; border: 3px solid var(--border); border-top-color: var(--green); border-radius: 50%; animation: spin .8s linear infinite; margin-bottom: 12px; }
@keyframes spin { to { transform: rotate(360deg); } }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
.modal-overlay {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,.6); z-index: 1000;
align-items: center; justify-content: center;
}
.modal-overlay.show { display: flex; }
.modal {
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 24px; width: 90%; max-width: 480px; max-height: 90vh; overflow-y: auto;
}
.modal h3 { font-size: 1.05rem; margin-bottom: 20px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: .8rem; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
.form-group input, .form-group select {
width: 100%; background: var(--dark3); border: 1px solid var(--border);
border-radius: 8px; padding: 9px 12px; color: var(--text); font-size: .88rem;
outline: none; transition: border-color .2s; font-family: inherit;
}
.form-group input:focus, .form-group select:focus { border-color: var(--green); }
.form-actions { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
.toast {
position: fixed; bottom: 20px; right: 20px; z-index: 9999;
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px 20px; font-size: .88rem; box-shadow: 0 8px 24px rgba(0,0,0,.4);
display: none; max-width: 400px;
}
.toast.show { display: block; animation: slideIn .3s ease; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
.toast-success { border-color: var(--green); }
.toast-error { border-color: var(--error); }
.threshold-badge { padding: 3px 10px; border-radius: 12px; font-size: .72rem; font-weight: 700; }
.threshold-info { background: rgba(30,136,229,.15); color: var(--blue); }
.threshold-warn { background: rgba(255,214,0,.15); color: var(--gold); }
.threshold-danger { background: rgba(248,81,73,.15); color: var(--error); }
</style>
</head>
<body>
<a href="/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<div class="topbar">
<div class="topbar-title">
<span>🔔 Gestion des Alertes</span>
<a href="/dashboard/consumption">← Dashboard</a>
</div>
<div class="topbar-right">
<button class="btn btn-primary btn-sm" onclick="openCreateModal()">+ Nouvelle alerte</button>
</div>
</div>
<div class="content">
<div class="section-title">
<span>Règles d'alerte consommation</span>
</div>
<div class="loading" id="loading">
<div class="spinner"></div>
<div>Chargement des alertes...</div>
</div>
<div id="alerts-container"></div>
</div>
<div class="modal-overlay" id="modal">
<div class="modal">
<h3 id="modal-title">Nouvelle alerte</h3>
<input type="hidden" id="edit-id">
<div class="form-group">
<label>Type de seuil</label>
<select id="form-type">
<option value="tokens">Tokens</option>
<option value="cost">Coût (cents)</option>
</select>
</div>
<div class="form-group">
<label>Valeur du seuil</label>
<input type="number" id="form-value" min="1" step="0.01" placeholder="1000">
</div>
<div class="form-group">
<label>Période</label>
<select id="form-period">
<option value="daily">Journalier</option>
<option value="monthly">Mensuel</option>
</select>
</div>
<div class="form-group">
<label>Email notification (optionnel)</label>
<input type="email" id="form-email" placeholder="admin@example.com">
</div>
<div class="form-group">
<label>Webhook notification (optionnel)</label>
<input type="url" id="form-webhook" placeholder="https://hooks.example.com/alert">
</div>
<div class="form-actions">
<button class="btn btn-ghost" onclick="closeModal()">Annuler</button>
<button class="btn btn-primary" onclick="saveAlert()">Enregistrer</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
function showToast(msg, type) {
var t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show toast-' + type;
setTimeout(function() { t.classList.remove('show'); }, 4000);
}
function openModal() {
document.getElementById('modal').classList.add('show');
}
function closeModal() {
document.getElementById('modal').classList.remove('show');
document.getElementById('edit-id').value = '';
document.getElementById('modal-title').textContent = 'Nouvelle alerte';
document.getElementById('form-type').value = 'tokens';
document.getElementById('form-value').value = '';
document.getElementById('form-period').value = 'daily';
document.getElementById('form-email').value = '';
document.getElementById('form-webhook').value = '';
}
function openCreateModal() {
closeModal();
openModal();
}
function getThresholdClass(value, type) {
if (type === 'tokens') {
if (value >= 100000) return 'threshold-danger';
if (value >= 50000) return 'threshold-warn';
return 'threshold-info';
}
if (type === 'cost') {
if (value >= 10000) return 'threshold-danger';
if (value >= 5000) return 'threshold-warn';
return 'threshold-info';
}
return 'threshold-info';
}
function formatThreshold(value, type) {
if (type === 'tokens') {
if (value >= 1000000) return (value/1000000).toFixed(1) + 'M tokens';
if (value >= 1000) return (value/1000).toFixed(1) + 'k tokens';
return value + ' tokens';
}
if (type === 'cost') {
return (value / 100).toFixed(2) + '€';
}
return value;
}
function renderAlerts(alerts) {
var container = document.getElementById('alerts-container');
if (!alerts || alerts.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="icon">🔔</div><p>Aucune alerte configurée</p><button class="btn btn-primary" onclick="openCreateModal()">+ Créer une alerte</button></div>';
return;
}
var html = '';
alerts.forEach(function(a) {
var activeClass = a.is_active ? '' : 'inactive';
var toggleClass = a.is_active ? 'active' : '';
var thresholdClass = getThresholdClass(a.threshold_value, a.threshold_type);
html += '<div class="alert-card ' + activeClass + '" data-id="' + a.id + '">' +
'<div class="alert-icon">' + (a.threshold_type === 'tokens' ? '🔷' : '💰') + '</div>' +
'<div class="alert-info">' +
'<div class="alert-name">' + (a.threshold_type === 'tokens' ? 'Seuil tokens' : 'Seuil coût') + ' <span class="threshold-badge ' + thresholdClass + '">' + formatThreshold(a.threshold_value, a.threshold_type) + '</span></div>' +
'<div class="alert-desc">Période: ' + (a.period === 'daily' ? 'Journalier' : 'Mensuel') + (a.notify_email ? ' · 📧 ' + a.notify_email : '') + (a.notify_webhook ? ' · 🔗 webhook' : '') + '</div>' +
'</div>' +
'<div class="alert-actions">' +
'<div class="toggle-switch ' + toggleClass + '" onclick="toggleAlert(' + a.id + ', ' + !a.is_active + ')"></div>' +
'<button class="btn btn-ghost btn-sm" onclick="editAlert(' + a.id + ')">✏️</button>' +
'<button class="btn btn-danger btn-sm" onclick="deleteAlert(' + a.id + ')">🗑️</button>' +
'</div>' +
'</div>';
});
container.innerHTML = html;
}
async function loadAlerts() {
document.getElementById('loading').style.display = '';
try {
var r = await fetch('/api/v1/consumption/alerts');
var data = await r.json();
document.getElementById('loading').style.display = 'none';
renderAlerts(data.alerts || []);
} catch (e) {
document.getElementById('loading').innerHTML = '<div style="color:var(--error);padding:40px">❌ Erreur: ' + e.message + '</div>';
}
}
async function toggleAlert(id, newState) {
try {
var r = await fetch('/api/v1/consumption/alerts/' + id, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ is_active: newState })
});
if (!r.ok) { showToast('Erreur lors de la modification', 'error'); return; }
showToast('Alerte ' + (newState ? 'activée' : 'désactivée'), 'success');
loadAlerts();
} catch (e) {
showToast('Erreur: ' + e.message, 'error');
}
}
function editAlert(id) {
var card = document.querySelector('.alert-card[data-id="' + id + '"]');
if (!card) return;
// Re-fetch from API to get full data
fetch('/api/v1/consumption/alerts/' + id)
.then(function(r) { return r.json(); })
.then(function(a) {
document.getElementById('edit-id').value = a.id;
document.getElementById('modal-title').textContent = 'Modifier l\'alerte #' + a.id;
document.getElementById('form-type').value = a.threshold_type;
document.getElementById('form-value').value = a.threshold_value;
document.getElementById('form-period').value = a.period;
document.getElementById('form-email').value = a.notify_email || '';
document.getElementById('form-webhook').value = a.notify_webhook || '';
openModal();
})
.catch(function(e) { showToast('Erreur: ' + e.message, 'error'); });
}
async function saveAlert() {
var id = document.getElementById('edit-id').value;
var data = {
client_id: 'internal',
threshold_type: document.getElementById('form-type').value,
threshold_value: parseFloat(document.getElementById('form-value').value),
period: document.getElementById('form-period').value,
notify_email: document.getElementById('form-email').value || null,
notify_webhook: document.getElementById('form-webhook').value || null,
};
if (!data.threshold_value || data.threshold_value <= 0) {
showToast('Veuillez entrer une valeur de seuil valide', 'error');
return;
}
try {
var url = id ? '/api/v1/consumption/alerts/' + id : '/api/v1/consumption/alerts';
var method = id ? 'PUT' : 'POST';
var r = await fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (!r.ok) { showToast('Erreur lors de l\'enregistrement', 'error'); return; }
showToast(id ? 'Alerte modifiée' : 'Alerte créée', 'success');
closeModal();
loadAlerts();
} catch (e) {
showToast('Erreur: ' + e.message, 'error');
}
}
async function deleteAlert(id) {
if (!confirm('Supprimer cette alerte ?')) return;
try {
var r = await fetch('/api/v1/consumption/alerts/' + id, { method: 'DELETE' });
if (!r.ok) { showToast('Erreur lors de la suppression', 'error'); return; }
showToast('Alerte supprimée', 'success');
loadAlerts();
} catch (e) {
showToast('Erreur: ' + e.message, 'error');
}
}
loadAlerts();
</script>
</body>
</html>

415
consumption_dashboard.html Normal file
View File

@@ -0,0 +1,415 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Consommation IA — Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
--gold: #ffd600; --orange: #ff6d00;
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
--radius: 10px; --error: #f85149; --purple: #7c3aed; --cyan: #00d9ff;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; }
a { color: inherit; text-decoration: none; }
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 16px 28px; border-bottom: 1px solid var(--border); background: var(--dark); position: sticky; top: 0; z-index: 10; flex-wrap: wrap; gap: 10px; }
.topbar-title { font-size: 1.1rem; font-weight: 700; display: flex; align-items: center; gap: 10px; }
.topbar-title a { color: var(--muted); font-weight: 400; font-size: .9rem; }
.topbar-title a:hover { color: var(--text); }
.topbar-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; border-radius: 8px; font-size: .85rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
.btn-primary { background: var(--green); color: #000; }
.btn-primary:hover { background: var(--green-d); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover { border-color: var(--muted); }
.btn-sm { padding: 5px 12px; font-size: .8rem; }
.btn-active { background: var(--green); color: #000; border-color: var(--green); }
.content { padding: 28px; max-width: 1400px; margin: 0 auto; }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; }
.stat-label { font-size: .78rem; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.stat-value { font-size: 1.8rem; font-weight: 800; }
.stat-sub { font-size: .78rem; color: var(--muted); margin-top: 4px; }
.stat-warn { color: var(--gold); }
.stat-err { color: var(--error); }
.period-bar { display: flex; gap: 6px; margin-bottom: 20px; flex-wrap: wrap; }
.period-btn { padding: 6px 16px; border-radius: 20px; font-size: .82rem; font-weight: 600; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--muted); transition: all .15s; }
.period-btn:hover { border-color: var(--muted); color: var(--text); }
.period-btn.active { background: var(--green); color: #000; border-color: var(--green); }
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
@media (max-width: 900px) { .charts-grid { grid-template-columns: 1fr; } }
.chart-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; }
.chart-title { font-size: .9rem; font-weight: 700; margin-bottom: 14px; color: var(--muted); }
.chart-container { height: 280px; position: relative; }
.alert-banner {
background: linear-gradient(135deg, rgba(248,81,73,.1), rgba(255,109,0,.08));
border: 1px solid rgba(248,81,73,.25); border-radius: var(--radius);
padding: 12px 18px; margin-bottom: 20px;
display: none; align-items: center; gap: 12px;
}
.alert-banner.visible { display: flex; }
.alert-banner .alert-icon { font-size: 1.3rem; }
.alert-banner .alert-text { flex: 1; font-size: .9rem; }
.alert-banner .alert-text strong { color: var(--error); }
.alert-banner .alert-close { cursor: pointer; font-size: 1.2rem; color: var(--muted); }
.loading { text-align: center; padding: 60px 20px; color: var(--muted); }
.loading .spinner { display: inline-block; width: 32px; height: 32px; border: 3px solid var(--border); border-top-color: var(--green); border-radius: 50%; animation: spin .8s linear infinite; margin-bottom: 12px; }
@keyframes spin { to { transform: rotate(360deg); } }
.provider-breakdown { margin-top: 12px; }
.provider-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }
.provider-row:last-child { border-bottom: none; }
.provider-name { flex: 1; font-weight: 600; font-size: .88rem; }
.provider-bar-wrap { flex: 2; height: 8px; border-radius: 4px; background: var(--border); overflow: hidden; }
.provider-bar-fill { height: 100%; border-radius: 4px; transition: width .5s; }
.provider-stats { flex: 1; text-align: right; font-size: .82rem; color: var(--muted); }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
</style>
</head>
<body>
<a href="/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<div class="topbar">
<div class="topbar-title">
<span>📊 Consommation IA</span>
<a href="/dashboard/consumption/history">Historique →</a>
</div>
<div class="topbar-right">
<a href="/dashboard/consumption/alerts" class="btn btn-ghost btn-sm">⚙️ Alertes</a>
<span id="last-refresh" style="font-size:.78rem;color:var(--muted)"></span>
</div>
</div>
<div class="content">
<div class="alert-banner" id="alert-banner">
<span class="alert-icon">⚠️</span>
<div class="alert-text" id="alert-text"></div>
<span class="alert-close" onclick="this.parentElement.classList.remove('visible')"></span>
</div>
<div class="period-bar" id="period-bar">
<button class="period-btn" data-period="24h" onclick="setPeriod('24h')">24h</button>
<button class="period-btn active" data-period="7d" onclick="setPeriod('7d')">7 jours</button>
<button class="period-btn" data-period="30d" onclick="setPeriod('30d')">30 jours</button>
</div>
<div class="loading" id="loading">
<div class="spinner"></div>
<div>Chargement des données...</div>
</div>
<div id="dashboard-content" style="display:none">
<div class="stats-row" id="stats-row"></div>
<div class="charts-grid">
<div class="chart-card">
<div class="chart-title">🔷 Tokens par jour</div>
<div class="chart-container"><canvas id="tokensChart"></canvas></div>
</div>
<div class="chart-card">
<div class="chart-title">💰 Coût par jour (€)</div>
<div class="chart-container"><canvas id="costChart"></canvas></div>
</div>
<div class="chart-card">
<div class="chart-title">🏢 Répartition par provider</div>
<div class="chart-container"><canvas id="providerChart"></canvas></div>
</div>
<div class="chart-card">
<div class="chart-title">📞 Appels par jour</div>
<div class="chart-container"><canvas id="callsChart"></canvas></div>
</div>
</div>
</div>
</div>
<script>
var currentPeriod = '7d';
var statsData = null;
function formatDate(isoStr) {
var d = new Date(isoStr);
return d.toLocaleDateString('fr-FR', {day:'2-digit', month:'short'});
}
function formatNumber(n) {
if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
if (n >= 1000) return (n/1000).toFixed(1) + 'k';
return n.toLocaleString('fr-FR');
}
function getDateRange(period) {
var now = new Date();
var start = new Date(now);
if (period === '24h') start.setDate(start.getDate() - 1);
else if (period === '7d') start.setDate(start.getDate() - 7);
else if (period === '30d') start.setDate(start.getDate() - 30);
return {
start: start.toISOString().split('T')[0],
end: now.toISOString().split('T')[0]
};
}
function setPeriod(period) {
currentPeriod = period;
document.querySelectorAll('.period-btn').forEach(function(b) {
b.classList.toggle('active', b.dataset.period === period);
});
loadData();
}
function checkAlerts(totals) {
var banner = document.getElementById('alert-banner');
var text = document.getElementById('alert-text');
var dailyTokens = 0;
var dailyCost = 0;
if (statsData && statsData.by_day && statsData.by_day.length > 0) {
var today = statsData.by_day[0];
dailyTokens = today.tokens || 0;
dailyCost = (today.cost_cents || 0) / 100;
}
if (dailyTokens > 0) {
var tokensPct = 100;
var costPct = 100;
banner.classList.add('visible');
if (dailyTokens > 100000) {
text.innerHTML = '<strong>⚠️ Seuil critique</strong> — ' + formatNumber(dailyTokens) + ' tokens aujourd\'hui (dépassement >100k)';
} else if (dailyCost > 5) {
text.innerHTML = '<strong>⚠️ Alerte coût</strong> — ' + dailyCost.toFixed(2) + '€ aujourd\'hui (seuil >5€)';
} else {
banner.classList.remove('visible');
}
}
}
function renderStats(totals) {
var html = '';
var cards = [
{ label: 'Requêtes totales', value: formatNumber(totals.calls_count || 0), sub: 'période sélectionnée' },
{ label: 'Tokens totaux', value: formatNumber(totals.total_tokens || 0), sub: 'entrée + sortie' },
{ label: 'Coût estimé', value: (totals.total_cost_cents / 100).toFixed(2) + '€', sub: 'coût total période', cls: totals.total_cost_cents > 500 ? 'stat-warn' : '' },
{ label: 'Latence moyenne', value: (totals.avg_latency_ms || 0).toFixed(0) + 'ms', sub: 'temps de réponse' },
{ label: 'Erreurs', value: totals.error_count || 0, sub: 'requêtes échouées', cls: totals.error_count > 0 ? 'stat-err' : '' },
];
cards.forEach(function(c) {
html += '<div class="stat-card"><div class="stat-label">' + c.label + '</div><div class="stat-value ' + (c.cls || '') + '">' + c.value + '</div><div class="stat-sub">' + c.sub + '</div></div>';
});
document.getElementById('stats-row').innerHTML = html;
}
function renderTokensChart(byDay) {
var ctx = document.getElementById('tokensChart').getContext('2d');
var labels = byDay.map(function(d) { return formatDate(d.date); }).reverse();
var data = byDay.map(function(d) { return d.tokens; }).reverse();
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Tokens',
data: data,
borderColor: '#00c853',
backgroundColor: 'rgba(0,200,83,0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#8b949e', callback: function(v) { return formatNumber(v); } }
},
x: {
grid: { display: false },
ticks: { color: '#8b949e', maxTicksLimit: 10 }
}
}
}
});
}
function renderCostChart(byDay) {
var ctx = document.getElementById('costChart').getContext('2d');
var labels = byDay.map(function(d) { return formatDate(d.date); }).reverse();
var data = byDay.map(function(d) { return (d.cost_cents / 100).toFixed(2); }).reverse();
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Coût (€)',
data: data,
backgroundColor: 'rgba(30,136,229,0.6)',
borderColor: '#1e88e5',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#8b949e', callback: function(v) { return v + '€'; } }
},
x: {
grid: { display: false },
ticks: { color: '#8b949e', maxTicksLimit: 10 }
}
}
}
});
}
function renderProviderChart(byProvider) {
var ctx = document.getElementById('providerChart').getContext('2d');
var labels = byProvider.map(function(p) { return p.provider; });
var data = byProvider.map(function(p) { return p.cost_cents / 100; });
var colors = ['#00c853', '#1e88e5', '#ffd600', '#7c3aed', '#ff6d00', '#f85149'];
new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: colors.slice(0, labels.length),
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: { color: '#8b949e', padding: 12, font: { size: 11 } }
},
tooltip: {
callbacks: {
label: function(ctx) {
return ctx.label + ': ' + ctx.parsed.toFixed(2) + '€';
}
}
}
}
}
});
}
function renderCallsChart(byDay) {
var ctx = document.getElementById('callsChart').getContext('2d');
var labels = byDay.map(function(d) { return formatDate(d.date); }).reverse();
var data = byDay.map(function(d) { return d.calls; }).reverse();
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Appels',
data: data,
backgroundColor: 'rgba(124,58,237,0.5)',
borderColor: '#7c3aed',
borderWidth: 1,
borderRadius: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#8b949e' }
},
x: {
grid: { display: false },
ticks: { color: '#8b949e', maxTicksLimit: 10 }
}
}
}
});
}
async function loadData() {
var range = getDateRange(currentPeriod);
document.getElementById('loading').style.display = '';
document.getElementById('dashboard-content').style.display = 'none';
try {
var url = '/api/v1/consumption/stats?client_id=internal&start_date=' + range.start + '&end_date=' + range.end;
var r = await fetch(url);
statsData = await r.json();
document.getElementById('last-refresh').textContent = '🔄 ' + new Date().toLocaleTimeString('fr-FR');
document.getElementById('loading').style.display = 'none';
document.getElementById('dashboard-content').style.display = '';
var totals = statsData.totals || {};
var byDay = statsData.by_day || [];
var byProvider = statsData.by_provider || [];
renderStats(totals);
checkAlerts(totals);
if (byDay.length > 0) {
renderTokensChart(byDay);
renderCostChart(byDay);
renderCallsChart(byDay);
} else {
['tokensChart','costChart','callsChart'].forEach(function(id) {
var ctx = document.getElementById(id).getContext('2d');
new Chart(ctx, {
type: 'line',
data: { labels: ['Aucune donnée'], datasets: [{ data: [0], backgroundColor: 'rgba(139,148,158,0.2)', borderColor: '#8b949e' }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
});
});
}
if (byProvider.length > 0) {
renderProviderChart(byProvider);
} else {
var ctx = document.getElementById('providerChart').getContext('2d');
new Chart(ctx, { type: 'doughnut', data: { labels: ['Aucune donnée'], datasets: [{ data: [1], backgroundColor: ['#8b949e'], borderWidth: 0 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#8b949e' } } } } });
}
} catch (e) {
document.getElementById('loading').innerHTML = '<div style="color:var(--error);padding:40px">❌ Erreur de chargement: ' + e.message + '</div>';
}
}
loadData();
setInterval(loadData, 30000);
</script>
</body>
</html>

327
consumption_history.html Normal file
View File

@@ -0,0 +1,327 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Consommation IA — Historique</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
--gold: #ffd600; --orange: #ff6d00;
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
--radius: 10px; --error: #f85149; --purple: #7c3aed;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; }
a { color: inherit; text-decoration: none; }
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 16px 28px; border-bottom: 1px solid var(--border); background: var(--dark); position: sticky; top: 0; z-index: 10; flex-wrap: wrap; gap: 10px; }
.topbar-title { font-size: 1.1rem; font-weight: 700; display: flex; align-items: center; gap: 10px; }
.topbar-title a { color: var(--muted); font-weight: 400; font-size: .9rem; }
.topbar-title a:hover { color: var(--text); }
.topbar-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; border-radius: 8px; font-size: .85rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
.btn-primary { background: var(--green); color: #000; }
.btn-primary:hover { background: var(--green-d); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover { border-color: var(--muted); }
.btn-sm { padding: 5px 12px; font-size: .8rem; }
.content { padding: 28px; max-width: 1400px; margin: 0 auto; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
.filter-group { display: flex; flex-direction: column; gap: 4px; }
.filter-group label { font-size: .72rem; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; font-weight: 600; }
.filter-group select, .filter-group input {
background: var(--dark3); border: 1px solid var(--border); border-radius: 8px;
padding: 8px 12px; color: var(--text); font-size: .85rem; outline: none;
transition: border-color .2s; font-family: inherit;
}
.filter-group select:focus, .filter-group input:focus { border-color: var(--green); }
.table-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; }
thead th { padding: 10px 14px; font-size: .75rem; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); text-align: left; border-bottom: 1px solid var(--border); background: var(--dark3); white-space: nowrap; cursor: pointer; user-select: none; }
thead th:hover { color: var(--text); }
tbody tr { border-bottom: 1px solid var(--border); transition: background .15s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--dark3); }
tbody td { padding: 10px 14px; font-size: .85rem; white-space: nowrap; }
.status-badge { padding: 2px 8px; border-radius: 10px; font-size: .72rem; font-weight: 700; }
.status-success { background: rgba(0,200,83,.15); color: var(--green); }
.status-error { background: rgba(248,81,73,.15); color: var(--error); }
.pagination { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 16px; flex-wrap: wrap; }
.page-btn { padding: 6px 14px; border-radius: 6px; font-size: .85rem; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--muted); transition: all .15s; }
.page-btn:hover { border-color: var(--muted); color: var(--text); }
.page-btn.active { background: var(--green); color: #000; border-color: var(--green); }
.page-btn:disabled { opacity: .4; cursor: default; }
.page-info { font-size: .82rem; color: var(--muted); }
.loading { text-align: center; padding: 60px 20px; color: var(--muted); }
.loading .spinner { display: inline-block; width: 32px; height: 32px; border: 3px solid var(--border); border-top-color: var(--green); border-radius: 50%; animation: spin .8s linear infinite; margin-bottom: 12px; }
@keyframes spin { to { transform: rotate(360deg); } }
.home-btn{position:fixed;top:10px;left:10px;z-index:9999;background:linear-gradient(135deg,#00d9ff,#7b2cbf);color:#fff;border:none;border-radius:8px;padding:10px 15px;font-size:14px;cursor:pointer;text-decoration:none;display:flex;align-items:center;gap:8px;box-shadow:0 2px 10px rgba(0,217,255,0.3);transition:all 0.3s;font-family:-apple-system,BlinkMacSystemFont,sans-serif}.home-btn:hover{transform:translateY(-2px);box-shadow:0 4px 15px rgba(0,217,255,0.5)}
.empty-state { text-align: center; padding: 60px 20px; color: var(--muted); }
.empty-state .icon { font-size: 2.5rem; margin-bottom: 12px; }
.empty-state p { font-size: .9rem; }
.toast {
position: fixed; bottom: 20px; right: 20px; z-index: 9999;
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px 20px; font-size: .88rem; box-shadow: 0 8px 24px rgba(0,0,0,.4);
display: none; max-width: 400px;
}
.toast.show { display: block; animation: slideIn .3s ease; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
.toast-success { border-color: var(--green); }
.toast-error { border-color: var(--error); }
</style>
</head>
<body>
<a href="/" class="home-btn" title="Retour au portail"><span style="font-size:18px">🏠</span><span>Accueil</span></a>
<div class="topbar">
<div class="topbar-title">
<span>📋 Historique Consommation</span>
<a href="/dashboard/consumption">← Dashboard</a>
</div>
<div class="topbar-right">
<button class="btn btn-ghost btn-sm" onclick="exportCSV()">📥 Export CSV</button>
<button class="btn btn-ghost btn-sm" onclick="loadHistory()">🔄 Rafraîchir</button>
</div>
</div>
<div class="content">
<div class="filter-bar">
<div class="filter-group">
<label>Provider</label>
<select id="filter-provider" onchange="loadHistory()">
<option value="">Tous</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="google">Google</option>
<option value="mistral">Mistral</option>
<option value="deepseek">DeepSeek</option>
<option value="meta">Meta</option>
</select>
</div>
<div class="filter-group">
<label>Statut</label>
<select id="filter-status" onchange="loadHistory()">
<option value="">Tous</option>
<option value="success">Succès</option>
<option value="error">Erreur</option>
</select>
</div>
<div class="filter-group">
<label>Du</label>
<input type="date" id="filter-date-from" onchange="loadHistory()">
</div>
<div class="filter-group">
<label>Au</label>
<input type="date" id="filter-date-to" onchange="loadHistory()">
</div>
<div class="filter-group" style="align-self:flex-end">
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('filter-provider').value='';document.getElementById('filter-status').value='';document.getElementById('filter-date-from').value='';document.getElementById('filter-date-to').value='';loadHistory()">✕ Réinitialiser</button>
</div>
</div>
<div class="loading" id="loading">
<div class="spinner"></div>
<div>Chargement de l'historique...</div>
</div>
<div class="table-card" id="table-wrap" style="display:none">
<div style="overflow-x:auto">
<table>
<thead>
<tr>
<th onclick="sortBy('created_at')">Date ⬍</th>
<th onclick="sortBy('provider')">Provider ⬍</th>
<th onclick="sortBy('model')">Modèle ⬍</th>
<th onclick="sortBy('tokens_in')">Tokens In ⬍</th>
<th onclick="sortBy('tokens_out')">Tokens Out ⬍</th>
<th onclick="sortBy('tokens_total')">Total ⬍</th>
<th onclick="sortBy('cost_cents')">Coût ⬍</th>
<th onclick="sortBy('latency_ms')">Latence ⬍</th>
<th onclick="sortBy('status')">Statut ⬍</th>
</tr>
</thead>
<tbody id="history-body"></tbody>
</table>
</div>
<div class="pagination" id="pagination"></div>
</div>
<div class="empty-state" id="empty-state" style="display:none">
<div class="icon">📭</div>
<p>Aucune donnée de consommation pour cette période.</p>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
var currentPage = 1;
var totalPages = 0;
var totalItems = 0;
var historyData = [];
var sortField = 'created_at';
var sortDir = 'desc';
function formatDate(isoStr) {
var d = new Date(isoStr);
return d.toLocaleDateString('fr-FR', {day:'2-digit', month:'short', year:'numeric'}) + ' ' +
d.toLocaleTimeString('fr-FR', {hour:'2-digit', minute:'2-digit'});
}
function formatNumber(n) {
if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
if (n >= 1000) return (n/1000).toFixed(1) + 'k';
return n.toLocaleString('fr-FR');
}
function showToast(msg, type) {
var t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show toast-' + type;
setTimeout(function() { t.classList.remove('show'); }, 4000);
}
function sortBy(field) {
if (sortField === field) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortDir = 'desc';
}
renderTable();
}
function renderTable() {
var tbody = document.getElementById('history-body');
var sorted = [...historyData].sort(function(a, b) {
var va = a[sortField], vb = b[sortField];
if (typeof va === 'string') va = va.toLowerCase();
if (typeof vb === 'string') vb = vb.toLowerCase();
if (va < vb) return sortDir === 'asc' ? -1 : 1;
if (va > vb) return sortDir === 'asc' ? 1 : -1;
return 0;
});
var html = '';
sorted.forEach(function(r) {
var cost = ((r.cost_cents || 0) / 100);
var statusClass = r.status === 'success' ? 'status-success' : 'status-error';
html += '<tr>' +
'<td>' + formatDate(r.created_at) + '</td>' +
'<td>' + (r.provider || '—') + '</td>' +
'<td>' + (r.model || '—') + '</td>' +
'<td>' + formatNumber(r.tokens_in || 0) + '</td>' +
'<td>' + formatNumber(r.tokens_out || 0) + '</td>' +
'<td><strong>' + formatNumber(r.tokens_total || 0) + '</strong></td>' +
'<td>' + cost.toFixed(4) + '€</td>' +
'<td>' + (r.latency_ms ? r.latency_ms + 'ms' : '—') + '</td>' +
'<td><span class="status-badge ' + statusClass + '">' + (r.status || '—') + '</span></td>' +
'</tr>';
});
if (!html) {
html = '<tr><td colspan="9" style="text-align:center;padding:40px;color:var(--muted)">Aucun résultat</td></tr>';
}
tbody.innerHTML = html;
}
function renderPagination() {
var el = document.getElementById('pagination');
if (totalPages <= 1) { el.innerHTML = ''; return; }
var html = '<span class="page-info">Page ' + currentPage + ' / ' + totalPages + ' (' + totalItems + ' entrées)</span>';
html += '<button class="page-btn" onclick="goPage(1)" ' + (currentPage <= 1 ? 'disabled' : '') + '>«</button>';
html += '<button class="page-btn" onclick="goPage(' + (currentPage - 1) + ')" ' + (currentPage <= 1 ? 'disabled' : '') + '></button>';
var start = Math.max(1, currentPage - 2);
var end = Math.min(totalPages, currentPage + 2);
for (var i = start; i <= end; i++) {
html += '<button class="page-btn' + (i === currentPage ? ' active' : '') + '" onclick="goPage(' + i + ')">' + i + '</button>';
}
html += '<button class="page-btn" onclick="goPage(' + (currentPage + 1) + ')" ' + (currentPage >= totalPages ? 'disabled' : '') + '></button>';
html += '<button class="page-btn" onclick="goPage(' + totalPages + ')" ' + (currentPage >= totalPages ? 'disabled' : '') + '>»</button>';
el.innerHTML = html;
}
function goPage(page) {
if (page < 1 || page > totalPages) return;
currentPage = page;
loadHistory();
}
async function loadHistory() {
document.getElementById('loading').style.display = '';
document.getElementById('table-wrap').style.display = 'none';
document.getElementById('empty-state').style.display = 'none';
var params = 'client_id=internal&page=' + currentPage + '&per_page=25';
var provider = document.getElementById('filter-provider').value;
var status = document.getElementById('filter-status').value;
var dateFrom = document.getElementById('filter-date-from').value;
var dateTo = document.getElementById('filter-date-to').value;
if (provider) params += '&provider=' + encodeURIComponent(provider);
if (dateFrom) params += '&start_date=' + dateFrom;
if (dateTo) params += '&end_date=' + dateTo;
try {
var r = await fetch('/api/v1/consumption/history?' + params);
var data = await r.json();
var items = data.history || [];
totalItems = data.total || 0;
totalPages = data.total_pages || 0;
document.getElementById('loading').style.display = 'none';
if (items.length === 0) {
document.getElementById('empty-state').style.display = '';
return;
}
document.getElementById('table-wrap').style.display = '';
historyData = items;
renderTable();
renderPagination();
} catch (e) {
document.getElementById('loading').innerHTML = '<div style="color:var(--error);padding:40px">❌ Erreur: ' + e.message + '</div>';
}
}
function exportCSV() {
if (!historyData || historyData.length === 0) {
showToast('Aucune donnée à exporter', 'error');
return;
}
var headers = ['Date','Provider','Modèle','Tokens In','Tokens Out','Tokens Total','Coût (€)','Latence (ms)','Statut'];
var rows = historyData.map(function(r) {
return [
r.created_at || '',
r.provider || '',
r.model || '',
r.tokens_in || 0,
r.tokens_out || 0,
r.tokens_total || 0,
((r.cost_cents || 0) / 100).toFixed(4),
r.latency_ms || '',
r.status || ''
].join(',');
});
var csv = '\uFEFF' + headers.join(',') + '\n' + rows.join('\n');
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
var link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'consommation_ia_' + new Date().toISOString().split('T')[0] + '.csv';
link.click();
showToast('CSV téléchargé', 'success');
}
loadHistory();
</script>
</body>
</html>

View File

@@ -755,6 +755,63 @@ def turf_static(filename):
return send_from_directory("/home/h3r7/turf_saas", filename)
# --- Consumption Dashboard (HRT-226) ---
CONSUMPTION_API_URL = "http://localhost:8784"
CONSUMPTION_API_KEY = os.environ.get("CONSUMPTION_API_KEY", "dev-key-change-in-production")
@app.route("/dashboard/consumption")
def consumption_dashboard():
return send_from_directory(SAAS_DIR, "consumption_dashboard.html")
@app.route("/dashboard/consumption/history")
def consumption_history():
return send_from_directory(SAAS_DIR, "consumption_history.html")
@app.route("/dashboard/consumption/alerts")
def consumption_alerts():
return send_from_directory(SAAS_DIR, "consumption_alerts.html")
# Proxy: /api/v1/consumption/* -> consumption-tracker (port 8784)
@app.route("/api/v1/consumption/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
def proxy_consumption_api(subpath):
full_url = f"{CONSUMPTION_API_URL}/api/v1/consumption/{subpath}"
if request.query_string:
full_url += "?" + request.query_string.decode()
try:
headers = {k: v for k, v in request.headers if k.lower() not in ("host", "content-length", "transfer-encoding", "connection")}
if not any(k.lower() == "authorization" for k in headers):
headers["Authorization"] = f"Bearer {CONSUMPTION_API_KEY}"
raw_body = request.get_data()
resp = requests.request(request.method, full_url, headers=headers, data=raw_body, cookies=request.cookies, allow_redirects=False, timeout=15)
response = make_response(resp.content, resp.status_code)
for k, v in resp.headers.items():
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
response.headers[k] = v
return response
except Exception as e:
return jsonify({"error": f"Consumption proxy error: {e}"}), 502
# Proxy: /api/v1/ai/usage* -> AI Router (port 8783)
@app.route("/api/v1/ai/usage")
@app.route("/api/v1/ai/usage/<path:subpath>")
def proxy_ai_usage(subpath=""):
full_url = f"http://localhost:8783/api/v1/ai/usage"
if subpath:
full_url += "/" + subpath
if request.query_string:
full_url += "?" + request.query_string.decode()
try:
resp = requests.get(full_url, timeout=15)
return resp.content, resp.status_code, {"Content-Type": "application/json"}
except Exception as e:
return jsonify({"error": f"AI usage proxy error: {e}"}), 502
# --- POD Routes ---
@app.route("/pod/")
@app.route("/pod/<path:filename>")

255
tests/test_ai_router.py Normal file
View File

@@ -0,0 +1,255 @@
"""Unit tests for AI Router — router, providers, models, API."""
import json
import os
import tempfile
import pytest
from unittest.mock import patch, MagicMock
TEST_DB = os.path.join(tempfile.mkdtemp(), "test_ai_router.db")
os.environ["AI_ROUTER_DB"] = TEST_DB
os.environ["OPENAI_API_KEY"] = "sk-test-openai"
os.environ["ANTHROPIC_API_KEY"] = "sk-test-anthropic"
os.environ["GOOGLE_API_KEY"] = "sk-test-google"
os.environ["MISTRAL_API_KEY"] = "sk-test-mistral"
@pytest.fixture(autouse=True)
def clean_db():
yield
try:
os.remove(TEST_DB)
except OSError:
pass
@pytest.fixture
def app():
from ai_router_api import create_app
app = create_app()
app.config["TESTING"] = True
return app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def router():
from ai_router.router import AIRouter
return AIRouter()
# ─────────────────────────────────────────────
# Provider Base Tests
# ─────────────────────────────────────────────
class TestProviderInterface:
def test_provider_map_has_all(self):
from ai_router.providers import PROVIDER_MAP
assert "openai" in PROVIDER_MAP
assert "anthropic" in PROVIDER_MAP
assert "google" in PROVIDER_MAP
assert "mistral" in PROVIDER_MAP
def test_api_key_resolution_env(self):
from ai_router.providers.base import AIProvider
class TestProvider(AIProvider):
@property
def name(self): return "openai"
def chat(self, messages, model, **kwargs): return {}
def models(self): return []
def check_health(self): return {"status": "ok"}
p = TestProvider()
assert p.get_api_key() == "sk-test-openai"
def test_api_key_resolution_db_overrides_env(self):
from ai_router.providers.base import AIProvider
class TestProvider(AIProvider):
@property
def name(self): return "openai"
def chat(self, messages, model, **kwargs): return {}
def models(self): return []
def check_health(self): return {"status": "ok"}
p = TestProvider()
assert p.get_api_key({"api_key": "sk-db-key"}) == "sk-db-key"
# ─────────────────────────────────────────────
# Router Tests
# ─────────────────────────────────────────────
class TestRouter:
def test_resolve_known_model(self, router):
info = router._resolve_model("gpt-4o")
assert info is not None
assert info["provider"] == "openai"
assert info["real_model"] == "gpt-4o"
def test_resolve_unknown_model(self, router):
info = router._resolve_model("nonexistent-model")
assert info is None
def test_list_models_includes_defaults(self, router):
models = router.list_available_models()
aliases = [m["alias"] for m in models]
assert "gpt-4o" in aliases
assert "claude-3-opus" in aliases
assert "gemini-pro" in aliases
assert "mistral-large" in aliases
def test_prioritized_providers_default_order(self, router):
providers = router._get_prioritized_providers()
names = [p[0] for p in providers]
assert names == ["openai", "anthropic", "google", "mistral"]
def test_chat_unknown_model(self, router):
result = router.chat([{"role": "user", "content": "hi"}], "unknown-model")
assert result["status"] == "error"
@patch("ai_router.providers.openai_adapter.OpenAIAdapter.chat")
def test_chat_success_first_provider(self, mock_chat, router):
mock_chat.return_value = {
"content": "Hello!",
"model": "gpt-4o",
"provider": "openai",
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
result = router.chat([{"role": "user", "content": "hi"}], "gpt-4o")
assert result["status"] == "success"
assert result["content"] == "Hello!"
assert result["provider"] == "openai"
@patch("ai_router.providers.openai_adapter.OpenAIAdapter.chat")
@patch("ai_router.providers.anthropic_adapter.AnthropicAdapter.chat")
def test_chat_failover_to_second_provider(self, mock_anthropic, mock_openai, router):
mock_openai.side_effect = Exception("OpenAI down")
mock_anthropic.return_value = {
"content": "Hello from Anthropic!",
"model": "claude-3-sonnet-20240229",
"provider": "anthropic",
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
result = router.chat([{"role": "user", "content": "hi"}], "claude-3-sonnet")
assert result["status"] == "success"
assert result["provider"] == "anthropic"
@patch("ai_router.providers.openai_adapter.OpenAIAdapter.chat")
@patch("ai_router.providers.anthropic_adapter.AnthropicAdapter.chat")
@patch("ai_router.providers.google_adapter.GoogleAdapter.chat")
@patch("ai_router.providers.mistral_adapter.MistralAdapter.chat")
def test_chat_all_providers_fail(self, mock_mistral, mock_google, mock_anthropic, mock_openai, router):
for mock in (mock_openai, mock_anthropic, mock_google, mock_mistral):
mock.side_effect = Exception("Provider unavailable")
result = router.chat([{"role": "user", "content": "hi"}], "gpt-4o")
assert result["status"] == "error"
assert "All providers failed" in result["error"]
def test_health_all_providers_returns_dict(self, router):
health = router.check_all_providers_health()
for name in ("openai", "anthropic", "google", "mistral"):
assert name in health
assert "status" in health[name]
# ─────────────────────────────────────────────
# Database Tests
# ─────────────────────────────────────────────
class TestModels:
def test_init_db_creates_tables(self):
from ai_router.models import init_db, get_db
init_db()
conn = get_db()
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
names = [r[0] for r in tables]
assert "ai_providers" in names
assert "ai_model_mapping" in names
assert "ai_router_log" in names
conn.close()
def test_upsert_and_get_providers(self):
from ai_router.models import init_db, upsert_provider, get_providers_from_db
init_db()
upsert_provider("Test OpenAI", "openai", "sk-test", priority=1)
providers = get_providers_from_db()
assert len(providers) > 0
assert any(p["name"] == "Test OpenAI" for p in providers)
def test_log_router_attempt(self):
from ai_router.models import init_db, log_router_attempt, get_db
init_db()
log_router_attempt("req-1", 42, "gpt-4o", "openai", 10, 5, 200, "success")
conn = get_db()
rows = conn.execute("SELECT * FROM ai_router_log").fetchall()
assert len(rows) == 1
assert rows[0]["status"] == "success"
conn.close()
# ─────────────────────────────────────────────
# API Blueprint Tests
# ─────────────────────────────────────────────
class TestAPI:
def test_health_endpoint(self, client):
resp = client.get("/api/v1/ai/health")
assert resp.status_code in (200, 503)
data = resp.get_json()
assert data["service"] == "ai-router"
assert "providers" in data
def test_models_endpoint(self, client):
resp = client.get("/api/v1/ai/models")
assert resp.status_code == 200
data = resp.get_json()
assert "models" in data
assert len(data["models"]) > 0
def test_chat_no_auth(self, client):
resp = client.post(
"/api/v1/ai/chat",
json={"messages": [{"role": "user", "content": "hi"}], "model": "gpt-4o"},
)
assert resp.status_code == 401
def test_chat_no_messages(self, client):
resp = client.post(
"/api/v1/ai/chat",
json={"model": "gpt-4o"},
headers={"X-API-Key": "test-key"},
)
assert resp.status_code == 401
@patch("ai_router.utils.verify_token_via_broker")
@patch("ai_router.providers.openai_adapter.OpenAIAdapter.chat")
def test_chat_success_with_auth(self, mock_chat, mock_verify, client):
mock_verify.return_value = {"valid": True, "user_id": 1, "scopes": ["user"]}
mock_chat.return_value = {
"content": "Hello!",
"model": "gpt-4o",
"provider": "openai",
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
resp = client.post(
"/api/v1/ai/chat",
json={"messages": [{"role": "user", "content": "hi"}], "model": "gpt-4o"},
headers={"Authorization": "Bearer test-token"},
)
data = resp.get_json()
assert resp.status_code == 200, f"Got {resp.status_code}: {data}"
assert data["status"] == "success"
def test_usage_requires_admin(self, client):
resp = client.get("/api/v1/ai/usage")
assert resp.status_code == 401