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
@@ -0,0 +1,181 @@
1
+ """LLM connection settings and the optional ``crewforge.yaml`` file.
2
+
3
+ Resolution order for each connection field (first non-empty wins):
4
+
5
+ 1. an explicit value (constructor argument, CLI flag or YAML file)
6
+ 2. the generic ``CREWFORGE_API_KEY`` / ``CREWFORGE_BASE_URL`` / ``CREWFORGE_MODEL``
7
+ 3. the provider's own variables, e.g. ``OPENAI_API_KEY`` / ``OPENAI_BASE_URL``
8
+ 4. the preset default
9
+
10
+ Secrets are only ever read from the environment (or from a YAML ``${VAR}``
11
+ reference that is expanded at load time). Nothing in this module writes a key
12
+ to disk: :meth:`Settings.to_template` emits ``${VAR}`` references instead.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import yaml
23
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
24
+
25
+ from .errors import ConfigError
26
+ from .providers import (
27
+ DEFAULT_PROVIDER,
28
+ ENV_API_KEY,
29
+ ENV_BASE_URL,
30
+ ENV_MODEL,
31
+ ENV_PROVIDER,
32
+ PROVIDERS,
33
+ get_provider,
34
+ )
35
+
36
+ _ENV_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
37
+
38
+
39
+ def expand_env(value: Any) -> Any:
40
+ """Recursively expand ``${VAR}`` references from the environment.
41
+
42
+ An unset variable expands to an empty string, so a missing key is reported
43
+ by :meth:`LLMConfig.validate` instead of being sent literally as the key.
44
+ """
45
+ if isinstance(value, str):
46
+ return _ENV_REF.sub(lambda m: os.environ.get(m.group(1), ""), value)
47
+ if isinstance(value, dict):
48
+ return {k: expand_env(v) for k, v in value.items()}
49
+ if isinstance(value, list):
50
+ return [expand_env(v) for v in value]
51
+ return value
52
+
53
+
54
+ class LLMConfig(BaseModel):
55
+ """Connection settings for one OpenAI-compatible chat completions endpoint."""
56
+
57
+ model_config = ConfigDict(validate_assignment=False)
58
+
59
+ provider: str = ""
60
+ # repr=False keeps the key out of logs, tracebacks and ``print(config)``.
61
+ api_key: str = Field(default="", repr=False)
62
+ base_url: str = ""
63
+ model: str = ""
64
+ auth_style: str = "" # "bearer" | "api-key"; blank = provider default
65
+ temperature: float = 0.3
66
+ max_tokens: int = 4096
67
+ timeout: float = 120.0 # seconds, per HTTP attempt
68
+ max_retries: int = 3 # total attempts for 429 / 5xx / network errors
69
+ retry_delay: float = 1.0 # base delay for exponential backoff (seconds)
70
+ max_retry_delay: float = 30.0
71
+
72
+ @model_validator(mode="after")
73
+ def _resolve(self) -> LLMConfig:
74
+ self.provider = (self.provider or os.environ.get(ENV_PROVIDER) or DEFAULT_PROVIDER).lower()
75
+ preset = get_provider(self.provider)
76
+ env_key = preset.env_key if preset else ENV_API_KEY
77
+ env_base = preset.env_base if preset else ENV_BASE_URL
78
+ if not self.api_key:
79
+ self.api_key = os.environ.get(ENV_API_KEY) or os.environ.get(env_key, "")
80
+ if not self.base_url:
81
+ self.base_url = (
82
+ os.environ.get(ENV_BASE_URL)
83
+ or os.environ.get(env_base)
84
+ or (preset.base_url if preset else "")
85
+ )
86
+ self.base_url = self.base_url.rstrip("/")
87
+ if not self.model:
88
+ self.model = os.environ.get(ENV_MODEL) or (preset.default_model if preset else "")
89
+ if not self.auth_style:
90
+ self.auth_style = preset.auth_style if preset else "bearer"
91
+ return self
92
+
93
+ @property
94
+ def preset(self):
95
+ return get_provider(self.provider)
96
+
97
+ @property
98
+ def key_env_var(self) -> str:
99
+ """The variable a user should set to provide the key for this provider."""
100
+ preset = self.preset
101
+ return preset.env_key if preset else ENV_API_KEY
102
+
103
+ @property
104
+ def headers(self) -> dict[str, str]:
105
+ headers = {"Content-Type": "application/json"}
106
+ if self.api_key:
107
+ if self.auth_style == "api-key":
108
+ headers["api-key"] = self.api_key
109
+ else:
110
+ headers["Authorization"] = f"Bearer {self.api_key}"
111
+ return headers
112
+
113
+ def validate_config(self) -> list[str]:
114
+ """Return human-readable problems; empty list means the config can run."""
115
+ problems: list[str] = []
116
+ preset = self.preset
117
+ if preset is None:
118
+ problems.append(
119
+ f"unknown provider '{self.provider}'. Known: {', '.join(PROVIDERS)} "
120
+ "(use 'custom' with --base-url for any other OpenAI-compatible server)"
121
+ )
122
+ if not self.base_url:
123
+ problems.append(f"no base URL: set {ENV_BASE_URL} or pass --base-url")
124
+ if not self.model:
125
+ problems.append(f"no model: set {ENV_MODEL} or pass --model")
126
+ if not self.api_key and not (preset and preset.keyless):
127
+ problems.append(f"no API key: set {self.key_env_var} (or {ENV_API_KEY})")
128
+ if self.auth_style not in ("bearer", "api-key"):
129
+ problems.append(f"auth_style must be 'bearer' or 'api-key', not '{self.auth_style}'")
130
+ if self.max_retries < 1:
131
+ problems.append("max_retries must be >= 1 (it counts total attempts)")
132
+ if self.timeout <= 0:
133
+ problems.append("timeout must be > 0 seconds")
134
+ return problems
135
+
136
+ def require_valid(self) -> LLMConfig:
137
+ """Raise :class:`ConfigError` listing every problem, or return ``self``."""
138
+ problems = self.validate_config()
139
+ if problems:
140
+ raise ConfigError("; ".join(problems))
141
+ return self
142
+
143
+ def public_dict(self) -> dict[str, Any]:
144
+ """Settings safe to print or save: the key is replaced by its env reference."""
145
+ data = self.model_dump()
146
+ data["api_key"] = "${" + self.key_env_var + "}"
147
+ return data
148
+
149
+
150
+ class Settings(BaseModel):
151
+ """Root of ``crewforge.yaml``: one ``llm`` block plus per-recipe sections."""
152
+
153
+ llm: LLMConfig = Field(default_factory=LLMConfig)
154
+ review: dict[str, Any] = Field(default_factory=dict)
155
+ docs: dict[str, Any] = Field(default_factory=dict)
156
+ api: dict[str, Any] = Field(default_factory=dict)
157
+ content: dict[str, Any] = Field(default_factory=dict)
158
+
159
+ @classmethod
160
+ def from_yaml(cls, path: str | Path) -> Settings:
161
+ try:
162
+ data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
163
+ except (OSError, yaml.YAMLError) as exc:
164
+ raise ConfigError(f"cannot read config {path}: {exc}") from exc
165
+ if not isinstance(data, dict):
166
+ raise ConfigError(f"config {path} must be a mapping at the top level")
167
+ return cls(**expand_env(data))
168
+
169
+ def to_template(self) -> dict[str, Any]:
170
+ """Dict safe to write to disk: secrets are ``${VAR}`` references, never values."""
171
+ data = self.model_dump()
172
+ data["llm"] = self.llm.public_dict()
173
+ return data
174
+
175
+ def write_template(self, path: str | Path) -> Path:
176
+ out = Path(path)
177
+ out.write_text(
178
+ yaml.safe_dump(self.to_template(), sort_keys=False, allow_unicode=True),
179
+ encoding="utf-8",
180
+ )
181
+ return out
crewforge/core/crew.py ADDED
@@ -0,0 +1,144 @@
1
+ """A :class:`Crew` runs agents in sequential steps and parallel stages.
2
+
3
+ The crew owns the shared client and usage tracker, and it is where honest
4
+ failure is enforced for a whole run: a step whose agent raised, returned
5
+ nothing, or returned unparseable output stops the run with
6
+ :class:`CrewFailedError` naming the agent(s). Recipes never decide on their own
7
+ that a half-empty run is a success.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from collections.abc import Awaitable, Mapping
15
+ from typing import Any, Literal, TypeVar
16
+
17
+ import httpx
18
+
19
+ from .agent import Agent
20
+ from .client import LLMClient
21
+ from .config import LLMConfig
22
+ from .errors import AgentFailure, ConfigError, CrewFailedError, EmptyOutputError, classify
23
+ from .usage import UsageTracker
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ T = TypeVar("T")
28
+ A = TypeVar("A", bound=Agent)
29
+
30
+
31
+ def is_empty(value: Any) -> bool:
32
+ """``None``, blank strings and empty containers count as "returned nothing"."""
33
+ if value is None:
34
+ return True
35
+ if isinstance(value, str):
36
+ return not value.strip()
37
+ if isinstance(value, (list, tuple, dict, set, frozenset)):
38
+ return len(value) == 0
39
+ return False
40
+
41
+
42
+ class Crew:
43
+ """Shared client + tracker + failure bookkeeping for one run of a recipe."""
44
+
45
+ def __init__(
46
+ self,
47
+ name: str,
48
+ config: LLMConfig,
49
+ *,
50
+ client: LLMClient | None = None,
51
+ tracker: UsageTracker | None = None,
52
+ transport: httpx.AsyncBaseTransport | None = None,
53
+ validate: bool = True,
54
+ ) -> None:
55
+ if validate and client is None:
56
+ config.require_valid()
57
+ self.name = name
58
+ self.config = config
59
+ self._owns_client = client is None
60
+ self.client = client if client is not None else LLMClient(config, transport=transport)
61
+ self.tracker = tracker if tracker is not None else UsageTracker()
62
+ self.failures: list[AgentFailure] = []
63
+
64
+ async def __aenter__(self) -> Crew:
65
+ return self
66
+
67
+ async def __aexit__(self, *exc: object) -> None:
68
+ self.tracker.finish()
69
+ if self._owns_client:
70
+ await self.client.aclose()
71
+
72
+ def agent(self, cls: type[A], **kwargs: Any) -> A:
73
+ """Instantiate an agent bound to this crew's client and tracker."""
74
+ return cls(self.client, self.tracker, **kwargs)
75
+
76
+ def _fail(self, agent: str, exc: BaseException) -> AgentFailure:
77
+ failure = AgentFailure(
78
+ agent=agent, error=str(exc) or type(exc).__name__, kind=classify(exc)
79
+ )
80
+ self.failures.append(failure)
81
+ logger.info("[%s] agent %r failed: %s", self.name, agent, failure.error)
82
+ return failure
83
+
84
+ @staticmethod
85
+ def _checked(agent: str, value: T, allow_empty: bool) -> T:
86
+ # Second honest-failure guard: even if an agent's own code swallowed an
87
+ # error and returned a default, "nothing" is never a successful result.
88
+ if not allow_empty and is_empty(value):
89
+ raise EmptyOutputError(agent, "result was empty")
90
+ return value
91
+
92
+ async def step(self, agent: str | Agent, work: Awaitable[T], *, allow_empty: bool = False) -> T:
93
+ """Run one required step; raise :class:`CrewFailedError` if it fails."""
94
+ name = agent.name if isinstance(agent, Agent) else agent
95
+ try:
96
+ return self._checked(name, await work, allow_empty)
97
+ except ConfigError:
98
+ raise
99
+ except Exception as exc:
100
+ raise CrewFailedError(self.name, [self._fail(name, exc)]) from exc
101
+
102
+ async def parallel(
103
+ self,
104
+ work: Mapping[str, Awaitable[Any]],
105
+ *,
106
+ require: Literal["all", "any"] = "all",
107
+ allow_empty: bool = False,
108
+ ) -> dict[str, Any]:
109
+ """Run several agents concurrently.
110
+
111
+ ``require="all"`` (default): any failure fails the run, naming every
112
+ failed agent. ``require="any"``: failures are recorded in
113
+ :attr:`failures` and the successful results are returned, unless none
114
+ succeeded.
115
+ """
116
+ names = list(work)
117
+ outcomes = await asyncio.gather(*work.values(), return_exceptions=True)
118
+ results: dict[str, Any] = {}
119
+ failed: list[AgentFailure] = []
120
+ for name, outcome in zip(names, outcomes, strict=True):
121
+ if isinstance(outcome, ConfigError):
122
+ raise outcome
123
+ if isinstance(outcome, BaseException):
124
+ if not isinstance(outcome, Exception):
125
+ raise outcome # CancelledError, KeyboardInterrupt: never swallow
126
+ failed.append(self._fail(name, outcome))
127
+ continue
128
+ try:
129
+ results[name] = self._checked(name, outcome, allow_empty)
130
+ except EmptyOutputError as exc:
131
+ failed.append(self._fail(name, exc))
132
+ if failed and (require == "all" or not results):
133
+ raise CrewFailedError(self.name, failed)
134
+ return results
135
+
136
+ def report(self) -> dict[str, Any]:
137
+ return {
138
+ "crew": self.name,
139
+ "status": "partial" if self.failures else "success",
140
+ "failures": [
141
+ {"agent": f.agent, "kind": f.kind, "error": f.error} for f in self.failures
142
+ ],
143
+ "usage": self.tracker.summary(),
144
+ }
@@ -0,0 +1,106 @@
1
+ """Exception hierarchy.
2
+
3
+ Every failure a user can hit maps to one of these, and every agent-level
4
+ failure carries the agent's name so the CLI can say exactly *who* failed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ class CrewForgeError(Exception):
13
+ """Base class for all crewforge errors."""
14
+
15
+
16
+ class ConfigError(CrewForgeError):
17
+ """The configuration cannot work (unknown provider, missing key or URL, ...)."""
18
+
19
+
20
+ class LLMError(CrewForgeError):
21
+ """The LLM endpoint could not produce a usable response."""
22
+
23
+
24
+ class LLMHTTPError(LLMError):
25
+ """The endpoint answered with a non-2xx status after any allowed retries."""
26
+
27
+ def __init__(self, status_code: int, message: str, *, attempts: int = 1) -> None:
28
+ super().__init__(message)
29
+ self.status_code = status_code
30
+ self.attempts = attempts
31
+
32
+
33
+ class LLMResponseError(LLMError):
34
+ """The endpoint answered 2xx but the body is not a chat completion."""
35
+
36
+
37
+ class AgentError(CrewForgeError):
38
+ """An agent produced output that cannot be used. Always names the agent."""
39
+
40
+ def __init__(self, agent: str, reason: str) -> None:
41
+ super().__init__(f"agent '{agent}' {reason}")
42
+ self.agent = agent
43
+ self.reason = reason
44
+
45
+
46
+ class EmptyOutputError(AgentError):
47
+ """The model returned no content (blank, whitespace or null)."""
48
+
49
+ def __init__(self, agent: str, detail: str = "") -> None:
50
+ reason = "returned empty output"
51
+ if detail:
52
+ reason += f" ({detail})"
53
+ super().__init__(agent, reason)
54
+
55
+
56
+ class OutputParseError(AgentError):
57
+ """The model returned content, but not in the structure the agent requires."""
58
+
59
+ def __init__(self, agent: str, detail: str, excerpt: str = "") -> None:
60
+ reason = f"returned unparseable output: {detail}"
61
+ if excerpt:
62
+ reason += f" — got: {excerpt!r}"
63
+ super().__init__(agent, reason)
64
+ self.excerpt = excerpt
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class AgentFailure:
69
+ """One agent that failed inside a crew run."""
70
+
71
+ agent: str
72
+ error: str
73
+ kind: str = "error" # empty | parse | http | error
74
+
75
+ def __str__(self) -> str: # pragma: no cover - trivial
76
+ return f"{self.agent}: {self.error}"
77
+
78
+
79
+ class CrewFailedError(CrewForgeError):
80
+ """A crew run cannot honestly report success.
81
+
82
+ Raised when a required agent failed, returned nothing, or returned output
83
+ that could not be parsed. ``failures`` names every agent involved.
84
+ """
85
+
86
+ def __init__(self, crew: str, failures: list[AgentFailure]) -> None:
87
+ self.crew = crew
88
+ self.failures = list(failures)
89
+ names = ", ".join(f"'{f.agent}'" for f in self.failures) or "(none)"
90
+ detail = "; ".join(f"{f.agent}: {f.error}" for f in self.failures)
91
+ super().__init__(f"{crew} failed — agent(s) {names}: {detail}")
92
+
93
+ @property
94
+ def agents(self) -> list[str]:
95
+ return [f.agent for f in self.failures]
96
+
97
+
98
+ def classify(exc: BaseException) -> str:
99
+ """Short machine-readable kind for an exception (used in reports/JSON)."""
100
+ if isinstance(exc, EmptyOutputError):
101
+ return "empty"
102
+ if isinstance(exc, OutputParseError):
103
+ return "parse"
104
+ if isinstance(exc, LLMHTTPError):
105
+ return "http"
106
+ return "error"
@@ -0,0 +1,110 @@
1
+ """Structured-output parsing for LLM replies.
2
+
3
+ Real models wrap JSON in Markdown fences, add a sentence before it, or answer
4
+ with prose. :func:`extract_json` tolerates the first two; :func:`parse_json`
5
+ turns the third into an :class:`OutputParseError` that names the agent instead
6
+ of letting a default value slip through as if it were an answer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from typing import Any, TypeVar
14
+
15
+ from pydantic import BaseModel, ValidationError
16
+
17
+ from .errors import EmptyOutputError, OutputParseError
18
+
19
+ _FENCE = re.compile(r"```[ \t]*([A-Za-z0-9_+-]*)[ \t]*\n?(.*?)```", re.DOTALL)
20
+
21
+ M = TypeVar("M", bound=BaseModel)
22
+
23
+ _MISSING = object()
24
+
25
+
26
+ def extract_json(text: str | None) -> Any:
27
+ """Best-effort JSON extraction. Returns ``None`` when nothing parses.
28
+
29
+ Tries, in order: the whole reply, each fenced code block, the outermost
30
+ ``{...}`` span, then the outermost ``[...]`` span.
31
+ """
32
+ value = _extract(text)
33
+ return None if value is _MISSING else value
34
+
35
+
36
+ def _extract(text: str | None) -> Any:
37
+ if not text or not text.strip():
38
+ return _MISSING
39
+ candidates = [text.strip()]
40
+ candidates += [m.group(2).strip() for m in _FENCE.finditer(text)]
41
+ for open_ch, close_ch in (("{", "}"), ("[", "]")):
42
+ start, end = text.find(open_ch), text.rfind(close_ch)
43
+ if start != -1 and end > start:
44
+ candidates.append(text[start : end + 1])
45
+ for candidate in candidates:
46
+ if not candidate:
47
+ continue
48
+ try:
49
+ return json.loads(candidate)
50
+ except (json.JSONDecodeError, ValueError):
51
+ continue
52
+ return _MISSING
53
+
54
+
55
+ def _excerpt(text: str, limit: int = 120) -> str:
56
+ flat = " ".join(text.split())
57
+ return flat if len(flat) <= limit else flat[: limit - 1] + "…"
58
+
59
+
60
+ def parse_json(
61
+ text: str | None,
62
+ *,
63
+ agent: str,
64
+ expect: type | tuple[type, ...] = dict,
65
+ schema: type[M] | None = None,
66
+ ) -> Any:
67
+ """Parse an agent's reply as JSON or raise an error naming the agent.
68
+
69
+ ``expect`` is the required top-level type (``dict`` by default). With
70
+ ``schema`` (a pydantic model) the object is validated and the model
71
+ instance is returned.
72
+ """
73
+ if not text or not text.strip():
74
+ raise EmptyOutputError(agent)
75
+ value = _extract(text)
76
+ if value is _MISSING:
77
+ raise OutputParseError(agent, "no JSON found", _excerpt(text))
78
+ if not isinstance(value, expect):
79
+ want = (
80
+ " or ".join(t.__name__ for t in expect)
81
+ if isinstance(expect, tuple)
82
+ else expect.__name__
83
+ )
84
+ raise OutputParseError(
85
+ agent, f"expected a JSON {want}, got {type(value).__name__}", _excerpt(text)
86
+ )
87
+ if schema is not None:
88
+ try:
89
+ return schema.model_validate(value)
90
+ except ValidationError as exc:
91
+ first = exc.errors()[0]
92
+ loc = ".".join(str(p) for p in first.get("loc", ())) or "(root)"
93
+ raise OutputParseError(
94
+ agent, f"JSON does not match {schema.__name__}: {loc}: {first.get('msg')}"
95
+ ) from exc
96
+ return value
97
+
98
+
99
+ def extract_code_block(text: str | None, language: str = "") -> str | None:
100
+ """Return the first fenced code block (preferring ``language``), else ``None``."""
101
+ if not text:
102
+ return None
103
+ blocks = [(m.group(1).lower(), m.group(2)) for m in _FENCE.finditer(text)]
104
+ if not blocks:
105
+ return None
106
+ if language:
107
+ for lang, body in blocks:
108
+ if lang == language.lower():
109
+ return body.strip("\n")
110
+ return blocks[0][1].strip("\n")
@@ -0,0 +1,131 @@
1
+ """Provider presets for OpenAI-compatible ``/chat/completions`` endpoints.
2
+
3
+ A preset only supplies *defaults*: base URL, auth header style, a default
4
+ model and the environment variables that hold the key and an optional base-URL
5
+ override. Anything can be overridden explicitly or through the generic
6
+ ``CREWFORGE_*`` variables, so any compatible server works via ``custom``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ #: Generic environment variables that apply to whichever provider is active.
14
+ ENV_PROVIDER = "CREWFORGE_PROVIDER"
15
+ ENV_API_KEY = "CREWFORGE_API_KEY"
16
+ ENV_BASE_URL = "CREWFORGE_BASE_URL"
17
+ ENV_MODEL = "CREWFORGE_MODEL"
18
+
19
+ DEFAULT_PROVIDER = "openai"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Provider:
24
+ name: str
25
+ label: str
26
+ base_url: str
27
+ default_model: str
28
+ env_key: str
29
+ env_base: str
30
+ auth_style: str = "bearer" # "bearer" -> Authorization: Bearer; "api-key" -> api-key header
31
+ keyless: bool = False # local servers that accept requests without a key
32
+
33
+
34
+ PROVIDERS: dict[str, Provider] = {
35
+ p.name: p
36
+ for p in (
37
+ Provider(
38
+ "openai",
39
+ "OpenAI",
40
+ "https://api.openai.com/v1",
41
+ "gpt-4o-mini",
42
+ "OPENAI_API_KEY",
43
+ "OPENAI_BASE_URL",
44
+ ),
45
+ Provider(
46
+ "openrouter",
47
+ "OpenRouter",
48
+ "https://openrouter.ai/api/v1",
49
+ "openai/gpt-4o-mini",
50
+ "OPENROUTER_API_KEY",
51
+ "OPENROUTER_BASE_URL",
52
+ ),
53
+ Provider(
54
+ "groq",
55
+ "Groq",
56
+ "https://api.groq.com/openai/v1",
57
+ "llama-3.3-70b-versatile",
58
+ "GROQ_API_KEY",
59
+ "GROQ_BASE_URL",
60
+ ),
61
+ Provider(
62
+ "deepseek",
63
+ "DeepSeek",
64
+ "https://api.deepseek.com/v1",
65
+ "deepseek-chat",
66
+ "DEEPSEEK_API_KEY",
67
+ "DEEPSEEK_BASE_URL",
68
+ ),
69
+ Provider(
70
+ "together",
71
+ "Together AI",
72
+ "https://api.together.xyz/v1",
73
+ "meta-llama/Llama-3.3-70B-Instruct-Turbo",
74
+ "TOGETHER_API_KEY",
75
+ "TOGETHER_BASE_URL",
76
+ ),
77
+ Provider(
78
+ "mistral",
79
+ "Mistral",
80
+ "https://api.mistral.ai/v1",
81
+ "mistral-small-latest",
82
+ "MISTRAL_API_KEY",
83
+ "MISTRAL_BASE_URL",
84
+ ),
85
+ Provider(
86
+ "ollama",
87
+ "Ollama (local)",
88
+ "http://localhost:11434/v1",
89
+ "llama3.1",
90
+ "OLLAMA_API_KEY",
91
+ "OLLAMA_BASE_URL",
92
+ keyless=True,
93
+ ),
94
+ Provider(
95
+ "llamacpp",
96
+ "llama.cpp server (local)",
97
+ "http://localhost:8080/v1",
98
+ "local",
99
+ "LLAMACPP_API_KEY",
100
+ "LLAMACPP_BASE_URL",
101
+ keyless=True,
102
+ ),
103
+ Provider(
104
+ "mimo",
105
+ "Xiaomi MiMo",
106
+ "https://token-plan-sgp.xiaomimimo.com/v1",
107
+ "mimo-v2.5-pro",
108
+ "MIMO_API_KEY",
109
+ "MIMO_BASE_URL",
110
+ auth_style="api-key",
111
+ ),
112
+ Provider(
113
+ "custom",
114
+ "Any OpenAI-compatible endpoint",
115
+ "", # must be supplied: CREWFORGE_BASE_URL or --base-url
116
+ "", # must be supplied: CREWFORGE_MODEL or --model
117
+ ENV_API_KEY,
118
+ ENV_BASE_URL,
119
+ keyless=True, # many self-hosted servers need no key
120
+ ),
121
+ )
122
+ }
123
+
124
+
125
+ def get_provider(name: str) -> Provider | None:
126
+ """Return the preset called ``name`` (case-insensitive), or ``None``."""
127
+ return PROVIDERS.get((name or "").strip().lower())
128
+
129
+
130
+ def provider_names() -> list[str]:
131
+ return list(PROVIDERS)