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
cli_relay/quota.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Remembers which providers are rate-limited and until when, across runs.
|
|
2
|
+
|
|
3
|
+
State is a small JSON file (see :func:`default_state_path`). Writes are atomic; concurrent
|
|
4
|
+
cli-relay processes may overwrite each other's newest observation, which is acceptable
|
|
5
|
+
for a single-user tool.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from cli_relay.events import Event, Failed, FailureKind, QuotaInfo
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def default_state_path() -> Path:
|
|
23
|
+
if env := os.environ.get("CLI_RELAY_STATE"):
|
|
24
|
+
return Path(env)
|
|
25
|
+
if sys.platform == "win32":
|
|
26
|
+
base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
|
27
|
+
else:
|
|
28
|
+
base = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
|
29
|
+
return base / "cli-relay" / "state.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Block:
|
|
34
|
+
until: float
|
|
35
|
+
reason: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class QuotaTracker:
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
path: Path | None = None,
|
|
42
|
+
*,
|
|
43
|
+
default_cooldown_s: float = 1800,
|
|
44
|
+
skip_above_utilization: float | None = 0.98,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.path = path
|
|
47
|
+
self.default_cooldown_s = default_cooldown_s
|
|
48
|
+
self.skip_above_utilization = skip_above_utilization
|
|
49
|
+
self._data: dict[str, dict[str, Any]] = self._load()
|
|
50
|
+
|
|
51
|
+
# -- persistence -------------------------------------------------------------------
|
|
52
|
+
def _load(self) -> dict[str, dict[str, Any]]:
|
|
53
|
+
if not self.path or not self.path.exists():
|
|
54
|
+
return {}
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
57
|
+
except (OSError, ValueError):
|
|
58
|
+
return {}
|
|
59
|
+
return data if isinstance(data, dict) else {}
|
|
60
|
+
|
|
61
|
+
def _save(self) -> None:
|
|
62
|
+
if not self.path:
|
|
63
|
+
return
|
|
64
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=".state-", suffix=".json")
|
|
66
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
67
|
+
json.dump(self._data, fh, indent=2, sort_keys=True)
|
|
68
|
+
os.replace(tmp, self.path)
|
|
69
|
+
|
|
70
|
+
def _entry(self, provider: str) -> dict[str, Any]:
|
|
71
|
+
return self._data.setdefault(provider, {})
|
|
72
|
+
|
|
73
|
+
# -- observation -------------------------------------------------------------------
|
|
74
|
+
def observe(self, event: Event) -> None:
|
|
75
|
+
if isinstance(event, QuotaInfo):
|
|
76
|
+
windows = self._entry(event.provider).setdefault("windows", {})
|
|
77
|
+
windows[event.window] = {
|
|
78
|
+
"status": event.status,
|
|
79
|
+
"utilization": event.utilization,
|
|
80
|
+
"resets_at": event.resets_at,
|
|
81
|
+
"seen_at": event.at,
|
|
82
|
+
}
|
|
83
|
+
self._save()
|
|
84
|
+
elif isinstance(event, Failed) and event.kind is FailureKind.RATE_LIMIT:
|
|
85
|
+
until = event.resets_at or (event.at + self.default_cooldown_s)
|
|
86
|
+
self.block(event.provider, until, event.message[:300] or "rate limited")
|
|
87
|
+
|
|
88
|
+
def block(self, provider: str, until: float, reason: str) -> None:
|
|
89
|
+
self._entry(provider)["blocked"] = {"until": until, "reason": reason}
|
|
90
|
+
self._save()
|
|
91
|
+
|
|
92
|
+
def clear(self, provider: str | None = None) -> None:
|
|
93
|
+
if provider is None:
|
|
94
|
+
self._data.clear()
|
|
95
|
+
else:
|
|
96
|
+
self._data.pop(provider, None)
|
|
97
|
+
self._save()
|
|
98
|
+
|
|
99
|
+
# -- queries -----------------------------------------------------------------------
|
|
100
|
+
def blocked(self, provider: str, now: float | None = None) -> Block | None:
|
|
101
|
+
now = time.time() if now is None else now
|
|
102
|
+
entry = self._data.get(provider, {})
|
|
103
|
+
blocked = entry.get("blocked")
|
|
104
|
+
if blocked and float(blocked.get("until", 0)) > now:
|
|
105
|
+
return Block(float(blocked["until"]), str(blocked.get("reason", "")))
|
|
106
|
+
limit = self.skip_above_utilization
|
|
107
|
+
for name, window in (entry.get("windows") or {}).items():
|
|
108
|
+
resets = window.get("resets_at")
|
|
109
|
+
if not resets or float(resets) <= now:
|
|
110
|
+
continue
|
|
111
|
+
util = window.get("utilization")
|
|
112
|
+
exhausted = window.get("status") not in (None, "allowed", "allowed_warning")
|
|
113
|
+
if exhausted or (limit is not None and util is not None and float(util) >= limit):
|
|
114
|
+
return Block(
|
|
115
|
+
float(resets),
|
|
116
|
+
f"{name} window at {util:.0%}"
|
|
117
|
+
if util is not None
|
|
118
|
+
else f"{name} window {window.get('status')}",
|
|
119
|
+
)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def snapshot(self) -> dict[str, dict[str, Any]]:
|
|
123
|
+
return json.loads(json.dumps(self._data)) # type: ignore[no-any-return]
|
cli_relay/relay.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""The relay: try providers in order, switch on limits, brief the next one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import AsyncIterator, Sequence
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from cli_relay.adapters.base import Adapter
|
|
11
|
+
from cli_relay.events import (
|
|
12
|
+
Completed,
|
|
13
|
+
Event,
|
|
14
|
+
Failed,
|
|
15
|
+
FailureKind,
|
|
16
|
+
Skipped,
|
|
17
|
+
Switched,
|
|
18
|
+
Waiting,
|
|
19
|
+
)
|
|
20
|
+
from cli_relay.handoff import HandoffLog
|
|
21
|
+
from cli_relay.models import Task
|
|
22
|
+
from cli_relay.quota import QuotaTracker
|
|
23
|
+
|
|
24
|
+
RELAY = "relay"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RelayPolicy:
|
|
29
|
+
#: Failure kinds that make the relay move on to the next provider.
|
|
30
|
+
switch_on: frozenset[FailureKind] = field(
|
|
31
|
+
default_factory=lambda: frozenset(
|
|
32
|
+
{
|
|
33
|
+
FailureKind.RATE_LIMIT,
|
|
34
|
+
FailureKind.AUTH,
|
|
35
|
+
FailureKind.NOT_INSTALLED,
|
|
36
|
+
FailureKind.ERROR,
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
#: If a blocked provider resets within this many seconds, wait for it instead of
|
|
41
|
+
#: skipping it. ``0`` disables waiting.
|
|
42
|
+
wait_if_reset_within_s: float = 0
|
|
43
|
+
#: Write HANDOFF.md into the task's working directory when switching mid-task.
|
|
44
|
+
write_handoff_file: bool = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Relay:
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
adapters: Sequence[Adapter],
|
|
51
|
+
quota: QuotaTracker | None = None,
|
|
52
|
+
policy: RelayPolicy | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
if not adapters:
|
|
55
|
+
raise ValueError("at least one provider is required")
|
|
56
|
+
self.adapters = list(adapters)
|
|
57
|
+
self.quota = quota or QuotaTracker()
|
|
58
|
+
self.policy = policy or RelayPolicy()
|
|
59
|
+
|
|
60
|
+
async def run(self, task: Task) -> AsyncIterator[Event]:
|
|
61
|
+
"""Stream events from the first provider that finishes the task.
|
|
62
|
+
|
|
63
|
+
The stream ends with the provider's :class:`Completed` event, or with a
|
|
64
|
+
:class:`Failed` event from provider ``"relay"`` when every provider failed.
|
|
65
|
+
"""
|
|
66
|
+
log = HandoffLog(task.prompt)
|
|
67
|
+
previous: str | None = None
|
|
68
|
+
last_failure: Failed | None = None
|
|
69
|
+
for adapter in self.adapters:
|
|
70
|
+
ok, reason = adapter.available()
|
|
71
|
+
if not ok:
|
|
72
|
+
yield Skipped(provider=adapter.name, reason=reason)
|
|
73
|
+
continue
|
|
74
|
+
block = self.quota.blocked(adapter.name)
|
|
75
|
+
if block:
|
|
76
|
+
wait = block.until - time.time()
|
|
77
|
+
if 0 < wait <= self.policy.wait_if_reset_within_s:
|
|
78
|
+
yield Waiting(provider=adapter.name, until=block.until, reason=block.reason)
|
|
79
|
+
await asyncio.sleep(wait)
|
|
80
|
+
else:
|
|
81
|
+
yield Skipped(
|
|
82
|
+
provider=adapter.name,
|
|
83
|
+
reason=f"limited until {_clock(block.until)}: {block.reason}",
|
|
84
|
+
)
|
|
85
|
+
continue
|
|
86
|
+
if previous is not None:
|
|
87
|
+
yield Switched(
|
|
88
|
+
provider=previous,
|
|
89
|
+
to_provider=adapter.name,
|
|
90
|
+
reason=last_failure.kind.value if last_failure else "",
|
|
91
|
+
)
|
|
92
|
+
if self.policy.write_handoff_file and task.cwd and log.has_progress:
|
|
93
|
+
log.write(task.cwd)
|
|
94
|
+
attempt = task if previous is None else task.with_prompt(log.continuation_prompt())
|
|
95
|
+
result: Event | None = None
|
|
96
|
+
async for event in adapter.run(attempt):
|
|
97
|
+
self.quota.observe(event)
|
|
98
|
+
log.observe(event)
|
|
99
|
+
yield event
|
|
100
|
+
if event.terminal:
|
|
101
|
+
result = event
|
|
102
|
+
if isinstance(result, Completed):
|
|
103
|
+
return
|
|
104
|
+
last_failure = (
|
|
105
|
+
result
|
|
106
|
+
if isinstance(result, Failed)
|
|
107
|
+
else Failed(
|
|
108
|
+
provider=adapter.name, kind=FailureKind.ERROR, message="no terminal event"
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
if last_failure.kind not in self.policy.switch_on:
|
|
112
|
+
yield Failed(
|
|
113
|
+
provider=RELAY,
|
|
114
|
+
kind=last_failure.kind,
|
|
115
|
+
message=f"{adapter.name}: {last_failure.message}",
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
previous = adapter.name
|
|
119
|
+
yield Failed(
|
|
120
|
+
provider=RELAY,
|
|
121
|
+
kind=last_failure.kind if last_failure else FailureKind.NOT_INSTALLED,
|
|
122
|
+
message="no provider could complete the task"
|
|
123
|
+
+ (
|
|
124
|
+
f"; last error from {last_failure.provider}: {last_failure.message}"
|
|
125
|
+
if last_failure
|
|
126
|
+
else "; none was available"
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def run_to_end(self, task: Task) -> tuple[Event, list[Event]]:
|
|
131
|
+
"""Convenience wrapper: return ``(terminal_event, all_events)``."""
|
|
132
|
+
events = [event async for event in self.run(task)]
|
|
133
|
+
return events[-1], events
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _clock(epoch: float) -> str:
|
|
137
|
+
return time.strftime("%Y-%m-%d %H:%M", time.localtime(epoch))
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cli-relay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run coding-agent CLIs (Claude Code, Antigravity, Codex, any OpenAI-compatible API) as one worker with automatic fallback when a subscription hits its limit.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mustikon/cli-relay
|
|
6
|
+
Project-URL: Issues, https://github.com/mustikon/cli-relay/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/mustikon/cli-relay/blob/main/CHANGELOG.md
|
|
8
|
+
Author: mustikon
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,antigravity,claude-code,codex,fallback,gemini,llm,mcp,rate-limit
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Provides-Extra: mcp
|
|
24
|
+
Requires-Dist: mcp<2,>=1.10; extra == 'mcp'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# cli-relay
|
|
28
|
+
|
|
29
|
+
[](https://github.com/mustikon/cli-relay/actions/workflows/ci.yml)
|
|
30
|
+
[](https://pypi.org/project/cli-relay/)
|
|
31
|
+
[](https://pypi.org/project/cli-relay/)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
|
|
34
|
+
**One worker interface over several coding-agent CLIs, with automatic fallback when a
|
|
35
|
+
subscription hits its limit.**
|
|
36
|
+
|
|
37
|
+
[Русская версия](README.ru.md)
|
|
38
|
+
|
|
39
|
+
You pay for Claude, ChatGPT and Google subscriptions, and each comes with its own agent CLI
|
|
40
|
+
and its own usage window. `cli-relay` runs a task on the first provider that can take it.
|
|
41
|
+
If that provider runs out of quota mid-task, the relay hands the work to the next one with
|
|
42
|
+
a briefing on what was already done.
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
cli-relay run "fix the failing test in tests/test_api.py" --cwd ~/code/app --permission edit
|
|
46
|
+
|
|
47
|
+
[cli-relay] claude started (claude-opus-5-5)
|
|
48
|
+
...
|
|
49
|
+
[cli-relay] claude failed [rate_limit]: You've hit your limit · resets 3pm
|
|
50
|
+
[cli-relay] switching claude -> antigravity (rate_limit)
|
|
51
|
+
[cli-relay] antigravity started
|
|
52
|
+
...
|
|
53
|
+
[cli-relay] done by antigravity
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Why
|
|
57
|
+
|
|
58
|
+
* **The official CLIs, unmodified.** `cli-relay` launches `claude`, `agy` and `codex`
|
|
59
|
+
exactly as you would, under your own login. It never extracts, proxies or reuses OAuth
|
|
60
|
+
tokens. You keep each tool's own permissions, hooks, `CLAUDE.md` / `AGENTS.md`, skills
|
|
61
|
+
and MCP servers.
|
|
62
|
+
* **Limit-aware.** Claude Code reports its five-hour and weekly windows in its event
|
|
63
|
+
stream. `cli-relay` records them and skips a provider *before* it fails when a window is
|
|
64
|
+
nearly used up. A provider that failed on a limit is skipped until its reset time.
|
|
65
|
+
* **Handoff, not restart.** When it switches providers mid-task, the next agent gets the
|
|
66
|
+
original task, the actions already taken and the last output. It is also told to check
|
|
67
|
+
`git status` / `git diff` before continuing.
|
|
68
|
+
* **One event stream.** Every provider is normalised to the same events (`started`,
|
|
69
|
+
`text_delta`, `tool_activity`, `quota_info`, `completed`, `failed`, plus the relay's
|
|
70
|
+
`skipped` / `waiting` / `switched`). Use `--format ndjson` to drive it from another
|
|
71
|
+
program, such as a voice assistant.
|
|
72
|
+
* **MCP server.** `cli-relay mcp` exposes `relay_run` and `relay_status`, so any MCP client
|
|
73
|
+
can delegate work and get fallback for free.
|
|
74
|
+
* **Small and dependency-free.** The core uses only the Python standard library. That
|
|
75
|
+
matters for a tool that runs next to all your credentials.
|
|
76
|
+
|
|
77
|
+
## Providers
|
|
78
|
+
|
|
79
|
+
| Type | CLI / endpoint | Status | Session resume | Quota telemetry |
|
|
80
|
+
|---|---|---|---|---|
|
|
81
|
+
| `claude` | Claude Code `claude -p --output-format stream-json` | verified live | `--resume` | five-hour / weekly windows |
|
|
82
|
+
| `antigravity` | Google Antigravity `agy -p --output-format stream-json` | verified live | `--conversation` | from error text |
|
|
83
|
+
| `codex` | OpenAI Codex `codex exec --json` (also finds the copy bundled with the Codex desktop app on Windows) | command layout and limit path verified live on 0.167; success path from docs | `exec resume` | from error text, incl. "try again at 11:05 AM" |
|
|
84
|
+
| `openai` | any OpenAI-compatible `/chat/completions` (OpenRouter, vLLM, Ollama, LM Studio…) | tested with a local server | — | HTTP 429 + `Retry-After` |
|
|
85
|
+
|
|
86
|
+
The `openai` type is a **text-only** fallback: it has no tools and cannot edit files.
|
|
87
|
+
|
|
88
|
+
## Install
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pipx install cli-relay # or: uv tool install cli-relay
|
|
92
|
+
pipx install "cli-relay[mcp]" # with the MCP server
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Requires Python 3.11+. Install and log in to the agent CLIs you want to use first.
|
|
96
|
+
|
|
97
|
+
## Usage
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
cli-relay status # what is installed / limited
|
|
101
|
+
cli-relay run "explain src/auth.py" # read-only by default
|
|
102
|
+
cli-relay run - < task.md --cwd ./repo --permission edit
|
|
103
|
+
cli-relay run "continue" --resume claude=SESSION_ID
|
|
104
|
+
cli-relay run "..." --providers antigravity,claude --format ndjson
|
|
105
|
+
cli-relay reset [provider] # forget recorded limits
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Permissions
|
|
109
|
+
|
|
110
|
+
`--permission` is mapped to each CLI's own permission system. Non-interactive runs never
|
|
111
|
+
wait for a prompt: anything that would ask is denied.
|
|
112
|
+
|
|
113
|
+
| `--permission` | Claude Code | Antigravity | Codex |
|
|
114
|
+
|---|---|---|---|
|
|
115
|
+
| `read_only` (default) | `--permission-mode dontAsk` | `--mode plan` | `--sandbox read-only` |
|
|
116
|
+
| `edit` | `--permission-mode acceptEdits` | `--mode accept-edits` | `--sandbox workspace-write` |
|
|
117
|
+
| `full` | `--permission-mode auto` | `--mode auto` | `workspace-write` + `--approve-for-me` |
|
|
118
|
+
|
|
119
|
+
A provider's `permission` setting is an upper bound, so a task never gets more than that.
|
|
120
|
+
`cli-relay` never uses "skip all permission checks" flags.
|
|
121
|
+
|
|
122
|
+
## Configuration
|
|
123
|
+
|
|
124
|
+
`cli-relay` looks for its config file in this order:
|
|
125
|
+
|
|
126
|
+
1. `--config PATH`
|
|
127
|
+
2. `$CLI_RELAY_CONFIG`
|
|
128
|
+
3. `./cli-relay.toml`
|
|
129
|
+
4. `%APPDATA%\cli-relay\config.toml` on Windows, `~/.config/cli-relay/config.toml` elsewhere
|
|
130
|
+
|
|
131
|
+
Without a file the chain is `claude → antigravity → codex`. See
|
|
132
|
+
[`examples/cli-relay.toml`](examples/cli-relay.toml):
|
|
133
|
+
|
|
134
|
+
```toml
|
|
135
|
+
order = ["claude", "antigravity", "codex", "openrouter", "local"]
|
|
136
|
+
|
|
137
|
+
[relay]
|
|
138
|
+
wait_if_reset_within_s = 900 # wait for a provider that resets within 15 min
|
|
139
|
+
skip_above_utilization = 0.97 # skip Claude when a usage window is ≥ 97 %
|
|
140
|
+
default_cooldown_s = 1800 # when a limit error carries no reset time
|
|
141
|
+
write_handoff_file = false # also write HANDOFF.md into --cwd on a switch
|
|
142
|
+
|
|
143
|
+
[providers.claude]
|
|
144
|
+
model = "opus"
|
|
145
|
+
permission = "edit" # upper bound for this provider
|
|
146
|
+
|
|
147
|
+
[providers.openrouter]
|
|
148
|
+
type = "openai"
|
|
149
|
+
base_url = "https://openrouter.ai/api/v1"
|
|
150
|
+
model = "vendor/model-id" # any OpenRouter model id
|
|
151
|
+
api_key_env = "OPENROUTER_API_KEY"
|
|
152
|
+
|
|
153
|
+
[providers.local]
|
|
154
|
+
type = "openai"
|
|
155
|
+
base_url = "http://127.0.0.1:8000/v1" # vLLM / Ollama / LM Studio
|
|
156
|
+
model = "your-served-model-name"
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Recorded limits live in `%LOCALAPPDATA%\cli-relay\state.json` on Windows and
|
|
160
|
+
`~/.local/state/cli-relay/state.json` elsewhere. Override the path with `$CLI_RELAY_STATE`.
|
|
161
|
+
|
|
162
|
+
## As an MCP server
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
claude mcp add cli-relay -- cli-relay mcp
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The server exposes two tools:
|
|
169
|
+
* `relay_run(prompt, cwd?, providers?, permission?)` returns `{ok, provider, text, session_id, switches, skipped}`.
|
|
170
|
+
* `relay_status()` returns installed providers and their known limits.
|
|
171
|
+
|
|
172
|
+
By default an MCP client can only request `read_only` work, whatever it asks for.
|
|
173
|
+
Otherwise a sandboxed agent could escalate by delegating to another one. To allow more,
|
|
174
|
+
set this in the config:
|
|
175
|
+
|
|
176
|
+
```toml
|
|
177
|
+
[mcp]
|
|
178
|
+
max_permission = "edit"
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## As a library
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
import asyncio
|
|
185
|
+
from cli_relay import Relay, Task, TextDelta, Completed
|
|
186
|
+
from cli_relay.config import load
|
|
187
|
+
|
|
188
|
+
async def main() -> None:
|
|
189
|
+
cfg = load()
|
|
190
|
+
relay = Relay(cfg.adapters(), cfg.quota(), cfg.policy)
|
|
191
|
+
async for event in relay.run(Task(prompt="summarise README.md")):
|
|
192
|
+
if isinstance(event, TextDelta):
|
|
193
|
+
print(event.text, end="")
|
|
194
|
+
elif isinstance(event, Completed):
|
|
195
|
+
print(f"\n-- by {event.provider}")
|
|
196
|
+
|
|
197
|
+
asyncio.run(main())
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Terms of service
|
|
201
|
+
|
|
202
|
+
`cli-relay` is meant for **your own** subscriptions on **your own** machine. It runs each
|
|
203
|
+
vendor's official CLI as a normal user would, and the vendors' terms still apply. For
|
|
204
|
+
unattended automation, the vendors recommend API keys over subscription logins. Do not
|
|
205
|
+
use `cli-relay` to share one subscription between several people.
|
|
206
|
+
|
|
207
|
+
## Security notes
|
|
208
|
+
|
|
209
|
+
* Agent processes are started from an argument list, never through a shell.
|
|
210
|
+
* On Windows, `claude` and `codex` are npm `.cmd` shims, which `cmd.exe` re-parses. Prompts
|
|
211
|
+
and system prompts are therefore always sent through stdin or a temp file. `cli-relay`
|
|
212
|
+
refuses to put `cmd.exe` metacharacters on such a command line.
|
|
213
|
+
* Corrupted or interleaved NDJSON lines are salvaged rather than crashing a long run.
|
|
214
|
+
|
|
215
|
+
See [SECURITY.md](SECURITY.md) for how to report a vulnerability.
|
|
216
|
+
|
|
217
|
+
## Roadmap
|
|
218
|
+
|
|
219
|
+
- Voice and push approvals for permission prompts (Claude Code `PermissionRequest` hook,
|
|
220
|
+
Codex app-server approvals)
|
|
221
|
+
- Codex app-server adapter with first-class rate-limit telemetry
|
|
222
|
+
- Git worktree per task with automatic WIP checkpoints
|
|
223
|
+
- Same-harness fallback (Claude Code against an Anthropic-compatible endpoint)
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
[Apache-2.0](LICENSE)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
cli_relay/__init__.py,sha256=7SXYjS-7SXGqQ4l1McyMDO4rgBxIod2wotUJy3-evX8,717
|
|
2
|
+
cli_relay/__main__.py,sha256=7Hwal8yYdxsKc-0gXmz0qH-RkuzVSj4C8tX7wuCehdQ,61
|
|
3
|
+
cli_relay/cli.py,sha256=NOITCKfeNG3x2kdf-DjG1dRy_Ky4lUTwc3uu8FzmPVU,8791
|
|
4
|
+
cli_relay/config.py,sha256=5NBvYgyvmwzHfHrq3st2ysiTOkz1s0yVS24HwMPUcmo,4879
|
|
5
|
+
cli_relay/errors.py,sha256=oTHU5hrFIqBmjNu1GAmDNFtto73gpH6F61rmLypHf-M,3013
|
|
6
|
+
cli_relay/events.py,sha256=m-yp-Cyuc3DcFBdK-g3zzHKycpWiMxgZqxQsZsnVP88,2662
|
|
7
|
+
cli_relay/handoff.py,sha256=EXmQQVU0HFbsPAR-L1fNi98R9ZjVCLwKpHmOhllfmCs,2668
|
|
8
|
+
cli_relay/mcp_server.py,sha256=hgB9qkscUqraMiCpSUuOOJ9cmkQ8OeCWtjgrLgydJjI,4122
|
|
9
|
+
cli_relay/models.py,sha256=TxAUx5oWDnFsz-VgZQb0lzuvZSN9FUa7V575w0lLrKw,2318
|
|
10
|
+
cli_relay/ndjson.py,sha256=Vr2iahV9edbe8kk2-0gwzyqlTpU9j8z_pIffBb4nLLY,1153
|
|
11
|
+
cli_relay/proc.py,sha256=d2vt9LLgUfA2ZSXpCfpEAyoAth0QA5JuxxFB4F--_Dg,6748
|
|
12
|
+
cli_relay/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
cli_relay/quota.py,sha256=VmPR3BRl2_pbAz9UIzqYFI9qJ8-wHdDGPDMYsHGvra8,4700
|
|
14
|
+
cli_relay/relay.py,sha256=V2hIu6nj7yaT7FNuT8cWJinODABJzU8qkf0Tjj_hnA8,4946
|
|
15
|
+
cli_relay/adapters/__init__.py,sha256=lnsNmU5Dbs1jtbJWrG0kUBT7yYuqGJkD-CLjasPWAx0,1092
|
|
16
|
+
cli_relay/adapters/antigravity.py,sha256=hdxYgsU2iJr0HQhNo7niezR72iOeAewn3nZlsfPbF3U,4347
|
|
17
|
+
cli_relay/adapters/base.py,sha256=KYfIiW4PuArFFFIO49c3d1Cvm_3Wma6Hxdam9eoKHvg,5492
|
|
18
|
+
cli_relay/adapters/claude.py,sha256=uVRHgAe-X_jXGGz_dJfGP8vaa3IUF9aRBWwCDErHBdI,6471
|
|
19
|
+
cli_relay/adapters/codex.py,sha256=6hxkFUXPH1UIq6ayyjf_G72KsT1LDhld4gmJAXOlzso,4840
|
|
20
|
+
cli_relay/adapters/openai_compat.py,sha256=G8fJ9YfeLTsaSnHJ8NYazjQQG-cfFd9jH7T_fPGA9Ds,5576
|
|
21
|
+
cli_relay-0.1.0.dist-info/METADATA,sha256=BQpq9wqPvPd8q2dCJxS291kTiiRPB8DjRghaQfj4it8,9568
|
|
22
|
+
cli_relay-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
23
|
+
cli_relay-0.1.0.dist-info/entry_points.txt,sha256=zmAcRlUT8NX4-s0KIojlVEoTxBjAWjLBHi2NAe5KFkA,49
|
|
24
|
+
cli_relay-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
25
|
+
cli_relay-0.1.0.dist-info/RECORD,,
|