Merge remote-tracking branch 'origin/main' into pr-2722
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
@@ -135,6 +136,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
api_key=api_key or "no-key",
|
||||
base_url=effective_base,
|
||||
default_headers=default_headers,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
def _setup_env(self, api_key: str, api_base: str | None) -> None:
|
||||
@@ -223,6 +225,21 @@ class OpenAICompatProvider(LLMProvider):
|
||||
# Build kwargs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _supports_temperature(
|
||||
model_name: str,
|
||||
reasoning_effort: str | None = None,
|
||||
) -> bool:
|
||||
"""Return True when the model accepts a temperature parameter.
|
||||
|
||||
GPT-5 family and reasoning models (o1/o3/o4) reject temperature
|
||||
when reasoning_effort is set to anything other than ``"none"``.
|
||||
"""
|
||||
if reasoning_effort and reasoning_effort.lower() != "none":
|
||||
return False
|
||||
name = model_name.lower()
|
||||
return not any(token in name for token in ("gpt-5", "o1", "o3", "o4"))
|
||||
|
||||
def _build_kwargs(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
@@ -237,7 +254,9 @@ class OpenAICompatProvider(LLMProvider):
|
||||
spec = self._spec
|
||||
|
||||
if spec and spec.supports_prompt_caching:
|
||||
messages, tools = self._apply_cache_control(messages, tools)
|
||||
model_name = model or self.default_model
|
||||
if any(model_name.lower().startswith(k) for k in ("anthropic/", "claude")):
|
||||
messages, tools = self._apply_cache_control(messages, tools)
|
||||
|
||||
if spec and spec.strip_model_prefix:
|
||||
model_name = model_name.split("/")[-1]
|
||||
@@ -245,9 +264,13 @@ class OpenAICompatProvider(LLMProvider):
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model_name,
|
||||
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
# GPT-5 and reasoning models (o1/o3/o4) reject temperature when
|
||||
# reasoning_effort is active. Only include it when safe.
|
||||
if self._supports_temperature(model_name, reasoning_effort):
|
||||
kwargs["temperature"] = temperature
|
||||
|
||||
if spec and getattr(spec, "supports_max_completion_tokens", False):
|
||||
kwargs["max_completion_tokens"] = max(1, max_tokens)
|
||||
else:
|
||||
@@ -310,6 +333,13 @@ class OpenAICompatProvider(LLMProvider):
|
||||
|
||||
@classmethod
|
||||
def _extract_usage(cls, response: Any) -> dict[str, int]:
|
||||
"""Extract token usage from an OpenAI-compatible response.
|
||||
|
||||
Handles both dict-based (raw JSON) and object-based (SDK Pydantic)
|
||||
responses. Provider-specific ``cached_tokens`` fields are normalised
|
||||
under a single key; see the priority chain inside for details.
|
||||
"""
|
||||
# --- resolve usage object ---
|
||||
usage_obj = None
|
||||
response_map = cls._maybe_mapping(response)
|
||||
if response_map is not None:
|
||||
@@ -319,19 +349,53 @@ class OpenAICompatProvider(LLMProvider):
|
||||
|
||||
usage_map = cls._maybe_mapping(usage_obj)
|
||||
if usage_map is not None:
|
||||
return {
|
||||
result = {
|
||||
"prompt_tokens": int(usage_map.get("prompt_tokens") or 0),
|
||||
"completion_tokens": int(usage_map.get("completion_tokens") or 0),
|
||||
"total_tokens": int(usage_map.get("total_tokens") or 0),
|
||||
}
|
||||
|
||||
if usage_obj:
|
||||
return {
|
||||
elif usage_obj:
|
||||
result = {
|
||||
"prompt_tokens": getattr(usage_obj, "prompt_tokens", 0) or 0,
|
||||
"completion_tokens": getattr(usage_obj, "completion_tokens", 0) or 0,
|
||||
"total_tokens": getattr(usage_obj, "total_tokens", 0) or 0,
|
||||
}
|
||||
return {}
|
||||
else:
|
||||
return {}
|
||||
|
||||
# --- cached_tokens (normalised across providers) ---
|
||||
# Try nested paths first (dict), fall back to attribute (SDK object).
|
||||
# Priority order ensures the most specific field wins.
|
||||
for path in (
|
||||
("prompt_tokens_details", "cached_tokens"), # OpenAI/Zhipu/MiniMax/Qwen/Mistral/xAI
|
||||
("cached_tokens",), # StepFun/Moonshot (top-level)
|
||||
("prompt_cache_hit_tokens",), # DeepSeek/SiliconFlow
|
||||
):
|
||||
cached = cls._get_nested_int(usage_map, path)
|
||||
if not cached and usage_obj:
|
||||
cached = cls._get_nested_int(usage_obj, path)
|
||||
if cached:
|
||||
result["cached_tokens"] = cached
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _get_nested_int(obj: Any, path: tuple[str, ...]) -> int:
|
||||
"""Drill into *obj* by *path* segments and return an ``int`` value.
|
||||
|
||||
Supports both dict-key access and attribute access so it works
|
||||
uniformly with raw JSON dicts **and** SDK Pydantic models.
|
||||
"""
|
||||
current = obj
|
||||
for segment in path:
|
||||
if current is None:
|
||||
return 0
|
||||
if isinstance(current, dict):
|
||||
current = current.get(segment)
|
||||
else:
|
||||
current = getattr(current, segment, None)
|
||||
return int(current or 0) if current is not None else 0
|
||||
|
||||
def _parse(self, response: Any) -> LLMResponse:
|
||||
if isinstance(response, str):
|
||||
@@ -344,9 +408,13 @@ class OpenAICompatProvider(LLMProvider):
|
||||
content = self._extract_text_content(
|
||||
response_map.get("content") or response_map.get("output_text")
|
||||
)
|
||||
reasoning_content = self._extract_text_content(
|
||||
response_map.get("reasoning_content")
|
||||
)
|
||||
if content is not None:
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
reasoning_content=reasoning_content,
|
||||
finish_reason=str(response_map.get("finish_reason") or "stop"),
|
||||
usage=self._extract_usage(response_map),
|
||||
)
|
||||
@@ -441,6 +509,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
@classmethod
|
||||
def _parse_chunks(cls, chunks: list[Any]) -> LLMResponse:
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
tc_bufs: dict[int, dict[str, Any]] = {}
|
||||
finish_reason = "stop"
|
||||
usage: dict[str, int] = {}
|
||||
@@ -494,6 +563,9 @@ class OpenAICompatProvider(LLMProvider):
|
||||
text = cls._extract_text_content(delta.get("content"))
|
||||
if text:
|
||||
content_parts.append(text)
|
||||
text = cls._extract_text_content(delta.get("reasoning_content"))
|
||||
if text:
|
||||
reasoning_parts.append(text)
|
||||
for idx, tc in enumerate(delta.get("tool_calls") or []):
|
||||
_accum_tc(tc, idx)
|
||||
usage = cls._extract_usage(chunk_map) or usage
|
||||
@@ -508,6 +580,10 @@ class OpenAICompatProvider(LLMProvider):
|
||||
delta = choice.delta
|
||||
if delta and delta.content:
|
||||
content_parts.append(delta.content)
|
||||
if delta:
|
||||
reasoning = getattr(delta, "reasoning_content", None)
|
||||
if reasoning:
|
||||
reasoning_parts.append(reasoning)
|
||||
for tc in (delta.tool_calls or []) if delta else []:
|
||||
_accum_tc(tc, getattr(tc, "index", 0))
|
||||
|
||||
@@ -526,13 +602,19 @@ class OpenAICompatProvider(LLMProvider):
|
||||
],
|
||||
finish_reason=finish_reason,
|
||||
usage=usage,
|
||||
reasoning_content="".join(reasoning_parts) or None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _handle_error(e: Exception) -> LLMResponse:
|
||||
body = getattr(e, "doc", None) or getattr(getattr(e, "response", None), "text", None)
|
||||
msg = f"Error: {body.strip()[:500]}" if body and body.strip() else f"Error calling LLM: {e}"
|
||||
return LLMResponse(content=msg, finish_reason="error")
|
||||
response = getattr(e, "response", None)
|
||||
body = getattr(e, "doc", None) or getattr(response, "text", None)
|
||||
body_text = str(body).strip() if body is not None else ""
|
||||
msg = f"Error: {body_text[:500]}" if body_text else f"Error calling LLM: {e}"
|
||||
retry_after = LLMProvider._extract_retry_after_from_headers(getattr(response, "headers", None))
|
||||
if retry_after is None:
|
||||
retry_after = LLMProvider._extract_retry_after(msg)
|
||||
return LLMResponse(content=msg, finish_reason="error", retry_after=retry_after)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
@@ -574,16 +656,36 @@ class OpenAICompatProvider(LLMProvider):
|
||||
)
|
||||
kwargs["stream"] = True
|
||||
kwargs["stream_options"] = {"include_usage": True}
|
||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||
try:
|
||||
stream = await self._client.chat.completions.create(**kwargs)
|
||||
chunks: list[Any] = []
|
||||
async for chunk in stream:
|
||||
stream_iter = stream.__aiter__()
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(
|
||||
stream_iter.__anext__(),
|
||||
timeout=idle_timeout_s,
|
||||
)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
if on_content_delta and chunk.choices:
|
||||
text = getattr(chunk.choices[0].delta, "reasoning_content", None)
|
||||
if text:
|
||||
await on_content_delta(text)
|
||||
text = getattr(chunk.choices[0].delta, "content", None)
|
||||
if text:
|
||||
await on_content_delta(text)
|
||||
return self._parse_chunks(chunks)
|
||||
except asyncio.TimeoutError:
|
||||
return LLMResponse(
|
||||
content=(
|
||||
f"Error calling LLM: stream stalled for more than "
|
||||
f"{idle_timeout_s} seconds"
|
||||
),
|
||||
finish_reason="error",
|
||||
)
|
||||
except Exception as e:
|
||||
return self._handle_error(e)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user