Spaces:
Running
Running
File size: 9,408 Bytes
0772b5a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 | import os
import re
import time
import uuid
import logging
from typing import List, Dict, Any, Optional, AsyncGenerator
import litellm
from llm.key_pool import APIKeyPool, get_key_pool
from llm.errors import ErrorClassifier, ErrorCategory
from llm.telemetry import telemetry, LLMTelemetryRecord
from config.settings import settings
logger = logging.getLogger(__name__)
# Suppress noisy LiteLLM logs
litellm.suppress_debug_info = True
class LLMService:
"""Core LLM Service providing LiteLLM Provider Abstraction, Key Pool Rotation, and First-Chunk Safe Streaming."""
def __init__(self, key_pool: Optional[APIKeyPool] = None):
self.key_pool = key_pool or get_key_pool()
def _parse_provider_and_model(self, raw_model: str) -> tuple[str, str]:
"""Extracts provider and normalized model identifier (e.g. 'gemini/gemini-2.5-flash' -> ('gemini', 'gemini/gemini-2.5-flash'))."""
raw = raw_model.strip()
if "/" in raw:
parts = raw.split("/", 1)
provider = parts[0].lower()
return provider, raw
else:
# Default to gemini if not prefixed
return "gemini", f"gemini/{raw}"
def _strip_thought_tags(self, text: str) -> str:
"""Strips <thought>...</thought> tags from thinking models for clean downstream consumption."""
if not text or not isinstance(text, str):
return text
cleaned = re.sub(r"<thought>[\s\S]*?</thought>", "", text, flags=re.IGNORECASE).strip()
cleaned = re.sub(r"<think>[\s\S]*?</think>", "", cleaned, flags=re.IGNORECASE).strip()
return cleaned
async def acomplete(
self,
model: str,
messages: List[Dict[str, Any]],
temperature: float = 0.2,
max_tokens: int = 8192,
timeout: int = 120,
response_format: Optional[Dict[str, Any]] = None,
reasoning_effort: Optional[str] = None,
agent_name: str = "general",
tier_index: int = 1,
**kwargs
) -> str:
"""
Executes an LLM completion with Level-1 Key Pool rotation and safe retry.
"""
provider, model_name = self._parse_provider_and_model(model)
request_id = str(uuid.uuid4())
max_key_retries = 3
last_error = None
for attempt in range(max_key_retries):
key_info = await self.key_pool.get_next_key(provider)
if not key_info:
# No keys available, attempt with default env or raise
api_key = os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY") or ""
key_hash = "env_default"
else:
api_key, key_hash = key_info
start_time = time.time()
try:
# For Gemini 3.x models, Google AI Studio recommends temperature=1.0 for optimal reasoning
eff_temperature = 1.0 if ("gemini-3" in model_name and temperature < 1.0) else temperature
litellm_kwargs: Dict[str, Any] = {
"model": model_name,
"messages": messages,
"temperature": eff_temperature,
"max_tokens": max_tokens,
"timeout": timeout,
"api_key": api_key,
}
if response_format:
litellm_kwargs["response_format"] = response_format
if reasoning_effort and "gemini" in model_name:
litellm_kwargs["reasoning_effort"] = reasoning_effort
# Forward custom kwargs
litellm_kwargs.update(kwargs)
response = await litellm.acompletion(**litellm_kwargs)
latency_ms = (time.time() - start_time) * 1000
# Mark key success
if key_info:
await self.key_pool.mark_success(api_key)
content = response.choices[0].message.content or ""
cleaned_content = self._strip_thought_tags(content)
# Token usage
in_tok = getattr(getattr(response, "usage", None), "prompt_tokens", 0)
out_tok = getattr(getattr(response, "usage", None), "completion_tokens", 0)
telemetry.log_record(
LLMTelemetryRecord(
request_id=request_id,
agent=agent_name,
tier=tier_index,
model=model_name,
provider=provider,
key_id=key_hash,
latency_ms=latency_ms,
input_tokens=in_tok,
output_tokens=out_tok,
status="success",
retry_count=attempt,
)
)
return cleaned_content
except Exception as e:
latency_ms = (time.time() - start_time) * 1000
category = ErrorClassifier.classify(e)
retry_after = ErrorClassifier.extract_retry_after(e)
last_error = e
logger.warning(
f"[LLMService] Request {request_id[:8]} failed on {key_hash} ({category.value}): {e}"
)
# Update key state according to category
if key_info:
if category == ErrorCategory.RATE_LIMIT:
await self.key_pool.mark_cooldown(api_key, retry_after=retry_after, error_msg=str(e))
elif category == ErrorCategory.QUOTA_EXHAUSTED:
await self.key_pool.mark_exhausted(api_key, error_msg=str(e))
elif category == ErrorCategory.AUTH_ERROR:
await self.key_pool.mark_disabled(api_key, error_msg=str(e))
else:
await self.key_pool.mark_cooldown(api_key, retry_after=15, error_msg=str(e))
telemetry.log_record(
LLMTelemetryRecord(
request_id=request_id,
agent=agent_name,
tier=tier_index,
model=model_name,
provider=provider,
key_id=key_hash,
latency_ms=latency_ms,
status="retry" if attempt < max_key_retries - 1 else "failed",
retry_count=attempt + 1,
error=str(e),
)
)
# Non-retryable request errors should immediately fail up to cascade
if category == ErrorCategory.INVALID_REQUEST:
raise
# All key retries exhausted for this tier
raise last_error or RuntimeError(f"All API key retries failed for model {model}")
async def astream(
self,
model: str,
messages: List[Dict[str, Any]],
temperature: float = 0.2,
max_tokens: int = 8192,
timeout: int = 120,
agent_name: str = "general",
**kwargs
) -> AsyncGenerator[str, None]:
"""
Streaming with first-chunk dry-run protection to prevent duplicated token generation upon key retry.
"""
provider, model_name = self._parse_provider_and_model(model)
max_key_retries = 3
for attempt in range(max_key_retries):
key_info = await self.key_pool.get_next_key(provider)
api_key = key_info[0] if key_info else (os.getenv("GOOGLE_API_KEY") or "")
key_hash = key_info[1] if key_info else "env_default"
try:
response = await litellm.acompletion(
model=model_name,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
api_key=api_key,
stream=True,
**kwargs
)
# Dry-run receive first chunk
first_chunk = None
async for chunk in response:
delta = chunk.choices[0].delta.content or ""
if delta:
first_chunk = delta
break
if key_info:
await self.key_pool.mark_success(api_key)
# First chunk succeeded, start yielding without retries
if first_chunk:
yield first_chunk
async for chunk in response:
delta = chunk.choices[0].delta.content or ""
if delta:
yield delta
return
except Exception as e:
category = ErrorClassifier.classify(e)
logger.warning(f"[LLMService.astream] Stream attempt {attempt+1} failed on {key_hash}: {e}")
if key_info:
await self.key_pool.mark_cooldown(api_key, error_msg=str(e))
if attempt == max_key_retries - 1:
raise
_GLOBAL_LLM_SERVICE: Optional[LLMService] = None
def get_llm_service() -> LLMService:
global _GLOBAL_LLM_SERVICE
if _GLOBAL_LLM_SERVICE is None:
_GLOBAL_LLM_SERVICE = LLMService()
return _GLOBAL_LLM_SERVICE
|