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 +27 -0
- llmbuffet/__main__.py +4 -0
- llmbuffet/cli.py +239 -0
- llmbuffet/client.py +249 -0
- llmbuffet/config.py +81 -0
- llmbuffet/errors.py +37 -0
- llmbuffet/models.py +76 -0
- llmbuffet/providers.toml +218 -0
- llmbuffet/proxy.py +261 -0
- llmbuffet/quota.py +101 -0
- llmbuffet/router.py +215 -0
- llmbuffet-0.2.0.dist-info/METADATA +240 -0
- llmbuffet-0.2.0.dist-info/RECORD +16 -0
- llmbuffet-0.2.0.dist-info/WHEEL +4 -0
- llmbuffet-0.2.0.dist-info/entry_points.txt +3 -0
- llmbuffet-0.2.0.dist-info/licenses/LICENSE +21 -0
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
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}")
|