feat(llm): add LLM integration module with OpenRouter and Claude Code adapters
Implements markitect/llm/ package with concrete LLMAdapter implementations:
- OpenRouterAdapter: HTTP via urllib with retry/backoff on 429/5xx
- ClaudeCodeAdapter: subprocess-based Claude CLI with stdin piping
- Factory pattern: create_adapter("openrouter") or create_adapter("claude-code")
- API key resolution chain: constructor > env var > project-root key file
- 42 unit tests, 2 integration tests (gated on API key / CLI availability)
Also adds the infospace-with-history example with Wealth of Nations VSM
analysis pipeline, templates, schemas, source chapters, and processed
output for chapters 1-2. process_chapters.py now supports --provider
and --model flags for automatic LLM-driven processing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
40
markitect/llm/__init__.py
Normal file
40
markitect/llm/__init__.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
markitect.llm — LLM integration adapters for MarkiTect.
|
||||
|
||||
Provides concrete :class:`LLMAdapter` implementations backed by
|
||||
OpenRouter (HTTP) and Claude Code CLI (subprocess).
|
||||
|
||||
Quick start::
|
||||
|
||||
from markitect.llm import create_adapter
|
||||
|
||||
adapter = create_adapter("openrouter", model="anthropic/claude-sonnet-4")
|
||||
response = adapter.execute_prompt(prompt, run_config)
|
||||
"""
|
||||
|
||||
from markitect.llm.factory import create_adapter
|
||||
from markitect.llm.openrouter import OpenRouterAdapter
|
||||
from markitect.llm.claude_code import ClaudeCodeAdapter
|
||||
from markitect.llm.config import LLMConfig, load_config
|
||||
from markitect.llm.exceptions import (
|
||||
LLMError,
|
||||
LLMConfigurationError,
|
||||
LLMAPIError,
|
||||
LLMRateLimitError,
|
||||
LLMTimeoutError,
|
||||
LLMSubprocessError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"create_adapter",
|
||||
"OpenRouterAdapter",
|
||||
"ClaudeCodeAdapter",
|
||||
"LLMConfig",
|
||||
"load_config",
|
||||
"LLMError",
|
||||
"LLMConfigurationError",
|
||||
"LLMAPIError",
|
||||
"LLMRateLimitError",
|
||||
"LLMTimeoutError",
|
||||
"LLMSubprocessError",
|
||||
]
|
||||
79
markitect/llm/_http.py
Normal file
79
markitect/llm/_http.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Thin synchronous HTTP helper built on :mod:`urllib.request`.
|
||||
|
||||
Translates HTTP errors into typed :mod:`markitect.llm.exceptions`.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from markitect.llm.exceptions import (
|
||||
LLMAPIError,
|
||||
LLMRateLimitError,
|
||||
LLMTimeoutError,
|
||||
)
|
||||
|
||||
|
||||
def post_json(
|
||||
url: str,
|
||||
payload: Dict[str, Any],
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 300,
|
||||
) -> Dict[str, Any]:
|
||||
"""POST *payload* as JSON and return the parsed response body.
|
||||
|
||||
Raises:
|
||||
LLMRateLimitError: on HTTP 429
|
||||
LLMAPIError: on other non-2xx responses
|
||||
LLMTimeoutError: on socket / read timeout
|
||||
"""
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json", **(headers or {})},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
body = resp.read().decode()
|
||||
return json.loads(body)
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = ""
|
||||
try:
|
||||
body = exc.read().decode()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if exc.code == 429:
|
||||
raise LLMRateLimitError(
|
||||
f"Rate limited (429) from {url}",
|
||||
status_code=429,
|
||||
response_body=body,
|
||||
cause=exc,
|
||||
) from exc
|
||||
|
||||
raise LLMAPIError(
|
||||
f"HTTP {exc.code} from {url}",
|
||||
status_code=exc.code,
|
||||
response_body=body,
|
||||
cause=exc,
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
if "timed out" in str(exc.reason):
|
||||
raise LLMTimeoutError(
|
||||
f"Request to {url} timed out after {timeout}s",
|
||||
cause=exc,
|
||||
) from exc
|
||||
raise LLMAPIError(
|
||||
f"URL error for {url}: {exc.reason}",
|
||||
cause=exc,
|
||||
) from exc
|
||||
except TimeoutError as exc:
|
||||
raise LLMTimeoutError(
|
||||
f"Request to {url} timed out after {timeout}s",
|
||||
cause=exc,
|
||||
) from exc
|
||||
16
markitect/llm/_token_estimator.py
Normal file
16
markitect/llm/_token_estimator.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Rough token estimation for backends that don't return usage data.
|
||||
|
||||
Uses the ~4 characters per token heuristic common across English LLM tokenizers.
|
||||
"""
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""Estimate the number of tokens in *text*.
|
||||
|
||||
This is intentionally coarse — it is only used by the Claude Code CLI
|
||||
adapter where real token counts are unavailable.
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
return max(1, len(text) // 4)
|
||||
94
markitect/llm/claude_code.py
Normal file
94
markitect/llm/claude_code.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Claude Code CLI adapter — runs the ``claude`` CLI as a subprocess.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
from markitect.prompts.execution.llm_adapter import LLMAdapter
|
||||
from markitect.prompts.execution.models import RunConfig, LLMResponse
|
||||
from markitect.llm.config import LLMConfig
|
||||
from markitect.llm._token_estimator import estimate_tokens
|
||||
from markitect.llm.exceptions import (
|
||||
LLMSubprocessError,
|
||||
LLMTimeoutError,
|
||||
)
|
||||
|
||||
|
||||
class ClaudeCodeAdapter(LLMAdapter):
|
||||
"""LLM adapter that shells out to the ``claude`` CLI with ``--print``.
|
||||
|
||||
The compiled prompt is piped via **stdin** to avoid shell argument
|
||||
length limits (compiled prompts can exceed 30 KB).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cli_path: str = "claude",
|
||||
model: Optional[str] = None,
|
||||
config: Optional[LLMConfig] = None,
|
||||
):
|
||||
self._config = config or LLMConfig(provider="claude-code")
|
||||
self._cli_path = cli_path or self._config.claude_cli_path
|
||||
self._model = model
|
||||
|
||||
# ── LLMAdapter interface ────────────────────────────────────────
|
||||
|
||||
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
|
||||
cmd = [self._cli_path, "--print"]
|
||||
if self._model:
|
||||
cmd.extend(["--model", self._model])
|
||||
|
||||
timeout = config.timeout_seconds or self._config.timeout_seconds
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise LLMTimeoutError(
|
||||
f"claude CLI timed out after {timeout}s",
|
||||
cause=exc,
|
||||
) from exc
|
||||
|
||||
if result.returncode != 0:
|
||||
raise LLMSubprocessError(
|
||||
f"claude CLI exited with code {result.returncode}",
|
||||
return_code=result.returncode,
|
||||
stderr=result.stderr,
|
||||
)
|
||||
|
||||
content = result.stdout
|
||||
prompt_tokens = estimate_tokens(prompt)
|
||||
completion_tokens = estimate_tokens(content)
|
||||
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
model=self._model or "claude-code-cli",
|
||||
usage={
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": prompt_tokens + completion_tokens,
|
||||
},
|
||||
finish_reason="stop",
|
||||
metadata={
|
||||
"provider": "claude-code",
|
||||
"cli_path": self._cli_path,
|
||||
},
|
||||
)
|
||||
|
||||
def validate_config(self, config: RunConfig) -> bool:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self._cli_path, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
return False
|
||||
108
markitect/llm/config.py
Normal file
108
markitect/llm/config.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
LLM configuration and API key resolution.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
import os
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMConfig:
|
||||
"""Configuration for an LLM adapter.
|
||||
|
||||
Attributes:
|
||||
provider: Backend identifier (``"openrouter"`` or ``"claude-code"``).
|
||||
model: Model name / path sent to the provider.
|
||||
api_key: Resolved API key (may be ``None`` for CLI backends).
|
||||
api_base: Base URL for HTTP-based providers.
|
||||
claude_cli_path: Path to the ``claude`` CLI binary.
|
||||
timeout_seconds: Per-request timeout.
|
||||
max_retries: Number of retry attempts on transient errors.
|
||||
extra: Arbitrary provider-specific overrides.
|
||||
"""
|
||||
|
||||
provider: str = "openrouter"
|
||||
model: str = "anthropic/claude-sonnet-4"
|
||||
api_key: Optional[str] = None
|
||||
api_base: str = "https://openrouter.ai/api/v1"
|
||||
claude_cli_path: str = "claude"
|
||||
timeout_seconds: int = 300
|
||||
max_retries: int = 3
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def resolve_api_key(
|
||||
explicit: Optional[str] = None,
|
||||
env_var: str = "OPENROUTER_API_KEY",
|
||||
key_file_paths: Optional[list[Path]] = None,
|
||||
) -> Optional[str]:
|
||||
"""Return an API key from the first available source.
|
||||
|
||||
Resolution order:
|
||||
1. *explicit* argument (passed directly by caller)
|
||||
2. Environment variable *env_var*
|
||||
3. First readable file in *key_file_paths* whose content is non-empty
|
||||
|
||||
Returns ``None`` if no key can be found.
|
||||
"""
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
from_env = os.environ.get(env_var)
|
||||
if from_env:
|
||||
return from_env.strip()
|
||||
|
||||
for path in key_file_paths or []:
|
||||
try:
|
||||
text = path.read_text().strip()
|
||||
if text:
|
||||
return text
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_project_root(start: Optional[Path] = None) -> Optional[Path]:
|
||||
"""Walk up from *start* (default CWD) looking for ``pyproject.toml``.
|
||||
|
||||
Returns the directory containing the marker file, or ``None``.
|
||||
"""
|
||||
current = (start or Path.cwd()).resolve()
|
||||
for directory in [current, *current.parents]:
|
||||
if (directory / "pyproject.toml").is_file():
|
||||
return directory
|
||||
return None
|
||||
|
||||
|
||||
def load_config(
|
||||
provider: str = "openrouter",
|
||||
model: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
**overrides: Any,
|
||||
) -> LLMConfig:
|
||||
"""Build an :class:`LLMConfig` with sensible defaults.
|
||||
|
||||
For the ``openrouter`` provider the API key is resolved via
|
||||
:func:`resolve_api_key` (env var → project-root key file).
|
||||
"""
|
||||
root = find_project_root()
|
||||
key_file_paths = [root / "apikey-openrouter.txt"] if root else []
|
||||
|
||||
resolved_key = api_key
|
||||
if provider == "openrouter" and not resolved_key:
|
||||
resolved_key = resolve_api_key(
|
||||
explicit=None,
|
||||
env_var="OPENROUTER_API_KEY",
|
||||
key_file_paths=key_file_paths,
|
||||
)
|
||||
|
||||
defaults: Dict[str, Any] = {
|
||||
"provider": provider,
|
||||
"model": model or "anthropic/claude-sonnet-4",
|
||||
"api_key": resolved_key,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return LLMConfig(**defaults)
|
||||
71
markitect/llm/exceptions.py
Normal file
71
markitect/llm/exceptions.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
LLM-specific exceptions.
|
||||
|
||||
Extends the MarkitectError hierarchy for LLM integration errors.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from markitect.exceptions import MarkitectError
|
||||
|
||||
|
||||
class LLMError(MarkitectError):
|
||||
"""Base exception for all LLM operations."""
|
||||
pass
|
||||
|
||||
|
||||
class LLMConfigurationError(LLMError):
|
||||
"""Missing API key, invalid model name, or bad provider config."""
|
||||
pass
|
||||
|
||||
|
||||
class LLMAPIError(LLMError):
|
||||
"""HTTP-level failure from an LLM provider API.
|
||||
|
||||
Attributes:
|
||||
status_code: HTTP status code (e.g. 500, 502).
|
||||
response_body: Raw response body text, if available.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = 0,
|
||||
response_body: str = "",
|
||||
cause: Optional[Exception] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(message, cause=cause, context=context)
|
||||
self.status_code = status_code
|
||||
self.response_body = response_body
|
||||
|
||||
|
||||
class LLMRateLimitError(LLMAPIError):
|
||||
"""429 Too Many Requests from the provider."""
|
||||
pass
|
||||
|
||||
|
||||
class LLMTimeoutError(LLMError):
|
||||
"""Request or subprocess exceeded the configured timeout."""
|
||||
pass
|
||||
|
||||
|
||||
class LLMSubprocessError(LLMError):
|
||||
"""Claude Code CLI subprocess failed.
|
||||
|
||||
Attributes:
|
||||
return_code: Process exit code.
|
||||
stderr: Captured stderr text.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
return_code: int = 1,
|
||||
stderr: str = "",
|
||||
cause: Optional[Exception] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(message, cause=cause, context=context)
|
||||
self.return_code = return_code
|
||||
self.stderr = stderr
|
||||
58
markitect/llm/factory.py
Normal file
58
markitect/llm/factory.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Factory for creating LLM adapters by provider name.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from markitect.prompts.execution.llm_adapter import LLMAdapter
|
||||
from markitect.llm.exceptions import LLMConfigurationError
|
||||
|
||||
# Lazy imports to avoid pulling in every adapter at module load time.
|
||||
_PROVIDERS: Dict[str, str] = {
|
||||
"openrouter": "markitect.llm.openrouter.OpenRouterAdapter",
|
||||
"claude-code": "markitect.llm.claude_code.ClaudeCodeAdapter",
|
||||
}
|
||||
|
||||
|
||||
def create_adapter(
|
||||
provider: str = "openrouter",
|
||||
model: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
) -> LLMAdapter:
|
||||
"""Instantiate an :class:`LLMAdapter` for the given *provider*.
|
||||
|
||||
Args:
|
||||
provider: ``"openrouter"`` or ``"claude-code"``.
|
||||
model: Model name (passed to the adapter constructor).
|
||||
api_key: Explicit API key (OpenRouter only).
|
||||
system_prompt: Optional system prompt (OpenRouter only).
|
||||
**kwargs: Extra keyword arguments forwarded to the adapter.
|
||||
|
||||
Returns:
|
||||
A ready-to-use :class:`LLMAdapter` instance.
|
||||
|
||||
Raises:
|
||||
LLMConfigurationError: If *provider* is not recognised.
|
||||
"""
|
||||
if provider not in _PROVIDERS:
|
||||
known = ", ".join(sorted(_PROVIDERS))
|
||||
raise LLMConfigurationError(
|
||||
f"Unknown LLM provider {provider!r}. Choose from: {known}",
|
||||
context={"provider": provider},
|
||||
)
|
||||
|
||||
# Lazy import
|
||||
fqn = _PROVIDERS[provider]
|
||||
module_path, class_name = fqn.rsplit(".", 1)
|
||||
import importlib
|
||||
mod = importlib.import_module(module_path)
|
||||
cls = getattr(mod, class_name)
|
||||
|
||||
if provider == "openrouter":
|
||||
return cls(model=model, api_key=api_key, system_prompt=system_prompt, **kwargs)
|
||||
elif provider == "claude-code":
|
||||
return cls(model=model, **kwargs)
|
||||
else:
|
||||
return cls(**kwargs) # pragma: no cover
|
||||
139
markitect/llm/openrouter.py
Normal file
139
markitect/llm/openrouter.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
OpenRouter adapter — calls the OpenAI-compatible chat completions API.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from markitect.prompts.execution.llm_adapter import LLMAdapter
|
||||
from markitect.prompts.execution.models import RunConfig, LLMResponse
|
||||
from markitect.llm.config import LLMConfig, resolve_api_key, find_project_root
|
||||
from markitect.llm._http import post_json
|
||||
from markitect.llm.exceptions import (
|
||||
LLMConfigurationError,
|
||||
LLMAPIError,
|
||||
LLMRateLimitError,
|
||||
)
|
||||
|
||||
_DEFAULT_MODEL = "anthropic/claude-sonnet-4"
|
||||
|
||||
|
||||
class OpenRouterAdapter(LLMAdapter):
|
||||
"""LLM adapter that calls the OpenRouter chat completions endpoint.
|
||||
|
||||
Constructor args override values from *config*; *config* overrides
|
||||
global defaults. The model used for a given call is resolved as:
|
||||
``constructor model > RunConfig.model_name > default``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
api_base: Optional[str] = None,
|
||||
config: Optional[LLMConfig] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
max_retries: Optional[int] = None,
|
||||
):
|
||||
self._config = config or LLMConfig()
|
||||
self._model = model or self._config.model or _DEFAULT_MODEL
|
||||
self._api_base = (api_base or self._config.api_base).rstrip("/")
|
||||
self._system_prompt = system_prompt
|
||||
self._extra_headers = extra_headers or {}
|
||||
self._max_retries = max_retries if max_retries is not None else self._config.max_retries
|
||||
|
||||
# Resolve API key
|
||||
root = find_project_root()
|
||||
key_file_paths = [root / "apikey-openrouter.txt"] if root else []
|
||||
self._api_key = resolve_api_key(
|
||||
explicit=api_key or self._config.api_key,
|
||||
env_var="OPENROUTER_API_KEY",
|
||||
key_file_paths=key_file_paths,
|
||||
)
|
||||
|
||||
# ── LLMAdapter interface ────────────────────────────────────────
|
||||
|
||||
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
|
||||
model = self._model if self._model != _DEFAULT_MODEL else (config.model_name or self._model)
|
||||
|
||||
messages: list[Dict[str, str]] = []
|
||||
if self._system_prompt:
|
||||
messages.append({"role": "system", "content": self._system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": config.temperature,
|
||||
"max_tokens": config.max_tokens,
|
||||
}
|
||||
# Merge extra model_params from RunConfig
|
||||
if config.model_params:
|
||||
payload.update(config.model_params)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
**self._extra_headers,
|
||||
}
|
||||
url = f"{self._api_base}/chat/completions"
|
||||
|
||||
start = time.time()
|
||||
data = self._post_with_retries(url, payload, headers, config.timeout_seconds)
|
||||
latency = time.time() - start
|
||||
|
||||
# Parse response
|
||||
choice = data.get("choices", [{}])[0]
|
||||
content = choice.get("message", {}).get("content", "")
|
||||
finish_reason = choice.get("finish_reason", "stop")
|
||||
usage = data.get("usage", {})
|
||||
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
model=data.get("model", model),
|
||||
usage={
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
},
|
||||
finish_reason=finish_reason,
|
||||
metadata={
|
||||
"provider": "openrouter",
|
||||
"latency_seconds": round(latency, 3),
|
||||
"response_id": data.get("id", ""),
|
||||
},
|
||||
)
|
||||
|
||||
def validate_config(self, config: RunConfig) -> bool:
|
||||
if not self._api_key:
|
||||
return False
|
||||
if not (self._model or config.model_name):
|
||||
return False
|
||||
if not (0.0 <= config.temperature <= 2.0):
|
||||
return False
|
||||
return True
|
||||
|
||||
# ── Internals ───────────────────────────────────────────────────
|
||||
|
||||
def _post_with_retries(
|
||||
self,
|
||||
url: str,
|
||||
payload: Dict[str, Any],
|
||||
headers: Dict[str, str],
|
||||
timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(self._max_retries + 1):
|
||||
try:
|
||||
return post_json(url, payload, headers, timeout=timeout)
|
||||
except LLMRateLimitError as exc:
|
||||
last_exc = exc
|
||||
if attempt < self._max_retries:
|
||||
time.sleep(2 ** attempt)
|
||||
except LLMAPIError as exc:
|
||||
if exc.status_code >= 500 and attempt < self._max_retries:
|
||||
last_exc = exc
|
||||
time.sleep(2 ** attempt)
|
||||
else:
|
||||
raise
|
||||
raise last_exc # type: ignore[misc]
|
||||
Reference in New Issue
Block a user