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.
- crewforge/__init__.py +3 -0
- crewforge/__main__.py +4 -0
- crewforge/cli.py +128 -0
- crewforge/cli_support.py +109 -0
- crewforge/core/__init__.py +54 -0
- crewforge/core/agent.py +118 -0
- crewforge/core/client.py +253 -0
- crewforge/core/config.py +181 -0
- crewforge/core/crew.py +144 -0
- crewforge/core/errors.py +106 -0
- crewforge/core/parsing.py +110 -0
- crewforge/core/providers.py +131 -0
- crewforge/core/usage.py +163 -0
- crewforge/py.typed +0 -0
- crewforge/recipes/__init__.py +1 -0
- crewforge/recipes/api/__init__.py +38 -0
- crewforge/recipes/api/agents.py +523 -0
- crewforge/recipes/api/analytics.py +99 -0
- crewforge/recipes/api/appcheck.py +73 -0
- crewforge/recipes/api/cli.py +127 -0
- crewforge/recipes/api/formatters.py +32 -0
- crewforge/recipes/api/generator.py +37 -0
- crewforge/recipes/api/pipeline.py +86 -0
- crewforge/recipes/api/scaffold.py +68 -0
- crewforge/recipes/api/templates/docker-compose.j2 +9 -0
- crewforge/recipes/api/templates/dockerfile.j2 +7 -0
- crewforge/recipes/api/templates/env.example.j2 +2 -0
- crewforge/recipes/api/templates/fastapi_main.j2 +10 -0
- crewforge/recipes/api/templates/model.j2 +4 -0
- crewforge/recipes/api/templates/requirements.j2 +6 -0
- crewforge/recipes/api/templates/route.j2 +4 -0
- crewforge/recipes/api/templates/test.j2 +3 -0
- crewforge/recipes/api/utils.py +134 -0
- crewforge/recipes/content/__init__.py +20 -0
- crewforge/recipes/content/agents.py +735 -0
- crewforge/recipes/content/cli.py +128 -0
- crewforge/recipes/content/export.py +84 -0
- crewforge/recipes/content/pipeline.py +222 -0
- crewforge/recipes/content/settings.py +63 -0
- crewforge/recipes/content/text.py +57 -0
- crewforge/recipes/docs/__init__.py +48 -0
- crewforge/recipes/docs/agents.py +426 -0
- crewforge/recipes/docs/cli.py +156 -0
- crewforge/recipes/docs/export.py +118 -0
- crewforge/recipes/docs/index.py +117 -0
- crewforge/recipes/docs/ingest.py +212 -0
- crewforge/recipes/docs/pipeline.py +223 -0
- crewforge/recipes/docs/retrieve.py +70 -0
- crewforge/recipes/docs/settings.py +54 -0
- crewforge/recipes/review/__init__.py +1 -0
- crewforge/recipes/review/agents.py +331 -0
- crewforge/recipes/review/cli.py +144 -0
- crewforge/recipes/review/pipeline.py +125 -0
- crewforge/recipes/review/report.py +103 -0
- crewforge/recipes/review/sources.py +89 -0
- crewforge-0.1.0.dist-info/METADATA +372 -0
- crewforge-0.1.0.dist-info/RECORD +61 -0
- crewforge-0.1.0.dist-info/WHEEL +5 -0
- crewforge-0.1.0.dist-info/entry_points.txt +2 -0
- crewforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- crewforge-0.1.0.dist-info/top_level.txt +1 -0
crewforge/__init__.py
ADDED
crewforge/__main__.py
ADDED
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()
|
crewforge/cli_support.py
ADDED
|
@@ -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
|
+
]
|
crewforge/core/agent.py
ADDED
|
@@ -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}>"
|
crewforge/core/client.py
ADDED
|
@@ -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
|
+
)
|