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/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""cli-relay: one worker interface over several coding-agent CLIs, with fallback on limits."""
|
|
2
|
+
|
|
3
|
+
from cli_relay.events import (
|
|
4
|
+
Completed,
|
|
5
|
+
Event,
|
|
6
|
+
Failed,
|
|
7
|
+
FailureKind,
|
|
8
|
+
QuotaInfo,
|
|
9
|
+
Skipped,
|
|
10
|
+
Started,
|
|
11
|
+
Switched,
|
|
12
|
+
TextDelta,
|
|
13
|
+
ToolActivity,
|
|
14
|
+
Waiting,
|
|
15
|
+
)
|
|
16
|
+
from cli_relay.models import Permission, ProviderSettings, Task
|
|
17
|
+
from cli_relay.relay import Relay, RelayPolicy
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Completed",
|
|
23
|
+
"Event",
|
|
24
|
+
"Failed",
|
|
25
|
+
"FailureKind",
|
|
26
|
+
"Permission",
|
|
27
|
+
"ProviderSettings",
|
|
28
|
+
"QuotaInfo",
|
|
29
|
+
"Relay",
|
|
30
|
+
"RelayPolicy",
|
|
31
|
+
"Skipped",
|
|
32
|
+
"Started",
|
|
33
|
+
"Switched",
|
|
34
|
+
"Task",
|
|
35
|
+
"TextDelta",
|
|
36
|
+
"ToolActivity",
|
|
37
|
+
"Waiting",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
cli_relay/__main__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Provider adapters and the type registry used by the config loader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cli_relay.adapters.antigravity import AntigravityAdapter
|
|
6
|
+
from cli_relay.adapters.base import Adapter, CliAdapter
|
|
7
|
+
from cli_relay.adapters.claude import ClaudeAdapter
|
|
8
|
+
from cli_relay.adapters.codex import CodexAdapter
|
|
9
|
+
from cli_relay.adapters.openai_compat import OpenAICompatAdapter
|
|
10
|
+
from cli_relay.models import ProviderSettings
|
|
11
|
+
|
|
12
|
+
REGISTRY: dict[str, type[Adapter]] = {
|
|
13
|
+
cls.type_name: cls
|
|
14
|
+
for cls in (ClaudeAdapter, AntigravityAdapter, CodexAdapter, OpenAICompatAdapter)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create(settings: ProviderSettings) -> Adapter:
|
|
19
|
+
try:
|
|
20
|
+
cls = REGISTRY[settings.type]
|
|
21
|
+
except KeyError:
|
|
22
|
+
known = ", ".join(sorted(REGISTRY))
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"provider {settings.name!r}: unknown type {settings.type!r} (known: {known})"
|
|
25
|
+
) from None
|
|
26
|
+
return cls(settings)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"REGISTRY",
|
|
31
|
+
"Adapter",
|
|
32
|
+
"AntigravityAdapter",
|
|
33
|
+
"ClaudeAdapter",
|
|
34
|
+
"CliAdapter",
|
|
35
|
+
"CodexAdapter",
|
|
36
|
+
"OpenAICompatAdapter",
|
|
37
|
+
"create",
|
|
38
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Google Antigravity CLI (``agy -p --output-format stream-json``).
|
|
2
|
+
|
|
3
|
+
``agy`` is a native executable, so the prompt can go on the command line. Very long
|
|
4
|
+
prompts are written to a temp file that the agent is asked to read (Windows limits a
|
|
5
|
+
command line to ~32k characters).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any, ClassVar
|
|
12
|
+
|
|
13
|
+
from cli_relay.adapters.base import CliAdapter, Invocation, ParseState, short
|
|
14
|
+
from cli_relay.errors import classify, reset_hint
|
|
15
|
+
from cli_relay.events import Completed, Event, Failed, Started, TextDelta, ToolActivity
|
|
16
|
+
from cli_relay.models import Permission, Task
|
|
17
|
+
|
|
18
|
+
_MODE = {
|
|
19
|
+
Permission.READ_ONLY: "plan",
|
|
20
|
+
Permission.EDIT: "accept-edits",
|
|
21
|
+
Permission.FULL: "auto",
|
|
22
|
+
}
|
|
23
|
+
_MAX_ARG_PROMPT = 20_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AntigravityAdapter(CliAdapter):
|
|
27
|
+
type_name: ClassVar[str] = "antigravity"
|
|
28
|
+
default_command: ClassVar[list[str]] = ["agy"]
|
|
29
|
+
|
|
30
|
+
def build(self, task: Task, exe: list[str]) -> Invocation:
|
|
31
|
+
inv = Invocation(argv=[*exe])
|
|
32
|
+
prompt = task.prompt
|
|
33
|
+
if task.system_prompt:
|
|
34
|
+
prompt = f"{task.system_prompt.strip()}\n\n---\n\n{prompt}"
|
|
35
|
+
if len(prompt) > _MAX_ARG_PROMPT:
|
|
36
|
+
path = self.temp_text_file(inv, prompt)
|
|
37
|
+
inv.argv += ["--add-dir", os.path.dirname(path)]
|
|
38
|
+
prompt = (
|
|
39
|
+
f"Your full task is in the file {path}. Read that file completely and "
|
|
40
|
+
f"do exactly what it says."
|
|
41
|
+
)
|
|
42
|
+
inv.argv += [
|
|
43
|
+
"-p",
|
|
44
|
+
prompt,
|
|
45
|
+
"--output-format",
|
|
46
|
+
"stream-json",
|
|
47
|
+
"--mode",
|
|
48
|
+
_MODE[self.permission_for(task)],
|
|
49
|
+
]
|
|
50
|
+
if self.settings.model:
|
|
51
|
+
inv.argv += ["--model", self.settings.model]
|
|
52
|
+
if session := task.resume.get(self.name):
|
|
53
|
+
inv.argv += ["--conversation", session]
|
|
54
|
+
inv.argv += self.settings.extra_args
|
|
55
|
+
return inv
|
|
56
|
+
|
|
57
|
+
def parse(self, obj: dict[str, Any], state: ParseState) -> list[Event]:
|
|
58
|
+
p = self.name
|
|
59
|
+
event = obj.get("event")
|
|
60
|
+
if event == "init":
|
|
61
|
+
init = obj.get("init") or {}
|
|
62
|
+
state.session_id = obj.get("conversation_id") or init.get("conversation_id")
|
|
63
|
+
state.model = init.get("model")
|
|
64
|
+
return [Started(provider=p, session_id=state.session_id, model=state.model)]
|
|
65
|
+
if event == "step_update":
|
|
66
|
+
step = obj.get("step_update") or {}
|
|
67
|
+
kind = step.get("step_type")
|
|
68
|
+
if kind == "agent_response" and step.get("text_delta"):
|
|
69
|
+
return [TextDelta(provider=p, text=step["text_delta"])]
|
|
70
|
+
if kind == "tool" and step.get("state") == "ACTIVE":
|
|
71
|
+
info = step.get("tool_info") or {}
|
|
72
|
+
params = info.get("parameters") or {}
|
|
73
|
+
detail = next((short(v) for v in params.values() if isinstance(v, str)), "")
|
|
74
|
+
return [
|
|
75
|
+
ToolActivity(provider=p, tool=str(step.get("tool_name", "?")), detail=detail)
|
|
76
|
+
]
|
|
77
|
+
if step.get("error"):
|
|
78
|
+
state.error_text.append(short(step["error"], 500))
|
|
79
|
+
return []
|
|
80
|
+
if event == "error" or "error" in obj:
|
|
81
|
+
err = obj.get("error")
|
|
82
|
+
state.error_text.append(short(err if err is not None else obj, 500))
|
|
83
|
+
return []
|
|
84
|
+
if event == "result":
|
|
85
|
+
return [self._result(obj.get("result") or {}, state)]
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
def _result(self, res: dict[str, Any], state: ParseState) -> Event:
|
|
89
|
+
session = res.get("conversation_id") or state.session_id
|
|
90
|
+
status = str(res.get("status", ""))
|
|
91
|
+
response = res.get("response") if isinstance(res.get("response"), str) else ""
|
|
92
|
+
if status.upper() == "SUCCESS":
|
|
93
|
+
return Completed(
|
|
94
|
+
provider=self.name,
|
|
95
|
+
text=(response or "").strip(),
|
|
96
|
+
session_id=session,
|
|
97
|
+
usage=res.get("usage") or {},
|
|
98
|
+
duration_s=res.get("duration_seconds"),
|
|
99
|
+
)
|
|
100
|
+
message = "\n".join(
|
|
101
|
+
x for x in (status, str(res.get("error") or ""), response or "", *state.error_text) if x
|
|
102
|
+
)
|
|
103
|
+
return Failed(
|
|
104
|
+
provider=self.name,
|
|
105
|
+
kind=classify(message),
|
|
106
|
+
message=short(message, 2000),
|
|
107
|
+
resets_at=reset_hint(message),
|
|
108
|
+
session_id=session,
|
|
109
|
+
)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Adapter interface and the shared implementation for NDJSON-streaming CLIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
import contextlib
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import AsyncIterator
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, ClassVar
|
|
12
|
+
|
|
13
|
+
from cli_relay.errors import classify
|
|
14
|
+
from cli_relay.events import Event, Failed, FailureKind
|
|
15
|
+
from cli_relay.models import Permission, ProviderSettings, Task
|
|
16
|
+
from cli_relay.ndjson import parse_line
|
|
17
|
+
from cli_relay.proc import ProcessRun, UnsafeArgumentError, resolve
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Adapter(abc.ABC):
|
|
21
|
+
"""A provider the relay can hand a task to."""
|
|
22
|
+
|
|
23
|
+
#: Adapter type name used in config files (``type = "claude"``).
|
|
24
|
+
type_name: ClassVar[str]
|
|
25
|
+
|
|
26
|
+
def __init__(self, settings: ProviderSettings) -> None:
|
|
27
|
+
self.settings = settings
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def name(self) -> str:
|
|
31
|
+
return self.settings.name
|
|
32
|
+
|
|
33
|
+
@abc.abstractmethod
|
|
34
|
+
def available(self) -> tuple[bool, str]:
|
|
35
|
+
"""``(True, "")`` if the provider can be used right now, else ``(False, reason)``."""
|
|
36
|
+
|
|
37
|
+
@abc.abstractmethod
|
|
38
|
+
def run(self, task: Task) -> AsyncIterator[Event]:
|
|
39
|
+
"""Yield progress events, then exactly one terminal event."""
|
|
40
|
+
|
|
41
|
+
def permission_for(self, task: Task) -> Permission:
|
|
42
|
+
return task.permission.capped_by(self.settings.permission)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Invocation:
|
|
47
|
+
argv: list[str]
|
|
48
|
+
stdin: bytes | None = None
|
|
49
|
+
temp_files: list[str] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ParseState:
|
|
54
|
+
"""Mutable per-run state an adapter's parser can use."""
|
|
55
|
+
|
|
56
|
+
session_id: str | None = None
|
|
57
|
+
model: str | None = None
|
|
58
|
+
text: list[str] = field(default_factory=list)
|
|
59
|
+
error_text: list[str] = field(default_factory=list)
|
|
60
|
+
resets_at: float | None = None
|
|
61
|
+
terminal: bool = False
|
|
62
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CliAdapter(Adapter):
|
|
66
|
+
"""Base for adapters that launch a CLI emitting one JSON event per line."""
|
|
67
|
+
|
|
68
|
+
default_command: ClassVar[list[str]]
|
|
69
|
+
|
|
70
|
+
def command(self) -> list[str] | None:
|
|
71
|
+
return resolve(self.settings.command or self.default_command)
|
|
72
|
+
|
|
73
|
+
def available(self) -> tuple[bool, str]:
|
|
74
|
+
if self.command() is None:
|
|
75
|
+
wanted = (self.settings.command or self.default_command)[0]
|
|
76
|
+
return False, f"'{wanted}' not found on PATH"
|
|
77
|
+
return True, ""
|
|
78
|
+
|
|
79
|
+
# -- to implement -------------------------------------------------------------------
|
|
80
|
+
@abc.abstractmethod
|
|
81
|
+
def build(self, task: Task, exe: list[str]) -> Invocation: ...
|
|
82
|
+
|
|
83
|
+
@abc.abstractmethod
|
|
84
|
+
def parse(self, obj: dict[str, Any], state: ParseState) -> list[Event]: ...
|
|
85
|
+
|
|
86
|
+
# -- shared ------------------------------------------------------------------------
|
|
87
|
+
def finish(self, state: ParseState, returncode: int | None, stderr: str) -> Event:
|
|
88
|
+
"""Terminal event when the stream ended without one."""
|
|
89
|
+
message = "\n".join(state.error_text) or stderr.strip()[-2000:]
|
|
90
|
+
if not message:
|
|
91
|
+
message = f"{self.name} exited with code {returncode} without a result"
|
|
92
|
+
return Failed(
|
|
93
|
+
provider=self.name,
|
|
94
|
+
kind=classify(message),
|
|
95
|
+
message=message,
|
|
96
|
+
resets_at=state.resets_at,
|
|
97
|
+
session_id=state.session_id,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def temp_text_file(self, inv: Invocation, text: str, suffix: str = ".md") -> str:
|
|
101
|
+
fd, path = tempfile.mkstemp(prefix="cli-relay-", suffix=suffix)
|
|
102
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
103
|
+
fh.write(text)
|
|
104
|
+
inv.temp_files.append(path)
|
|
105
|
+
return path
|
|
106
|
+
|
|
107
|
+
async def run(self, task: Task) -> AsyncIterator[Event]:
|
|
108
|
+
exe = self.command()
|
|
109
|
+
if exe is None:
|
|
110
|
+
yield Failed(
|
|
111
|
+
provider=self.name, kind=FailureKind.NOT_INSTALLED, message=self.available()[1]
|
|
112
|
+
)
|
|
113
|
+
return
|
|
114
|
+
inv = self.build(task, exe)
|
|
115
|
+
state = ParseState()
|
|
116
|
+
try:
|
|
117
|
+
try:
|
|
118
|
+
proc = ProcessRun(
|
|
119
|
+
inv.argv,
|
|
120
|
+
cwd=task.cwd,
|
|
121
|
+
stdin_data=inv.stdin,
|
|
122
|
+
env=self.settings.env or None,
|
|
123
|
+
timeout_s=task.timeout_s,
|
|
124
|
+
)
|
|
125
|
+
except UnsafeArgumentError as exc:
|
|
126
|
+
yield Failed(provider=self.name, kind=FailureKind.ERROR, message=str(exc))
|
|
127
|
+
return
|
|
128
|
+
async for line in proc.lines():
|
|
129
|
+
for obj in parse_line(line):
|
|
130
|
+
for event in self.parse(obj, state):
|
|
131
|
+
if state.terminal and event.terminal:
|
|
132
|
+
continue # keep the first terminal event only
|
|
133
|
+
if event.terminal:
|
|
134
|
+
state.terminal = True
|
|
135
|
+
yield event
|
|
136
|
+
if proc.timed_out and not state.terminal:
|
|
137
|
+
yield Failed(
|
|
138
|
+
provider=self.name,
|
|
139
|
+
kind=FailureKind.TIMEOUT,
|
|
140
|
+
message=f"no result within {task.timeout_s}s",
|
|
141
|
+
session_id=state.session_id,
|
|
142
|
+
)
|
|
143
|
+
elif not state.terminal:
|
|
144
|
+
yield self.finish(state, proc.returncode, proc.stderr_tail)
|
|
145
|
+
finally:
|
|
146
|
+
for path in inv.temp_files:
|
|
147
|
+
with contextlib.suppress(OSError):
|
|
148
|
+
os.unlink(path)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def short(value: object, limit: int = 120) -> str:
|
|
152
|
+
text = " ".join(str(value).split())
|
|
153
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Claude Code (``claude -p --output-format stream-json``).
|
|
2
|
+
|
|
3
|
+
Runs the official, unmodified ``claude`` binary under the user's own login. The prompt is
|
|
4
|
+
sent on stdin and the system prompt through a temp file, so no user text ever goes on
|
|
5
|
+
the command line (``claude`` is an npm ``.cmd`` shim on Windows).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, ClassVar
|
|
11
|
+
|
|
12
|
+
from cli_relay.adapters.base import CliAdapter, Invocation, ParseState, short
|
|
13
|
+
from cli_relay.errors import classify, reset_hint
|
|
14
|
+
from cli_relay.events import (
|
|
15
|
+
Completed,
|
|
16
|
+
Event,
|
|
17
|
+
Failed,
|
|
18
|
+
FailureKind,
|
|
19
|
+
QuotaInfo,
|
|
20
|
+
Started,
|
|
21
|
+
TextDelta,
|
|
22
|
+
ToolActivity,
|
|
23
|
+
)
|
|
24
|
+
from cli_relay.models import Permission, Task
|
|
25
|
+
|
|
26
|
+
_PERMISSION_MODE = {
|
|
27
|
+
Permission.READ_ONLY: "dontAsk", # pre-approved reads only, everything else denied
|
|
28
|
+
Permission.EDIT: "acceptEdits",
|
|
29
|
+
Permission.FULL: "auto",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ClaudeAdapter(CliAdapter):
|
|
34
|
+
type_name: ClassVar[str] = "claude"
|
|
35
|
+
default_command: ClassVar[list[str]] = ["claude"]
|
|
36
|
+
|
|
37
|
+
def build(self, task: Task, exe: list[str]) -> Invocation:
|
|
38
|
+
argv = [
|
|
39
|
+
*exe,
|
|
40
|
+
"-p",
|
|
41
|
+
"--output-format",
|
|
42
|
+
"stream-json",
|
|
43
|
+
"--verbose",
|
|
44
|
+
"--include-partial-messages",
|
|
45
|
+
"--permission-mode",
|
|
46
|
+
_PERMISSION_MODE[self.permission_for(task)],
|
|
47
|
+
"--permission-prompts",
|
|
48
|
+
"none",
|
|
49
|
+
]
|
|
50
|
+
inv = Invocation(argv=argv, stdin=task.prompt.encode("utf-8"))
|
|
51
|
+
if self.settings.model:
|
|
52
|
+
argv += ["--model", self.settings.model]
|
|
53
|
+
if session := task.resume.get(self.name):
|
|
54
|
+
argv += ["--resume", session]
|
|
55
|
+
if task.system_prompt:
|
|
56
|
+
argv += ["--append-system-prompt-file", self.temp_text_file(inv, task.system_prompt)]
|
|
57
|
+
argv += self.settings.extra_args
|
|
58
|
+
return inv
|
|
59
|
+
|
|
60
|
+
def parse(self, obj: dict[str, Any], state: ParseState) -> list[Event]:
|
|
61
|
+
kind = obj.get("type")
|
|
62
|
+
p = self.name
|
|
63
|
+
if kind == "system":
|
|
64
|
+
sub = obj.get("subtype")
|
|
65
|
+
if sub == "init":
|
|
66
|
+
state.session_id = obj.get("session_id")
|
|
67
|
+
state.model = obj.get("model")
|
|
68
|
+
return [Started(provider=p, session_id=state.session_id, model=state.model)]
|
|
69
|
+
if sub == "api_retry":
|
|
70
|
+
err = obj.get("error")
|
|
71
|
+
if err:
|
|
72
|
+
state.error_text.append(short(err, 500))
|
|
73
|
+
return []
|
|
74
|
+
if kind == "stream_event":
|
|
75
|
+
ev = obj.get("event") or {}
|
|
76
|
+
delta = ev.get("delta") or {}
|
|
77
|
+
if ev.get("type") == "content_block_delta" and delta.get("type") == "text_delta":
|
|
78
|
+
state.extra["partial"] = True
|
|
79
|
+
return [TextDelta(provider=p, text=delta.get("text", ""))]
|
|
80
|
+
return []
|
|
81
|
+
if kind == "assistant":
|
|
82
|
+
out: list[Event] = []
|
|
83
|
+
for block in (obj.get("message") or {}).get("content") or []:
|
|
84
|
+
if not isinstance(block, dict):
|
|
85
|
+
continue
|
|
86
|
+
if block.get("type") == "tool_use":
|
|
87
|
+
out.append(
|
|
88
|
+
ToolActivity(
|
|
89
|
+
provider=p,
|
|
90
|
+
tool=str(block.get("name", "?")),
|
|
91
|
+
detail=_tool_detail(block.get("input")),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
elif block.get("type") == "text" and not state.extra.get("partial"):
|
|
95
|
+
out.append(TextDelta(provider=p, text=block.get("text", "")))
|
|
96
|
+
return out
|
|
97
|
+
if kind == "rate_limit_event":
|
|
98
|
+
return self._quota(obj.get("rate_limit_info") or {}, state)
|
|
99
|
+
if kind == "result":
|
|
100
|
+
return [self._result(obj, state)]
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
def _quota(self, info: dict[str, Any], state: ParseState) -> list[Event]:
|
|
104
|
+
events: list[Event] = []
|
|
105
|
+
windows = info.get("unifiedWindows") or {}
|
|
106
|
+
if not windows and info.get("rateLimitType"):
|
|
107
|
+
windows = {info["rateLimitType"]: info}
|
|
108
|
+
for window, data in windows.items():
|
|
109
|
+
if not isinstance(data, dict):
|
|
110
|
+
continue
|
|
111
|
+
status = str(data.get("status", "unknown"))
|
|
112
|
+
resets = _num(data.get("resetsAt"))
|
|
113
|
+
if status not in ("allowed", "allowed_warning") and resets:
|
|
114
|
+
state.resets_at = max(state.resets_at or 0, resets)
|
|
115
|
+
events.append(
|
|
116
|
+
QuotaInfo(
|
|
117
|
+
provider=self.name,
|
|
118
|
+
window=str(window),
|
|
119
|
+
status=status,
|
|
120
|
+
utilization=_num(data.get("utilization")),
|
|
121
|
+
resets_at=resets,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
return events
|
|
125
|
+
|
|
126
|
+
def _result(self, obj: dict[str, Any], state: ParseState) -> Event:
|
|
127
|
+
session = obj.get("session_id") or state.session_id
|
|
128
|
+
text = obj.get("result")
|
|
129
|
+
if obj.get("subtype") == "success" and not obj.get("is_error"):
|
|
130
|
+
duration = _num(obj.get("duration_ms"))
|
|
131
|
+
return Completed(
|
|
132
|
+
provider=self.name,
|
|
133
|
+
text=text if isinstance(text, str) else "",
|
|
134
|
+
session_id=session,
|
|
135
|
+
usage=obj.get("usage") or {},
|
|
136
|
+
cost_usd=_num(obj.get("total_cost_usd")),
|
|
137
|
+
duration_s=duration / 1000 if duration is not None else None,
|
|
138
|
+
)
|
|
139
|
+
message = (
|
|
140
|
+
"\n".join(str(x) for x in (text, *obj.get("errors", []), *state.error_text) if x)
|
|
141
|
+
or f"claude result: {obj.get('subtype')}"
|
|
142
|
+
)
|
|
143
|
+
status = obj.get("api_error_status")
|
|
144
|
+
failure = FailureKind.RATE_LIMIT if status in (429, "429") else classify(message, status)
|
|
145
|
+
if failure is FailureKind.ERROR and state.resets_at:
|
|
146
|
+
failure = FailureKind.RATE_LIMIT # a window was reported as exhausted
|
|
147
|
+
return Failed(
|
|
148
|
+
provider=self.name,
|
|
149
|
+
kind=failure,
|
|
150
|
+
message=short(message, 2000),
|
|
151
|
+
resets_at=state.resets_at or reset_hint(message),
|
|
152
|
+
session_id=session,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _tool_detail(data: object) -> str:
|
|
157
|
+
if isinstance(data, dict):
|
|
158
|
+
for key in ("command", "file_path", "path", "pattern", "url", "query", "description"):
|
|
159
|
+
if data.get(key):
|
|
160
|
+
return short(data[key])
|
|
161
|
+
return ""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _num(value: object) -> float | None:
|
|
165
|
+
if isinstance(value, bool):
|
|
166
|
+
return None
|
|
167
|
+
if isinstance(value, int | float):
|
|
168
|
+
return float(value)
|
|
169
|
+
return None
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""OpenAI Codex CLI (``codex exec --json``).
|
|
2
|
+
|
|
3
|
+
Command layout and the failure / rate-limit path are verified against codex-cli 0.167;
|
|
4
|
+
the success path follows the published JSONL event documentation. The prompt always goes
|
|
5
|
+
on stdin (``codex`` is an npm shim on Windows).
|
|
6
|
+
|
|
7
|
+
If ``codex`` is not on ``PATH``, the copy bundled with the Codex desktop app on Windows
|
|
8
|
+
(``%LOCALAPPDATA%\\OpenAI\\Codex\\bin\\<build>\\codex.exe``) is used.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, ClassVar
|
|
17
|
+
|
|
18
|
+
from cli_relay.adapters.base import CliAdapter, Invocation, ParseState, short
|
|
19
|
+
from cli_relay.errors import classify, reset_hint
|
|
20
|
+
from cli_relay.events import Completed, Event, Failed, Started, TextDelta, ToolActivity
|
|
21
|
+
from cli_relay.models import Permission, Task
|
|
22
|
+
|
|
23
|
+
_SANDBOX = {
|
|
24
|
+
Permission.READ_ONLY: ["--sandbox", "read-only"],
|
|
25
|
+
Permission.EDIT: ["--sandbox", "workspace-write"],
|
|
26
|
+
Permission.FULL: ["--sandbox", "workspace-write", "--approve-for-me"],
|
|
27
|
+
}
|
|
28
|
+
_TOOL_ITEMS = {
|
|
29
|
+
"command_execution": "command",
|
|
30
|
+
"file_change": "path",
|
|
31
|
+
"mcp_tool_call": "tool",
|
|
32
|
+
"web_search": "query",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def desktop_bundled_codex() -> Path | None:
|
|
37
|
+
"""Newest ``codex.exe`` shipped with the Codex desktop app (Windows only)."""
|
|
38
|
+
if sys.platform != "win32":
|
|
39
|
+
return None
|
|
40
|
+
base = os.environ.get("LOCALAPPDATA")
|
|
41
|
+
if not base:
|
|
42
|
+
return None
|
|
43
|
+
candidates = list((Path(base) / "OpenAI" / "Codex" / "bin").glob("*/codex.exe"))
|
|
44
|
+
if not candidates:
|
|
45
|
+
return None
|
|
46
|
+
return max(candidates, key=lambda p: p.stat().st_mtime)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CodexAdapter(CliAdapter):
|
|
50
|
+
type_name: ClassVar[str] = "codex"
|
|
51
|
+
default_command: ClassVar[list[str]] = ["codex"]
|
|
52
|
+
|
|
53
|
+
def command(self) -> list[str] | None:
|
|
54
|
+
found = super().command()
|
|
55
|
+
if found is None and self.settings.command is None:
|
|
56
|
+
bundled = desktop_bundled_codex()
|
|
57
|
+
if bundled is not None:
|
|
58
|
+
return [str(bundled)]
|
|
59
|
+
return found
|
|
60
|
+
|
|
61
|
+
def build(self, task: Task, exe: list[str]) -> Invocation:
|
|
62
|
+
# `exec` options (sandbox, model, extra args) must come before the `resume`
|
|
63
|
+
# subcommand: `codex exec resume` itself rejects --sandbox.
|
|
64
|
+
argv = [*exe, "exec", *_SANDBOX[self.permission_for(task)]]
|
|
65
|
+
if self.settings.model:
|
|
66
|
+
argv += ["--model", self.settings.model]
|
|
67
|
+
argv += self.settings.extra_args
|
|
68
|
+
if session := task.resume.get(self.name):
|
|
69
|
+
argv += ["resume", session]
|
|
70
|
+
argv += ["--json", "--skip-git-repo-check", "-"] # "-": prompt from stdin
|
|
71
|
+
prompt = task.prompt
|
|
72
|
+
if task.system_prompt:
|
|
73
|
+
prompt = f"{task.system_prompt.strip()}\n\n---\n\n{prompt}"
|
|
74
|
+
return Invocation(argv=argv, stdin=prompt.encode("utf-8"))
|
|
75
|
+
|
|
76
|
+
def parse(self, obj: dict[str, Any], state: ParseState) -> list[Event]:
|
|
77
|
+
p = self.name
|
|
78
|
+
kind = obj.get("type")
|
|
79
|
+
if kind == "thread.started":
|
|
80
|
+
state.session_id = obj.get("thread_id")
|
|
81
|
+
return [Started(provider=p, session_id=state.session_id)]
|
|
82
|
+
if kind in ("item.started", "item.completed"):
|
|
83
|
+
item = obj.get("item") or {}
|
|
84
|
+
itype = item.get("type")
|
|
85
|
+
if itype == "agent_message" and kind == "item.completed":
|
|
86
|
+
text = str(item.get("text", ""))
|
|
87
|
+
state.text.append(text)
|
|
88
|
+
return [TextDelta(provider=p, text=text)]
|
|
89
|
+
if itype in _TOOL_ITEMS and kind == "item.started":
|
|
90
|
+
detail = item.get(_TOOL_ITEMS[itype]) or item.get("changes") or ""
|
|
91
|
+
return [ToolActivity(provider=p, tool=str(itype), detail=short(detail))]
|
|
92
|
+
return []
|
|
93
|
+
if kind == "error":
|
|
94
|
+
state.error_text.append(short(obj.get("message") or obj, 500))
|
|
95
|
+
return []
|
|
96
|
+
if kind == "turn.failed":
|
|
97
|
+
err = obj.get("error") or {}
|
|
98
|
+
main = str(err.get("message", err)) if isinstance(err, dict) else str(err)
|
|
99
|
+
# codex repeats the same text in the preceding `error` event: keep it once
|
|
100
|
+
parts = [main, *(t for t in state.error_text if t != short(main, 500))]
|
|
101
|
+
message = "\n".join(dict.fromkeys(p for p in parts if p))
|
|
102
|
+
return [
|
|
103
|
+
Failed(
|
|
104
|
+
provider=p,
|
|
105
|
+
kind=classify(message),
|
|
106
|
+
message=short(message, 2000),
|
|
107
|
+
resets_at=reset_hint(message),
|
|
108
|
+
session_id=state.session_id,
|
|
109
|
+
)
|
|
110
|
+
]
|
|
111
|
+
if kind == "turn.completed":
|
|
112
|
+
return [
|
|
113
|
+
Completed(
|
|
114
|
+
provider=p,
|
|
115
|
+
text=state.text[-1] if state.text else "",
|
|
116
|
+
session_id=state.session_id,
|
|
117
|
+
usage=obj.get("usage") or {},
|
|
118
|
+
)
|
|
119
|
+
]
|
|
120
|
+
return []
|