-
Notifications
You must be signed in to change notification settings - Fork 137
Shared LiteLLM helper for chat, embeddings, and Module B #1102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,215 @@ | ||
| """Shared LiteLLM wrapper for chat, embeddings, Module B, and GSoC/OIE. | ||
|
|
||
| PromptHandler stays DB-coupled (embedding contract + RAG). Everything else | ||
| that only needs a completion or embedding should call this module instead of | ||
| ``import litellm`` so retry, rate-limit detection, and response parsing stay | ||
| in one place. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from cre_logging import get_logger | ||
|
|
||
| logger = get_logger(__name__) | ||
|
|
||
| import os | ||
| import time | ||
| from typing import Any, Callable, List, Optional, Sequence | ||
|
|
||
| from application.prompt_client.llm_error_utils import is_rate_limit_error | ||
|
|
||
| LlmFn = Callable[[str, str], str] | ||
|
|
||
|
|
||
| def retry_policy() -> tuple[int, int]: | ||
| """Return (max_retries, sleep_seconds) from CRE_LLM_* env vars.""" | ||
| return ( | ||
| int(os.environ.get("CRE_LLM_MAX_RETRIES", "2")), | ||
| int(os.environ.get("CRE_LLM_RETRY_SLEEP_SECONDS", "15")), | ||
| ) | ||
|
|
||
|
|
||
| def get_litellm() -> Any: | ||
| """Import LiteLLM or raise the same RuntimeError PromptHandler uses.""" | ||
| try: | ||
| import litellm # type: ignore | ||
| except ImportError as exc: | ||
| raise RuntimeError( | ||
| "litellm package is required for PromptHandler LLM calls" | ||
| ) from exc | ||
| return litellm | ||
|
|
||
|
|
||
| def with_rate_limit_retry( | ||
| fn: Callable[[], Any], | ||
| *, | ||
| context: str, | ||
| max_retries: Optional[int] = None, | ||
| retry_sleep_seconds: Optional[int] = None, | ||
| ) -> Any: | ||
| default_retries, default_sleep = retry_policy() | ||
| retries = default_retries if max_retries is None else max_retries | ||
| sleep_s = default_sleep if retry_sleep_seconds is None else retry_sleep_seconds | ||
| for attempt in range(retries + 1): | ||
| try: | ||
| return fn() | ||
| except Exception as err: | ||
| if not is_rate_limit_error(err) or attempt >= retries: | ||
| raise | ||
| logger.info( | ||
| "rate/quota limited during %s; sleeping %ss (attempt %s/%s)", | ||
| context, | ||
| sleep_s, | ||
| attempt + 1, | ||
| retries + 1, | ||
| ) | ||
| time.sleep(sleep_s) | ||
| raise RuntimeError("unreachable: retry loop exited unexpectedly") | ||
|
|
||
|
|
||
| def extract_content_text(response: Any, *, strict: bool = True) -> str: | ||
| """Pull assistant text from a LiteLLM completion (object or dict).""" | ||
| choices = getattr(response, "choices", None) | ||
| if not choices and isinstance(response, dict): | ||
| choices = response.get("choices") | ||
| if not choices: | ||
| if strict: | ||
| raise ValueError("LLM response did not contain choices") | ||
| return "" | ||
| first = choices[0] | ||
| msg = getattr(first, "message", None) | ||
| if msg is None and isinstance(first, dict): | ||
| msg = first.get("message") | ||
| if msg is None: | ||
| if strict: | ||
| raise ValueError("LLM response did not contain message content") | ||
| return "" | ||
| content = getattr(msg, "content", None) | ||
| if content is None and isinstance(msg, dict): | ||
| content = msg.get("content") | ||
| if content is None: | ||
| if strict: | ||
| raise ValueError("LLM response did not contain message content") | ||
| return "" | ||
| if isinstance(content, list): | ||
| return "".join( | ||
| part.get("text", "") if isinstance(part, dict) else str(part) | ||
| for part in content | ||
| ).strip() | ||
| return str(content).strip() | ||
|
|
||
|
|
||
| def extract_embeddings(response: Any) -> List[List[float]]: | ||
| data = getattr(response, "data", None) | ||
| if data is None and isinstance(response, dict): | ||
| data = response.get("data") | ||
| if not isinstance(data, list): | ||
| raise ValueError("Embedding response missing data list") | ||
| vectors: List[List[float]] = [] | ||
| for item in data: | ||
| emb = getattr(item, "embedding", None) | ||
| if emb is None and isinstance(item, dict): | ||
| emb = item.get("embedding") | ||
| if not isinstance(emb, list): | ||
| raise ValueError("Embedding item missing vector") | ||
| vectors.append([float(x) for x in emb]) | ||
| return vectors | ||
|
|
||
|
|
||
| def completion( | ||
| *, | ||
| model: str, | ||
| messages: Sequence[dict[str, Any]], | ||
| context: str = "LiteLLM completion", | ||
| client: Any = None, | ||
| max_retries: Optional[int] = None, | ||
| retry_sleep_seconds: Optional[int] = None, | ||
| **kwargs: Any, | ||
| ) -> Any: | ||
| """LiteLLM chat completion with shared rate-limit retry.""" | ||
| llm = client if client is not None else get_litellm() | ||
|
|
||
| def _call() -> Any: | ||
| return llm.completion(model=model, messages=list(messages), **kwargs) | ||
|
|
||
| return with_rate_limit_retry( | ||
| _call, | ||
| context=context, | ||
| max_retries=max_retries, | ||
| retry_sleep_seconds=retry_sleep_seconds, | ||
| ) | ||
|
|
||
|
|
||
| def embedding( | ||
| *, | ||
| model: str, | ||
| input: str | List[str], | ||
| context: str = "LiteLLM embeddings", | ||
| client: Any = None, | ||
| max_retries: Optional[int] = None, | ||
| retry_sleep_seconds: Optional[int] = None, | ||
| **kwargs: Any, | ||
| ) -> Any: | ||
| llm = client if client is not None else get_litellm() | ||
|
|
||
| def _call() -> Any: | ||
| return llm.embedding(model=model, input=input, **kwargs) | ||
|
|
||
| return with_rate_limit_retry( | ||
| _call, | ||
| context=context, | ||
| max_retries=max_retries, | ||
| retry_sleep_seconds=retry_sleep_seconds, | ||
| ) | ||
|
|
||
|
|
||
| def completion_text( | ||
| *, | ||
| model: str, | ||
| messages: Sequence[dict[str, Any]], | ||
| strict: bool = True, | ||
| **kwargs: Any, | ||
| ) -> str: | ||
| return extract_content_text( | ||
| completion(model=model, messages=messages, **kwargs), | ||
| strict=strict, | ||
| ) | ||
|
|
||
|
|
||
| def system_user_fn( | ||
| model: str, | ||
| *, | ||
| temperature: float = 0.0, | ||
| extra_try_kwargs: Optional[dict[str, Any]] = None, | ||
| **kwargs: Any, | ||
| ) -> LlmFn: | ||
| """Build ``(system, user) -> text`` for Librarian / OIE call sites.""" | ||
|
|
||
| def _call(system: str, user: str) -> str: | ||
| messages = [ | ||
| {"role": "system", "content": system}, | ||
| {"role": "user", "content": user}, | ||
| ] | ||
| if extra_try_kwargs: | ||
| try: | ||
| return completion_text( | ||
| model=model, | ||
| messages=messages, | ||
| temperature=temperature, | ||
| **extra_try_kwargs, | ||
| **kwargs, | ||
| ) | ||
| except Exception: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: python - <<'PY'
from pathlib import Path
for p,a,b in [
("application/prompt_client/litellm_router.py",179,215),
("application/prompt_client/prompt_client.py",810,844),
("application/tests/test_smart_embeddings_e2e_llm.py",55,75),
]:
print(f"\n--- {p}:{a}-{b} ---")
lines=Path(p).read_text().splitlines()
for n in range(a,b+1):
print(f"{n}: {lines[n-1]}")
PY
git show HEAD^:application/prompt_client/prompt_client.py | sed -n '810,850p'
git show HEAD^:application/tests/test_smart_embeddings_e2e_llm.py | sed -n '55,78p'
rg -n --glob '*.py' 'unsupported.*(parameter|schema)|capability.*error|extra_try_kwargs|json_schema' application/prompt_client application/tests | head -80Repository: OWASP/OpenCRE Length of output: 8520 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- focused test/helper: application/tests/noise_filter/llm_classifier_test.py:200-265 ---'
sed -n '200,265p' application/tests/noise_filter/llm_classifier_test.py
printf '%s\n' '--- focused test client: application/tests/test_smart_embeddings_e2e_llm.py:1-90 ---'
sed -n '1,90p' application/tests/test_smart_embeddings_e2e_llm.py
printf '%s\n' '--- current parent excerpts ---'
sed -n '150,220p' application/prompt_client/litellm_router.py
sed -n '780,850p' application/prompt_client/prompt_client.py
printf '%s\n' '--- concise diff for the three cited files ---'
git diff --unified=12 -- application/prompt_client/litellm_router.py application/prompt_client/prompt_client.py application/tests/test_smart_embeddings_e2e_llm.pyRepository: OWASP/OpenCRE Length of output: 10191 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- focused test/helper ---'
sed -n '200,265p' application/tests/noise_filter/llm_classifier_test.py
printf '%s\n' '--- focused test client ---'
sed -n '1,90p' application/tests/test_smart_embeddings_e2e_llm.py
printf '%s\n' '--- current parent excerpts ---'
sed -n '150,220p' application/prompt_client/litellm_router.py
sed -n '780,850p' application/prompt_client/prompt_client.py
printf '%s\n' '--- concise diff ---'
git diff --unified=12 -- application/prompt_client/litellm_router.py application/prompt_client/prompt_client.py application/tests/test_smart_embeddings_e2e_llm.pyRepository: OWASP/OpenCRE Length of output: 10045 🏁 Script executed: set -e
sed -n '200,265p' application/tests/noise_filter/llm_classifier_test.py
printf '\n--- diff stats ---\n'
git diff --stat -- application/prompt_client/litellm_router.py application/prompt_client/prompt_client.py application/tests/test_smart_embeddings_e2e_llm.py
printf '\n--- per-file diff names and hunks ---\n'
git diff --unified=5 -- application/prompt_client/litellm_router.py application/prompt_client/prompt_client.py application/tests/test_smart_embeddings_e2e_llm.py | sed -n '1,240p'Repository: OWASP/OpenCRE Length of output: 2506 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- classifier fallback implementation ---'
rg -n -C 12 'strict|json_object|json_schema|capability|unsupported|classify_batch|llm_call_failed' application/tests/noise_filter application/prompt_client
printf '%s\n' '--- parent/current relevant lines ---'
for spec in \
'application/prompt_client/litellm_router.py:170,220' \
'application/prompt_client/prompt_client.py:790,850' \
'application/tests/test_smart_embeddings_e2e_llm.py:40,80'; do
file=${spec%%:*}; range=${spec#*:}
printf '\n### %s (current)\n' "$file"
sed -n "${range}p" "$file"
printf '### %s (parent)\n' "$file"
git show "HEAD^:$file" 2>/dev/null | sed -n "${range}p" || true
done
printf '%s\n' '--- focused commit summary ---'
git diff --stat HEAD^ HEAD -- application/prompt_client/litellm_router.py application/prompt_client/prompt_client.py application/tests/test_smart_embeddings_e2e_llm.py
git diff --unified=8 HEAD^ HEAD -- application/prompt_client/litellm_router.py application/prompt_client/prompt_client.py application/tests/test_smart_embeddings_e2e_llm.pyRepository: OWASP/OpenCRE Length of output: 50370 🏁 Script executed: #!/bin/bash
set -e
files=(
application/prompt_client/litellm_router.py
application/prompt_client/prompt_client.py
application/tests/test_smart_embeddings_e2e_llm.py
)
for file in "${files[@]}"; do
echo "### $file"
echo "status:"
git diff --name-status HEAD^ HEAD -- "$file"
for rev in HEAD^ HEAD; do
echo "--- $rev matching fallback lines ---"
git show "$rev:$file" 2>/dev/null | grep -n -A8 -B8 -E 'except Exception|retrying without|retrying json_object|response_format=\{"type": "json_object"\}' || true
done
done
echo '--- files defining the classifier ---'
rg -l 'classify_batch|class FallbackTests' application --glob '*.py' | head -30
echo '--- production strict/json fallback implementations ---'
rg -n -C4 'response_format.*json_object|provider does not support strict|authentication token invalid|unsupported.*schema|unsupported.*parameter' application --glob '*.py' | head -120Repository: OWASP/OpenCRE Length of output: 19945 🏁 Script executed: #!/bin/bash
set -e
sed -n '180,235p' application/utils/noise_filter/llm_classifier.py
printf '%s\n' '--- nearby helper definitions ---'
rg -n '^(def| def) |is_.*error|error.*type|unsupported|schema' application/utils/noise_filter/llm_classifier.py | head -80Repository: OWASP/OpenCRE Length of output: 4010 Restrict optional-feature fallbacks to capability errors.
Use a shared capability predicate, following This is a localized duplicate-request or masked-failure risk, so it is a minor stability issue rather than a major availability issue. 🤖 Prompt for AI Agents |
||
| logger.debug( | ||
| "LiteLLM extra kwargs failed for model=%s; retrying without them", | ||
| model, | ||
| exc_info=True, | ||
| ) | ||
| return completion_text( | ||
| model=model, | ||
| messages=messages, | ||
| temperature=temperature, | ||
| **kwargs, | ||
| ) | ||
|
|
||
| return _call | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: OWASP/OpenCRE
Length of output: 50370
🏁 Script executed:
Repository: OWASP/OpenCRE
Length of output: 26588
🤖 get_repo_knowledge executed:
get_repo_knowledge OWASP/OpenCRE /tmp/coderabbit-repo-knowledge/owasp-opencre-65b95960/architecture /tmp/coderabbit-repo-knowledge/owasp-opencre-65b95960/conventionsLength of output: 45771
🏁 Script executed:
Repository: OWASP/OpenCRE
Length of output: 26254
🏁 Script executed:
Repository: OWASP/OpenCRE
Length of output: 37274
🌐 Web query:
LiteLLM official documentation exception types RateLimitError APIConnectionError ServiceUnavailableError 529 retry policy💡 Result:
<search_synthesis>
LiteLLM provides a standardized, typed exception hierarchy that mirrors OpenAI&
#39;s error structure, ensuring consistency across different LLM providers [1][2]. Exception Types LiteLLM maps provider-specific errors into unified Python classes that inherit from corresponding OpenAI exceptions [3][1][2]. Key exceptions include: RateLimitError (429): Raised when rate limits are exceeded. It often includes additional metadata regarding the rate limit category [3][2]. APIConnectionError (500-level/Connection): Raised when a connection to the API fails or when an unmapped error occurs [3][4][2]. ServiceUnavailableError (503): Raised when the provider reports that its service is unavailable or overloaded [3][1][2]. 529 Error Handling: The 529 status code (typically associated with Anthropic's 'overloaded_error') is specifically handled by LiteLLM as a transient, provider-side error [5][6]. LiteLLM implements bounded, transport-level retries for 529 errors to help mitigate temporary provider instability, distinct from the standard proxy-side retry logic [5]. Retry Policy LiteLLM offers robust retry mechanisms configurable through its Router or directly within completion calls [7][8]. Configuration: You can define a RetryPolicy (via the Router or proxy configuration) to specify the number of retries for specific exception types (e.g., TimeoutErrorRetries, RateLimitErrorRetries, InternalServerErrorRetries) [9][8]. Mechanism: LiteLLM uses exponential backoff strategies [8]. You can set allowed failure limits via AllowedFailsPolicy to manage when a deployment is "cooled down" (removed from the rotation) after repeated failures [10][9]. Fallback Strategy: Beyond simple retries, LiteLLM supports automatic failover (fallbacks) to alternative models or providers if a primary request fails after the configured number of retries [7][11]. Developers are encouraged to catch these unified exceptions directly in their code, as they are designed to be compatible with existing OpenAI error-handling patterns [2]. Detailed mapping tables for all status codes and exception types are available in the official LiteLLM documentation [3][2].</search_synthesis>
<source_evidence>
Citations:
🏁 Script executed:
Repository: OWASP/OpenCRE
Length of output: 37135
🌐 Web query:
site:docs.litellm.ai quota exceeded budget_exceeded exception retryable permanent usage limit LiteLLM💡 Result:
<search_synthesis>
In LiteLLM, a budget_exceeded error is fundamentally different from a standard rate limit (throttling) error and is generally not considered a retryable exception [1][2]. 1. Nature of the Exception: A budget_exceeded error (often accompanied by a 400 Bad Request code) indicates that a configured spend cap for a key, team, user, or session has been reached [1][2]. Because this is a business-logic restriction rather than a transient provider-side capacity issue, retrying the request will not succeed until the budget window resets or the budget is manually increased [1][2]. 2. Exception Handling: LiteLLM explicitly distinguishes between these types of errors in its architecture: - BudgetExceededError: Categorized as a non-retryable proxy-level error occurring when budget limits are breached [1][3]. - RateLimitError: Categorized as a potential retryable error (often carrying a 429 status code and a retry-after header) that occurs when provider-side or router-level rate limits (RPM/TPM) are hit [1][4]. 3. Mitigation Strategies: Rather than retrying, LiteLLM provides alternative mechanisms to handle budget exhaustion: - Budget Fallbacks: You can configure a per-model fallback chain on an API key [5][6]. If the primary model exceeds its model_max_budget, the LiteLLM proxy will transparently reroute the request to a designated fallback model that still has available budget, preventing the error from reaching the client [5][6]. - Manual Intervention: Administrative action is required to raise the budget limits or wait for the defined budget window (e.g., daily or monthly resets) to conclude [1][2]. In summary, do not implement automatic retries for budget_exceeded exceptions. If your application requires high availability despite potential budget hits, utilize LiteLLM&
#39;s built-in budget fallback configurations to reroute traffic to alternative models [5].</search_synthesis>
<source_evidence>
Citations:
Do not retry permanent usage-limit errors.
is_rate_limit_errorreturnsTruefor any exception whose message contains"quota", before it evaluates the exception type or structured status. LiteLLM definesBudgetExceededErroras a non-retryable usage-limit error. If that error carries quota text,with_rate_limit_retryrepeats the request and waits up to two additional 15-second intervals before raising it. Restrict quota detection to structured, retryable rate-limit errors and exclude LiteLLM budget or usage-limit errors.🤖 Prompt for AI Agents