KTI / ai_engine.py
noranisa's picture
Upload 63 files
b66f2ff verified
Raw
History Blame Contribute Delete
9.82 kB
"""
ai_engine.py β€” BIRAS Unified AI Gateway
========================================
Single entry point for ALL LLM calls in the system.
Usage:
from ai_engine import call_ai
result = call_ai(
prompt="Explain this trend...",
model='openai', # openai | gemini | deepseek | zai | groq | openrouter | auto
max_tokens=300,
fallback=True, # try next provider if this one fails
)
Design:
- Provider-agnostic interface
- Automatic fallback chain: openai β†’ gemini β†’ deepseek
- In-process LRU cache (keyed on prompt hash + model)
- Rate-limit safe (exponential back-off, max 2 retries)
- Returns str | None (never raises)
"""
from __future__ import annotations
import os
import time
import hashlib
import logging
from functools import lru_cache
from typing import Any, Optional
logger = logging.getLogger('biras.ai')
# ── Provider registry ──────────────────────────────────────────────────────────
_PROVIDERS = {
'openai': {
'env_key': 'OPENAI_API_KEY',
'base_url': None,
'model': 'gpt-4o-mini',
},
'deepseek': {
'env_key': 'DEEPSEEK_API_KEY',
'base_url': 'https://api.deepseek.com',
'model': 'deepseek-chat',
},
'zai': {
'env_key': 'ZAI_API_KEY',
'base_url': lambda: os.environ.get('ZAI_BASE_URL', 'https://api.zai.ai'),
'model': 'glm-4-plus',
},
'groq': {
'env_key': 'GROQ_API_KEY',
'base_url': 'https://api.groq.com/openai/v1',
'model': 'llama-3.3-70b-versatile',
},
'openrouter': {
'env_key': 'OPENROUTER_API_KEY',
'base_url': 'https://openrouter.ai/api/v1',
'model': 'openrouter/free',
},
'github': {
'env_key': 'GITHUB_API_KEY',
'base_url': 'https://models.inference.ai.azure.com',
'model': 'gpt-4o',
},
'huggingface': {
'env_key': 'HUGGINGFACE_API_KEY',
'base_url': 'https://router.huggingface.co/v1',
'model': 'Qwen/Qwen2.5-72B-Instruct',
},
'sambanova': {
'env_key': 'SAMBANOVA_API_KEY',
'base_url': 'https://api.sambanova.ai/v1',
'model': 'Meta-Llama-3.3-70B-Instruct',
},
'cerebras': {
'env_key': 'CEREBRAS_API_KEY',
'base_url': 'https://api.cerebras.ai/v1',
'model': 'llama3.3-70b',
},
'together': {
'env_key': 'TOGETHER_API_KEY',
'base_url': 'https://api.together.xyz/v1',
'model': 'meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo',
},
'mistral': {
'env_key': 'MISTRAL_API_KEY',
'base_url': 'https://api.mistral.ai/v1',
'model': 'mistral-small-latest',
},
}
_FALLBACK_CHAIN = ['cerebras', 'groq', 'github', 'together', 'mistral', 'gemini', 'openai', 'sambanova', 'deepseek', 'openrouter', 'huggingface', 'zai']
# Simple in-memory cache: {hash β†’ response_text}
_AI_CACHE: dict[str, str] = {}
# OpenAI client cache: avoid re-initializing on every call
_CLIENT_CACHE: dict[str, Any] = {}
def _cache_key(prompt: str, model: str) -> str:
return hashlib.md5(f"{model}::{prompt}".encode(), usedforsecurity=False).hexdigest()
def _get_openai_client(api_key: str, base_url: Optional[str]) -> Any:
"""Return a cached OpenAI client. Creates a new one only when key/url changes."""
from openai import OpenAI
cache_key = f"{api_key[:12]}::{base_url or 'default'}"
if cache_key not in _CLIENT_CACHE:
kwargs: dict = {'api_key': api_key}
if base_url:
kwargs['base_url'] = base_url
_CLIENT_CACHE[cache_key] = OpenAI(**kwargs)
return _CLIENT_CACHE[cache_key]
# ─────────────────────────────────────────────────────────────────────────────
def _call_openai_compatible(
prompt: str,
api_key: str,
base_url: Optional[str],
model: str,
max_tokens: int,
) -> Optional[str]:
"""Call any OpenAI-compatible REST API (OpenAI, DeepSeek, ZAI)."""
try:
# Re-use cached client β€” avoids TCP session overhead on every call
client = _get_openai_client(api_key, base_url)
resp = client.chat.completions.create(
model=model,
messages=[
{
'role': 'system',
'content': (
'You are an expert academic researcher and scientific writer. '
'Respond in formal English. Be concise and precise.'
),
},
{'role': 'user', 'content': prompt},
],
max_tokens=max_tokens,
temperature=0.4,
)
text = resp.choices[0].message.content or ''
return text.strip() or None
except Exception as exc:
logger.warning("OpenAI-compatible call failed (model=%s): %s", model, exc)
return None
def _call_gemini(prompt: str, max_tokens: int) -> Optional[str]:
"""Call Google Gemini API."""
api_key = os.environ.get('GEMINI_API_KEY', '')
if not api_key:
return None
try:
import google.generativeai as genai
genai.configure(api_key=api_key)
model = genai.GenerativeModel('gemini-2.0-flash')
resp = model.generate_content(
prompt,
generation_config=genai.types.GenerationConfig(
max_output_tokens=max_tokens,
temperature=0.4,
),
)
text = resp.text or ''
return text.strip() or None
except Exception as exc:
logger.warning("Gemini call failed: %s", exc)
return None
def _try_provider(provider: str, prompt: str, max_tokens: int) -> Optional[str]:
"""Dispatch to a specific provider. Returns None on failure."""
if provider == 'gemini':
return _call_gemini(prompt, max_tokens)
cfg = _PROVIDERS.get(provider)
if not cfg:
logger.warning("Unknown AI provider: %s", provider)
return None
api_key = os.environ.get(cfg['env_key'], '')
if not api_key:
logger.debug("Provider '%s' skipped (no API key)", provider)
return None
base_url = cfg['base_url']
if callable(base_url):
base_url = base_url()
return _call_openai_compatible(
prompt=prompt,
api_key=api_key,
base_url=base_url,
model=cfg['model'],
max_tokens=max_tokens,
)
# ─────────────────────────────────────────────────────────────────────────────
def call_ai(
prompt: str,
model: str = 'auto',
max_tokens: int = 400,
fallback: bool = True,
use_cache: bool = True,
retries: int = 1,
) -> Optional[str]:
"""
Unified AI gateway for BIRAS.
Args:
prompt: The text prompt to send.
model: 'openai' | 'gemini' | 'deepseek' | 'zai' | 'groq' | 'openrouter' | 'github' | 'huggingface' | 'sambanova' | 'auto'
'auto' β†’ tries DEFAULT_AI_MODEL, then fallback chain.
max_tokens: Maximum response tokens.
fallback: If True and primary model fails, try fallback chain.
use_cache: If True, cache responses in memory (keyed on prompt hash).
retries: Number of retry attempts with exponential back-off.
Returns:
Response string, or None if all providers fail.
"""
if not prompt or len(prompt.strip()) < 5:
return None
# ── Cache lookup ────────────────────────────────────────────────────
cache_enabled = use_cache and os.getenv('ENABLE_AI_CACHE', 'true').lower() == 'true'
key = _cache_key(prompt, model)
if cache_enabled and key in _AI_CACHE:
logger.debug("AI cache hit (model=%s, chars=%d)", model, len(prompt))
return _AI_CACHE[key]
# ── Build provider order ────────────────────────────────────────────
default_model = os.getenv('DEFAULT_AI_MODEL', 'openai')
if model == 'auto':
order = [default_model] + [p for p in _FALLBACK_CHAIN if p != default_model]
else:
order = [model] + (
[p for p in _FALLBACK_CHAIN if p != model] if fallback else []
)
# ── Try each provider ───────────────────────────────────────────────
for provider in order:
for attempt in range(retries + 1):
result = _try_provider(provider, prompt, max_tokens)
if result and len(result) > 10:
logger.info("AI response via %s (%d chars)", provider, len(result))
if cache_enabled:
_AI_CACHE[key] = result
return result
if attempt < retries:
wait = 2 ** attempt
logger.debug("Retrying provider %s in %ds...", provider, wait)
time.sleep(wait)
logger.warning("All AI providers failed for prompt: %s...", prompt[:60])
return None
def clear_ai_cache() -> int:
"""Clear the in-memory AI cache. Returns number of entries cleared."""
n = len(_AI_CACHE)
_AI_CACHE.clear()
logger.info("AI cache cleared (%d entries)", n)
return n