llmbuffet 0.2.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.
llmbuffet/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """llmbuffet — pool free-tier LLM APIs behind one OpenAI-compatible endpoint.
2
+
3
+ Public API:
4
+
5
+ from llmbuffet import Buffet
6
+
7
+ buffet = Buffet.from_default_config()
8
+ reply = buffet.ask("Explain CAP theorem in one sentence.")
9
+ print(reply.text)
10
+ """
11
+
12
+ from .errors import AllProvidersExhausted, BuffetError, NoProvidersConfigured
13
+ from .models import Model, Provider, Reply
14
+ from .router import Buffet
15
+
16
+ __version__ = "0.2.0"
17
+
18
+ __all__ = [
19
+ "Buffet",
20
+ "Provider",
21
+ "Model",
22
+ "Reply",
23
+ "BuffetError",
24
+ "NoProvidersConfigured",
25
+ "AllProvidersExhausted",
26
+ "__version__",
27
+ ]
llmbuffet/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
llmbuffet/cli.py ADDED
@@ -0,0 +1,239 @@
1
+ """Command-line interface for llmbuffet.
2
+
3
+ llmbuffet ask "question" one-shot completion (reads stdin too)
4
+ llmbuffet providers list configured / available providers
5
+ llmbuffet quota show today's per-provider usage
6
+ llmbuffet proxy run the OpenAI-compatible proxy server
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import os
13
+ import sys
14
+
15
+ from . import __version__
16
+ from .config import configured_providers, load_catalog
17
+ from .errors import AllProvidersExhausted, NoProvidersConfigured
18
+ from .quota import QuotaStore
19
+ from .router import Buffet
20
+
21
+
22
+ def _read_stdin() -> str:
23
+ if sys.stdin is None or sys.stdin.isatty():
24
+ return ""
25
+ return sys.stdin.read()
26
+
27
+
28
+ def cmd_ask(args: argparse.Namespace) -> int:
29
+ stdin = _read_stdin()
30
+ prompt = args.prompt or ""
31
+ if stdin:
32
+ prompt = f"{stdin}\n\n{prompt}".strip() if prompt else stdin
33
+
34
+ if not prompt.strip():
35
+ print("llmbuffet: no prompt provided (pass text or pipe stdin)", file=sys.stderr)
36
+ return 3
37
+
38
+ # Support `--model provider/model` as a shorthand for picking an exact
39
+ # model on an exact provider (in addition to `--providers` + bare `--model`).
40
+ model_filter = args.model
41
+ provider_filter = args.providers.split(",") if args.providers else None
42
+ if model_filter and "/" in model_filter:
43
+ prov, _, mdl = model_filter.partition("/")
44
+ provider_filter = [prov]
45
+ model_filter = mdl
46
+
47
+ system = args.system
48
+ if args.json:
49
+ json_rule = "Respond with a single valid JSON value and nothing else — no prose, no markdown fences."
50
+ system = f"{system}\n{json_rule}" if system else json_rule
51
+
52
+ buffet = Buffet.from_default_config()
53
+ try:
54
+ reply = buffet.ask(
55
+ prompt,
56
+ system=system,
57
+ model=model_filter,
58
+ providers=provider_filter,
59
+ max_tokens=args.max_tokens,
60
+ temperature=args.temperature,
61
+ )
62
+ except NoProvidersConfigured as exc:
63
+ print(f"llmbuffet: {exc}", file=sys.stderr)
64
+ return 3
65
+ except AllProvidersExhausted as exc:
66
+ print(f"llmbuffet: {exc}", file=sys.stderr)
67
+ return 4
68
+
69
+ text = reply.text
70
+ if args.json:
71
+ text = _strip_fences(text)
72
+ print(text)
73
+ if args.verbose:
74
+ print(f"\n[served by {reply.provider_id}/{reply.model}]", file=sys.stderr)
75
+ return 0
76
+
77
+
78
+ def _strip_fences(text: str) -> str:
79
+ """Remove a leading ```json / ``` fence and trailing ``` if present."""
80
+ t = text.strip()
81
+ if t.startswith("```"):
82
+ t = t.split("\n", 1)[1] if "\n" in t else t[3:]
83
+ if t.rstrip().endswith("```"):
84
+ t = t.rstrip()[:-3]
85
+ return t.strip()
86
+
87
+
88
+ def cmd_providers(args: argparse.Namespace) -> int:
89
+ catalog = load_catalog()
90
+ configured = {p.id for p in configured_providers(catalog)}
91
+ n_models = sum(len(p.models) for p in catalog)
92
+ print(f"llmbuffet catalog: {len(catalog)} providers, {n_models} models\n")
93
+ for p in catalog:
94
+ mark = "✓" if p.id in configured else "·"
95
+ status = "configured" if p.id in configured else f"set {p.key_env}"
96
+ print(f" {mark} {p.id:<12} {p.label:<28} {len(p.models):>2} models [{status}]")
97
+ if not configured:
98
+ print("\nNo providers configured yet. See .env.example for the env vars to set.")
99
+ return 0
100
+
101
+
102
+ def cmd_models(args: argparse.Namespace) -> int:
103
+ catalog = load_catalog()
104
+ configured = {p.id for p in configured_providers(catalog)}
105
+ only = set(args.providers.split(",")) if args.providers else None
106
+ shown = 0
107
+ for p in catalog:
108
+ if only is not None and p.id not in only:
109
+ continue
110
+ if args.configured_only and p.id not in configured:
111
+ continue
112
+ mark = "✓" if p.id in configured else "·"
113
+ keyless = " (keyless)" if p.keyless and p.id in configured else ""
114
+ print(f"\n{mark} {p.id} — {p.label}{keyless}")
115
+ for m in p.models:
116
+ shown += 1
117
+ print(f" {p.id}/{m.name}")
118
+ if shown == 0:
119
+ print("No models match. Try `llmbuffet providers` to see configuration status.")
120
+ return 0
121
+ print(
122
+ f"\nPass any id above to `--model`, e.g. "
123
+ f'`llmbuffet ask -m {catalog[0].id}/{catalog[0].models[0].name} "hi"`,'
124
+ )
125
+ print("or just `--model <model-name>` to use that model on any provider that has it.")
126
+ return 0
127
+
128
+
129
+ def cmd_quota(args: argparse.Namespace) -> int:
130
+ store = QuotaStore()
131
+ snap = store.snapshot()
132
+ if not snap:
133
+ print("No usage recorded today (UTC).")
134
+ return 0
135
+ print("Today's usage (UTC):")
136
+ for key, count in sorted(snap.items(), key=lambda kv: -kv[1]):
137
+ print(f" {count:>6} {key}")
138
+ return 0
139
+
140
+
141
+ def cmd_proxy(args: argparse.Namespace) -> int:
142
+ from .proxy import serve # lazy: avoids http.server import on other paths
143
+
144
+ buffet = Buffet.from_default_config()
145
+ if not buffet.providers:
146
+ print(
147
+ "llmbuffet: no providers configured; set at least one API key "
148
+ "(see .env.example) before starting the proxy.",
149
+ file=sys.stderr,
150
+ )
151
+ return 3
152
+
153
+ proxy_key = args.api_key or os.environ.get("LLMBUFFET_PROXY_KEY") or None
154
+ loopback = args.host in {"127.0.0.1", "localhost", "::1"}
155
+ if not loopback and not proxy_key:
156
+ print(
157
+ f"llmbuffet: WARNING — binding to {args.host} (not loopback) with NO proxy key "
158
+ "exposes all your configured providers to the network. Set --api-key or "
159
+ "LLMBUFFET_PROXY_KEY, or bind to 127.0.0.1.",
160
+ file=sys.stderr,
161
+ )
162
+
163
+ httpd = serve(buffet, host=args.host, port=args.port, api_key=proxy_key)
164
+ n_models = sum(len(p.models) for p in buffet.providers)
165
+ auth_note = " auth: Bearer key required\n" if proxy_key else ""
166
+ print(
167
+ f"llmbuffet proxy on http://{args.host}:{args.port}/v1 "
168
+ f"({len(buffet.providers)} providers, {n_models} models)\n"
169
+ f"{auth_note}"
170
+ f" point your OpenAI client at: OPENAI_BASE_URL=http://{args.host}:{args.port}/v1\n"
171
+ " press Ctrl-C to stop",
172
+ file=sys.stderr,
173
+ )
174
+ try:
175
+ httpd.serve_forever()
176
+ except KeyboardInterrupt:
177
+ print("\nllmbuffet: shutting down", file=sys.stderr)
178
+ finally:
179
+ httpd.server_close()
180
+ return 0
181
+
182
+
183
+ def build_parser() -> argparse.ArgumentParser:
184
+ parser = argparse.ArgumentParser(
185
+ prog="llmbuffet",
186
+ description="Pool free-tier LLM APIs behind one OpenAI-compatible endpoint.",
187
+ )
188
+ parser.add_argument("--version", action="version", version=f"llmbuffet {__version__}")
189
+ sub = parser.add_subparsers(dest="command", required=True)
190
+
191
+ p_ask = sub.add_parser("ask", help="one-shot completion")
192
+ p_ask.add_argument("prompt", nargs="?", default="", help="prompt text (stdin is appended)")
193
+ p_ask.add_argument("-s", "--system", help="system prompt")
194
+ p_ask.add_argument(
195
+ "-m", "--model", help="model name, or provider/model (e.g. groq/llama-3.3-70b-versatile)"
196
+ )
197
+ p_ask.add_argument("-p", "--providers", help="comma-separated provider ids to allow")
198
+ p_ask.add_argument("--max-tokens", type=int, default=1024)
199
+ p_ask.add_argument("--temperature", type=float, default=0.0)
200
+ p_ask.add_argument(
201
+ "--json", action="store_true", help="ask for JSON output and strip code fences"
202
+ )
203
+ p_ask.add_argument("-v", "--verbose", action="store_true", help="report which provider served")
204
+ p_ask.set_defaults(func=cmd_ask)
205
+
206
+ p_prov = sub.add_parser("providers", help="list providers and configuration status")
207
+ p_prov.set_defaults(func=cmd_providers)
208
+
209
+ p_models = sub.add_parser("models", help="list every available provider/model id")
210
+ p_models.add_argument("-p", "--providers", help="comma-separated provider ids to filter")
211
+ p_models.add_argument(
212
+ "-c", "--configured-only", action="store_true", help="only show configured providers"
213
+ )
214
+ p_models.set_defaults(func=cmd_models)
215
+
216
+ p_quota = sub.add_parser("quota", help="show today's per-provider usage")
217
+ p_quota.set_defaults(func=cmd_quota)
218
+
219
+ p_proxy = sub.add_parser("proxy", help="run the OpenAI-compatible proxy server")
220
+ p_proxy.add_argument("--host", default="127.0.0.1")
221
+ p_proxy.add_argument("--port", type=int, default=8080)
222
+ p_proxy.add_argument(
223
+ "--api-key",
224
+ default=None,
225
+ help="require this Bearer token on requests (or set LLMBUFFET_PROXY_KEY)",
226
+ )
227
+ p_proxy.set_defaults(func=cmd_proxy)
228
+
229
+ return parser
230
+
231
+
232
+ def main(argv: list[str] | None = None) -> int:
233
+ parser = build_parser()
234
+ args = parser.parse_args(argv)
235
+ return args.func(args)
236
+
237
+
238
+ if __name__ == "__main__": # pragma: no cover
239
+ raise SystemExit(main())
llmbuffet/client.py ADDED
@@ -0,0 +1,249 @@
1
+ """HTTP client and per-adapter request/response shaping.
2
+
3
+ Three adapters cover every provider in the catalog:
4
+
5
+ * ``openai`` — standard ``/chat/completions`` (Groq, Cerebras, OpenRouter,
6
+ GitHub Models, Mistral, Cohere, SambaNova, ...).
7
+ * ``cloudflare`` — Cloudflare Workers AI, which exposes an OpenAI-compatible
8
+ route once ``{account_id}`` is substituted into the URL.
9
+ * ``gemini`` — Google Generative Language API (different body shape).
10
+
11
+ All network access goes through a single injectable ``post`` callable so the
12
+ router and adapters can be unit-tested without touching the network.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass
21
+
22
+ from .errors import ProviderHTTPError
23
+ from .models import Provider, Reply
24
+
25
+ Message = dict[str, str]
26
+
27
+ _THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
28
+ # Reasoning models burn output budget on hidden reasoning; give them headroom
29
+ # if the caller left max_tokens at a small default.
30
+ _THINKING_HINTS = (
31
+ "glm-4.7",
32
+ "-r1",
33
+ "reasoning",
34
+ "thinking",
35
+ "magistral",
36
+ "deepseek-r1",
37
+ "nemotron",
38
+ )
39
+ _THINKING_FLOOR = 8192
40
+
41
+
42
+ def _is_thinking(model: str) -> bool:
43
+ m = model.lower()
44
+ return any(h in m for h in _THINKING_HINTS)
45
+
46
+
47
+ def _strip_think(text: str) -> str:
48
+ return _THINK_RE.sub("", text).strip()
49
+
50
+
51
+ @dataclass
52
+ class HTTPResult:
53
+ status: int
54
+ body: dict
55
+ text: str
56
+
57
+
58
+ PostFn = Callable[[str, dict, dict, float], HTTPResult]
59
+
60
+ _USER_AGENT = "llmbuffet/0.2 (+https://github.com/0xzr/llmbuffet)"
61
+
62
+
63
+ def default_post(url: str, headers: dict, json_body: dict, timeout: float) -> HTTPResult:
64
+ """Real network POST via httpx. Imported lazily so tests need no httpx."""
65
+ import httpx
66
+
67
+ headers = {"User-Agent": _USER_AGENT, **headers}
68
+ resp = httpx.post(url, headers=headers, json=json_body, timeout=timeout)
69
+ try:
70
+ body = resp.json()
71
+ except (json.JSONDecodeError, ValueError):
72
+ body = {}
73
+ return HTTPResult(status=resp.status_code, body=body, text=resp.text)
74
+
75
+
76
+ def _retryable(status: int) -> bool:
77
+ # 429 (rate limit) and 5xx are worth trying another provider for.
78
+ # 408 request timeout too. 4xx config errors are not retryable per-call but
79
+ # the router still advances to a different provider regardless.
80
+ return status == 429 or status == 408 or 500 <= status < 600
81
+
82
+
83
+ def _err_message(result: HTTPResult) -> str:
84
+ err = result.body.get("error")
85
+ if isinstance(err, dict):
86
+ return str(err.get("message") or err)
87
+ if isinstance(err, str):
88
+ return err
89
+ return (result.text or "").strip()[:200] or "no body"
90
+
91
+
92
+ def _to_gemini_contents(messages: list[Message]) -> tuple[dict | None, list[dict]]:
93
+ """Split OpenAI-style messages into (systemInstruction, contents)."""
94
+ system: str | None = None
95
+ contents: list[dict] = []
96
+ for msg in messages:
97
+ role = msg.get("role", "user")
98
+ text = msg.get("content", "")
99
+ if role == "system":
100
+ system = f"{system}\n{text}" if system else text
101
+ continue
102
+ gem_role = "model" if role == "assistant" else "user"
103
+ contents.append({"role": gem_role, "parts": [{"text": text}]})
104
+ system_instruction = {"parts": [{"text": system}]} if system else None
105
+ return system_instruction, contents
106
+
107
+
108
+ def call(
109
+ provider: Provider,
110
+ model: str,
111
+ messages: list[Message],
112
+ *,
113
+ api_key: str | None,
114
+ env: dict[str, str],
115
+ max_tokens: int = 1024,
116
+ temperature: float = 0.0,
117
+ timeout: float = 90.0,
118
+ post: PostFn = default_post,
119
+ ) -> Reply:
120
+ """Dispatch one completion to ``provider`` and normalize the response.
121
+
122
+ Raises :class:`ProviderHTTPError` on a non-200 status.
123
+ """
124
+ if _is_thinking(model) and max_tokens < _THINKING_FLOOR:
125
+ # Give reasoning models room so hidden reasoning doesn't eat the whole
126
+ # budget and return empty content.
127
+ max_tokens = _THINKING_FLOOR
128
+ if provider.adapter == "gemini":
129
+ return _call_gemini(
130
+ provider,
131
+ model,
132
+ messages,
133
+ api_key=api_key,
134
+ max_tokens=max_tokens,
135
+ temperature=temperature,
136
+ timeout=timeout,
137
+ post=post,
138
+ )
139
+ # openai + cloudflare share the chat/completions shape.
140
+ return _call_openai(
141
+ provider,
142
+ model,
143
+ messages,
144
+ api_key=api_key,
145
+ env=env,
146
+ max_tokens=max_tokens,
147
+ temperature=temperature,
148
+ timeout=timeout,
149
+ post=post,
150
+ )
151
+
152
+
153
+ def _call_openai(
154
+ provider: Provider,
155
+ model: str,
156
+ messages: list[Message],
157
+ *,
158
+ api_key: str | None,
159
+ env: dict[str, str],
160
+ max_tokens: int,
161
+ temperature: float,
162
+ timeout: float,
163
+ post: PostFn,
164
+ ) -> Reply:
165
+ base_url = provider.base_url
166
+ if provider.adapter == "cloudflare":
167
+ account_id = env.get("CLOUDFLARE_ACCOUNT_ID", "")
168
+ base_url = base_url.replace("{account_id}", account_id)
169
+
170
+ url = f"{base_url}/chat/completions"
171
+ headers = {"Content-Type": "application/json"}
172
+ if api_key: # keyless providers (e.g. OVH anonymous) send no auth header
173
+ headers["Authorization"] = f"Bearer {api_key}"
174
+ body = {
175
+ "model": model,
176
+ "messages": messages,
177
+ "max_tokens": max_tokens,
178
+ "temperature": temperature,
179
+ "stream": False,
180
+ }
181
+ result = post(url, headers, body, timeout)
182
+ if result.status != 200:
183
+ raise ProviderHTTPError(
184
+ result.status, _err_message(result), retryable=_retryable(result.status)
185
+ )
186
+
187
+ choices = result.body.get("choices") or []
188
+ if not choices:
189
+ raise ProviderHTTPError(502, "no choices in response", retryable=True)
190
+ message = choices[0].get("message") or {}
191
+ text = _strip_think(message.get("content") or "")
192
+ usage = result.body.get("usage") or {}
193
+ return Reply(
194
+ text=text,
195
+ provider_id=provider.id,
196
+ model=model,
197
+ raw=result.body,
198
+ prompt_tokens=usage.get("prompt_tokens"),
199
+ completion_tokens=usage.get("completion_tokens"),
200
+ )
201
+
202
+
203
+ def _call_gemini(
204
+ provider: Provider,
205
+ model: str,
206
+ messages: list[Message],
207
+ *,
208
+ api_key: str | None,
209
+ max_tokens: int,
210
+ temperature: float,
211
+ timeout: float,
212
+ post: PostFn,
213
+ ) -> Reply:
214
+ system_instruction, contents = _to_gemini_contents(messages)
215
+ url = f"{provider.base_url}/models/{model}:generateContent"
216
+ headers = {
217
+ "Content-Type": "application/json",
218
+ "x-goog-api-key": api_key,
219
+ }
220
+ body: dict = {
221
+ "contents": contents,
222
+ "generationConfig": {
223
+ "maxOutputTokens": max_tokens,
224
+ "temperature": temperature,
225
+ },
226
+ }
227
+ if system_instruction:
228
+ body["systemInstruction"] = system_instruction
229
+
230
+ result = post(url, headers, body, timeout)
231
+ if result.status != 200:
232
+ raise ProviderHTTPError(
233
+ result.status, _err_message(result), retryable=_retryable(result.status)
234
+ )
235
+
236
+ candidates = result.body.get("candidates") or []
237
+ if not candidates:
238
+ raise ProviderHTTPError(502, "no candidates in response", retryable=True)
239
+ parts = (candidates[0].get("content") or {}).get("parts") or []
240
+ text = _strip_think("".join(p.get("text", "") for p in parts))
241
+ usage = result.body.get("usageMetadata") or {}
242
+ return Reply(
243
+ text=text,
244
+ provider_id=provider.id,
245
+ model=model,
246
+ raw=result.body,
247
+ prompt_tokens=usage.get("promptTokenCount"),
248
+ completion_tokens=usage.get("candidatesTokenCount"),
249
+ )
llmbuffet/config.py ADDED
@@ -0,0 +1,81 @@
1
+ """Configuration loading: provider catalog + user overrides.
2
+
3
+ Resolution order for the provider catalog:
4
+
5
+ 1. The packaged ``providers.toml`` (the built-in catalog).
6
+ 2. A user catalog at ``$LLMBUFFET_CONFIG`` or
7
+ ``~/.config/llmbuffet/providers.toml`` if present. Providers with the same
8
+ ``id`` override the built-ins; new ids are appended.
9
+
10
+ Only providers whose API key (and any extra env vars) are present in the
11
+ environment are returned by :func:`configured_providers`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import tomllib
18
+ from pathlib import Path
19
+
20
+ from .models import Model, Provider
21
+
22
+ _PACKAGED_CATALOG = Path(__file__).with_name("providers.toml")
23
+
24
+
25
+ def _user_catalog_path() -> Path | None:
26
+ override = os.environ.get("LLMBUFFET_CONFIG")
27
+ if override:
28
+ return Path(override).expanduser()
29
+ default = Path.home() / ".config" / "llmbuffet" / "providers.toml"
30
+ return default if default.exists() else None
31
+
32
+
33
+ def _parse_catalog(data: dict) -> list[Provider]:
34
+ providers: list[Provider] = []
35
+ for row in data.get("provider", []):
36
+ models = tuple(
37
+ Model(name=m["name"], rpd=int(m.get("rpd", 0))) for m in row.get("models", [])
38
+ )
39
+ providers.append(
40
+ Provider(
41
+ id=row["id"],
42
+ label=row.get("label", row["id"]),
43
+ adapter=row.get("adapter", "openai"),
44
+ base_url=row["base_url"].rstrip("/"),
45
+ key_env=row.get("key_env"),
46
+ auth=row.get("auth", "bearer"),
47
+ key_optional=bool(row.get("key_optional", False)),
48
+ models=models,
49
+ extra_env=tuple(row.get("extra_env", [])),
50
+ )
51
+ )
52
+ return providers
53
+
54
+
55
+ def load_catalog(path: Path | None = None) -> list[Provider]:
56
+ """Load the full provider catalog (built-ins + user overrides)."""
57
+ base_path = path or _PACKAGED_CATALOG
58
+ with base_path.open("rb") as fh:
59
+ providers = _parse_catalog(tomllib.load(fh))
60
+
61
+ if path is None:
62
+ user_path = _user_catalog_path()
63
+ if user_path is not None:
64
+ with user_path.open("rb") as fh:
65
+ user_providers = _parse_catalog(tomllib.load(fh))
66
+ by_id = {p.id: p for p in providers}
67
+ for up in user_providers:
68
+ by_id[up.id] = up
69
+ providers = list(by_id.values())
70
+
71
+ return providers
72
+
73
+
74
+ def configured_providers(
75
+ catalog: list[Provider] | None = None,
76
+ env: dict[str, str] | None = None,
77
+ ) -> list[Provider]:
78
+ """Return only providers that have a usable API key in the environment."""
79
+ catalog = catalog if catalog is not None else load_catalog()
80
+ env = env if env is not None else dict(os.environ)
81
+ return [p for p in catalog if p.is_configured(env)]
llmbuffet/errors.py ADDED
@@ -0,0 +1,37 @@
1
+ """Exception hierarchy for llmbuffet."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class BuffetError(Exception):
7
+ """Base class for all llmbuffet errors."""
8
+
9
+
10
+ class NoProvidersConfigured(BuffetError):
11
+ """Raised when no provider has a usable API key in the environment."""
12
+
13
+
14
+ class AllProvidersExhausted(BuffetError):
15
+ """Raised when every candidate provider failed or is over budget.
16
+
17
+ The ``attempts`` attribute holds a list of ``(target, reason)`` tuples
18
+ describing what was tried and why each one was skipped or failed.
19
+ """
20
+
21
+ def __init__(self, attempts: list[tuple[str, str]]):
22
+ self.attempts = attempts
23
+ detail = "; ".join(f"{name}: {reason}" for name, reason in attempts) or "no candidates"
24
+ super().__init__(f"all providers exhausted ({detail})")
25
+
26
+
27
+ class ProviderHTTPError(BuffetError):
28
+ """A provider returned a non-success HTTP status.
29
+
30
+ ``status`` is the HTTP status code; ``retryable`` indicates whether the
31
+ router should move on to another provider (True) or give up (False).
32
+ """
33
+
34
+ def __init__(self, status: int, message: str, *, retryable: bool):
35
+ self.status = status
36
+ self.retryable = retryable
37
+ super().__init__(f"HTTP {status}: {message}")