cli-relay 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.
- cli_relay/__init__.py +39 -0
- cli_relay/__main__.py +5 -0
- cli_relay/adapters/__init__.py +38 -0
- cli_relay/adapters/antigravity.py +109 -0
- cli_relay/adapters/base.py +153 -0
- cli_relay/adapters/claude.py +169 -0
- cli_relay/adapters/codex.py +120 -0
- cli_relay/adapters/openai_compat.py +144 -0
- cli_relay/cli.py +238 -0
- cli_relay/config.py +131 -0
- cli_relay/errors.py +85 -0
- cli_relay/events.py +115 -0
- cli_relay/handoff.py +68 -0
- cli_relay/mcp_server.py +102 -0
- cli_relay/models.py +67 -0
- cli_relay/ndjson.py +41 -0
- cli_relay/proc.py +184 -0
- cli_relay/py.typed +0 -0
- cli_relay/quota.py +123 -0
- cli_relay/relay.py +137 -0
- cli_relay-0.1.0.dist-info/METADATA +227 -0
- cli_relay-0.1.0.dist-info/RECORD +25 -0
- cli_relay-0.1.0.dist-info/WHEEL +4 -0
- cli_relay-0.1.0.dist-info/entry_points.txt +2 -0
- cli_relay-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Any OpenAI-compatible chat endpoint: OpenRouter, a paid API, vLLM, Ollama, LM Studio...
|
|
2
|
+
|
|
3
|
+
This is a *text-only* fallback: it has no tools and cannot edit files. It is meant as the
|
|
4
|
+
last links of the chain ("answer from somewhere rather than nowhere"). Standard library
|
|
5
|
+
only - no HTTP client dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from collections.abc import AsyncIterator
|
|
19
|
+
from typing import Any, ClassVar
|
|
20
|
+
|
|
21
|
+
from cli_relay.adapters.base import Adapter
|
|
22
|
+
from cli_relay.errors import classify, reset_hint
|
|
23
|
+
from cli_relay.events import Completed, Event, Failed, FailureKind, Started, TextDelta
|
|
24
|
+
from cli_relay.models import Task
|
|
25
|
+
|
|
26
|
+
_DONE = object()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OpenAICompatAdapter(Adapter):
|
|
30
|
+
type_name: ClassVar[str] = "openai"
|
|
31
|
+
|
|
32
|
+
def available(self) -> tuple[bool, str]:
|
|
33
|
+
if not self.settings.base_url:
|
|
34
|
+
return False, "no base_url configured"
|
|
35
|
+
if not self.settings.model:
|
|
36
|
+
return False, "no model configured"
|
|
37
|
+
env = self.settings.api_key_env
|
|
38
|
+
if env and not os.environ.get(env):
|
|
39
|
+
return False, f"environment variable {env} is not set"
|
|
40
|
+
return True, ""
|
|
41
|
+
|
|
42
|
+
async def run(self, task: Task) -> AsyncIterator[Event]:
|
|
43
|
+
ok, reason = self.available()
|
|
44
|
+
if not ok:
|
|
45
|
+
yield Failed(provider=self.name, kind=FailureKind.NOT_INSTALLED, message=reason)
|
|
46
|
+
return
|
|
47
|
+
messages: list[dict[str, str]] = []
|
|
48
|
+
if task.system_prompt:
|
|
49
|
+
messages.append({"role": "system", "content": task.system_prompt})
|
|
50
|
+
messages.append({"role": "user", "content": task.prompt})
|
|
51
|
+
body = json.dumps(
|
|
52
|
+
{"model": self.settings.model, "messages": messages, "stream": True}
|
|
53
|
+
).encode()
|
|
54
|
+
headers = {"Content-Type": "application/json", "Accept": "text/event-stream"}
|
|
55
|
+
if self.settings.api_key_env:
|
|
56
|
+
headers["Authorization"] = f"Bearer {os.environ[self.settings.api_key_env]}"
|
|
57
|
+
url = self.settings.base_url.rstrip("/") + "/chat/completions" # type: ignore[union-attr]
|
|
58
|
+
req = urllib.request.Request(url, data=body, headers=headers, method="POST") # noqa: S310
|
|
59
|
+
|
|
60
|
+
loop = asyncio.get_running_loop()
|
|
61
|
+
queue: asyncio.Queue[object] = asyncio.Queue()
|
|
62
|
+
stop = threading.Event()
|
|
63
|
+
|
|
64
|
+
def put(item: object) -> None:
|
|
65
|
+
loop.call_soon_threadsafe(queue.put_nowait, item)
|
|
66
|
+
|
|
67
|
+
def worker() -> None:
|
|
68
|
+
try:
|
|
69
|
+
with urllib.request.urlopen(req, timeout=task.timeout_s or 600) as resp: # noqa: S310
|
|
70
|
+
for raw in resp:
|
|
71
|
+
if stop.is_set():
|
|
72
|
+
return
|
|
73
|
+
put(raw)
|
|
74
|
+
except urllib.error.HTTPError as exc:
|
|
75
|
+
put(exc)
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
put(exc)
|
|
78
|
+
finally:
|
|
79
|
+
put(_DONE)
|
|
80
|
+
|
|
81
|
+
started = time.monotonic()
|
|
82
|
+
thread = threading.Thread(target=worker, name=f"cli-relay-{self.name}", daemon=True)
|
|
83
|
+
thread.start()
|
|
84
|
+
yield Started(provider=self.name, model=self.settings.model)
|
|
85
|
+
parts: list[str] = []
|
|
86
|
+
usage: dict[str, Any] = {}
|
|
87
|
+
try:
|
|
88
|
+
while True:
|
|
89
|
+
item = await queue.get()
|
|
90
|
+
if item is _DONE:
|
|
91
|
+
break
|
|
92
|
+
if isinstance(item, urllib.error.HTTPError):
|
|
93
|
+
yield self._http_failure(item)
|
|
94
|
+
return
|
|
95
|
+
if isinstance(item, Exception):
|
|
96
|
+
yield Failed(provider=self.name, kind=classify(item), message=str(item))
|
|
97
|
+
return
|
|
98
|
+
assert isinstance(item, bytes)
|
|
99
|
+
line = item.decode("utf-8", errors="replace").strip()
|
|
100
|
+
if not line.startswith("data:"):
|
|
101
|
+
continue
|
|
102
|
+
payload = line[5:].strip()
|
|
103
|
+
if payload == "[DONE]":
|
|
104
|
+
continue
|
|
105
|
+
with contextlib.suppress(ValueError):
|
|
106
|
+
chunk = json.loads(payload)
|
|
107
|
+
if chunk.get("usage"):
|
|
108
|
+
usage = chunk["usage"]
|
|
109
|
+
for choice in chunk.get("choices") or []:
|
|
110
|
+
text = (choice.get("delta") or {}).get("content")
|
|
111
|
+
if text:
|
|
112
|
+
parts.append(text)
|
|
113
|
+
yield TextDelta(provider=self.name, text=text)
|
|
114
|
+
yield Completed(
|
|
115
|
+
provider=self.name,
|
|
116
|
+
text="".join(parts),
|
|
117
|
+
usage=usage,
|
|
118
|
+
duration_s=time.monotonic() - started,
|
|
119
|
+
)
|
|
120
|
+
finally:
|
|
121
|
+
stop.set()
|
|
122
|
+
|
|
123
|
+
def _http_failure(self, exc: urllib.error.HTTPError) -> Failed:
|
|
124
|
+
try:
|
|
125
|
+
detail = exc.read().decode("utf-8", errors="replace")[:1000]
|
|
126
|
+
except Exception:
|
|
127
|
+
detail = ""
|
|
128
|
+
message = f"HTTP {exc.code}: {detail or exc.reason}"
|
|
129
|
+
resets_at = None
|
|
130
|
+
retry_after = exc.headers.get("Retry-After") if exc.headers else None
|
|
131
|
+
if retry_after and retry_after.strip().isdigit():
|
|
132
|
+
resets_at = time.time() + int(retry_after.strip())
|
|
133
|
+
if exc.code == 429:
|
|
134
|
+
kind = FailureKind.RATE_LIMIT
|
|
135
|
+
elif exc.code in (401, 403):
|
|
136
|
+
kind = FailureKind.AUTH
|
|
137
|
+
else:
|
|
138
|
+
kind = classify(message)
|
|
139
|
+
return Failed(
|
|
140
|
+
provider=self.name,
|
|
141
|
+
kind=kind,
|
|
142
|
+
message=message,
|
|
143
|
+
resets_at=resets_at or reset_hint(message),
|
|
144
|
+
)
|
cli_relay/cli.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Command line interface: ``cli-relay run | status | reset | mcp``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TextIO
|
|
12
|
+
|
|
13
|
+
from cli_relay import __version__
|
|
14
|
+
from cli_relay.config import Config, load
|
|
15
|
+
from cli_relay.events import (
|
|
16
|
+
Completed,
|
|
17
|
+
Event,
|
|
18
|
+
Failed,
|
|
19
|
+
QuotaInfo,
|
|
20
|
+
Skipped,
|
|
21
|
+
Started,
|
|
22
|
+
Switched,
|
|
23
|
+
TextDelta,
|
|
24
|
+
ToolActivity,
|
|
25
|
+
Waiting,
|
|
26
|
+
)
|
|
27
|
+
from cli_relay.models import Permission, Task
|
|
28
|
+
from cli_relay.relay import Relay
|
|
29
|
+
|
|
30
|
+
EXIT_OK, EXIT_FAILED, EXIT_USAGE = 0, 1, 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="cli-relay",
|
|
36
|
+
description="Run a task on Claude Code / Antigravity / Codex / an API, "
|
|
37
|
+
"falling back to the next one when a provider hits its limit.",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument("--version", action="version", version=f"cli-relay {__version__}")
|
|
40
|
+
parser.add_argument("--config", help="path to cli-relay.toml")
|
|
41
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
42
|
+
|
|
43
|
+
run = sub.add_parser("run", help="run a task")
|
|
44
|
+
run.add_argument("prompt", help="task text, or '-' to read it from stdin")
|
|
45
|
+
run.add_argument("--cwd", type=Path, help="working directory for the agent")
|
|
46
|
+
run.add_argument("--providers", help="comma-separated provider order (overrides config)")
|
|
47
|
+
run.add_argument(
|
|
48
|
+
"--permission",
|
|
49
|
+
choices=[p.value for p in Permission],
|
|
50
|
+
default=Permission.READ_ONLY.value,
|
|
51
|
+
help="what the agent may do (default: read_only)",
|
|
52
|
+
)
|
|
53
|
+
run.add_argument("--system", help="extra system instructions for the agent")
|
|
54
|
+
run.add_argument(
|
|
55
|
+
"--resume",
|
|
56
|
+
action="append",
|
|
57
|
+
default=[],
|
|
58
|
+
metavar="PROVIDER=SESSION",
|
|
59
|
+
help="resume a provider session (repeatable)",
|
|
60
|
+
)
|
|
61
|
+
run.add_argument("--timeout", type=float, help="seconds per provider attempt")
|
|
62
|
+
run.add_argument(
|
|
63
|
+
"--format",
|
|
64
|
+
choices=["text", "ndjson"],
|
|
65
|
+
default="text",
|
|
66
|
+
help="text for humans, ndjson for programs (default: text)",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
status = sub.add_parser("status", help="show providers and known limits")
|
|
70
|
+
status.add_argument("--format", choices=["text", "json"], default="text")
|
|
71
|
+
|
|
72
|
+
reset = sub.add_parser("reset", help="forget recorded limits")
|
|
73
|
+
reset.add_argument("provider", nargs="?", help="only this provider")
|
|
74
|
+
|
|
75
|
+
sub.add_parser(
|
|
76
|
+
"mcp",
|
|
77
|
+
help="serve cli-relay as an MCP server over stdio (needs: pip install 'cli-relay[mcp]')",
|
|
78
|
+
)
|
|
79
|
+
return parser
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main(argv: list[str] | None = None) -> int:
|
|
83
|
+
for stream in (sys.stdout, sys.stderr):
|
|
84
|
+
if hasattr(stream, "reconfigure"):
|
|
85
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
86
|
+
args = _build_parser().parse_args(argv)
|
|
87
|
+
try:
|
|
88
|
+
config = load(args.config)
|
|
89
|
+
except (OSError, ValueError) as exc:
|
|
90
|
+
print(f"cli-relay: config error: {exc}", file=sys.stderr)
|
|
91
|
+
return EXIT_USAGE
|
|
92
|
+
try:
|
|
93
|
+
if args.command == "run":
|
|
94
|
+
return asyncio.run(_run(args, config))
|
|
95
|
+
if args.command == "status":
|
|
96
|
+
return _status(args, config)
|
|
97
|
+
if args.command == "reset":
|
|
98
|
+
config.quota().clear(args.provider)
|
|
99
|
+
print("limits cleared" + (f" for {args.provider}" if args.provider else ""))
|
|
100
|
+
return EXIT_OK
|
|
101
|
+
if args.command == "mcp":
|
|
102
|
+
from cli_relay.mcp_server import serve
|
|
103
|
+
|
|
104
|
+
serve(config)
|
|
105
|
+
return EXIT_OK
|
|
106
|
+
except KeyboardInterrupt:
|
|
107
|
+
return 130
|
|
108
|
+
except ValueError as exc:
|
|
109
|
+
print(f"cli-relay: {exc}", file=sys.stderr)
|
|
110
|
+
return EXIT_USAGE
|
|
111
|
+
return EXIT_USAGE
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _run(args: argparse.Namespace, config: Config) -> int:
|
|
115
|
+
# Read stdin as UTF-8 regardless of the console code page (cp1252/cp866 on Windows).
|
|
116
|
+
prompt = _read_stdin_utf8() if args.prompt == "-" else args.prompt
|
|
117
|
+
if not prompt.strip():
|
|
118
|
+
print("cli-relay: empty prompt", file=sys.stderr)
|
|
119
|
+
return EXIT_USAGE
|
|
120
|
+
resume: dict[str, str] = {}
|
|
121
|
+
for item in args.resume:
|
|
122
|
+
provider, sep, session = item.partition("=")
|
|
123
|
+
if not sep or not session:
|
|
124
|
+
raise ValueError(f"--resume expects PROVIDER=SESSION, got {item!r}")
|
|
125
|
+
resume[provider] = session
|
|
126
|
+
order = [p.strip() for p in args.providers.split(",")] if args.providers else None
|
|
127
|
+
relay = Relay(config.adapters(order), config.quota(), config.policy)
|
|
128
|
+
task = Task(
|
|
129
|
+
prompt=prompt,
|
|
130
|
+
cwd=args.cwd,
|
|
131
|
+
permission=Permission(args.permission),
|
|
132
|
+
system_prompt=args.system,
|
|
133
|
+
resume=resume,
|
|
134
|
+
timeout_s=args.timeout,
|
|
135
|
+
)
|
|
136
|
+
last: Event | None = None
|
|
137
|
+
async for event in relay.run(task):
|
|
138
|
+
last = event
|
|
139
|
+
if args.format == "ndjson":
|
|
140
|
+
print(json.dumps(event.to_dict(), ensure_ascii=False), flush=True)
|
|
141
|
+
else:
|
|
142
|
+
_print_human(event, sys.stdout, sys.stderr)
|
|
143
|
+
return EXIT_OK if isinstance(last, Completed) else EXIT_FAILED
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _read_stdin_utf8() -> str:
|
|
147
|
+
buffer = getattr(sys.stdin, "buffer", None)
|
|
148
|
+
if buffer is None: # replaced stdin (tests, embedding)
|
|
149
|
+
return sys.stdin.read()
|
|
150
|
+
return buffer.read().decode("utf-8-sig", errors="replace") # type: ignore[no-any-return]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _print_human(event: Event, out: TextIO, err: TextIO) -> None:
|
|
154
|
+
def note(text: str) -> None:
|
|
155
|
+
print(f"\n[cli-relay] {text}", file=err, flush=True)
|
|
156
|
+
|
|
157
|
+
if isinstance(event, TextDelta):
|
|
158
|
+
out.write(event.text)
|
|
159
|
+
out.flush()
|
|
160
|
+
elif isinstance(event, Started):
|
|
161
|
+
note(f"{event.provider} started" + (f" ({event.model})" if event.model else ""))
|
|
162
|
+
elif isinstance(event, ToolActivity):
|
|
163
|
+
note(f"{event.provider}: {event.tool} {event.detail}".rstrip())
|
|
164
|
+
elif isinstance(event, QuotaInfo):
|
|
165
|
+
if event.utilization is not None and event.utilization >= 0.8:
|
|
166
|
+
note(f"{event.provider} {event.window} usage at {event.utilization:.0%}")
|
|
167
|
+
elif isinstance(event, Skipped):
|
|
168
|
+
note(f"skipping {event.provider}: {event.reason}")
|
|
169
|
+
elif isinstance(event, Waiting):
|
|
170
|
+
until = time.strftime("%H:%M", time.localtime(event.until))
|
|
171
|
+
note(f"waiting for {event.provider} until {until}")
|
|
172
|
+
elif isinstance(event, Switched):
|
|
173
|
+
note(f"switching {event.provider} -> {event.to_provider} ({event.reason})")
|
|
174
|
+
elif isinstance(event, Failed):
|
|
175
|
+
note(f"{event.provider} failed [{event.kind.value}]: {event.message}")
|
|
176
|
+
elif isinstance(event, Completed):
|
|
177
|
+
# On a subscription this is the API-equivalent price reported by the CLI, not a bill.
|
|
178
|
+
cost = f", api-equivalent ${event.cost_usd:.4f}" if event.cost_usd else ""
|
|
179
|
+
note(
|
|
180
|
+
f"done by {event.provider}"
|
|
181
|
+
+ (f" (session {event.session_id}{cost})" if event.session_id else "")
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _status(args: argparse.Namespace, config: Config) -> int:
|
|
186
|
+
quota = config.quota()
|
|
187
|
+
rows = []
|
|
188
|
+
for adapter in config.adapters():
|
|
189
|
+
ok, reason = adapter.available()
|
|
190
|
+
block = quota.blocked(adapter.name)
|
|
191
|
+
rows.append(
|
|
192
|
+
{
|
|
193
|
+
"provider": adapter.name,
|
|
194
|
+
"type": adapter.settings.type,
|
|
195
|
+
"available": ok,
|
|
196
|
+
"reason": reason,
|
|
197
|
+
"blocked_until": block.until if block else None,
|
|
198
|
+
"blocked_reason": block.reason if block else None,
|
|
199
|
+
"windows": quota.snapshot().get(adapter.name, {}).get("windows", {}),
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
if args.format == "json":
|
|
203
|
+
print(
|
|
204
|
+
json.dumps(
|
|
205
|
+
{
|
|
206
|
+
"config": str(config.source) if config.source else None,
|
|
207
|
+
"order": config.order,
|
|
208
|
+
"providers": rows,
|
|
209
|
+
},
|
|
210
|
+
indent=2,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
return EXIT_OK
|
|
214
|
+
print(f"config: {config.source or '(built-in defaults)'}")
|
|
215
|
+
for row in rows:
|
|
216
|
+
state = (
|
|
217
|
+
"ready"
|
|
218
|
+
if row["available"] and not row["blocked_until"]
|
|
219
|
+
else ("limited" if row["blocked_until"] else "unavailable")
|
|
220
|
+
)
|
|
221
|
+
line = f" {row['provider']:<14} {state:<12}"
|
|
222
|
+
if row["blocked_until"]:
|
|
223
|
+
until = time.strftime("%Y-%m-%d %H:%M", time.localtime(row["blocked_until"]))
|
|
224
|
+
reason = " ".join(str(row["blocked_reason"]).split())
|
|
225
|
+
if len(reason) > 80:
|
|
226
|
+
reason = reason[:79] + "…"
|
|
227
|
+
line += f" until {until} ({reason})"
|
|
228
|
+
elif not row["available"]:
|
|
229
|
+
line += f" {row['reason']}"
|
|
230
|
+
for name, window in row["windows"].items():
|
|
231
|
+
if window.get("utilization") is not None:
|
|
232
|
+
line += f" {name}={window['utilization']:.0%}"
|
|
233
|
+
print(line)
|
|
234
|
+
return EXIT_OK
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
if __name__ == "__main__":
|
|
238
|
+
sys.exit(main())
|
cli_relay/config.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Load ``cli-relay.toml``.
|
|
2
|
+
|
|
3
|
+
Lookup order: ``--config PATH``, ``$CLI_RELAY_CONFIG``, ``./cli-relay.toml``, then the user
|
|
4
|
+
config dir (``%APPDATA%/cli-relay/config.toml`` or ``~/.config/cli-relay/config.toml``).
|
|
5
|
+
Without a file, the default chain is Claude Code -> Antigravity -> Codex.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import tomllib
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from cli_relay.adapters import Adapter, create
|
|
18
|
+
from cli_relay.events import FailureKind
|
|
19
|
+
from cli_relay.models import Permission, ProviderSettings
|
|
20
|
+
from cli_relay.quota import QuotaTracker, default_state_path
|
|
21
|
+
from cli_relay.relay import RelayPolicy
|
|
22
|
+
|
|
23
|
+
DEFAULT_ORDER = ["claude", "antigravity", "codex"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def user_config_path() -> Path:
|
|
27
|
+
if sys.platform == "win32":
|
|
28
|
+
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
|
|
29
|
+
else:
|
|
30
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
31
|
+
return base / "cli-relay" / "config.toml"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def find_config(explicit: str | Path | None = None) -> Path | None:
|
|
35
|
+
candidates = [
|
|
36
|
+
explicit,
|
|
37
|
+
os.environ.get("CLI_RELAY_CONFIG"),
|
|
38
|
+
Path("cli-relay.toml"),
|
|
39
|
+
user_config_path(),
|
|
40
|
+
]
|
|
41
|
+
for candidate in candidates:
|
|
42
|
+
if candidate and Path(candidate).is_file():
|
|
43
|
+
return Path(candidate)
|
|
44
|
+
if explicit:
|
|
45
|
+
raise FileNotFoundError(f"config file not found: {explicit}")
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Config:
|
|
51
|
+
order: list[str]
|
|
52
|
+
providers: dict[str, ProviderSettings]
|
|
53
|
+
policy: RelayPolicy = field(default_factory=RelayPolicy)
|
|
54
|
+
state_path: Path | None = None
|
|
55
|
+
default_cooldown_s: float = 1800
|
|
56
|
+
skip_above_utilization: float | None = 0.98
|
|
57
|
+
#: Highest permission an MCP client may request through ``relay_run``. Defaults to
|
|
58
|
+
#: read-only so a sandboxed agent cannot escalate by delegating to another one.
|
|
59
|
+
mcp_max_permission: Permission = Permission.READ_ONLY
|
|
60
|
+
source: Path | None = None
|
|
61
|
+
|
|
62
|
+
def adapters(self, only: list[str] | None = None) -> list[Adapter]:
|
|
63
|
+
names = only or self.order
|
|
64
|
+
unknown = [n for n in names if n not in self.providers]
|
|
65
|
+
if unknown:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"unknown provider(s): {', '.join(unknown)}; "
|
|
68
|
+
f"configured: {', '.join(self.providers)}"
|
|
69
|
+
)
|
|
70
|
+
return [create(self.providers[n]) for n in names]
|
|
71
|
+
|
|
72
|
+
def quota(self) -> QuotaTracker:
|
|
73
|
+
return QuotaTracker(
|
|
74
|
+
self.state_path or default_state_path(),
|
|
75
|
+
default_cooldown_s=self.default_cooldown_s,
|
|
76
|
+
skip_above_utilization=self.skip_above_utilization,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load(path: str | Path | None = None) -> Config:
|
|
81
|
+
source = find_config(path)
|
|
82
|
+
data: dict[str, Any] = {}
|
|
83
|
+
if source:
|
|
84
|
+
with source.open("rb") as fh:
|
|
85
|
+
data = tomllib.load(fh)
|
|
86
|
+
return parse(data, source)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse(data: dict[str, Any], source: Path | None = None) -> Config:
|
|
90
|
+
providers: dict[str, ProviderSettings] = {
|
|
91
|
+
name: ProviderSettings(name=name, type=name) for name in DEFAULT_ORDER
|
|
92
|
+
}
|
|
93
|
+
for name, raw in (data.get("providers") or {}).items():
|
|
94
|
+
if not isinstance(raw, dict):
|
|
95
|
+
raise ValueError(f"[providers.{name}] must be a table")
|
|
96
|
+
permission = raw.get("permission")
|
|
97
|
+
command = raw.get("command")
|
|
98
|
+
if isinstance(command, str):
|
|
99
|
+
command = [command]
|
|
100
|
+
providers[name] = ProviderSettings(
|
|
101
|
+
name=name,
|
|
102
|
+
type=str(raw.get("type", name)),
|
|
103
|
+
command=list(command) if command else None,
|
|
104
|
+
model=raw.get("model"),
|
|
105
|
+
permission=Permission(permission) if permission else None,
|
|
106
|
+
extra_args=[str(a) for a in raw.get("extra_args", [])],
|
|
107
|
+
env={str(k): str(v) for k, v in (raw.get("env") or {}).items()},
|
|
108
|
+
base_url=raw.get("base_url"),
|
|
109
|
+
api_key_env=raw.get("api_key_env"),
|
|
110
|
+
)
|
|
111
|
+
relay = data.get("relay") or {}
|
|
112
|
+
policy = RelayPolicy(
|
|
113
|
+
wait_if_reset_within_s=float(relay.get("wait_if_reset_within_s", 0)),
|
|
114
|
+
write_handoff_file=bool(relay.get("write_handoff_file", False)),
|
|
115
|
+
)
|
|
116
|
+
if "switch_on" in relay:
|
|
117
|
+
policy.switch_on = frozenset(FailureKind(k) for k in relay["switch_on"])
|
|
118
|
+
state = relay.get("state_path")
|
|
119
|
+
skip = relay.get("skip_above_utilization", 0.98)
|
|
120
|
+
return Config(
|
|
121
|
+
order=list(data.get("order") or DEFAULT_ORDER),
|
|
122
|
+
providers=providers,
|
|
123
|
+
policy=policy,
|
|
124
|
+
state_path=Path(state).expanduser() if state else None,
|
|
125
|
+
default_cooldown_s=float(relay.get("default_cooldown_s", 1800)),
|
|
126
|
+
skip_above_utilization=None if skip in (None, False) else float(skip),
|
|
127
|
+
mcp_max_permission=Permission(
|
|
128
|
+
(data.get("mcp") or {}).get("max_permission", Permission.READ_ONLY.value)
|
|
129
|
+
),
|
|
130
|
+
source=source,
|
|
131
|
+
)
|
cli_relay/errors.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Heuristics that turn provider error text into a :class:`FailureKind`.
|
|
2
|
+
|
|
3
|
+
The CLIs do not document their quota errors consistently, so classification is
|
|
4
|
+
pattern based and deliberately conservative: anything not recognised is ``ERROR``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from cli_relay.events import FailureKind
|
|
13
|
+
|
|
14
|
+
_RATE_LIMIT = re.compile(
|
|
15
|
+
r"rate[ _-]?limit|usage[ _-]?limit|limit (?:reached|exceeded)|quota|"
|
|
16
|
+
r"resource[ _-]?exhausted|too many requests|\b429\b|out of (?:credits|usage)|"
|
|
17
|
+
r"overloaded_error|\b529\b|hit your limit|limit will reset",
|
|
18
|
+
re.IGNORECASE,
|
|
19
|
+
)
|
|
20
|
+
_AUTH = re.compile(
|
|
21
|
+
r"\b401\b|\b403\b|unauthori[sz]ed|not (?:logged|signed) in|please (?:log|sign) ?in|"
|
|
22
|
+
r"invalid (?:api[ _-]?key|token|credentials)|authentication|oauth token|login required",
|
|
23
|
+
re.IGNORECASE,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_IN_DURATION = re.compile(
|
|
27
|
+
r"\b(?:in|after)\s+(?:(\d+)\s*h(?:ours?|rs?)?\b)?\s*(?:(\d+)\s*m(?:in(?:ute)?s?)?\b)?",
|
|
28
|
+
re.IGNORECASE,
|
|
29
|
+
)
|
|
30
|
+
_EPOCH = re.compile(r"\b(1[6-9]\d{8})\b")
|
|
31
|
+
# "try again at 11:05 AM", "resets 3pm", "resets at 15:30" (local wall-clock time)
|
|
32
|
+
_CLOCK = re.compile(
|
|
33
|
+
r"\b(?:at|resets?(?:\s+at)?)\s+(\d{1,2})(?::(\d{2}))?\s*([ap]\.?m\.?)?(?![\w:])",
|
|
34
|
+
re.IGNORECASE,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def classify(*texts: object) -> FailureKind:
|
|
39
|
+
blob = " ".join(str(t) for t in texts if t)
|
|
40
|
+
if _RATE_LIMIT.search(blob):
|
|
41
|
+
return FailureKind.RATE_LIMIT
|
|
42
|
+
if _AUTH.search(blob):
|
|
43
|
+
return FailureKind.AUTH
|
|
44
|
+
return FailureKind.ERROR
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def reset_hint(text: str, now: float | None = None) -> float | None:
|
|
48
|
+
"""Best-effort reset time (epoch seconds) from messages like "try again in 2h 30m"."""
|
|
49
|
+
now = time.time() if now is None else now
|
|
50
|
+
for match in _IN_DURATION.finditer(text):
|
|
51
|
+
hours, minutes = match.group(1), match.group(2)
|
|
52
|
+
if hours or minutes:
|
|
53
|
+
return now + int(hours or 0) * 3600 + int(minutes or 0) * 60
|
|
54
|
+
epoch = _EPOCH.search(text)
|
|
55
|
+
if epoch:
|
|
56
|
+
value = float(epoch.group(1))
|
|
57
|
+
if value > now:
|
|
58
|
+
return value
|
|
59
|
+
for match in _CLOCK.finditer(text):
|
|
60
|
+
clock = _next_clock_time(match, now)
|
|
61
|
+
if clock is not None:
|
|
62
|
+
return clock
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _next_clock_time(match: re.Match[str], now: float) -> float | None:
|
|
67
|
+
hour, minute, ampm = int(match.group(1)), match.group(2), match.group(3)
|
|
68
|
+
if minute is None and ampm is None:
|
|
69
|
+
return None # a bare number ("at 5") is too ambiguous
|
|
70
|
+
if ampm:
|
|
71
|
+
if not 1 <= hour <= 12:
|
|
72
|
+
return None
|
|
73
|
+
hour = hour % 12 + (12 if ampm.lower().startswith("p") else 0)
|
|
74
|
+
elif hour > 23:
|
|
75
|
+
return None
|
|
76
|
+
mins = int(minute or 0)
|
|
77
|
+
if mins > 59:
|
|
78
|
+
return None
|
|
79
|
+
local = time.localtime(now)
|
|
80
|
+
target = time.mktime((local.tm_year, local.tm_mon, local.tm_mday, hour, mins, 0, 0, 0, -1))
|
|
81
|
+
if target <= now: # already passed today -> tomorrow
|
|
82
|
+
target = time.mktime(
|
|
83
|
+
(local.tm_year, local.tm_mon, local.tm_mday + 1, hour, mins, 0, 0, 0, -1)
|
|
84
|
+
)
|
|
85
|
+
return target
|