返回 JoyAI-Echo
1 """Base LLM provider interface."""
2
3 import asyncio
4 import json
5 import re
6 import time
7 from abc import ABC, abstractmethod
8 from collections import deque
9 from collections.abc import Awaitable, Callable
10 from dataclasses import dataclass, field
11 from datetime import datetime, timezone
12 from email.utils import parsedate_to_datetime
13 from threading import Lock
14 from typing import Any
15
16 from loguru import logger
17
18 from nanobot.utils.helpers import image_placeholder_text
19
20
21 class _LLMRequestWindowStats:
22 """Rolling 60-second LLM request counter for rate-limit diagnostics."""
23
24 WINDOW_S = 60.0
25
26 def __init__(self) -> None:
27 self._timestamps: deque[float] = deque()
28 self._lock = Lock()
29
30 def record(self) -> None:
31 now = time.monotonic()
32 with self._lock:
33 self._prune(now)
34 self._timestamps.append(now)
35
36 def count_last_minute(self) -> int:
37 now = time.monotonic()
38 with self._lock:
39 self._prune(now)
40 return len(self._timestamps)
41
42 def _prune(self, now: float) -> None:
43 cutoff = now - self.WINDOW_S
44 while self._timestamps and self._timestamps[0] < cutoff:
45 self._timestamps.popleft()
46
47
48 _llm_request_window_stats = _LLMRequestWindowStats()
49
50
51 @dataclass
52 class ToolCallRequest:
53 """A tool call request from the LLM."""
54 id: str
55 name: str
56 arguments: dict[str, Any]
57 extra_content: dict[str, Any] | None = None
58 provider_specific_fields: dict[str, Any] | None = None
59 function_provider_specific_fields: dict[str, Any] | None = None
60
61 def to_openai_tool_call(self) -> dict[str, Any]:
62 """Serialize to an OpenAI-style tool_call payload."""
63 tool_call = {
64 "id": self.id,
65 "type": "function",
66 "function": {
67 "name": self.name,
68 "arguments": json.dumps(self.arguments, ensure_ascii=False),
69 },
70 }
71 if self.extra_content:
72 tool_call["extra_content"] = self.extra_content
73 if self.provider_specific_fields:
74 tool_call["provider_specific_fields"] = self.provider_specific_fields
75 if self.function_provider_specific_fields:
76 tool_call["function"]["provider_specific_fields"] = self.function_provider_specific_fields
77 return tool_call
78
79
80 @dataclass
81 class LLMResponse:
82 """Response from an LLM provider."""
83 content: str | None
84 tool_calls: list[ToolCallRequest] = field(default_factory=list)
85 finish_reason: str = "stop"
86 usage: dict[str, int] = field(default_factory=dict)
87 retry_after: float | None = None # Provider supplied retry wait in seconds.
88 reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc.
89 thinking_blocks: list[dict] | None = None # Anthropic extended thinking
90 # Structured error metadata used by retry policy when finish_reason == "error".
91 error_status_code: int | None = None
92 error_kind: str | None = None # e.g. "timeout", "connection"
93 error_type: str | None = None # Provider/type semantic, e.g. insufficient_quota.
94 error_code: str | None = None # Provider/code semantic, e.g. rate_limit_exceeded.
95 error_retry_after_s: float | None = None
96 error_should_retry: bool | None = None
97
98 @property
99 def has_tool_calls(self) -> bool:
100 """Check if response contains tool calls."""
101 return len(self.tool_calls) > 0
102
103 @property
104 def should_execute_tools(self) -> bool:
105 """Tools execute only when has_tool_calls AND finish_reason is ``tool_calls`` / ``stop``.
106 Blocks gateway-injected calls under ``refusal`` / ``content_filter`` / ``error`` (#3220)."""
107 if not self.has_tool_calls:
108 return False
109 return self.finish_reason in ("tool_calls", "stop")
110
111
112 @dataclass(frozen=True)
113 class GenerationSettings:
114 """Default generation settings."""
115
116 temperature: float = 0.7
117 max_tokens: int = 4096
118 reasoning_effort: str | None = None
119
120
121 _SYNTHETIC_USER_CONTENT = "(conversation continued)"
122
123
124 class LLMProvider(ABC):
125 """Base class for LLM providers."""
126
127 _CHAT_RETRY_DELAYS = (1, 2, 4)
128 _PERSISTENT_MAX_DELAY = 60
129 _PERSISTENT_IDENTICAL_ERROR_LIMIT = 10
130 _RETRY_HEARTBEAT_CHUNK = 30
131 _TRANSIENT_ERROR_MARKERS = (
132 "429",
133 "rate limit",
134 "500",
135 "502",
136 "503",
137 "504",
138 "overloaded",
139 "timeout",
140 "timed out",
141 "connection",
142 "server error",
143 "temporarily unavailable",
144 "速率限制",
145 "throughput limit",
146 )
147 _RATE_LIMIT_ERROR_MARKERS = (
148 "429",
149 "rate limit",
150 "rate_limit",
151 "throughput limit",
152 "throughput_limit",
153 "too many requests",
154 "速率限制",
155 )
156 _RETRYABLE_STATUS_CODES = frozenset({408, 409, 429})
157 _TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"})
158 _NON_RETRYABLE_429_ERROR_TOKENS = frozenset({
159 "insufficient_quota",
160 "quota_exceeded",
161 "quota_exhausted",
162 "billing_hard_limit_reached",
163 "insufficient_balance",
164 "credit_balance_too_low",
165 "billing_not_active",
166 "payment_required",
167 })
168 _RETRYABLE_429_ERROR_TOKENS = frozenset({
169 "rate_limit_exceeded",
170 "rate_limit_error",
171 "too_many_requests",
172 "request_limit_exceeded",
173 "requests_limit_exceeded",
174 "overloaded_error",
175 })
176 _NON_RETRYABLE_429_TEXT_MARKERS = (
177 "insufficient_quota",
178 "insufficient quota",
179 "quota exceeded",
180 "quota exhausted",
181 "billing hard limit",
182 "billing_hard_limit_reached",
183 "billing not active",
184 "insufficient balance",
185 "insufficient_balance",
186 "credit balance too low",
187 "payment required",
188 "out of credits",
189 "out of quota",
190 "exceeded your current quota",
191 )
192 _RETRYABLE_429_TEXT_MARKERS = (
193 "rate limit",
194 "rate_limit",
195 "too many requests",
196 "retry after",
197 "try again in",
198 "temporarily unavailable",
199 "overloaded",
200 "concurrency limit",
201 "速率限制",
202 )
203
204 _SENTINEL = object()
205
206 def __init__(self, api_key: str | None = None, api_base: str | None = None):
207 self.api_key = api_key
208 self.api_base = api_base
209 self.generation: GenerationSettings = GenerationSettings()
210
211 @staticmethod
212 def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
213 """Sanitize message content: fix empty blocks, strip internal _meta fields."""
214 result: list[dict[str, Any]] = []
215 for msg in messages:
216 content = msg.get("content")
217
218 if isinstance(content, str) and not content:
219 clean = dict(msg)
220 clean["content"] = None if (msg.get("role") == "assistant" and msg.get("tool_calls")) else "(empty)"
221 result.append(clean)
222 continue
223
224 if isinstance(content, list):
225 new_items: list[Any] = []
226 changed = False
227 for item in content:
228 if (
229 isinstance(item, dict)
230 and item.get("type") in ("text", "input_text", "output_text")
231 and not item.get("text")
232 ):
233 changed = True
234 continue
235 if isinstance(item, dict) and "_meta" in item:
236 new_items.append({k: v for k, v in item.items() if k != "_meta"})
237 changed = True
238 else:
239 new_items.append(item)
240 if changed:
241 clean = dict(msg)
242 if new_items:
243 clean["content"] = new_items
244 elif msg.get("role") == "assistant" and msg.get("tool_calls"):
245 clean["content"] = None
246 else:
247 clean["content"] = "(empty)"
248 result.append(clean)
249 continue
250
251 if isinstance(content, dict):
252 clean = dict(msg)
253 clean["content"] = [content]
254 result.append(clean)
255 continue
256
257 result.append(msg)
258 return result
259
260 @staticmethod
261 def _tool_name(tool: dict[str, Any]) -> str:
262 """Extract tool name from either OpenAI or Anthropic-style tool schemas."""
263 name = tool.get("name")
264 if isinstance(name, str):
265 return name
266 fn = tool.get("function")
267 if isinstance(fn, dict):
268 fname = fn.get("name")
269 if isinstance(fname, str):
270 return fname
271 return ""
272
273 @classmethod
274 def _tool_cache_marker_indices(cls, tools: list[dict[str, Any]]) -> list[int]:
275 """Return cache marker indices: builtin/MCP boundary and tail index."""
276 if not tools:
277 return []
278
279 tail_idx = len(tools) - 1
280 last_builtin_idx: int | None = None
281 for i in range(tail_idx, -1, -1):
282 if not cls._tool_name(tools[i]).startswith("mcp_"):
283 last_builtin_idx = i
284 break
285
286 ordered_unique: list[int] = []
287 for idx in (last_builtin_idx, tail_idx):
288 if idx is not None and idx not in ordered_unique:
289 ordered_unique.append(idx)
290 return ordered_unique
291
292 @staticmethod
293 def _sanitize_request_messages(
294 messages: list[dict[str, Any]],
295 allowed_keys: frozenset[str],
296 ) -> list[dict[str, Any]]:
297 """Keep only provider-safe message keys and normalize assistant content."""
298 sanitized = []
299 for msg in messages:
300 clean = {k: v for k, v in msg.items() if k in allowed_keys}
301 if clean.get("role") == "assistant" and "content" not in clean:
302 clean["content"] = None
303 sanitized.append(clean)
304 return sanitized
305
306 @abstractmethod
307 async def chat(
308 self,
309 messages: list[dict[str, Any]],
310 tools: list[dict[str, Any]] | None = None,
311 model: str | None = None,
312 max_tokens: int = 4096,
313 temperature: float = 0.7,
314 reasoning_effort: str | None = None,
315 tool_choice: str | dict[str, Any] | None = None,
316 ) -> LLMResponse:
317 """
318 Send a chat completion request.
319
320 Args:
321 messages: List of message dicts with 'role' and 'content'.
322 tools: Optional list of tool definitions.
323 model: Model identifier (provider-specific).
324 max_tokens: Maximum tokens in response.
325 temperature: Sampling temperature.
326 tool_choice: Tool selection strategy ("auto", "required", or specific tool dict).
327
328 Returns:
329 LLMResponse with content and/or tool calls.
330 """
331 pass
332
333 @classmethod
334 def _is_transient_error(cls, content: str | None) -> bool:
335 err = (content or "").lower()
336 return any(marker in err for marker in cls._TRANSIENT_ERROR_MARKERS)
337
338 @classmethod
339 def _is_transient_response(cls, response: LLMResponse) -> bool:
340 """Prefer structured error metadata, fallback to text markers for legacy providers."""
341 if response.error_should_retry is not None:
342 return bool(response.error_should_retry)
343
344 if response.error_status_code is not None:
345 status = int(response.error_status_code)
346 if status == 429:
347 return cls._is_retryable_429_response(response)
348 if status in cls._RETRYABLE_STATUS_CODES or status >= 500:
349 return True
350
351 kind = (response.error_kind or "").strip().lower()
352 if kind in cls._TRANSIENT_ERROR_KINDS:
353 return True
354
355 return cls._is_transient_error(response.content)
356
357 @classmethod
358 def _is_rate_limit_response(cls, response: LLMResponse) -> bool:
359 """Return True when the provider response indicates throughput/rate limiting."""
360 if response.error_status_code == 429:
361 return cls._is_retryable_429_response(response)
362
363 type_token = cls._normalize_error_token(response.error_type)
364 code_token = cls._normalize_error_token(response.error_code)
365 semantic_tokens = {token for token in (type_token, code_token) if token is not None}
366 if semantic_tokens.intersection(cls._RETRYABLE_429_ERROR_TOKENS):
367 return True
368
369 content = (response.content or "").lower()
370 return any(marker in content for marker in cls._RATE_LIMIT_ERROR_MARKERS)
371
372 @classmethod
373 def _log_rate_limit_stats(
374 cls,
375 response: LLMResponse,
376 *,
377 model: str | None,
378 attempt: int,
379 delay: int | None = None,
380 giving_up: bool = False,
381 ) -> None:
382 requests_last_60s = _llm_request_window_stats.count_last_minute()
383 snippet = (response.content or "")[:120].lower()
384 if giving_up:
385 logger.warning(
386 "LLM rate limit after {} attempts, giving up: requests_last_60s={} model={} error={!r}",
387 attempt,
388 requests_last_60s,
389 model or "-",
390 snippet,
391 )
392 return
393 logger.warning(
394 "LLM rate limit (attempt {}, retrying in {}s): requests_last_60s={} model={} error={!r}",
395 attempt,
396 delay if delay is not None else 0,
397 requests_last_60s,
398 model or "-",
399 snippet,
400 )
401
402 @staticmethod
403 def _normalize_error_token(value: Any) -> str | None:
404 if value is None:
405 return None
406 token = str(value).strip().lower()
407 return token or None
408
409 @classmethod
410 def _extract_error_type_code(cls, payload: Any) -> tuple[str | None, str | None]:
411 data: dict[str, Any] | None = None
412 if isinstance(payload, dict):
413 data = payload
414 elif isinstance(payload, str):
415 text = payload.strip()
416 if text:
417 try:
418 parsed = json.loads(text)
419 except Exception:
420 parsed = None
421 if isinstance(parsed, dict):
422 data = parsed
423 if not isinstance(data, dict):
424 return None, None
425
426 error_obj = data.get("error")
427 type_value = data.get("type")
428 code_value = data.get("code")
429 if isinstance(error_obj, dict):
430 type_value = error_obj.get("type") or type_value
431 code_value = error_obj.get("code") or code_value
432
433 return cls._normalize_error_token(type_value), cls._normalize_error_token(code_value)
434
435 @classmethod
436 def _is_retryable_429_response(cls, response: LLMResponse) -> bool:
437 type_token = cls._normalize_error_token(response.error_type)
438 code_token = cls._normalize_error_token(response.error_code)
439 semantic_tokens = {
440 token for token in (type_token, code_token)
441 if token is not None
442 }
443 if any(token in cls._NON_RETRYABLE_429_ERROR_TOKENS for token in semantic_tokens):
444 return False
445
446 content = (response.content or "").lower()
447 if any(marker in content for marker in cls._NON_RETRYABLE_429_TEXT_MARKERS):
448 return False
449
450 if any(token in cls._RETRYABLE_429_ERROR_TOKENS for token in semantic_tokens):
451 return True
452 if any(marker in content for marker in cls._RETRYABLE_429_TEXT_MARKERS):
453 return True
454 # Unknown 429 defaults to WAIT+retry.
455 return True
456
457 @staticmethod
458 def _enforce_role_alternation(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
459 """Merge consecutive same-role messages and drop trailing assistant messages.
460
461 Some providers (OpenAI-compat, Azure, vLLM, Ollama, etc.) reject requests
462 where the last message is 'assistant' (prefill not supported) or two
463 consecutive non-system messages share the same role.
464 """
465 if not messages:
466 return messages
467
468 merged: list[dict[str, Any]] = []
469 for msg in messages:
470 role = msg.get("role")
471 if (
472 merged
473 and role != "system"
474 and role not in ("tool",)
475 and merged[-1].get("role") == role
476 and role in ("user", "assistant")
477 ):
478 prev = merged[-1]
479 if role == "assistant":
480 prev_has_tools = bool(prev.get("tool_calls"))
481 curr_has_tools = bool(msg.get("tool_calls"))
482 if curr_has_tools:
483 merged[-1] = dict(msg)
484 continue
485 if prev_has_tools:
486 continue
487 prev_content = prev.get("content") or ""
488 curr_content = msg.get("content") or ""
489 if isinstance(prev_content, str) and isinstance(curr_content, str):
490 prev["content"] = (prev_content + "\n\n" + curr_content).strip()
491 else:
492 merged[-1] = dict(msg)
493 else:
494 merged.append(dict(msg))
495
496 last_popped = None
497 while merged and merged[-1].get("role") == "assistant":
498 last_popped = merged.pop()
499
500 # If removing trailing assistant messages left only system messages,
501 # the request would be invalid for most providers (e.g. Zhipu/GLM
502 # error 1214). Recover by converting the last popped assistant
503 # message to a user message so the LLM can still see the content.
504 if (
505 merged
506 and last_popped is not None
507 and not any(m.get("role") in ("user", "tool") for m in merged)
508 ):
509 recovered = dict(last_popped)
510 recovered["role"] = "user"
511 merged.append(recovered)
512
513 # Safety net: ensure the first non-system message is not a bare
514 # ``assistant`` message. Providers like GLM reject system→assistant
515 # with error 1214. This can happen when upstream truncation (e.g.
516 # _snip_history) drops the only user message. Insert a synthetic
517 # user message to keep the sequence valid.
518 for i, msg in enumerate(merged):
519 if msg.get("role") != "system":
520 if msg.get("role") == "assistant" and not msg.get("tool_calls"):
521 merged.insert(i, {"role": "user", "content": _SYNTHETIC_USER_CONTENT})
522 break
523
524 return merged
525
526 @staticmethod
527 def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
528 """Replace image_url blocks with text placeholder. Returns None if no images found."""
529 found = False
530 result = []
531 for msg in messages:
532 content = msg.get("content")
533 if isinstance(content, list):
534 new_content = []
535 for b in content:
536 if isinstance(b, dict) and b.get("type") == "image_url":
537 path = (b.get("_meta") or {}).get("path", "")
538 placeholder = image_placeholder_text(path, empty="[image omitted]")
539 new_content.append({"type": "text", "text": placeholder})
540 found = True
541 else:
542 new_content.append(b)
543 result.append({**msg, "content": new_content})
544 else:
545 result.append(msg)
546 return result if found else None
547
548 @staticmethod
549 def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
550 """Replace image_url blocks with text placeholder *in-place*.
551
552 Mutates the content lists of the original message dicts so that
553 callers holding references to those dicts also see the stripped
554 version.
555 """
556 found = False
557 for msg in messages:
558 content = msg.get("content")
559 if isinstance(content, list):
560 for i, b in enumerate(content):
561 if isinstance(b, dict) and b.get("type") == "image_url":
562 path = (b.get("_meta") or {}).get("path", "")
563 placeholder = image_placeholder_text(path, empty="[image omitted]")
564 content[i] = {"type": "text", "text": placeholder}
565 found = True
566 return found
567
568 async def _safe_chat(self, **kwargs: Any) -> LLMResponse:
569 """Call chat() and convert unexpected exceptions to error responses."""
570 try:
571 return await self.chat(**kwargs)
572 except asyncio.CancelledError:
573 raise
574 except Exception as exc:
575 return LLMResponse(content=f"Error calling LLM: {exc}", finish_reason="error")
576
577 async def chat_stream(
578 self,
579 messages: list[dict[str, Any]],
580 tools: list[dict[str, Any]] | None = None,
581 model: str | None = None,
582 max_tokens: int = 4096,
583 temperature: float = 0.7,
584 reasoning_effort: str | None = None,
585 tool_choice: str | dict[str, Any] | None = None,
586 on_content_delta: Callable[[str], Awaitable[None]] | None = None,
587 ) -> LLMResponse:
588 """Stream a chat completion, calling *on_content_delta* for each text chunk.
589
590 Returns the same ``LLMResponse`` as :meth:`chat`. The default
591 implementation falls back to a non-streaming call and delivers the
592 full content as a single delta. Providers that support native
593 streaming should override this method.
594 """
595 response = await self.chat(
596 messages=messages, tools=tools, model=model,
597 max_tokens=max_tokens, temperature=temperature,
598 reasoning_effort=reasoning_effort, tool_choice=tool_choice,
599 )
600 if on_content_delta and response.content:
601 await on_content_delta(response.content)
602 return response
603
604 async def _safe_chat_stream(self, **kwargs: Any) -> LLMResponse:
605 """Call chat_stream() and convert unexpected exceptions to error responses."""
606 try:
607 return await self.chat_stream(**kwargs)
608 except asyncio.CancelledError:
609 raise
610 except Exception as exc:
611 return LLMResponse(content=f"Error calling LLM: {exc}", finish_reason="error")
612
613 async def chat_stream_with_retry(
614 self,
615 messages: list[dict[str, Any]],
616 tools: list[dict[str, Any]] | None = None,
617 model: str | None = None,
618 max_tokens: object = _SENTINEL,
619 temperature: object = _SENTINEL,
620 reasoning_effort: object = _SENTINEL,
621 tool_choice: str | dict[str, Any] | None = None,
622 on_content_delta: Callable[[str], Awaitable[None]] | None = None,
623 retry_mode: str = "standard",
624 on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
625 ) -> LLMResponse:
626 """Call chat_stream() with retry on transient provider failures."""
627 if max_tokens is self._SENTINEL or max_tokens is None:
628 max_tokens = self.generation.max_tokens
629 if temperature is self._SENTINEL or temperature is None:
630 temperature = self.generation.temperature
631 if reasoning_effort is self._SENTINEL:
632 reasoning_effort = self.generation.reasoning_effort
633
634 kw: dict[str, Any] = dict(
635 messages=messages, tools=tools, model=model,
636 max_tokens=max_tokens, temperature=temperature,
637 reasoning_effort=reasoning_effort, tool_choice=tool_choice,
638 on_content_delta=on_content_delta,
639 )
640 return await self._run_with_retry(
641 self._safe_chat_stream,
642 kw,
643 messages,
644 retry_mode=retry_mode,
645 on_retry_wait=on_retry_wait,
646 )
647
648 async def chat_with_retry(
649 self,
650 messages: list[dict[str, Any]],
651 tools: list[dict[str, Any]] | None = None,
652 model: str | None = None,
653 max_tokens: object = _SENTINEL,
654 temperature: object = _SENTINEL,
655 reasoning_effort: object = _SENTINEL,
656 tool_choice: str | dict[str, Any] | None = None,
657 retry_mode: str = "standard",
658 on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
659 ) -> LLMResponse:
660 """Call chat() with retry on transient provider failures.
661
662 Parameters default to ``self.generation`` when not explicitly passed,
663 so callers no longer need to thread temperature / max_tokens /
664 reasoning_effort through every layer. Explicit ``None`` is also
665 normalized to the provider's generation defaults so that downstream
666 ``_build_kwargs`` never sees ``None`` for ``max_tokens`` / ``temperature``
667 (which would crash ``max(1, max_tokens)``).
668 """
669 if max_tokens is self._SENTINEL or max_tokens is None:
670 max_tokens = self.generation.max_tokens
671 if temperature is self._SENTINEL or temperature is None:
672 temperature = self.generation.temperature
673 if reasoning_effort is self._SENTINEL:
674 reasoning_effort = self.generation.reasoning_effort
675
676 kw: dict[str, Any] = dict(
677 messages=messages, tools=tools, model=model,
678 max_tokens=max_tokens, temperature=temperature,
679 reasoning_effort=reasoning_effort, tool_choice=tool_choice,
680 )
681 return await self._run_with_retry(
682 self._safe_chat,
683 kw,
684 messages,
685 retry_mode=retry_mode,
686 on_retry_wait=on_retry_wait,
687 )
688
689 @classmethod
690 def _extract_retry_after(cls, content: str | None) -> float | None:
691 text = (content or "").lower()
692 patterns = (
693 r"retry after\s+(\d+(?:\.\d+)?)\s*(ms|milliseconds|s|sec|secs|seconds|m|min|minutes)?",
694 r"try again in\s+(\d+(?:\.\d+)?)\s*(ms|milliseconds|s|sec|secs|seconds|m|min|minutes)",
695 r"wait\s+(\d+(?:\.\d+)?)\s*(ms|milliseconds|s|sec|secs|seconds|m|min|minutes)\s*before retry",
696 r"retry[_-]?after[\"'\s:=]+(\d+(?:\.\d+)?)",
697 )
698 for idx, pattern in enumerate(patterns):
699 match = re.search(pattern, text)
700 if not match:
701 continue
702 value = float(match.group(1))
703 unit = match.group(2) if idx < 3 else "s"
704 return cls._to_retry_seconds(value, unit)
705 return None
706
707 @classmethod
708 def _to_retry_seconds(cls, value: float, unit: str | None = None) -> float:
709 normalized_unit = (unit or "s").lower()
710 if normalized_unit in {"ms", "milliseconds"}:
711 return max(0.1, value / 1000.0)
712 if normalized_unit in {"m", "min", "minutes"}:
713 return max(0.1, value * 60.0)
714 return max(0.1, value)
715
716 @classmethod
717 def _extract_retry_after_from_headers(cls, headers: Any) -> float | None:
718 if not headers:
719 return None
720
721 def _header_value(name: str) -> Any:
722 if hasattr(headers, "get"):
723 value = headers.get(name) or headers.get(name.title())
724 if value is not None:
725 return value
726 if isinstance(headers, dict):
727 for key, value in headers.items():
728 if isinstance(key, str) and key.lower() == name.lower():
729 return value
730 return None
731
732 try:
733 retry_ms = _header_value("retry-after-ms")
734 if retry_ms is not None:
735 value = float(retry_ms) / 1000.0
736 if value > 0:
737 return value
738 except (TypeError, ValueError):
739 pass
740
741 retry_after = _header_value("retry-after")
742 if retry_after is None:
743 return None
744 retry_after_text = str(retry_after).strip()
745 if not retry_after_text:
746 return None
747 if re.fullmatch(r"\d+(?:\.\d+)?", retry_after_text):
748 return cls._to_retry_seconds(float(retry_after_text), "s")
749 try:
750 retry_at = parsedate_to_datetime(retry_after_text)
751 except Exception:
752 return None
753 if retry_at.tzinfo is None:
754 retry_at = retry_at.replace(tzinfo=timezone.utc)
755 remaining = (retry_at - datetime.now(retry_at.tzinfo)).total_seconds()
756 return max(0.1, remaining)
757
758 @classmethod
759 def _extract_retry_after_from_response(cls, response: LLMResponse) -> float | None:
760 if response.error_retry_after_s is not None and response.error_retry_after_s > 0:
761 return response.error_retry_after_s
762 if response.retry_after is not None and response.retry_after > 0:
763 return response.retry_after
764 return cls._extract_retry_after(response.content)
765
766 async def _sleep_with_heartbeat(
767 self,
768 delay: float,
769 *,
770 attempt: int,
771 persistent: bool,
772 on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
773 ) -> None:
774 remaining = max(0.0, delay)
775 while remaining > 0:
776 if on_retry_wait:
777 kind = "persistent retry" if persistent else "retry"
778 await on_retry_wait(
779 f"Model request failed, {kind} in {max(1, int(round(remaining)))}s "
780 f"(attempt {attempt})."
781 )
782 chunk = min(remaining, self._RETRY_HEARTBEAT_CHUNK)
783 await asyncio.sleep(chunk)
784 remaining -= chunk
785
786 async def _run_with_retry(
787 self,
788 call: Callable[..., Awaitable[LLMResponse]],
789 kw: dict[str, Any],
790 original_messages: list[dict[str, Any]],
791 *,
792 retry_mode: str,
793 on_retry_wait: Callable[[str], Awaitable[None]] | None,
794 ) -> LLMResponse:
795 attempt = 0
796 delays = list(self._CHAT_RETRY_DELAYS)
797 persistent = retry_mode == "persistent"
798 last_response: LLMResponse | None = None
799 last_error_key: str | None = None
800 identical_error_count = 0
801 model = kw.get("model")
802 while True:
803 attempt += 1
804 _llm_request_window_stats.record()
805 response = await call(**kw)
806 if response.finish_reason != "error":
807 return response
808 last_response = response
809 error_key = ((response.content or "").strip().lower() or None)
810 if error_key and error_key == last_error_key:
811 identical_error_count += 1
812 else:
813 last_error_key = error_key
814 identical_error_count = 1 if error_key else 0
815
816 if not self._is_transient_response(response):
817 stripped = self._strip_image_content(original_messages)
818 if stripped is not None and stripped != kw["messages"]:
819 logger.warning(
820 "Non-transient LLM error with image content, retrying without images. "
821 "error_status={} error_kind={} error_type={} error_code={} content={!r}",
822 getattr(response, "error_status_code", None),
823 getattr(response, "error_kind", None),
824 getattr(response, "error_type", None),
825 getattr(response, "error_code", None),
826 ((response.content or "")[:300]),
827 )
828 retry_kw = dict(kw)
829 retry_kw["messages"] = stripped
830 _llm_request_window_stats.record()
831 result = await call(**retry_kw)
832 # Permanently strip images from the original messages so
833 # subsequent iterations do not repeat the error-retry cycle.
834 if result.finish_reason != "error":
835 self._strip_image_content_inplace(original_messages)
836 return result
837 return response
838
839 if persistent and identical_error_count >= self._PERSISTENT_IDENTICAL_ERROR_LIMIT:
840 logger.warning(
841 "Stopping persistent retry after {} identical transient errors: {}",
842 identical_error_count,
843 (response.content or "")[:120].lower(),
844 )
845 if on_retry_wait:
846 await on_retry_wait(
847 f"Persistent retry stopped after {identical_error_count} identical errors."
848 )
849 return response
850
851 if not persistent and attempt > len(delays):
852 if self._is_rate_limit_response(response):
853 self._log_rate_limit_stats(
854 response,
855 model=model if isinstance(model, str) else None,
856 attempt=attempt,
857 giving_up=True,
858 )
859 logger.warning(
860 "LLM request failed after {} retries, giving up: {}",
861 attempt,
862 (response.content or "")[:120].lower(),
863 )
864 if on_retry_wait:
865 await on_retry_wait(
866 f"Model request failed after {attempt} retries, giving up."
867 )
868 break
869
870 base_delay = delays[min(attempt - 1, len(delays) - 1)]
871 delay = self._extract_retry_after_from_response(response) or base_delay
872 if persistent:
873 delay = min(delay, self._PERSISTENT_MAX_DELAY)
874
875 retry_delay_s = int(round(delay))
876 if self._is_rate_limit_response(response):
877 self._log_rate_limit_stats(
878 response,
879 model=model if isinstance(model, str) else None,
880 attempt=attempt,
881 delay=retry_delay_s,
882 )
883 else:
884 logger.warning(
885 "LLM transient error (attempt {}{}), retrying in {}s: {}",
886 attempt,
887 "+" if persistent and attempt > len(delays) else f"/{len(delays)}",
888 retry_delay_s,
889 (response.content or "")[:120].lower(),
890 )
891 await self._sleep_with_heartbeat(
892 delay,
893 attempt=attempt,
894 persistent=persistent,
895 on_retry_wait=on_retry_wait,
896 )
897
898 if last_response is not None:
899 return last_response
900 _llm_request_window_stats.record()
901 return await call(**kw)
902
903 @abstractmethod
904 def get_default_model(self) -> str:
905 """Get the default model for this provider."""
906 pass
907
907 lines PYTHON