callai-cli 0.9.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.
- callai/__init__.py +37 -0
- callai/__main__.py +8 -0
- callai/adapters/__init__.py +82 -0
- callai/adapters/base.py +190 -0
- callai/adapters/claude_code.py +194 -0
- callai/adapters/codex.py +155 -0
- callai/adapters/grok.py +207 -0
- callai/adapters/hermes.py +112 -0
- callai/adapters/opencode.py +186 -0
- callai/adapters/pi.py +179 -0
- callai/api.py +134 -0
- callai/cli.py +674 -0
- callai/config.py +273 -0
- callai/errors.py +65 -0
- callai/fanout.py +166 -0
- callai/py.typed +0 -0
- callai/request.py +70 -0
- callai/result.py +95 -0
- callai/runner.py +233 -0
- callai_cli-0.9.0.dist-info/METADATA +218 -0
- callai_cli-0.9.0.dist-info/RECORD +25 -0
- callai_cli-0.9.0.dist-info/WHEEL +5 -0
- callai_cli-0.9.0.dist-info/entry_points.txt +2 -0
- callai_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
- callai_cli-0.9.0.dist-info/top_level.txt +1 -0
callai/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""callai: run a prompt non-interactively through any coding-agent harness.
|
|
2
|
+
|
|
3
|
+
Usage mirrors ``claude -p``::
|
|
4
|
+
|
|
5
|
+
callai -n {claude-code,codex,opencode,pi,hermes,grok} -p PROMPT [-m MODEL]
|
|
6
|
+
|
|
7
|
+
From Python::
|
|
8
|
+
|
|
9
|
+
from callai import run
|
|
10
|
+
result = run("codex", "Fix the failing test", permission="auto")
|
|
11
|
+
print(result.text, result.session_id)
|
|
12
|
+
|
|
13
|
+
See ``docs/DESIGN.md`` for the CLI surface and ``conformance/`` for the executable spec.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .api import run
|
|
17
|
+
from .cli import main
|
|
18
|
+
from .errors import CallaiError, HarnessNotFound, HarnessTimeout, Interrupted, UsageError
|
|
19
|
+
from .result import Result
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"CallaiError",
|
|
23
|
+
"HarnessNotFound",
|
|
24
|
+
"HarnessTimeout",
|
|
25
|
+
"Interrupted",
|
|
26
|
+
"Result",
|
|
27
|
+
"UsageError",
|
|
28
|
+
"main",
|
|
29
|
+
"run",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
from importlib import metadata
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
__version__ = metadata.version("callai-cli") # the distribution name, not the import name
|
|
36
|
+
except metadata.PackageNotFoundError: # pragma: no cover - running from a source tree that was never installed
|
|
37
|
+
__version__ = "0.0.0+unknown"
|
callai/__main__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Harness adapters and the registry that maps ``--name`` values to them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..errors import UsageError
|
|
6
|
+
from .base import Adapter, strip_ansi
|
|
7
|
+
|
|
8
|
+
# Every harness callai supports, in --help order (DESIGN.md §2). Adapters register as they are built.
|
|
9
|
+
HARNESS_NAMES: tuple[str, ...] = ("claude-code", "codex", "opencode", "pi", "hermes", "grok")
|
|
10
|
+
|
|
11
|
+
REGISTRY: dict[str, Adapter] = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(adapter: Adapter) -> Adapter:
|
|
15
|
+
"""Add an adapter to the registry.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
adapter: The adapter instance; its ``name`` must be one of ``HARNESS_NAMES``.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
The same adapter, so this can wrap an instantiation.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
ValueError: If the name is not a supported harness or is already registered.
|
|
25
|
+
"""
|
|
26
|
+
if adapter.name not in HARNESS_NAMES:
|
|
27
|
+
raise ValueError(f"{adapter.name!r} is not a supported harness")
|
|
28
|
+
if adapter.name in REGISTRY:
|
|
29
|
+
raise ValueError(f"{adapter.name!r} is already registered")
|
|
30
|
+
REGISTRY[adapter.name] = adapter
|
|
31
|
+
return adapter
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_adapter(harness_name: str) -> Adapter:
|
|
35
|
+
"""Look up the adapter for a ``--name`` value.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
harness_name: Harness name from the command line, environment or config.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The registered adapter.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
UsageError: If the harness is unknown or its adapter is not implemented yet.
|
|
45
|
+
"""
|
|
46
|
+
if harness_name not in HARNESS_NAMES:
|
|
47
|
+
raise UsageError(f"unknown harness {harness_name!r}; choose from {', '.join(HARNESS_NAMES)}")
|
|
48
|
+
try:
|
|
49
|
+
return REGISTRY[harness_name]
|
|
50
|
+
except KeyError:
|
|
51
|
+
raise UsageError(f"harness {harness_name!r} is not implemented yet") from None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Built-in adapters (imported last: they use register() above).
|
|
55
|
+
from .claude_code import ClaudeCode # noqa: E402
|
|
56
|
+
from .codex import Codex # noqa: E402
|
|
57
|
+
from .grok import Grok # noqa: E402
|
|
58
|
+
from .hermes import Hermes # noqa: E402
|
|
59
|
+
from .opencode import OpenCode # noqa: E402
|
|
60
|
+
from .pi import Pi # noqa: E402
|
|
61
|
+
|
|
62
|
+
register(ClaudeCode())
|
|
63
|
+
register(Codex())
|
|
64
|
+
register(OpenCode())
|
|
65
|
+
register(Pi())
|
|
66
|
+
register(Grok())
|
|
67
|
+
register(Hermes())
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"HARNESS_NAMES",
|
|
71
|
+
"REGISTRY",
|
|
72
|
+
"Adapter",
|
|
73
|
+
"ClaudeCode",
|
|
74
|
+
"Codex",
|
|
75
|
+
"Grok",
|
|
76
|
+
"Hermes",
|
|
77
|
+
"OpenCode",
|
|
78
|
+
"Pi",
|
|
79
|
+
"get_adapter",
|
|
80
|
+
"register",
|
|
81
|
+
"strip_ansi",
|
|
82
|
+
]
|
callai/adapters/base.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""The interface every harness adapter implements (DESIGN.md §4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..request import Request
|
|
11
|
+
from ..result import Completed, Result
|
|
12
|
+
|
|
13
|
+
# CSI sequences (colours, cursor moves) and OSC sequences (titles, hyperlinks).
|
|
14
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def strip_ansi(text: str) -> str:
|
|
18
|
+
"""Remove ANSI escape sequences from terminal output.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
text: Text that may contain escape sequences.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The text without them.
|
|
25
|
+
"""
|
|
26
|
+
return _ANSI_RE.sub("", text)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def jsonl_events(stdout: str) -> list[dict[str, Any]]:
|
|
30
|
+
"""Parse JSON-lines output, skipping lines that are not JSON objects (banners, warnings).
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
stdout: The harness's stdout.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The event objects, in order.
|
|
37
|
+
"""
|
|
38
|
+
return [data for line in stdout.splitlines() if (data := json_line(line)) is not None]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def json_line(line: str) -> dict[str, Any] | None:
|
|
42
|
+
"""Parse one line of JSON-lines output.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
line: One stdout line (a trailing newline is fine).
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The object, or ``None`` if the line isn't a JSON object (a banner, a warning).
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(line)
|
|
52
|
+
except ValueError:
|
|
53
|
+
return None
|
|
54
|
+
return data if isinstance(data, dict) else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def content_text(blocks: Any) -> str:
|
|
58
|
+
"""Join the text of a content value: a string, or a list of ``{"type": "text", "text"}`` blocks.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
blocks: A tool result's content.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The text; non-text blocks are skipped.
|
|
65
|
+
"""
|
|
66
|
+
if isinstance(blocks, str):
|
|
67
|
+
return blocks
|
|
68
|
+
if not isinstance(blocks, list):
|
|
69
|
+
return ""
|
|
70
|
+
return "".join(b["text"] for b in blocks if isinstance(b, dict) and isinstance(b.get("text"), str))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tool_use(call_id: Any, name: Any, tool_input: Any) -> dict[str, Any]:
|
|
74
|
+
"""A normalised ``tool_use`` event (DESIGN.md §5).
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
call_id: The harness's id for the call.
|
|
78
|
+
name: The harness's own tool name.
|
|
79
|
+
tool_input: The tool's arguments.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The event.
|
|
83
|
+
"""
|
|
84
|
+
return {"type": "tool_use", "id": call_id, "name": name, "input": tool_input}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def tool_result(call_id: Any, is_error: bool, output: str) -> dict[str, Any]:
|
|
88
|
+
"""A normalised ``tool_result`` event (DESIGN.md §5).
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
call_id: The id of the ``tool_use`` it answers.
|
|
92
|
+
is_error: Whether the tool failed or was refused.
|
|
93
|
+
output: What the tool returned, as text.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The event.
|
|
97
|
+
"""
|
|
98
|
+
return {"type": "tool_result", "id": call_id, "is_error": is_error, "output": output}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Adapter(ABC):
|
|
102
|
+
"""Translates a :class:`Request` into one harness's command line and its output back into a :class:`Result`.
|
|
103
|
+
|
|
104
|
+
Both methods are pure (no I/O), so adapters are tested without the harness installed.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
name: Harness name used with ``--name`` (one of ``callai.adapters.HARNESS_NAMES``).
|
|
108
|
+
binary: Executable looked up on PATH.
|
|
109
|
+
install_url: Where to point users when the binary is missing.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
name: str
|
|
113
|
+
binary: str
|
|
114
|
+
install_url: str
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
def build_argv(self, req: Request) -> list[str]:
|
|
118
|
+
"""Build the exact harness command line, with the prompt last after ``--``.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
req: The request to run.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The argv list; ``argv[0]`` is the bare binary name.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
@abstractmethod
|
|
128
|
+
def parse(self, completed: Completed, req: Request) -> Result:
|
|
129
|
+
"""Turn the harness's raw output into a normalised result.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
completed: Raw process outcome.
|
|
133
|
+
req: The request that produced it.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The normalised result.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def build_env(self, req: Request) -> dict[str, str]:
|
|
140
|
+
"""Environment variables the harness needs for this request, set on top of callai's own.
|
|
141
|
+
|
|
142
|
+
For settings a harness takes only from its environment (hermes's extra system prompt).
|
|
143
|
+
Pure, like :meth:`build_argv`.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
req: The request to run.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Variables to set; empty (the default) leaves the inherited environment unchanged.
|
|
150
|
+
"""
|
|
151
|
+
return {}
|
|
152
|
+
|
|
153
|
+
def stream_events(self, line: str, state: dict[str, Any]) -> list[dict[str, Any]]:
|
|
154
|
+
"""Map one stdout line of a running harness to normalised ``-o stream-json`` events.
|
|
155
|
+
|
|
156
|
+
Called for every stdout line in order, as it arrives, with one ``state`` dict per run for
|
|
157
|
+
adapters that need to remember earlier lines. Returns ``text``, ``tool_use`` and
|
|
158
|
+
``tool_result`` events (DESIGN.md §5); the CLI writes ``start`` and ``result`` itself.
|
|
159
|
+
Pure apart from ``state``. The default, for harnesses without an event stream, returns none.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
line: One stdout line, newline included.
|
|
163
|
+
state: Scratch space shared by the calls of one run.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The events, possibly none.
|
|
167
|
+
"""
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
def text_result(self, completed: Completed, req: Request) -> Result:
|
|
171
|
+
"""Fallback parser for harnesses without structured output: cleaned stdout is the answer.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
completed: Raw process outcome.
|
|
175
|
+
req: The request that produced it.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
A result whose text is stdout without ANSI codes or surrounding whitespace.
|
|
179
|
+
"""
|
|
180
|
+
return Result(
|
|
181
|
+
harness_name=self.name,
|
|
182
|
+
model=req.model,
|
|
183
|
+
text=strip_ansi(completed.stdout).strip(),
|
|
184
|
+
exit_code=completed.exit_code,
|
|
185
|
+
is_error=completed.exit_code != 0,
|
|
186
|
+
duration_ms=completed.duration_ms,
|
|
187
|
+
argv=completed.argv,
|
|
188
|
+
raw_stdout=completed.stdout,
|
|
189
|
+
raw_stderr=completed.stderr,
|
|
190
|
+
)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Adapter for Anthropic's Claude Code CLI (``claude -p``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..request import Request
|
|
9
|
+
from ..result import Completed, Result
|
|
10
|
+
from .base import Adapter, content_text, json_line, tool_result, tool_use
|
|
11
|
+
|
|
12
|
+
# callai --permission level -> claude flags (DESIGN.md §7).
|
|
13
|
+
_PERMISSION_FLAGS: dict[str, list[str]] = {
|
|
14
|
+
"default": [],
|
|
15
|
+
"read-only": ["--permission-mode", "plan"],
|
|
16
|
+
"auto": ["--permission-mode", "acceptEdits"],
|
|
17
|
+
"bypass": ["--dangerously-skip-permissions"],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClaudeCode(Adapter):
|
|
22
|
+
"""``claude -p --output-format json`` — always asks for JSON so the answer is never scraped from text."""
|
|
23
|
+
|
|
24
|
+
name = "claude-code"
|
|
25
|
+
binary = "claude"
|
|
26
|
+
install_url = "https://code.claude.com/docs/en/setup"
|
|
27
|
+
|
|
28
|
+
def build_argv(self, req: Request) -> list[str]:
|
|
29
|
+
"""Build the ``claude`` command line.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
req: The request to run.
|
|
33
|
+
|
|
34
|
+
``-o stream-json`` asks for claude's own event stream, which needs ``--verbose`` with ``-p``;
|
|
35
|
+
its last line is the same result object ``--output-format json`` prints.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The argv list, prompt last after ``--``.
|
|
39
|
+
"""
|
|
40
|
+
stream = req.output_format == "stream-json"
|
|
41
|
+
argv = [self.binary, "-p", "--output-format", *(["stream-json", "--verbose"] if stream else ["json"])]
|
|
42
|
+
if req.model:
|
|
43
|
+
argv += ["--model", req.model]
|
|
44
|
+
argv += _PERMISSION_FLAGS[req.permission]
|
|
45
|
+
if req.system_prompt is not None:
|
|
46
|
+
argv += ["--system-prompt", req.system_prompt]
|
|
47
|
+
return [*argv, *_session_flags(req), *req.extra_args, "--", req.prompt]
|
|
48
|
+
|
|
49
|
+
def parse(self, completed: Completed, req: Request) -> Result:
|
|
50
|
+
"""Parse the JSON result object ``claude -p`` prints (alone, or as the last stream-json line).
|
|
51
|
+
|
|
52
|
+
Errors come from ``is_error`` or a non-zero exit, never ``subtype`` (claude reports
|
|
53
|
+
``"subtype": "success"`` with ``"is_error": true``, e.g. for an unknown model).
|
|
54
|
+
Output that is not a JSON object falls back to plain text.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
completed: Raw process outcome.
|
|
58
|
+
req: The request that produced it.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The normalised result.
|
|
62
|
+
"""
|
|
63
|
+
data = _last_json_object(completed.stdout)
|
|
64
|
+
if data is None:
|
|
65
|
+
return self.text_result(completed, req).with_updates(is_error=True)
|
|
66
|
+
is_error = bool(data.get("is_error")) or completed.exit_code != 0
|
|
67
|
+
text = data.get("result")
|
|
68
|
+
cost = data.get("total_cost_usd")
|
|
69
|
+
return Result(
|
|
70
|
+
harness_name=self.name,
|
|
71
|
+
model=req.model if is_error else (_main_model(data.get("modelUsage")) or req.model),
|
|
72
|
+
text=text if isinstance(text, str) else "",
|
|
73
|
+
exit_code=completed.exit_code,
|
|
74
|
+
is_error=is_error,
|
|
75
|
+
duration_ms=completed.duration_ms,
|
|
76
|
+
session_id=data.get("session_id"),
|
|
77
|
+
usage=_usage(data.get("usage")),
|
|
78
|
+
cost_usd=cost if isinstance(cost, int | float) else None,
|
|
79
|
+
argv=completed.argv,
|
|
80
|
+
raw_stdout=completed.stdout,
|
|
81
|
+
raw_stderr=completed.stderr,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def stream_events(self, line: str, state: dict[str, Any]) -> list[dict[str, Any]]:
|
|
85
|
+
"""Map a ``--output-format stream-json`` line: assistant text and tool calls, then tool results.
|
|
86
|
+
|
|
87
|
+
claude sends whole messages (no ``--include-partial-messages``), so each text event is one
|
|
88
|
+
complete text block.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
line: One stdout line.
|
|
92
|
+
state: Unused.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
The events.
|
|
96
|
+
"""
|
|
97
|
+
data = json_line(line)
|
|
98
|
+
message = data.get("message") if data else None
|
|
99
|
+
if not isinstance(message, dict) or not isinstance(message.get("content"), list):
|
|
100
|
+
return []
|
|
101
|
+
events = []
|
|
102
|
+
for block in message["content"]:
|
|
103
|
+
if not isinstance(block, dict):
|
|
104
|
+
continue
|
|
105
|
+
kind = block.get("type")
|
|
106
|
+
if data.get("type") == "assistant" and kind == "text":
|
|
107
|
+
events.append({"type": "text", "text": block.get("text", "")})
|
|
108
|
+
elif data.get("type") == "assistant" and kind == "tool_use":
|
|
109
|
+
events.append(tool_use(block.get("id"), block.get("name"), block.get("input")))
|
|
110
|
+
elif data.get("type") == "user" and kind == "tool_result":
|
|
111
|
+
events.append(
|
|
112
|
+
tool_result(
|
|
113
|
+
block.get("tool_use_id"), bool(block.get("is_error")), content_text(block.get("content"))
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
return events
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _last_json_object(stdout: str) -> dict[str, Any] | None:
|
|
120
|
+
"""Find the result object: the whole output, else the last line that is a JSON object.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
stdout: The harness's stdout.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The parsed object, or ``None`` if there is none.
|
|
127
|
+
"""
|
|
128
|
+
for candidate in (stdout, *reversed(stdout.splitlines())):
|
|
129
|
+
try:
|
|
130
|
+
data = json.loads(candidate)
|
|
131
|
+
except ValueError:
|
|
132
|
+
continue
|
|
133
|
+
if isinstance(data, dict):
|
|
134
|
+
return data
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _usage(raw: Any) -> dict[str, int] | None:
|
|
139
|
+
"""Normalise claude's usage: input counts cache reads and writes too (conformance/README.md).
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
raw: claude's ``usage`` value.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
``{"input_tokens", "output_tokens"}``, or ``None`` if absent.
|
|
146
|
+
"""
|
|
147
|
+
if not isinstance(raw, dict):
|
|
148
|
+
return None
|
|
149
|
+
keys = ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens")
|
|
150
|
+
return {
|
|
151
|
+
"input_tokens": sum(int(raw.get(k) or 0) for k in keys),
|
|
152
|
+
"output_tokens": int(raw.get("output_tokens") or 0),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _main_model(model_usage: Any) -> str | None:
|
|
157
|
+
"""Pick the model that did the work from ``modelUsage`` (claude also lists side-call models).
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
model_usage: claude's ``modelUsage`` mapping of model id to stats.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
The model with the highest cost (then output tokens), or ``None``.
|
|
164
|
+
"""
|
|
165
|
+
if not isinstance(model_usage, dict) or not model_usage:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def weight(item: tuple[str, Any]) -> tuple[float, float]:
|
|
169
|
+
"""Sort key: (cost, output tokens).
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
item: A ``(model id, stats)`` pair.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The key.
|
|
176
|
+
"""
|
|
177
|
+
stats = item[1] if isinstance(item[1], dict) else {}
|
|
178
|
+
return (float(stats.get("costUSD") or 0), float(stats.get("outputTokens") or 0))
|
|
179
|
+
|
|
180
|
+
return max(model_usage.items(), key=weight)[0]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _session_flags(req: Request) -> list[str]:
|
|
184
|
+
"""The harness's flags for ``--resume ID`` / ``--continue`` (none for a new session).
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
req: The request.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
The flags.
|
|
191
|
+
"""
|
|
192
|
+
if req.resume is not None:
|
|
193
|
+
return ["--resume", req.resume]
|
|
194
|
+
return ["--continue"] if req.continue_session else []
|
callai/adapters/codex.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Adapter for OpenAI's Codex CLI (``codex exec``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..request import Request
|
|
9
|
+
from ..result import Completed, Result
|
|
10
|
+
from .base import Adapter, json_line, jsonl_events, tool_result, tool_use
|
|
11
|
+
|
|
12
|
+
# callai --permission level -> codex flags (DESIGN.md §7; verified against codex-cli 0.166.0 --help).
|
|
13
|
+
_PERMISSION_FLAGS: dict[str, list[str]] = {
|
|
14
|
+
"default": [],
|
|
15
|
+
"read-only": ["--sandbox", "read-only"],
|
|
16
|
+
"auto": ["--sandbox", "workspace-write"],
|
|
17
|
+
"bypass": ["--dangerously-bypass-approvals-and-sandbox"],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Item types that are not tool calls; every other item type (command_execution, file_change,
|
|
22
|
+
# mcp_tool_call, web_search, ...) is reported as a tool call named after its type.
|
|
23
|
+
_NOT_TOOLS = {"agent_message", "reasoning", "error", "todo_list"}
|
|
24
|
+
# Item fields that describe the call's outcome rather than its input.
|
|
25
|
+
_OUTCOME_KEYS = {"id", "type", "status", "aggregated_output", "exit_code", "result", "error"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def toml_string(text: str) -> str:
|
|
29
|
+
"""Encode text as a TOML basic string (for codex ``-c key=value`` overrides).
|
|
30
|
+
|
|
31
|
+
JSON string escapes are valid TOML, except that TOML also forbids a raw DEL character.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
text: Any text.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The quoted TOML string.
|
|
38
|
+
"""
|
|
39
|
+
return json.dumps(text, ensure_ascii=False).replace("\x7f", "\\u007f")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Codex(Adapter):
|
|
43
|
+
"""``codex exec --json`` — parses the JSONL event stream rather than scraping text."""
|
|
44
|
+
|
|
45
|
+
name = "codex"
|
|
46
|
+
binary = "codex"
|
|
47
|
+
install_url = "https://developers.openai.com/codex/cli"
|
|
48
|
+
|
|
49
|
+
def build_argv(self, req: Request) -> list[str]:
|
|
50
|
+
"""Build the ``codex exec`` command line.
|
|
51
|
+
|
|
52
|
+
``--skip-git-repo-check`` is always added, so callai works in any directory like the other tools.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
req: The request to run.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The argv list, prompt last after ``--`` (so it can't be taken for a subcommand like ``review``).
|
|
59
|
+
"""
|
|
60
|
+
argv = [self.binary, "exec", "--json", "--skip-git-repo-check"]
|
|
61
|
+
if req.model:
|
|
62
|
+
argv += ["--model", req.model]
|
|
63
|
+
argv += _PERMISSION_FLAGS[req.permission]
|
|
64
|
+
if req.system_prompt is not None:
|
|
65
|
+
argv += ["-c", f"developer_instructions={toml_string(req.system_prompt)}"]
|
|
66
|
+
# `resume` is a subcommand of exec that rejects --sandbox, so exec's own options (and any extra
|
|
67
|
+
# args) go before it; only the session id or --last follow it.
|
|
68
|
+
if req.resume is not None:
|
|
69
|
+
resume = ["resume", req.resume]
|
|
70
|
+
else:
|
|
71
|
+
resume = ["resume", "--last"] if req.continue_session else []
|
|
72
|
+
return [*argv, *req.extra_args, *resume, "--", req.prompt]
|
|
73
|
+
|
|
74
|
+
def parse(self, completed: Completed, req: Request) -> Result:
|
|
75
|
+
"""Parse the JSONL events of ``codex exec --json``.
|
|
76
|
+
|
|
77
|
+
The answer is the last ``agent_message`` item. Failure comes only from ``turn.failed`` or a
|
|
78
|
+
non-zero exit: ``error`` events and ``item.type == "error"`` items are non-fatal warnings
|
|
79
|
+
(reconnects, missing model metadata). codex does not report the model it used.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
completed: Raw process outcome.
|
|
83
|
+
req: The request that produced it.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The normalised result.
|
|
87
|
+
"""
|
|
88
|
+
events = jsonl_events(completed.stdout)
|
|
89
|
+
if not events:
|
|
90
|
+
return self.text_result(completed, req).with_updates(is_error=True)
|
|
91
|
+
text, session_id, usage, failure = "", None, None, None
|
|
92
|
+
for ev in events:
|
|
93
|
+
kind = ev.get("type")
|
|
94
|
+
if kind == "thread.started":
|
|
95
|
+
session_id = ev.get("thread_id")
|
|
96
|
+
elif kind == "item.completed":
|
|
97
|
+
item = ev.get("item") or {}
|
|
98
|
+
if item.get("type") == "agent_message" and isinstance(item.get("text"), str):
|
|
99
|
+
text = item["text"]
|
|
100
|
+
elif kind == "turn.completed" and isinstance(ev.get("usage"), dict):
|
|
101
|
+
usage = {
|
|
102
|
+
# OpenAI semantics: input_tokens already includes cached_input_tokens.
|
|
103
|
+
"input_tokens": int(ev["usage"].get("input_tokens") or 0),
|
|
104
|
+
"output_tokens": int(ev["usage"].get("output_tokens") or 0),
|
|
105
|
+
}
|
|
106
|
+
elif kind == "turn.failed":
|
|
107
|
+
failure = (ev.get("error") or {}).get("message") or text
|
|
108
|
+
return Result(
|
|
109
|
+
harness_name=self.name,
|
|
110
|
+
model=req.model,
|
|
111
|
+
text=failure if failure is not None else text,
|
|
112
|
+
exit_code=completed.exit_code,
|
|
113
|
+
is_error=failure is not None or completed.exit_code != 0,
|
|
114
|
+
duration_ms=completed.duration_ms,
|
|
115
|
+
session_id=session_id,
|
|
116
|
+
usage=usage,
|
|
117
|
+
argv=completed.argv,
|
|
118
|
+
raw_stdout=completed.stdout,
|
|
119
|
+
raw_stderr=completed.stderr,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def stream_events(self, line: str, state: dict[str, Any]) -> list[dict[str, Any]]:
|
|
123
|
+
"""Map a ``codex exec --json`` line: ``agent_message`` items are text, other items tool calls.
|
|
124
|
+
|
|
125
|
+
A tool call is reported when its item starts (``item.started``) and its result when the item
|
|
126
|
+
completes. Items that only arrive completed get both then; ``state`` remembers which calls
|
|
127
|
+
were already reported. Verified for ``command_execution``; other item types follow the same
|
|
128
|
+
event schema (their fields, minus outcome fields, become the input).
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
line: One stdout line.
|
|
132
|
+
state: Remembers reported call ids (``"started"``).
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The events.
|
|
136
|
+
"""
|
|
137
|
+
data = json_line(line)
|
|
138
|
+
item = data.get("item") if data else None
|
|
139
|
+
if not isinstance(item, dict):
|
|
140
|
+
return []
|
|
141
|
+
kind, item_id = item.get("type"), item.get("id")
|
|
142
|
+
if data.get("type") == "item.completed" and kind == "agent_message":
|
|
143
|
+
return [{"type": "text", "text": item.get("text", "")}]
|
|
144
|
+
if kind in _NOT_TOOLS or data.get("type") not in ("item.started", "item.completed"):
|
|
145
|
+
return []
|
|
146
|
+
started = state.setdefault("started", set())
|
|
147
|
+
events = []
|
|
148
|
+
if item_id not in started:
|
|
149
|
+
started.add(item_id)
|
|
150
|
+
events.append(tool_use(item_id, kind, {k: v for k, v in item.items() if k not in _OUTCOME_KEYS}))
|
|
151
|
+
if data["type"] == "item.completed":
|
|
152
|
+
failed = item.get("status") == "failed" or item.get("exit_code") not in (None, 0) or bool(item.get("error"))
|
|
153
|
+
output = item.get("aggregated_output")
|
|
154
|
+
events.append(tool_result(item_id, failed, output if isinstance(output, str) else ""))
|
|
155
|
+
return events
|