feat: translate module — runtime knobs, GRACE anchors, two-layer testing, QA fixes
Implementation: - Performance knobs: llm_batch_max_rows, llm_concurrency, insert_concurrency, multi_lang_mode, batch_aggressiveness, max_in_flight_batches - Alembic migration f7a8b9c0d1e2 (idempotent, nullable, non-destructive) - LLM provider capabilities: throughput_class, reasoning_control, supports_json_object, default/max_llm_concurrency - TargetSchemaValidationRequest with conditional validator (sqllab/direct_db) - Scheduler: background dispatch, local imports for lazy bootstrap GRACE-Poly compliance: - Semantic anchors on orchestrator_aggregator, orchestrator_sql, llm_provider - Shared module _llm_http.py: _apply_reasoning_control extraction (INV_4) - Alembic migration anchors per C3/C1 template - Four renamed Svelte components: RunOutcomeSummary, DetectionQualityCard, LanguageStatList, SourceLanguageOverride QA (this session): - 11 backend test regressions fixed: spec'd MagicMock null fields for new columns, _check_translation_cache_bulk retarget, scheduler mock wiring, language_detection=auto assertions, token budget constant update - 4 frontend eslint errors fixed: SvelteSet/SvelteDate imports, unused lang parameter, dead isTransientError - Production bugfix: job_to_response() mapped 6 missing fields - Pre-existing auth test failure documented (dependencies.py untouched) - Axiom: 6440 contracts, 0 warnings
This commit is contained in:
@@ -29,9 +29,18 @@ from .logger import logger
|
||||
from .ssl import httpx_verify
|
||||
|
||||
# Module-level singleton clients, lazily initialized
|
||||
_http_client_600: httpx.AsyncClient | None = None
|
||||
_http_client_180: httpx.AsyncClient | None = None
|
||||
_http_client_10: httpx.AsyncClient | None = None
|
||||
|
||||
# Default timeout for long LLM chat/completions (local models need long prefill+decode).
|
||||
LLM_HTTP_TIMEOUT_SECONDS = 600.0
|
||||
# Safety margin reserved in context for special tokens / sampler overhead.
|
||||
CONTEXT_OUTPUT_MARGIN = 256
|
||||
# Hard ceiling on completion tokens when caller over-requests (e.g. 10850).
|
||||
# 8192 allows multi-lang single-row JSON without finish_reason=length mid-object.
|
||||
MAX_COMPLETION_TOKENS_HARD_CAP = 8192
|
||||
|
||||
|
||||
# #region SharedLlmHttpClient.GetSharedClient [C:2] [TYPE Function] [SEMANTICS shared,http,client,get]
|
||||
# @ingroup Shared
|
||||
@@ -39,7 +48,7 @@ _http_client_10: httpx.AsyncClient | None = None
|
||||
# Uses shared ssl module (capath) for certificate verification.
|
||||
# @POST Returns httpx.AsyncClient with verify=system_ssl_context() and specified timeout.
|
||||
# @SIDE_EFFECT Lazily creates the client on first call and caches it for reuse.
|
||||
def get_shared_http_client(timeout: float = 180.0) -> httpx.AsyncClient:
|
||||
def get_shared_http_client(timeout: float = LLM_HTTP_TIMEOUT_SECONDS) -> httpx.AsyncClient:
|
||||
"""Get or create a shared httpx.AsyncClient singleton with system SSL context.
|
||||
|
||||
Uses the system CA store (capath) for SSL verification — NOT certifi.
|
||||
@@ -47,25 +56,32 @@ def get_shared_http_client(timeout: float = 180.0) -> httpx.AsyncClient:
|
||||
are trusted for all HTTP calls.
|
||||
|
||||
Args:
|
||||
timeout: Total timeout in seconds (default 180).
|
||||
Use 10 for lightweight config fetches, 180 for LLM API calls.
|
||||
timeout: Total timeout in seconds (default 600 for LLM calls).
|
||||
Use 10 for lightweight config fetches, 600 for LLM API calls.
|
||||
180 is still cached for legacy callers.
|
||||
|
||||
Returns:
|
||||
httpx.AsyncClient with SSL context and specified timeout.
|
||||
"""
|
||||
global _http_client_180, _http_client_10
|
||||
global _http_client_600, _http_client_180, _http_client_10
|
||||
|
||||
if abs(timeout - 600.0) < 0.1 or abs(timeout - LLM_HTTP_TIMEOUT_SECONDS) < 0.1:
|
||||
if _http_client_600 is None:
|
||||
ssl_ctx = httpx_verify()
|
||||
_http_client_600 = httpx.AsyncClient(
|
||||
verify=ssl_ctx,
|
||||
# Connect fast-fail; long read/write for local generation.
|
||||
timeout=httpx.Timeout(LLM_HTTP_TIMEOUT_SECONDS, connect=30.0),
|
||||
)
|
||||
return _http_client_600
|
||||
|
||||
# Cache two common timeout configurations to avoid creating new clients
|
||||
if abs(timeout - 180.0) < 0.1:
|
||||
if _http_client_180 is None:
|
||||
ssl_ctx = httpx_verify()
|
||||
_http_client_180 = httpx.AsyncClient(
|
||||
verify=ssl_ctx,
|
||||
timeout=httpx.Timeout(180.0),
|
||||
timeout=httpx.Timeout(180.0, connect=30.0),
|
||||
)
|
||||
# @ADR [LOG-004] Removed "Created shared HTTP client (180s timeout)".
|
||||
# Singleton creation (cached). Once-per-process infra detail. Not a decision point.
|
||||
# Errors during actual LLM calls are properly EXPLOREd with context.
|
||||
return _http_client_180
|
||||
|
||||
if abs(timeout - 10.0) < 0.1:
|
||||
@@ -75,18 +91,14 @@ def get_shared_http_client(timeout: float = 180.0) -> httpx.AsyncClient:
|
||||
verify=ssl_ctx,
|
||||
timeout=httpx.Timeout(10.0),
|
||||
)
|
||||
# @ADR [LOG-004] Removed "Created shared HTTP client (10s timeout)".
|
||||
# Singleton creation (cached). Once-per-process infra detail. Not a decision point.
|
||||
return _http_client_10
|
||||
|
||||
# Custom timeout — create a new client (not cached)
|
||||
ssl_ctx = httpx_verify()
|
||||
client = httpx.AsyncClient(
|
||||
verify=ssl_ctx,
|
||||
timeout=httpx.Timeout(timeout),
|
||||
timeout=httpx.Timeout(timeout, connect=min(30.0, timeout)),
|
||||
)
|
||||
# @ADR [LOG-004] Removed "Created shared HTTP client (custom timeout)".
|
||||
# Rare (only custom timeout callers). Not worth the per-creation line.
|
||||
return client
|
||||
# #endregion SharedLlmHttpClient.GetSharedClient
|
||||
|
||||
@@ -111,6 +123,295 @@ def sanitize_url(url: str) -> str:
|
||||
# #endregion SharedLlmHttpClient.SanitizeUrl
|
||||
|
||||
|
||||
# #region SharedLlmHttpClient.EstimateMsgTokens [C:1] [TYPE Function] [SEMANTICS shared,llm,token,estimate]
|
||||
# @BRIEF Conservative char-based token estimate for prompt sizing (no external tokenizer).
|
||||
def _estimate_msg_tokens(text: str) -> int:
|
||||
if not text:
|
||||
return 1
|
||||
# ~1.5 chars/token is intentionally pessimistic vs cl100k (~4) and covers CJK/JSON.
|
||||
return max(1, int(len(text) / 1.5) + 1)
|
||||
# #endregion SharedLlmHttpClient.EstimateMsgTokens
|
||||
|
||||
|
||||
# #region SharedLlmHttpClient.ParseChatCompletionBody [C:3] [TYPE Function] [SEMANTICS shared,llm,sse,parse]
|
||||
# @BRIEF Parse non-stream JSON or SSE chat.completion.chunk stream into a completion dict.
|
||||
def _parse_chat_completion_body(response_text: str, status_code: int = 200) -> dict[str, Any]:
|
||||
"""Parse OpenAI chat completion body; aggregate SSE if the proxy streamed anyway."""
|
||||
text = response_text or ""
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
raise ValueError(
|
||||
f"LLM provider returned an empty body (status={status_code})"
|
||||
)
|
||||
|
||||
# Fast path: normal non-stream JSON object
|
||||
if stripped.startswith("{"):
|
||||
try:
|
||||
data = json.loads(stripped)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# SSE path: "data: {...}\n\ndata: {...}\n\ndata: [DONE]"
|
||||
if "data:" in stripped[:64] or stripped.startswith("data:") or "\ndata:" in stripped:
|
||||
aggregated = _aggregate_sse_chat_completion(stripped)
|
||||
if aggregated is not None:
|
||||
logger.explore(
|
||||
"Aggregated SSE chat.completion stream into non-stream payload",
|
||||
extra={
|
||||
"src": "SharedLlmHttpClient",
|
||||
"content_len": len((aggregated.get("choices") or [{}])[0].get("message", {}).get("content") or ""),
|
||||
"finish_reason": (aggregated.get("choices") or [{}])[0].get("finish_reason"),
|
||||
},
|
||||
)
|
||||
return aggregated
|
||||
|
||||
preview = stripped[:500]
|
||||
raise ValueError(
|
||||
f"LLM provider returned an invalid JSON response "
|
||||
f"(status={status_code}, body_len={len(text)}, preview={preview!r})"
|
||||
)
|
||||
# #endregion SharedLlmHttpClient.ParseChatCompletionBody
|
||||
|
||||
|
||||
# #region SharedLlmHttpClient.AggregateSseChatCompletion [C:3] [TYPE Function] [SEMANTICS shared,llm,sse,aggregate]
|
||||
# @BRIEF Merge OpenAI SSE chat.completion.chunk frames into one chat.completion-like dict.
|
||||
def _aggregate_sse_chat_completion(sse_text: str) -> dict[str, Any] | None:
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
finish_reason: str | None = None
|
||||
model: str | None = None
|
||||
completion_id: str | None = None
|
||||
saw_chunk = False
|
||||
|
||||
for raw_line in sse_text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith(":"):
|
||||
continue
|
||||
if line.startswith("data:"):
|
||||
line = line[5:].strip()
|
||||
if not line or line == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
if not isinstance(chunk, dict):
|
||||
continue
|
||||
# Some proxies wrap non-stream JSON as a single data: line
|
||||
if chunk.get("object") == "chat.completion" and chunk.get("choices"):
|
||||
return chunk
|
||||
saw_chunk = True
|
||||
model = model or chunk.get("model")
|
||||
completion_id = completion_id or chunk.get("id")
|
||||
for choice in chunk.get("choices") or []:
|
||||
if not isinstance(choice, dict):
|
||||
continue
|
||||
if choice.get("finish_reason"):
|
||||
finish_reason = choice.get("finish_reason")
|
||||
delta = choice.get("delta") or {}
|
||||
if not isinstance(delta, dict):
|
||||
# Non-delta message (rare in streams)
|
||||
msg = choice.get("message") or {}
|
||||
if isinstance(msg, dict):
|
||||
if msg.get("content"):
|
||||
content_parts.append(str(msg["content"]))
|
||||
for rk in ("reasoning_content", "reasoning", "thinking"):
|
||||
if msg.get(rk):
|
||||
reasoning_parts.append(str(msg[rk]))
|
||||
continue
|
||||
if delta.get("content"):
|
||||
content_parts.append(str(delta["content"]))
|
||||
for rk in ("reasoning_content", "reasoning", "thinking"):
|
||||
if delta.get(rk):
|
||||
reasoning_parts.append(str(delta[rk]))
|
||||
|
||||
if not saw_chunk and not content_parts and not reasoning_parts:
|
||||
return None
|
||||
|
||||
message: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": "".join(content_parts),
|
||||
}
|
||||
if reasoning_parts:
|
||||
message["reasoning_content"] = "".join(reasoning_parts)
|
||||
|
||||
return {
|
||||
"id": completion_id or "sse-aggregated",
|
||||
"object": "chat.completion",
|
||||
"model": model or "",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": message,
|
||||
"finish_reason": finish_reason or "stop",
|
||||
}
|
||||
],
|
||||
}
|
||||
# #endregion SharedLlmHttpClient.AggregateSseChatCompletion
|
||||
|
||||
|
||||
# #region SharedLlmHttpClient.ClampMaxTokens [C:2] [TYPE Function] [SEMANTICS shared,llm,token,clamp]
|
||||
# @BRIEF Cap completion tokens so prompt + output fits context and avoids multi-minute gens.
|
||||
def _clamp_max_tokens(
|
||||
requested: int,
|
||||
prompt_tokens: int,
|
||||
context_window: int | None,
|
||||
) -> int:
|
||||
capped = max(1, int(requested))
|
||||
capped = min(capped, MAX_COMPLETION_TOKENS_HARD_CAP)
|
||||
if context_window and context_window > 0:
|
||||
free = context_window - prompt_tokens - CONTEXT_OUTPUT_MARGIN
|
||||
if free < 64:
|
||||
# Still request a tiny completion so the API call is valid; caller should
|
||||
# have reduced batch size — this is a last-resort guard.
|
||||
free = 64
|
||||
capped = min(capped, free)
|
||||
return max(1, capped)
|
||||
# #endregion SharedLlmHttpClient.ClampMaxTokens
|
||||
|
||||
|
||||
# #region SharedLlmHttpClient.ExtractJsonBlob [C:2] [TYPE Function] [SEMANTICS shared,llm,json,extract]
|
||||
# @BRIEF Extract a parseable JSON object/array from free-form model text (e.g. reasoning).
|
||||
def _extract_json_blob(text: str) -> str | None:
|
||||
if not text or not isinstance(text, str):
|
||||
return None
|
||||
stripped = text.strip()
|
||||
if stripped.startswith(("{", "[")):
|
||||
try:
|
||||
json.loads(stripped)
|
||||
return stripped
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
for start_char, end_char in (("{", "}"), ("[", "]")):
|
||||
start = text.find(start_char)
|
||||
if start < 0:
|
||||
continue
|
||||
depth = 0
|
||||
in_str = False
|
||||
escape = False
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if in_str:
|
||||
if escape:
|
||||
escape = False
|
||||
elif ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_str = False
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = True
|
||||
elif ch == start_char:
|
||||
depth += 1
|
||||
elif ch == end_char:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidate = text[start : i + 1]
|
||||
try:
|
||||
json.loads(candidate)
|
||||
return candidate
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
break
|
||||
return None
|
||||
# #endregion SharedLlmHttpClient.ExtractJsonBlob
|
||||
|
||||
|
||||
# #region SharedLlmHttpClient.LooksLikeJson [C:1] [TYPE Function]
|
||||
def _looks_like_complete_json(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
t = text.strip()
|
||||
if not (t.startswith("{") or t.startswith("[")):
|
||||
return False
|
||||
try:
|
||||
json.loads(t)
|
||||
return True
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
# #endregion SharedLlmHttpClient.LooksLikeJson
|
||||
|
||||
|
||||
# #region SharedLlmHttpClient.ResolveMessageContent [C:2] [TYPE Function] [SEMANTICS shared,llm,content,reasoning]
|
||||
# @BRIEF Prefer message.content; fall back to JSON inside reasoning_content / thinking fields.
|
||||
# Also used when content is truncated mid-JSON (finish_reason=length) but reasoning holds
|
||||
# a fuller answer — common for DeepSeek/Gemma thinking models.
|
||||
def _resolve_message_content(msg: dict) -> tuple[str, str | None]:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
# Multimodal-style content blocks
|
||||
parts = []
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
parts.append(str(block.get("text") or ""))
|
||||
elif isinstance(block, str):
|
||||
parts.append(block)
|
||||
content = "".join(parts)
|
||||
if content is None:
|
||||
content = ""
|
||||
content = str(content).strip() if content else ""
|
||||
if content and _looks_like_complete_json(content):
|
||||
return content, None
|
||||
# Incomplete / non-JSON content: try extract JSON blob first, then reasoning fields.
|
||||
if content:
|
||||
extracted = _extract_json_blob(content)
|
||||
if extracted and _looks_like_complete_json(extracted):
|
||||
return extracted, "content_extracted"
|
||||
|
||||
for key in ("reasoning_content", "reasoning", "thinking", "reasoning_text"):
|
||||
alt = msg.get(key)
|
||||
if not alt or not isinstance(alt, str):
|
||||
continue
|
||||
extracted = _extract_json_blob(alt)
|
||||
if extracted and _looks_like_complete_json(extracted):
|
||||
return extracted, key
|
||||
alt_stripped = alt.strip()
|
||||
if _looks_like_complete_json(alt_stripped):
|
||||
return alt_stripped, key
|
||||
# Last resort: return partial content (caller may still recover truncated rows).
|
||||
if content:
|
||||
return content, None
|
||||
return "", None
|
||||
# #endregion SharedLlmHttpClient.ResolveMessageContent
|
||||
|
||||
|
||||
# #region SharedLlmHttpClient.ApplyReasoningControl [C:2] [TYPE Function] [SEMANTICS shared,llm,reasoning,capabilities]
|
||||
# @ingroup Shared
|
||||
# @BRIEF Mutate payload with anti-think wire fields from explicit reasoning_control capability.
|
||||
# @RATIONALE Wire format is a provider capability stored in DB — not inferred from model name.
|
||||
# @REJECTED if "deepseek" in model / localhost host sniff at request time — brand/host lock-in.
|
||||
def _apply_reasoning_control(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
disable_reasoning: bool,
|
||||
reasoning_control: str | None,
|
||||
) -> None:
|
||||
"""Apply anti-reasoning payload fields when job requests disable_reasoning.
|
||||
|
||||
reasoning_control values (provider capability):
|
||||
off | generic_none | openai_effort | deepseek_thinking | llamacpp_think | auto|None
|
||||
Unsupported fields are stripped on HTTP 400 by _handle_response_format_fallback.
|
||||
"""
|
||||
if not disable_reasoning:
|
||||
return
|
||||
mode = (reasoning_control or "generic_none").strip().lower()
|
||||
if mode in ("", "auto", "none"):
|
||||
# Safe default when capability unset: send generic thinking-disable;
|
||||
# strip-on-400 recovers for providers that reject the field.
|
||||
mode = "generic_none"
|
||||
if mode == "off":
|
||||
return
|
||||
if mode in ("generic_none", "deepseek_thinking"):
|
||||
payload["thinking"] = {"type": "disabled"}
|
||||
if mode == "openai_effort":
|
||||
payload["reasoning_effort"] = "none"
|
||||
if mode == "llamacpp_think":
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
payload["think"] = False
|
||||
# #endregion SharedLlmHttpClient.ApplyReasoningControl
|
||||
|
||||
|
||||
# #region SharedLlmHttpClient.CallOpenaiCompatible [C:3] [TYPE Function] [SEMANTICS shared,llm,http,openai,async]
|
||||
# @ingroup Shared
|
||||
# @BRIEF Call OpenAI-compatible API asynchronously with rate-limit handling and structured output fallback.
|
||||
@@ -118,6 +419,7 @@ def sanitize_url(url: str) -> str:
|
||||
# @POST Returns (response text, finish_reason).
|
||||
# @RAISES ValueError when the provider returns an invalid JSON response body.
|
||||
# @SIDE_EFFECT Async HTTP POST to LLM API with optional retry on 429.
|
||||
# @RELATION CALLS -> [SharedLlmHttpClient.ApplyReasoningControl]
|
||||
# @RATIONALE Normalize malformed successful HTTP bodies into a stable provider error so
|
||||
# preview and execution callers do not leak raw JSONDecodeError details.
|
||||
# @REJECTED Letting response.json() propagate raw decode failures was rejected — it
|
||||
@@ -132,6 +434,10 @@ async def call_openai_compatible(
|
||||
provider_type: str = "openai",
|
||||
max_tokens: int = 8192,
|
||||
disable_reasoning: bool = False,
|
||||
context_window: int | None = None,
|
||||
timeout: float = LLM_HTTP_TIMEOUT_SECONDS,
|
||||
reasoning_control: str | None = None,
|
||||
supports_json_object: bool | None = None,
|
||||
) -> tuple[str, str | None]:
|
||||
"""Call OpenAI-compatible API for LLM requests (async)."""
|
||||
if not base_url:
|
||||
@@ -154,6 +460,24 @@ async def call_openai_compatible(
|
||||
"Output ONLY valid JSON."
|
||||
)
|
||||
|
||||
prompt_tokens = (
|
||||
_estimate_msg_tokens(system_content)
|
||||
+ _estimate_msg_tokens(prompt)
|
||||
+ 16 # role/message framing overhead
|
||||
)
|
||||
effective_max_tokens = _clamp_max_tokens(max_tokens, prompt_tokens, context_window)
|
||||
if effective_max_tokens != max_tokens:
|
||||
logger.explore(
|
||||
"Clamped max_tokens to fit context / hard cap",
|
||||
extra={
|
||||
"src": "SharedLlmHttpClient",
|
||||
"requested_max_tokens": max_tokens,
|
||||
"effective_max_tokens": effective_max_tokens,
|
||||
"prompt_tokens_est": prompt_tokens,
|
||||
"context_window": context_window,
|
||||
},
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
@@ -161,21 +485,39 @@ async def call_openai_compatible(
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"max_tokens": max_tokens,
|
||||
"max_tokens": effective_max_tokens,
|
||||
# Explicit non-stream — some proxies (cliproxy) default to SSE chunks otherwise.
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
if provider_type in ("openai", "openai_compatible", "kilo", "openrouter", "litellm"):
|
||||
if not disable_reasoning:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
# Prefer strict JSON when capability allows (NULL = true for openai-compatible family).
|
||||
use_json = supports_json_object if supports_json_object is not None else (
|
||||
provider_type in ("openai", "openai_compatible", "kilo", "openrouter", "litellm")
|
||||
)
|
||||
if use_json:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
|
||||
if disable_reasoning:
|
||||
if provider_type not in ("kilo", "openrouter", "litellm"):
|
||||
payload["reasoning_effort"] = "none"
|
||||
payload["max_tokens"] = max_tokens
|
||||
_apply_reasoning_control(
|
||||
payload,
|
||||
disable_reasoning=disable_reasoning,
|
||||
reasoning_control=reasoning_control,
|
||||
)
|
||||
|
||||
client = get_shared_http_client()
|
||||
response, response_text = await _do_http_request(client, url, headers, payload)
|
||||
await _handle_response_format_fallback(client, response, response_text, payload, url, headers)
|
||||
client = get_shared_http_client(timeout=timeout)
|
||||
try:
|
||||
response, response_text = await _do_http_request(client, url, headers, payload)
|
||||
response, response_text = await _handle_response_format_fallback(
|
||||
client, response, response_text, payload, url, headers,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
# httpx often stringifies to "" — always include type + timeout budget.
|
||||
detail = str(exc).strip() or repr(exc)
|
||||
raise TimeoutError(
|
||||
f"LLM HTTP timeout after {timeout}s ({type(exc).__name__}: {detail})"
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
detail = str(exc).strip() or repr(exc)
|
||||
raise RuntimeError(f"LLM HTTP error ({type(exc).__name__}: {detail})") from exc
|
||||
|
||||
if not response.is_success:
|
||||
logger.explore(
|
||||
@@ -183,20 +525,7 @@ async def call_openai_compatible(
|
||||
extra={"src": "SharedLlmHttpClient"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
data = response.json()
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.explore(
|
||||
"LLM provider returned an invalid JSON response",
|
||||
extra={
|
||||
"src": "SharedLlmHttpClient",
|
||||
"status_code": response.status_code,
|
||||
"content_type": response.headers.get("content-type"),
|
||||
"response_preview": (response_text or "")[:2000],
|
||||
},
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
raise ValueError("LLM provider returned an invalid JSON response") from exc
|
||||
data = _parse_chat_completion_body(response_text, status_code=response.status_code)
|
||||
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
@@ -227,7 +556,10 @@ async def call_openai_compatible(
|
||||
)
|
||||
raise ValueError(f"LLM response processing failed: {e}")
|
||||
|
||||
refusal = msg.get("refusal") if isinstance(msg, dict) else None
|
||||
if not isinstance(msg, dict):
|
||||
raise ValueError("LLM response message is not an object")
|
||||
|
||||
refusal = msg.get("refusal")
|
||||
if refusal:
|
||||
logger.explore(
|
||||
"LLM refused to respond",
|
||||
@@ -239,18 +571,38 @@ async def call_openai_compatible(
|
||||
)
|
||||
raise ValueError(f"LLM refused to respond: {refusal}")
|
||||
|
||||
content = msg.get("content") if isinstance(msg, dict) else ""
|
||||
if not content and isinstance(msg, dict):
|
||||
content = msg.get("content") or ""
|
||||
content, fallback_field = _resolve_message_content(msg)
|
||||
if fallback_field:
|
||||
logger.explore(
|
||||
"Recovered LLM content from reasoning/thinking field",
|
||||
extra={
|
||||
"src": "SharedLlmHttpClient",
|
||||
"field": fallback_field,
|
||||
"content_len": len(content),
|
||||
"finish_reason": finish_reason,
|
||||
},
|
||||
)
|
||||
|
||||
if not content:
|
||||
# Surface diagnostic fields both as structured extras and in the message so
|
||||
# they survive log formatters that drop nested `extra` keys.
|
||||
usage = data.get("usage") if isinstance(data, dict) else None
|
||||
reasoning_len = 0
|
||||
for rk in ("reasoning_content", "reasoning", "thinking", "reasoning_text"):
|
||||
alt = msg.get(rk)
|
||||
if isinstance(alt, str) and alt:
|
||||
reasoning_len = max(reasoning_len, len(alt))
|
||||
logger.explore(
|
||||
"LLM returned empty content",
|
||||
extra={
|
||||
"src": "SharedLlmHttpClient",
|
||||
"finish_reason": finish_reason,
|
||||
"msg_keys": list(msg.keys()) if isinstance(msg, dict) else [],
|
||||
"response_preview": str(data)[:2000],
|
||||
"payload": {
|
||||
"finish_reason": finish_reason,
|
||||
"msg_keys": list(msg.keys()),
|
||||
"reasoning_len": reasoning_len,
|
||||
"usage": usage,
|
||||
"response_preview": str(data)[:1500],
|
||||
},
|
||||
},
|
||||
)
|
||||
raise ValueError("LLM returned empty content")
|
||||
@@ -303,20 +655,41 @@ async def _handle_response_format_fallback(
|
||||
payload: dict,
|
||||
url: str,
|
||||
headers: dict,
|
||||
) -> None:
|
||||
"""Handle 400 errors from structured_outputs not being supported."""
|
||||
_patterns = ("response_format", "structured_outputs", "structured", "json_object")
|
||||
if not response.is_success and response.status_code == 400 and any(p in (response_text or "").lower() for p in _patterns):
|
||||
logger.explore(
|
||||
"Structured outputs not supported, retrying without response_format",
|
||||
extra={"src": "SharedLlmHttpClient"},
|
||||
)
|
||||
payload.pop("response_format", None)
|
||||
new_response = await client.post(url, headers=headers, json=payload)
|
||||
# Mutate the original response object with new data
|
||||
response.status_code = new_response.status_code
|
||||
response._content = new_response.content
|
||||
response.encoding = new_response.encoding
|
||||
response.headers = new_response.headers
|
||||
) -> tuple[httpx.Response, str]:
|
||||
"""Handle 400 errors from unsupported request fields (json_object, think flags, …).
|
||||
|
||||
Returns a (possibly new) response + body text. Must not mutate encoding on a
|
||||
response whose `.text` was already read — httpx forbids that.
|
||||
"""
|
||||
if response.is_success or response.status_code != 400:
|
||||
return response, response_text
|
||||
body_l = (response_text or "").lower()
|
||||
_strip_keys: list[str] = []
|
||||
_patterns_to_keys = (
|
||||
(("response_format", "structured_outputs", "structured", "json_object"), "response_format"),
|
||||
(("reasoning_effort",), "reasoning_effort"),
|
||||
(("chat_template_kwargs", "enable_thinking"), "chat_template_kwargs"),
|
||||
(("think",), "think"),
|
||||
# DeepSeek V4 thinking toggle object — strip only when provider rejects it.
|
||||
(("thinking",), "thinking"),
|
||||
)
|
||||
for patterns, key in _patterns_to_keys:
|
||||
if key in payload and any(p in body_l for p in patterns):
|
||||
_strip_keys.append(key)
|
||||
# If body is vague ("unexpected keyword", "unknown field") strip optional anti-think flags.
|
||||
if not _strip_keys and any(p in body_l for p in ("unknown", "unexpected", "invalid", "not support")):
|
||||
for key in ("response_format", "reasoning_effort", "chat_template_kwargs", "think", "thinking"):
|
||||
if key in payload:
|
||||
_strip_keys.append(key)
|
||||
if not _strip_keys:
|
||||
return response, response_text
|
||||
for key in _strip_keys:
|
||||
payload.pop(key, None)
|
||||
logger.explore(
|
||||
"Retrying LLM request without unsupported fields",
|
||||
extra={"src": "SharedLlmHttpClient", "stripped": _strip_keys},
|
||||
)
|
||||
new_response = await client.post(url, headers=headers, json=payload)
|
||||
return new_response, new_response.text
|
||||
# #endregion SharedLlmHttpClient.HandleResponseFormatFallback
|
||||
# #endregion SharedLlmHttpClient
|
||||
|
||||
Reference in New Issue
Block a user