CollegioPartnersCCAI / backend /app /clients /openai_compat.py
NeonClary
perf(orchestrator): parallel phases, streaming, early table summaries
83add3c
Raw
History Blame Contribute Delete
11.1 kB
from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Any
import httpx
from app.utils.sanitize import strip_thinking, response_has_thinking
LOG = logging.getLogger(__name__)
_shared_client: httpx.AsyncClient | None = None
_MAX_COMPLETION_TOKEN_MODELS = {
"o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini",
"gpt-5", "gpt-oss",
}
_NO_TEMPERATURE_MODELS = {"o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini"}
# Models that emit long internal reasoning traces; allow a higher completion
# cap. Flash/mini/instruct models use the requested max_tokens as-is.
_THINKING_MULTIPLIER_MODEL_PREFIXES = _MAX_COMPLETION_TOKEN_MODELS
_THINKING_NAME_HINTS = ("thinking", "reasoning", "-think", "/think")
# Reserve a few tokens for chat-template framing the server tacks on (the
# vLLM server and OpenAI both add a small overhead per request that our
# input estimate doesn't account for).
_INPUT_SAFETY_MARGIN = 128
# Floor: never request fewer than this many output tokens, even if input
# is huge - we'd rather get a truncated reply than no reply at all.
_MIN_OUTPUT_TOKENS = 64
# HTTP status codes that map to "transient" — worth retrying the same
# model. 429 is rate-limit; 408/425 are timeout/too-early; 5xx are
# server-side. Everything else 4xx is treated as "permanent" (auth,
# invalid request, content filter, model gone) where retrying the same
# model won't help, so the orchestrator's resilience layer should jump
# straight to substituting the LLM backing the persona.
_TRANSIENT_HTTP_STATUSES = {408, 409, 425, 429, 500, 502, 503, 504}
def _classify_http_status(status_code: int) -> str:
if status_code in _TRANSIENT_HTTP_STATUSES:
return "transient"
return "permanent"
def _classify_exception(exc: BaseException) -> str:
"""Map a raw httpx/asyncio exception to transient vs permanent.
Network blips, read timeouts, and connection resets are transient
(the model itself is probably still healthy). Anything else falls
through to "permanent" to avoid retry loops on misconfiguration.
"""
if isinstance(exc, (httpx.TimeoutException, httpx.ConnectError,
httpx.ReadError, httpx.WriteError, httpx.PoolTimeout,
httpx.RemoteProtocolError)):
return "transient"
if isinstance(exc, asyncio.TimeoutError):
return "transient"
return "permanent"
def _estimate_input_tokens(messages: list[dict[str, str]]) -> int:
"""Crude chars/4 token estimate matching context_budget's heuristic."""
total = 0
for m in messages:
content = m.get("content") or ""
total += max(1, len(content) // 4)
total += 4 # per-message framing overhead
return total
def _model_wants_thinking_multiplier(model: str) -> bool:
mid = (model or "").lower()
if any(mid.startswith(prefix) for prefix in _THINKING_MULTIPLIER_MODEL_PREFIXES):
return True
return any(hint in mid for hint in _THINKING_NAME_HINTS)
def _resolve_effective_max(
model: str, requested: int, messages: list[dict[str, str]],
) -> tuple[int, int, int]:
"""Compute the actual max_tokens to send.
The ×4 multiplier exists so thinking models can spend tokens on
reasoning before producing the visible answer. On wide-window models
(128K+) it costs nothing. On narrow-window models (e.g. Neon 8K) it
can ask for more output tokens than the server will allow given the
input. Cap it to the actual headroom.
Returns (effective_max, input_estimate, window).
"""
from app.services.context_budget import context_window_for
window = context_window_for(model)
input_estimate = _estimate_input_tokens(messages)
headroom = max(_MIN_OUTPUT_TOKENS, window - input_estimate - _INPUT_SAFETY_MARGIN)
multiplier = 4 if _model_wants_thinking_multiplier(model) else 1
effective_max = max(_MIN_OUTPUT_TOKENS, min(requested * multiplier, headroom))
return effective_max, input_estimate, window
def _get_client() -> httpx.AsyncClient:
global _shared_client
if _shared_client is None or _shared_client.is_closed:
_shared_client = httpx.AsyncClient(timeout=45.0)
return _shared_client
# Thinking-trace detection and stripping live in app.utils.sanitize so every
# code path (HANA, vLLM-direct, OpenAI-compat, summarizer inputs, credential
# inputs) uses the same logic. See backend/app/utils/sanitize.py.
async def openai_chat_completion(
base_url: str,
api_key: str,
model: str,
messages: list[dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 1024,
timeout: float | None = None,
on_text_delta: Any | None = None,
) -> dict[str, Any]:
"""Send a chat completion request to any OpenAI-compatible endpoint."""
url = f"{base_url.rstrip('/')}/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
needs_mct = any(model.startswith(prefix) for prefix in _MAX_COMPLETION_TOKEN_MODELS)
skip_temp = any(model.startswith(prefix) for prefix in _NO_TEMPERATURE_MODELS)
effective_max, input_estimate, window = _resolve_effective_max(
model, max_tokens, messages,
)
mult = 4 if _model_wants_thinking_multiplier(model) else 1
if effective_max < max_tokens * mult:
LOG.info(
"Capped max_tokens for %s: requested %d (x%d=%d), input ~=%d, "
"window=%d, sending %d",
model, max_tokens, mult, max_tokens * mult, input_estimate, window,
effective_max,
)
effective_timeout = max(timeout * 2, 120) if timeout else timeout
body: dict[str, Any] = {
"model": model,
"messages": messages,
}
if needs_mct:
body["max_completion_tokens"] = effective_max
else:
body["max_tokens"] = effective_max
if not skip_temp:
body["temperature"] = temperature
req_timeout = httpx.Timeout(effective_timeout) if effective_timeout else None
client = _get_client()
t0 = time.time()
if on_text_delta is not None:
body["stream"] = True
try:
parts: list[str] = []
async with client.stream(
"POST", url, json=body, headers=headers, timeout=req_timeout,
) as resp:
if resp.status_code >= 400:
detail = (await resp.aread()).decode("utf-8", errors="replace")[:300]
return {
"response": f"[Error {resp.status_code}]: {detail}",
"elapsed_seconds": round(time.time() - t0, 2),
"model": model,
"error": True,
"error_kind": _classify_http_status(resp.status_code),
"error_status": resp.status_code,
}
async for line in resp.aiter_lines():
if not line or not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
choices = chunk.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta") or {}
piece = delta.get("content") or ""
if piece:
parts.append(piece)
on_text_delta(piece)
text = strip_thinking("".join(parts))
return {
"response": text.strip(),
"elapsed_seconds": round(time.time() - t0, 2),
"model": model,
"finish_reason": "stop",
}
except Exception as exc:
LOG.exception("OpenAI-compat stream failed: %s", exc)
return {
"response": f"[Error]: {exc}",
"elapsed_seconds": round(time.time() - t0, 2),
"model": model,
"error": True,
"error_kind": _classify_exception(exc),
}
for attempt in range(2):
try:
resp = await client.post(url, json=body, headers=headers, timeout=req_timeout)
if resp.status_code >= 400 and attempt == 0:
LOG.warning("Error %d on %s (attempt 1), retrying in 1.1s", resp.status_code, model)
await asyncio.sleep(1.1)
continue
elapsed = time.time() - t0
resp.raise_for_status()
data = resp.json()
choices = data.get("choices", [])
text = ""
finish_reason = ""
had_thinking = False
if choices:
msg = choices[0].get("message") or {}
text = msg.get("content") or ""
finish_reason = choices[0].get("finish_reason") or ""
had_thinking = response_has_thinking(text, msg)
text = strip_thinking(text)
if had_thinking:
LOG.info("Stripped thinking content from %s response", model)
return {
"response": text.strip(),
"elapsed_seconds": round(elapsed, 2),
"model": data.get("model", model),
"finish_reason": finish_reason,
}
except httpx.HTTPStatusError as exc:
if attempt == 0:
LOG.warning("HTTPStatusError on %s (attempt 1), retrying", model)
await asyncio.sleep(1.1)
continue
elapsed = time.time() - t0
detail = exc.response.text[:300] if exc.response else str(exc)
status = exc.response.status_code if exc.response is not None else 0
LOG.error("OpenAI-compat %s error %s: %s", base_url, status, detail)
return {
"response": f"[Error {status}]: {detail}",
"elapsed_seconds": round(elapsed, 2),
"model": model,
"error": True,
"error_kind": _classify_http_status(status),
"error_status": status,
}
except Exception as exc:
if attempt == 0:
LOG.warning("Exception on %s (attempt 1), retrying: %s", model, exc)
await asyncio.sleep(1.1)
continue
elapsed = time.time() - t0
LOG.exception("OpenAI-compat request failed: %s", exc)
return {
"response": f"[Error]: {exc}",
"elapsed_seconds": round(elapsed, 2),
"model": model,
"error": True,
"error_kind": _classify_exception(exc),
}
async def close_shared_client() -> None:
global _shared_client
if _shared_client and not _shared_client.is_closed:
await _shared_client.aclose()
_shared_client = None