crewforge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. crewforge/__init__.py +3 -0
  2. crewforge/__main__.py +4 -0
  3. crewforge/cli.py +128 -0
  4. crewforge/cli_support.py +109 -0
  5. crewforge/core/__init__.py +54 -0
  6. crewforge/core/agent.py +118 -0
  7. crewforge/core/client.py +253 -0
  8. crewforge/core/config.py +181 -0
  9. crewforge/core/crew.py +144 -0
  10. crewforge/core/errors.py +106 -0
  11. crewforge/core/parsing.py +110 -0
  12. crewforge/core/providers.py +131 -0
  13. crewforge/core/usage.py +163 -0
  14. crewforge/py.typed +0 -0
  15. crewforge/recipes/__init__.py +1 -0
  16. crewforge/recipes/api/__init__.py +38 -0
  17. crewforge/recipes/api/agents.py +523 -0
  18. crewforge/recipes/api/analytics.py +99 -0
  19. crewforge/recipes/api/appcheck.py +73 -0
  20. crewforge/recipes/api/cli.py +127 -0
  21. crewforge/recipes/api/formatters.py +32 -0
  22. crewforge/recipes/api/generator.py +37 -0
  23. crewforge/recipes/api/pipeline.py +86 -0
  24. crewforge/recipes/api/scaffold.py +68 -0
  25. crewforge/recipes/api/templates/docker-compose.j2 +9 -0
  26. crewforge/recipes/api/templates/dockerfile.j2 +7 -0
  27. crewforge/recipes/api/templates/env.example.j2 +2 -0
  28. crewforge/recipes/api/templates/fastapi_main.j2 +10 -0
  29. crewforge/recipes/api/templates/model.j2 +4 -0
  30. crewforge/recipes/api/templates/requirements.j2 +6 -0
  31. crewforge/recipes/api/templates/route.j2 +4 -0
  32. crewforge/recipes/api/templates/test.j2 +3 -0
  33. crewforge/recipes/api/utils.py +134 -0
  34. crewforge/recipes/content/__init__.py +20 -0
  35. crewforge/recipes/content/agents.py +735 -0
  36. crewforge/recipes/content/cli.py +128 -0
  37. crewforge/recipes/content/export.py +84 -0
  38. crewforge/recipes/content/pipeline.py +222 -0
  39. crewforge/recipes/content/settings.py +63 -0
  40. crewforge/recipes/content/text.py +57 -0
  41. crewforge/recipes/docs/__init__.py +48 -0
  42. crewforge/recipes/docs/agents.py +426 -0
  43. crewforge/recipes/docs/cli.py +156 -0
  44. crewforge/recipes/docs/export.py +118 -0
  45. crewforge/recipes/docs/index.py +117 -0
  46. crewforge/recipes/docs/ingest.py +212 -0
  47. crewforge/recipes/docs/pipeline.py +223 -0
  48. crewforge/recipes/docs/retrieve.py +70 -0
  49. crewforge/recipes/docs/settings.py +54 -0
  50. crewforge/recipes/review/__init__.py +1 -0
  51. crewforge/recipes/review/agents.py +331 -0
  52. crewforge/recipes/review/cli.py +144 -0
  53. crewforge/recipes/review/pipeline.py +125 -0
  54. crewforge/recipes/review/report.py +103 -0
  55. crewforge/recipes/review/sources.py +89 -0
  56. crewforge-0.1.0.dist-info/METADATA +372 -0
  57. crewforge-0.1.0.dist-info/RECORD +61 -0
  58. crewforge-0.1.0.dist-info/WHEEL +5 -0
  59. crewforge-0.1.0.dist-info/entry_points.txt +2 -0
  60. crewforge-0.1.0.dist-info/licenses/LICENSE +21 -0
  61. crewforge-0.1.0.dist-info/top_level.txt +1 -0
crewforge/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """crewforge — one crew of LLM agents, four recipes, any OpenAI-compatible provider."""
2
+
3
+ __version__ = "0.1.0"
crewforge/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from crewforge.cli import main
2
+
3
+ if __name__ == "__main__": # pragma: no cover
4
+ main()
crewforge/cli.py ADDED
@@ -0,0 +1,128 @@
1
+ """``crewforge`` command line: global provider options + the four recipes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.table import Table
10
+
11
+ from . import __version__
12
+ from .cli_support import EXIT_USAGE, console, err_console, fail
13
+ from .core.config import LLMConfig, Settings
14
+ from .core.errors import ConfigError
15
+ from .core.providers import ENV_API_KEY, PROVIDERS
16
+ from .recipes.api.cli import commands as api_commands
17
+ from .recipes.content.cli import commands as content_commands
18
+ from .recipes.docs.cli import commands as docs_commands
19
+ from .recipes.review.cli import commands as review_commands
20
+
21
+
22
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
23
+ @click.version_option(__version__, prog_name="crewforge")
24
+ @click.option(
25
+ "--provider",
26
+ "-P",
27
+ default=None,
28
+ help="Provider preset (see `crewforge providers`). Env: CREWFORGE_PROVIDER. Default: openai.",
29
+ )
30
+ @click.option("--model", "-m", default=None, help="Model name. Env: CREWFORGE_MODEL.")
31
+ @click.option(
32
+ "--base-url", default=None, help="OpenAI-compatible base URL. Env: CREWFORGE_BASE_URL."
33
+ )
34
+ @click.option(
35
+ "--config",
36
+ "config_path",
37
+ type=click.Path(exists=True, dir_okay=False),
38
+ default=None,
39
+ help="crewforge.yaml (written by `crewforge init`); ${VAR} references are expanded.",
40
+ )
41
+ @click.option("--verbose", "-v", is_flag=True, help="Log HTTP retries and agent progress.")
42
+ @click.pass_context
43
+ def main(ctx, provider, model, base_url, config_path, verbose):
44
+ """One crew of LLM agents, four recipes, any OpenAI-compatible provider.
45
+
46
+ \b
47
+ Recipes:
48
+ review multi-agent code review
49
+ ingest parse + chunk documents (no LLM calls)
50
+ ask document Q&A with citations (+ --verify fact-check)
51
+ summarize document summaries
52
+ scaffold natural language -> FastAPI project + OpenAPI spec
53
+ validate check an OpenAPI spec (and a scaffolded app) offline
54
+ write research -> outline -> write -> SEO -> edit -> quality -> publish
55
+
56
+ API keys are read from the environment only (e.g. OPENAI_API_KEY) and are
57
+ never written to disk. Exit codes: 0 ok, 1 run failed, 2 usage/config error.
58
+ """
59
+ logging.basicConfig(
60
+ level=logging.INFO if verbose else logging.WARNING, format="%(levelname)s %(message)s"
61
+ )
62
+ try:
63
+ settings = Settings.from_yaml(config_path) if config_path else Settings()
64
+ except ConfigError as exc:
65
+ fail(f"configuration: {exc}", EXIT_USAGE)
66
+ overrides = {
67
+ k: v for k, v in {"provider": provider, "model": model, "base_url": base_url}.items() if v
68
+ }
69
+ if overrides:
70
+ data = settings.llm.model_dump()
71
+ if "provider" in overrides and overrides["provider"] != settings.llm.provider:
72
+ # A different provider means different defaults: re-resolve from scratch,
73
+ # keeping only explicitly given values.
74
+ reset = ("api_key", "base_url", "model", "auth_style")
75
+ data = {k: v for k, v in data.items() if k not in reset}
76
+ data.update(overrides)
77
+ settings.llm = LLMConfig(**data)
78
+ ctx.ensure_object(dict)
79
+ ctx.obj["settings"] = settings
80
+
81
+
82
+ @main.command()
83
+ def providers():
84
+ """List provider presets and the env var that holds each key."""
85
+ table = Table(title="Provider presets", title_justify="left")
86
+ for title in ("Name", "Provider", "Base URL", "Default model", "Key env var", "Auth"):
87
+ table.add_column(title, overflow="fold", style="bold cyan" if title == "Name" else None)
88
+ for p in PROVIDERS.values():
89
+ table.add_row(
90
+ p.name,
91
+ p.label,
92
+ p.base_url or "(set CREWFORGE_BASE_URL / --base-url)",
93
+ p.default_model or "(set CREWFORGE_MODEL / --model)",
94
+ p.env_key + (" (optional)" if p.keyless else ""),
95
+ "api-key header" if p.auth_style == "api-key" else "Bearer",
96
+ )
97
+ console.print(table)
98
+ console.print(
99
+ f"Any preset's key, URL and model can be overridden with {ENV_API_KEY}, "
100
+ "CREWFORGE_BASE_URL and CREWFORGE_MODEL."
101
+ )
102
+
103
+
104
+ @main.command()
105
+ @click.option(
106
+ "--output", "-o", default="crewforge.yaml", show_default=True, type=click.Path(dir_okay=False)
107
+ )
108
+ @click.option("--force", is_flag=True, help="Overwrite an existing file.")
109
+ @click.pass_context
110
+ def init(ctx, output, force):
111
+ """Write a starter crewforge.yaml (the key is written as a ${VAR} reference)."""
112
+ path = Path(output)
113
+ if path.exists() and not force:
114
+ fail(f"{path} already exists (use --force to overwrite)", EXIT_USAGE)
115
+ settings: Settings = ctx.obj["settings"]
116
+ settings.write_template(path)
117
+ err_console.print(
118
+ f"[green]Wrote {path}[/] — api_key is the reference "
119
+ f"${{{settings.llm.key_env_var}}}; export that variable, never paste the key into the file."
120
+ )
121
+
122
+
123
+ for _cmd in (*review_commands, *docs_commands, *api_commands, *content_commands):
124
+ main.add_command(_cmd)
125
+
126
+
127
+ if __name__ == "__main__": # pragma: no cover
128
+ main()
@@ -0,0 +1,109 @@
1
+ """Helpers shared by every recipe's CLI commands.
2
+
3
+ Exit codes (stable, documented in the README):
4
+
5
+ * 0 — the run succeeded
6
+ * 1 — the run failed: an agent errored, returned empty/unparseable output, a
7
+ validator found problems, or the LLM endpoint refused the request
8
+ * 2 — usage or configuration error (bad flags, unknown provider, missing key)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import sys
15
+ from collections.abc import Coroutine
16
+ from typing import Any, NoReturn, TypeVar
17
+
18
+ import click
19
+ from rich.console import Console
20
+ from rich.markup import escape
21
+ from rich.table import Table
22
+
23
+ from .core.config import LLMConfig, Settings
24
+ from .core.errors import AgentError, ConfigError, CrewFailedError, LLMError
25
+ from .core.usage import UsageTracker
26
+
27
+ EXIT_OK = 0
28
+ EXIT_FAILED = 1
29
+ EXIT_USAGE = 2
30
+
31
+ T = TypeVar("T")
32
+
33
+ console = Console()
34
+ # Status, warnings and errors go to stderr so JSON on stdout stays parseable.
35
+ err_console = Console(stderr=True)
36
+
37
+
38
+ def settings_from_ctx(ctx: click.Context) -> Settings:
39
+ obj = ctx.find_root().obj or {}
40
+ settings = obj.get("settings")
41
+ if settings is None:
42
+ settings = Settings()
43
+ obj["settings"] = settings
44
+ return settings
45
+
46
+
47
+ def llm_config(ctx: click.Context) -> LLMConfig:
48
+ return settings_from_ctx(ctx).llm
49
+
50
+
51
+ def recipe_settings(ctx: click.Context, recipe: str) -> dict[str, Any]:
52
+ return dict(getattr(settings_from_ctx(ctx), recipe, {}) or {})
53
+
54
+
55
+ def fail(message: str, code: int = EXIT_FAILED) -> NoReturn:
56
+ err_console.print(f"[bold red]error:[/] {escape(message)}", highlight=False, soft_wrap=True)
57
+ sys.exit(code)
58
+
59
+
60
+ def report_crew_failure(exc: CrewFailedError) -> None:
61
+ err_console.print(f"[bold red]error:[/] {exc.crew} failed", highlight=False, soft_wrap=True)
62
+ for f in exc.failures:
63
+ err_console.print(
64
+ f" [red]✗ agent '{escape(f.agent)}'[/] ({f.kind}): {escape(f.error)}",
65
+ highlight=False,
66
+ soft_wrap=True,
67
+ )
68
+
69
+
70
+ def run(coro: Coroutine[Any, Any, T]) -> T:
71
+ """Run a recipe coroutine and translate failures into exit codes and messages."""
72
+ try:
73
+ return asyncio.run(coro)
74
+ except ConfigError as exc:
75
+ fail(f"configuration: {exc}", EXIT_USAGE)
76
+ except CrewFailedError as exc:
77
+ report_crew_failure(exc)
78
+ sys.exit(EXIT_FAILED)
79
+ except AgentError as exc:
80
+ fail(str(exc))
81
+ except LLMError as exc:
82
+ fail(f"LLM request failed: {exc}")
83
+
84
+
85
+ def print_usage(tracker: UsageTracker, *, title: str = "Token usage (provider-reported)") -> None:
86
+ """Per-agent usage table on stderr. Numbers come only from provider responses."""
87
+ agents = tracker.by_agent()
88
+ if not agents:
89
+ return
90
+ table = Table(title=title, title_justify="left")
91
+ table.add_column("Agent", style="cyan")
92
+ table.add_column("Calls", justify="right")
93
+ table.add_column("Prompt", justify="right")
94
+ table.add_column("Completion", justify="right")
95
+ table.add_column("Total", justify="right", style="green")
96
+ for name, a in agents.items():
97
+ total = f"{a.total_tokens:,}"
98
+ if a.unreported_calls:
99
+ total += f" (+{a.unreported_calls} unreported)"
100
+ table.add_row(name, str(a.calls), f"{a.prompt_tokens:,}", f"{a.completion_tokens:,}", total)
101
+ table.add_row(
102
+ "[bold]total[/]", str(tracker.total_calls), "", "", f"[bold]{tracker.total_tokens:,}[/]"
103
+ )
104
+ err_console.print(table)
105
+ if tracker.unreported_calls:
106
+ err_console.print(
107
+ f"[yellow]{tracker.unreported_calls} call(s) returned no usage; "
108
+ "they are not counted (nothing is estimated).[/]"
109
+ )
@@ -0,0 +1,54 @@
1
+ """Reusable core: providers, config, client, agents, crews, parsing, usage, errors."""
2
+
3
+ from .agent import Agent
4
+ from .client import ChatResponse, LLMClient, Message, Usage, is_retryable_status
5
+ from .config import LLMConfig, Settings, expand_env
6
+ from .crew import Crew, is_empty
7
+ from .errors import (
8
+ AgentError,
9
+ AgentFailure,
10
+ ConfigError,
11
+ CrewFailedError,
12
+ CrewForgeError,
13
+ EmptyOutputError,
14
+ LLMError,
15
+ LLMHTTPError,
16
+ LLMResponseError,
17
+ OutputParseError,
18
+ )
19
+ from .parsing import extract_code_block, extract_json, parse_json
20
+ from .providers import DEFAULT_PROVIDER, PROVIDERS, Provider, get_provider
21
+ from .usage import AgentUsage, UsageTracker
22
+
23
+ __all__ = [
24
+ "DEFAULT_PROVIDER",
25
+ "PROVIDERS",
26
+ "Agent",
27
+ "AgentError",
28
+ "AgentFailure",
29
+ "AgentUsage",
30
+ "ChatResponse",
31
+ "ConfigError",
32
+ "Crew",
33
+ "CrewFailedError",
34
+ "CrewForgeError",
35
+ "EmptyOutputError",
36
+ "LLMClient",
37
+ "LLMConfig",
38
+ "LLMError",
39
+ "LLMHTTPError",
40
+ "LLMResponseError",
41
+ "Message",
42
+ "OutputParseError",
43
+ "Provider",
44
+ "Settings",
45
+ "Usage",
46
+ "UsageTracker",
47
+ "expand_env",
48
+ "extract_code_block",
49
+ "extract_json",
50
+ "get_provider",
51
+ "is_empty",
52
+ "is_retryable_status",
53
+ "parse_json",
54
+ ]
@@ -0,0 +1,118 @@
1
+ """The :class:`Agent` base class: one role, one system prompt, one LLM call at a time."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, ClassVar
7
+
8
+ from .client import ChatResponse, LLMClient, Message
9
+ from .errors import EmptyOutputError
10
+ from .parsing import M, parse_json
11
+ from .usage import UsageTracker
12
+
13
+
14
+ class Agent:
15
+ """Base class for every agent in every recipe.
16
+
17
+ Subclasses set :attr:`name`, :attr:`description` and :attr:`system_prompt`
18
+ and implement their task with :meth:`ask` / :meth:`ask_json`. Both record
19
+ provider-reported usage and refuse to return empty or unparseable output:
20
+ that refusal is what lets a crew fail honestly instead of reporting success.
21
+ """
22
+
23
+ name: ClassVar[str] = "agent"
24
+ description: ClassVar[str] = ""
25
+ system_prompt: ClassVar[str] = "You are a precise, factual assistant."
26
+ #: Per-agent sampling defaults; ``None`` means "use the LLMConfig value".
27
+ temperature: ClassVar[float | None] = None
28
+ max_tokens: ClassVar[int | None] = None
29
+
30
+ def __init__(
31
+ self,
32
+ client: LLMClient,
33
+ tracker: UsageTracker | None = None,
34
+ *,
35
+ system_prompt: str | None = None,
36
+ model: str | None = None,
37
+ temperature: float | None = None,
38
+ max_tokens: int | None = None,
39
+ ) -> None:
40
+ self.client = client
41
+ self.tracker = tracker if tracker is not None else UsageTracker()
42
+ self._system_prompt = system_prompt
43
+ self._model = model
44
+ self._temperature = temperature if temperature is not None else self.temperature
45
+ self._max_tokens = max_tokens if max_tokens is not None else self.max_tokens
46
+
47
+ @property
48
+ def effective_system_prompt(self) -> str:
49
+ return self._system_prompt or self.system_prompt
50
+
51
+ def build_messages(
52
+ self, prompt: str, *, system: str | None = None, context: str | None = None
53
+ ) -> list[Message]:
54
+ messages = [Message("system", system or self.effective_system_prompt)]
55
+ if context:
56
+ messages.append(Message("user", f"Context:\n{context}"))
57
+ messages.append(Message("user", prompt))
58
+ return messages
59
+
60
+ async def ask(
61
+ self,
62
+ prompt: str,
63
+ *,
64
+ system: str | None = None,
65
+ context: str | None = None,
66
+ temperature: float | None = None,
67
+ max_tokens: int | None = None,
68
+ allow_empty: bool = False,
69
+ ) -> ChatResponse:
70
+ """Send one prompt; return the response or raise naming this agent."""
71
+ messages = self.build_messages(prompt, system=system, context=context)
72
+ start = time.monotonic()
73
+ try:
74
+ response = await self.client.chat(
75
+ messages,
76
+ model=self._model,
77
+ temperature=temperature if temperature is not None else self._temperature,
78
+ max_tokens=max_tokens if max_tokens is not None else self._max_tokens,
79
+ )
80
+ except Exception:
81
+ self.tracker.record(self.name, None, (time.monotonic() - start) * 1000, error=True)
82
+ raise
83
+ self.tracker.record(
84
+ self.name,
85
+ response.usage,
86
+ (time.monotonic() - start) * 1000,
87
+ retries=max(0, response.attempts - 1),
88
+ )
89
+ # Honest-failure guard: a blank completion is a failure of this agent,
90
+ # never an answer. Removing this check makes tests/core/test_honest_failure.py red.
91
+ if not allow_empty and not response.content.strip():
92
+ detail = f"finish_reason={response.finish_reason}" if response.finish_reason else ""
93
+ raise EmptyOutputError(self.name, detail)
94
+ return response
95
+
96
+ async def ask_json(
97
+ self,
98
+ prompt: str,
99
+ *,
100
+ schema: type[M] | None = None,
101
+ expect: type | tuple[type, ...] = dict,
102
+ system: str | None = None,
103
+ context: str | None = None,
104
+ temperature: float | None = None,
105
+ max_tokens: int | None = None,
106
+ ) -> Any:
107
+ """Like :meth:`ask` but the reply must parse as JSON (and match ``schema``)."""
108
+ response = await self.ask(
109
+ prompt,
110
+ system=system,
111
+ context=context,
112
+ temperature=temperature,
113
+ max_tokens=max_tokens,
114
+ )
115
+ return parse_json(response.content, agent=self.name, expect=expect, schema=schema)
116
+
117
+ def __repr__(self) -> str:
118
+ return f"<{type(self).__name__} name={self.name!r}>"
@@ -0,0 +1,253 @@
1
+ """One async client for every OpenAI-compatible ``/chat/completions`` endpoint.
2
+
3
+ Policy, in one place:
4
+
5
+ * Non-streaming: ``"stream": false`` is always sent. Some compatible servers
6
+ stream when the flag is omitted, which used to make every agent parse an
7
+ SSE body as nothing and "succeed" with empty output.
8
+ * Retries: only HTTP 429, HTTP 5xx and network/timeout errors are retried,
9
+ with bounded exponential backoff (``Retry-After`` honoured up to
10
+ ``max_retry_delay``). 401/403 and every other 4xx fail on the first attempt.
11
+ * Usage: token counts come only from the provider's ``usage`` object. When a
12
+ provider omits it, :attr:`Usage.reported` is ``False`` and the counts stay
13
+ zero; nothing is estimated.
14
+ * Secrets: the key lives in request headers only. It is never logged and never
15
+ part of an error message.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ import time
23
+ from collections.abc import Awaitable, Callable, Sequence
24
+ from dataclasses import dataclass, field
25
+ from typing import Any
26
+
27
+ import httpx
28
+
29
+ from .config import LLMConfig
30
+ from .errors import LLMError, LLMHTTPError, LLMResponseError
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ @dataclass
36
+ class Message:
37
+ role: str
38
+ content: str
39
+
40
+ def to_dict(self) -> dict[str, str]:
41
+ return {"role": self.role, "content": self.content}
42
+
43
+
44
+ @dataclass
45
+ class Usage:
46
+ """Token usage exactly as the provider reported it."""
47
+
48
+ prompt_tokens: int = 0
49
+ completion_tokens: int = 0
50
+ total_tokens: int = 0
51
+ cached_tokens: int = 0
52
+ reported: bool = False # False = provider sent no usage object
53
+
54
+ @classmethod
55
+ def from_payload(cls, data: Any) -> Usage:
56
+ if not isinstance(data, dict):
57
+ return cls()
58
+ prompt = _as_int(data.get("prompt_tokens"))
59
+ completion = _as_int(data.get("completion_tokens"))
60
+ total = _as_int(data.get("total_tokens")) or prompt + completion
61
+ details = data.get("prompt_tokens_details") or {}
62
+ cached = _as_int(details.get("cached_tokens")) if isinstance(details, dict) else 0
63
+ return cls(prompt, completion, total, cached, reported=True)
64
+
65
+ def add(self, other: Usage) -> None:
66
+ self.prompt_tokens += other.prompt_tokens
67
+ self.completion_tokens += other.completion_tokens
68
+ self.total_tokens += other.total_tokens
69
+ self.cached_tokens += other.cached_tokens
70
+ self.reported = self.reported or other.reported
71
+
72
+
73
+ def _as_int(value: Any) -> int:
74
+ try:
75
+ return int(value or 0)
76
+ except (TypeError, ValueError):
77
+ return 0
78
+
79
+
80
+ @dataclass
81
+ class ChatResponse:
82
+ content: str
83
+ usage: Usage = field(default_factory=Usage)
84
+ model: str = ""
85
+ finish_reason: str = ""
86
+ reasoning: str = ""
87
+ latency_ms: float = 0.0
88
+ attempts: int = 1
89
+
90
+
91
+ MessageLike = Message | dict[str, str]
92
+
93
+
94
+ def is_retryable_status(status: int) -> bool:
95
+ """429 and 5xx are transient; every other status is a caller/config problem."""
96
+ return status == 429 or status >= 500
97
+
98
+
99
+ class LLMClient:
100
+ """Async OpenAI-compatible chat client with a strict retry policy.
101
+
102
+ Use as an async context manager, or call :meth:`aclose` when done. A
103
+ ``transport`` can be injected (``httpx.MockTransport``) for tests.
104
+ """
105
+
106
+ def __init__(
107
+ self,
108
+ config: LLMConfig,
109
+ *,
110
+ transport: httpx.AsyncBaseTransport | None = None,
111
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
112
+ ) -> None:
113
+ self.config = config
114
+ self._transport = transport
115
+ self._sleep = sleep
116
+ self._http: httpx.AsyncClient | None = None
117
+ self.total_usage = Usage()
118
+ self.request_count = 0
119
+
120
+ async def __aenter__(self) -> LLMClient:
121
+ self._ensure_http()
122
+ return self
123
+
124
+ async def __aexit__(self, *exc: object) -> None:
125
+ await self.aclose()
126
+
127
+ def _ensure_http(self) -> httpx.AsyncClient:
128
+ if self._http is None or self._http.is_closed:
129
+ self._http = httpx.AsyncClient(
130
+ base_url=self.config.base_url,
131
+ headers=self.config.headers,
132
+ timeout=httpx.Timeout(self.config.timeout),
133
+ transport=self._transport,
134
+ )
135
+ return self._http
136
+
137
+ async def aclose(self) -> None:
138
+ if self._http is not None and not self._http.is_closed:
139
+ await self._http.aclose()
140
+
141
+ def _backoff(self, attempt: int, response: httpx.Response | None) -> float:
142
+ delay = self.config.retry_delay * (2 ** (attempt - 1))
143
+ if response is not None:
144
+ retry_after = response.headers.get("retry-after", "")
145
+ try:
146
+ delay = max(delay, float(retry_after))
147
+ except ValueError:
148
+ pass
149
+ return min(delay, self.config.max_retry_delay)
150
+
151
+ async def chat(
152
+ self,
153
+ messages: Sequence[MessageLike],
154
+ *,
155
+ model: str | None = None,
156
+ temperature: float | None = None,
157
+ max_tokens: int | None = None,
158
+ extra: dict[str, Any] | None = None,
159
+ ) -> ChatResponse:
160
+ payload: dict[str, Any] = {
161
+ "model": model or self.config.model,
162
+ "messages": [m.to_dict() if isinstance(m, Message) else dict(m) for m in messages],
163
+ # ``is not None``: an explicit temperature of 0 must be sent as 0.
164
+ "temperature": self.config.temperature if temperature is None else temperature,
165
+ "max_tokens": self.config.max_tokens if max_tokens is None else max_tokens,
166
+ "stream": False,
167
+ }
168
+ if extra:
169
+ payload.update(extra)
170
+ payload["stream"] = False
171
+
172
+ http = self._ensure_http()
173
+ attempts = max(1, self.config.max_retries)
174
+ start = time.monotonic()
175
+ last_exc: Exception | None = None
176
+ for attempt in range(1, attempts + 1):
177
+ response: httpx.Response | None = None
178
+ try:
179
+ response = await http.post("/chat/completions", json=payload)
180
+ except httpx.TransportError as exc: # connect/read errors and timeouts
181
+ last_exc = exc
182
+ if attempt < attempts:
183
+ logger.warning("LLM request failed (%s); retry %d", type(exc).__name__, attempt)
184
+ await self._sleep(self._backoff(attempt, None))
185
+ continue
186
+ raise LLMError(
187
+ f"cannot reach {self.config.base_url} after {attempt} attempt(s): "
188
+ f"{type(exc).__name__}: {exc}"
189
+ ) from exc
190
+
191
+ if response.status_code >= 400:
192
+ if is_retryable_status(response.status_code) and attempt < attempts:
193
+ logger.warning("LLM HTTP %s; retry %d", response.status_code, attempt)
194
+ await self._sleep(self._backoff(attempt, response))
195
+ continue
196
+ raise LLMHTTPError(
197
+ response.status_code,
198
+ self._http_error_message(response, attempt),
199
+ attempts=attempt,
200
+ )
201
+ return self._parse(response, start, attempt)
202
+ raise LLMError(f"request failed: {last_exc}") # pragma: no cover - loop always returns
203
+
204
+ def _http_error_message(self, response: httpx.Response, attempts: int) -> str:
205
+ detail = ""
206
+ try:
207
+ body = response.json()
208
+ err = body.get("error") if isinstance(body, dict) else None
209
+ if isinstance(err, dict):
210
+ detail = str(err.get("message") or err.get("code") or "")
211
+ elif err:
212
+ detail = str(err)
213
+ elif isinstance(body, dict):
214
+ detail = str(body.get("message") or body.get("detail") or "")
215
+ except ValueError:
216
+ detail = response.text
217
+ detail = " ".join(detail.split())[:300]
218
+ msg = f"HTTP {response.status_code} from {self.config.base_url}"
219
+ if response.status_code in (401, 403):
220
+ msg += f" (authentication failed — check {self.config.key_env_var})"
221
+ elif response.status_code == 404:
222
+ msg += f" (check the base URL and that model '{self.config.model}' exists)"
223
+ if attempts > 1:
224
+ msg += f" after {attempts} attempts"
225
+ if detail:
226
+ msg += f": {detail}"
227
+ return msg
228
+
229
+ def _parse(self, response: httpx.Response, start: float, attempts: int) -> ChatResponse:
230
+ try:
231
+ data = response.json()
232
+ except ValueError as exc:
233
+ snippet = " ".join(response.text.split())[:120]
234
+ raise LLMResponseError(
235
+ f"endpoint returned non-JSON body (is it streaming?): {snippet!r}"
236
+ ) from exc
237
+ choices = data.get("choices") if isinstance(data, dict) else None
238
+ if not choices or not isinstance(choices, list) or not isinstance(choices[0], dict):
239
+ raise LLMResponseError(f"response has no choices: {str(data)[:160]!r}")
240
+ choice = choices[0]
241
+ message = choice.get("message") or {}
242
+ usage = Usage.from_payload(data.get("usage"))
243
+ self.total_usage.add(usage)
244
+ self.request_count += 1
245
+ return ChatResponse(
246
+ content=message.get("content") or "",
247
+ usage=usage,
248
+ model=data.get("model") or "",
249
+ finish_reason=choice.get("finish_reason") or "",
250
+ reasoning=message.get("reasoning_content") or message.get("reasoning") or "",
251
+ latency_ms=round((time.monotonic() - start) * 1000, 1),
252
+ attempts=attempts,
253
+ )