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/config.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""User configuration: ``config.toml``, and model aliases (DESIGN.md §6).
|
|
2
|
+
|
|
3
|
+
Precedence, applied by the CLI: command-line flag > ``CALLAI_*`` environment variable > this file >
|
|
4
|
+
built-in default. The file is optional; a missing default file is an empty config.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import tomllib
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .adapters import HARNESS_NAMES
|
|
18
|
+
from .errors import UsageError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Invalid(ValueError):
|
|
22
|
+
"""A problem in the config file; :func:`load_config` adds the file name and raises UsageError."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_TOP_KEYS = {"default_harness", "model", "timeout", "harnesses", "aliases"}
|
|
26
|
+
_HARNESS_KEYS = {"binary", "model", "extra_args"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class HarnessConfig:
|
|
31
|
+
"""Settings for one harness, from ``[harnesses.<name>]``.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
binary: Executable to run instead of the adapter's default (``~`` is expanded).
|
|
35
|
+
model: Default model for this harness (may be an alias).
|
|
36
|
+
extra_args: Arguments passed to the harness before any given after ``--``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
binary: str | None = None
|
|
40
|
+
model: str | None = None
|
|
41
|
+
extra_args: tuple[str, ...] = ()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Config:
|
|
46
|
+
"""Parsed ``config.toml``; every field is optional.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
default_harness: Harness used when neither ``-n`` nor ``CALLAI_HARNESS_NAME`` is given.
|
|
50
|
+
model: Default model for every harness (typically an alias).
|
|
51
|
+
timeout: Default ``--timeout`` in seconds.
|
|
52
|
+
harnesses: Per-harness settings, by harness name.
|
|
53
|
+
aliases: Model alias -> {harness name -> model}.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
default_harness: str | None = None
|
|
57
|
+
model: str | None = None
|
|
58
|
+
timeout: float | None = None
|
|
59
|
+
harnesses: dict[str, HarnessConfig] = field(default_factory=dict)
|
|
60
|
+
aliases: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def config_path(environ: Mapping[str, str], home: str | None = None) -> tuple[str, bool]:
|
|
64
|
+
"""Where the config file is, and whether the user named it explicitly.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
environ: Environment (``CALLAI_CONFIG``, ``XDG_CONFIG_HOME``).
|
|
68
|
+
home: Home directory; defaults to the current user's.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
``(path, explicit)``: ``explicit`` is true when ``CALLAI_CONFIG`` set it.
|
|
72
|
+
"""
|
|
73
|
+
if environ.get("CALLAI_CONFIG"):
|
|
74
|
+
return environ["CALLAI_CONFIG"], True
|
|
75
|
+
base = environ.get("XDG_CONFIG_HOME") or os.path.join(home or str(Path.home()), ".config")
|
|
76
|
+
return os.path.join(base, "callai", "config.toml"), False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_config(environ: Mapping[str, str], home: str | None = None) -> Config:
|
|
80
|
+
"""Read and validate the config file.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
environ: Environment used to find the file.
|
|
84
|
+
home: Home directory; defaults to the current user's.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The config; empty if the default file doesn't exist.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
UsageError: If an explicit ``CALLAI_CONFIG`` file is missing, or the file is unreadable,
|
|
91
|
+
not TOML, or has unknown keys or invalid values. The message names the file.
|
|
92
|
+
"""
|
|
93
|
+
path, explicit = config_path(environ, home)
|
|
94
|
+
if not os.path.exists(path):
|
|
95
|
+
if explicit:
|
|
96
|
+
raise UsageError(f"CALLAI_CONFIG points at {path}, which doesn't exist")
|
|
97
|
+
return Config()
|
|
98
|
+
try:
|
|
99
|
+
with open(path, "rb") as fh:
|
|
100
|
+
data = tomllib.load(fh)
|
|
101
|
+
except tomllib.TOMLDecodeError as exc:
|
|
102
|
+
raise UsageError(f"{path}: invalid TOML ({exc})") from None
|
|
103
|
+
except OSError as exc:
|
|
104
|
+
raise UsageError(f"{path}: cannot read config ({exc.strerror or exc})") from None
|
|
105
|
+
try:
|
|
106
|
+
return _parse(data)
|
|
107
|
+
except _Invalid as exc:
|
|
108
|
+
raise UsageError(f"{path}: {exc}") from None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve_model(config: Config, harness_name: str, model: str | None) -> str | None:
|
|
112
|
+
"""Turn a model alias into the harness's own model name; anything else passes through.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
config: The config holding ``[aliases]``.
|
|
116
|
+
harness_name: Harness the model is for.
|
|
117
|
+
model: Model or alias, or ``None``.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The model to pass to the harness.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
UsageError: If ``model`` is an alias with no entry for this harness.
|
|
124
|
+
"""
|
|
125
|
+
if model is None or model not in config.aliases:
|
|
126
|
+
return model
|
|
127
|
+
try:
|
|
128
|
+
return config.aliases[model][harness_name]
|
|
129
|
+
except KeyError:
|
|
130
|
+
raise UsageError(f"model alias {model!r} has no entry for {harness_name}") from None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# --- validation ---------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse(data: dict[str, Any]) -> Config:
|
|
137
|
+
"""Validate a decoded config file.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
data: Decoded TOML.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The config.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
_Invalid: On unknown keys or invalid values.
|
|
147
|
+
"""
|
|
148
|
+
_check_keys(data, _TOP_KEYS, "")
|
|
149
|
+
default = data.get("default_harness")
|
|
150
|
+
if default is not None and default not in HARNESS_NAMES:
|
|
151
|
+
raise _Invalid(f"default_harness: unknown harness {default!r}; choose from {', '.join(HARNESS_NAMES)}")
|
|
152
|
+
model = _opt_str(data, "model", "")
|
|
153
|
+
timeout = data.get("timeout")
|
|
154
|
+
if timeout is not None and (
|
|
155
|
+
isinstance(timeout, bool) or not isinstance(timeout, int | float) or not math.isfinite(timeout) or timeout <= 0
|
|
156
|
+
):
|
|
157
|
+
raise _Invalid(f"timeout must be a positive number of seconds, got {timeout!r}")
|
|
158
|
+
return Config(
|
|
159
|
+
default_harness=default,
|
|
160
|
+
model=model,
|
|
161
|
+
timeout=timeout,
|
|
162
|
+
harnesses=_parse_harnesses(_table(data, "harnesses")),
|
|
163
|
+
aliases=_parse_aliases(_table(data, "aliases")),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _parse_harnesses(raw: dict[str, Any]) -> dict[str, HarnessConfig]:
|
|
168
|
+
"""Validate ``[harnesses.<name>]`` tables.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
raw: The ``harnesses`` table.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Settings by harness name.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
_Invalid: On an unknown harness, unknown key or invalid value.
|
|
178
|
+
"""
|
|
179
|
+
out = {}
|
|
180
|
+
for name, table in raw.items():
|
|
181
|
+
where = f"[harnesses.{name}]"
|
|
182
|
+
if name not in HARNESS_NAMES:
|
|
183
|
+
raise _Invalid(f"{where}: unknown harness; choose from {', '.join(HARNESS_NAMES)}")
|
|
184
|
+
if not isinstance(table, dict):
|
|
185
|
+
raise _Invalid(f"{where} must be a table")
|
|
186
|
+
_check_keys(table, _HARNESS_KEYS, where)
|
|
187
|
+
binary = _opt_str(table, "binary", where)
|
|
188
|
+
if binary == "":
|
|
189
|
+
raise _Invalid(f"{where}: binary must be a non-empty string")
|
|
190
|
+
extra = table.get("extra_args", [])
|
|
191
|
+
if not isinstance(extra, list) or not all(isinstance(a, str) for a in extra):
|
|
192
|
+
raise _Invalid(f"{where}: extra_args must be a list of strings")
|
|
193
|
+
out[name] = HarnessConfig(binary=binary, model=_opt_str(table, "model", where), extra_args=tuple(extra))
|
|
194
|
+
return out
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_aliases(raw: dict[str, Any]) -> dict[str, dict[str, str]]:
|
|
198
|
+
"""Validate ``[aliases]``: each alias maps harness names to model names.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
raw: The ``aliases`` table.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Alias -> {harness name -> model}.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
_Invalid: If an alias isn't a table of strings or names an unknown harness.
|
|
208
|
+
"""
|
|
209
|
+
out = {}
|
|
210
|
+
for alias, mapping in raw.items():
|
|
211
|
+
if not isinstance(mapping, dict) or not all(isinstance(m, str) for m in mapping.values()):
|
|
212
|
+
raise _Invalid(f"[aliases] {alias} must map harness names to models, e.g. {{ codex = 'o3' }}")
|
|
213
|
+
for name in mapping:
|
|
214
|
+
if name not in HARNESS_NAMES:
|
|
215
|
+
raise _Invalid(f"[aliases] {alias}: unknown harness {name!r}; choose from {', '.join(HARNESS_NAMES)}")
|
|
216
|
+
out[alias] = dict(mapping)
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _check_keys(table: dict[str, Any], allowed: set[str], where: str) -> None:
|
|
221
|
+
"""Refuse unknown keys, so a typo doesn't silently do nothing.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
table: A TOML table.
|
|
225
|
+
allowed: Keys it may have.
|
|
226
|
+
where: Table name for the message (``""`` for the top level).
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
_Invalid: On the first unknown key.
|
|
230
|
+
"""
|
|
231
|
+
for key in table:
|
|
232
|
+
if key not in allowed:
|
|
233
|
+
prefix = f"{where}: " if where else ""
|
|
234
|
+
raise _Invalid(f"{prefix}unknown key {key!r}; allowed: {', '.join(sorted(allowed))}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _table(data: dict[str, Any], key: str) -> dict[str, Any]:
|
|
238
|
+
"""An optional sub-table, ``{}`` if absent.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
data: The parent table.
|
|
242
|
+
key: Sub-table name.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
The sub-table.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
_Invalid: If the key holds something other than a table.
|
|
249
|
+
"""
|
|
250
|
+
value = data.get(key, {})
|
|
251
|
+
if not isinstance(value, dict):
|
|
252
|
+
raise _Invalid(f"{key} must be a table")
|
|
253
|
+
return value
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _opt_str(table: dict[str, Any], key: str, where: str) -> str | None:
|
|
257
|
+
"""An optional string value.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
table: The table.
|
|
261
|
+
key: The key.
|
|
262
|
+
where: Table name for the message.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The string, or ``None`` if absent.
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
_Invalid: If the value isn't a string.
|
|
269
|
+
"""
|
|
270
|
+
value = table.get(key)
|
|
271
|
+
if value is not None and not isinstance(value, str):
|
|
272
|
+
raise _Invalid(f"{where + ': ' if where else ''}{key} must be a string")
|
|
273
|
+
return value
|
callai/errors.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Exceptions raised by callai, each carrying the process exit code it maps to (DESIGN.md §8)."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CallaiError(Exception):
|
|
5
|
+
"""Base class for errors callai reports to the user instead of a traceback."""
|
|
6
|
+
|
|
7
|
+
exit_code = 1
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UsageError(CallaiError):
|
|
11
|
+
"""Invalid command line, configuration or request (exit code 2)."""
|
|
12
|
+
|
|
13
|
+
exit_code = 2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HarnessNotFound(CallaiError):
|
|
17
|
+
"""The harness executable is not on PATH (exit code 127, like a shell)."""
|
|
18
|
+
|
|
19
|
+
exit_code = 127
|
|
20
|
+
|
|
21
|
+
def __init__(self, binary: str, harness_name: str | None = None, install_url: str | None = None) -> None:
|
|
22
|
+
"""Build the message.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
binary: Executable that was looked up.
|
|
26
|
+
harness_name: The ``--name`` (harness) that needs it, if known.
|
|
27
|
+
install_url: Where to get it, if known.
|
|
28
|
+
"""
|
|
29
|
+
msg = f"harness binary {binary!r} not found on PATH"
|
|
30
|
+
if harness_name:
|
|
31
|
+
msg += f" (needed for --name {harness_name})"
|
|
32
|
+
if install_url:
|
|
33
|
+
msg += f"; install it: {install_url}"
|
|
34
|
+
super().__init__(msg)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NoHarnessInstalled(CallaiError):
|
|
38
|
+
"""``-n all`` found none of the supported harnesses on PATH (exit code 127)."""
|
|
39
|
+
|
|
40
|
+
exit_code = 127
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HarnessTimeout(CallaiError):
|
|
44
|
+
"""The harness ran longer than ``--timeout`` and was killed (exit code 124, like GNU timeout)."""
|
|
45
|
+
|
|
46
|
+
exit_code = 124
|
|
47
|
+
|
|
48
|
+
def __init__(self, binary: str, timeout: float) -> None:
|
|
49
|
+
"""Build the message.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
binary: Executable that was killed.
|
|
53
|
+
timeout: The limit, in seconds.
|
|
54
|
+
"""
|
|
55
|
+
super().__init__(f"{binary!r} timed out after {timeout:g}s and was killed")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Interrupted(CallaiError):
|
|
59
|
+
"""The user interrupted the run (exit code 130, like SIGINT in a shell)."""
|
|
60
|
+
|
|
61
|
+
exit_code = 130
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
"""Build the message."""
|
|
65
|
+
super().__init__("interrupted")
|
callai/fanout.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Fan-out: one prompt through several harnesses (DESIGN.md §3).
|
|
2
|
+
|
|
3
|
+
``-n a/b/c`` runs them at the same time, ``-n a,b,c`` one after another, ``-n all`` every installed
|
|
4
|
+
harness at the same time. Results come back in the order given, whatever order the runs finish in.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from . import runner
|
|
14
|
+
from .adapters import HARNESS_NAMES, Adapter
|
|
15
|
+
from .api import EventHandler, run_harness
|
|
16
|
+
from .errors import CallaiError, Interrupted, UsageError
|
|
17
|
+
from .request import Request
|
|
18
|
+
from .result import Completed, Result
|
|
19
|
+
|
|
20
|
+
SINGLE, PARALLEL, SEQUENTIAL, ALL = "single", "parallel", "sequential", "all"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def split_harness_names(value: str) -> tuple[list[str], str]:
|
|
24
|
+
"""Split a ``-n`` value into harness names and how to run them.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
value: ``"codex"``, ``"a/b/c"`` (parallel), ``"a,b,c"`` (one after another) or ``"all"``.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
``(names, mode)``; mode is ``SINGLE``, ``PARALLEL``, ``SEQUENTIAL`` or ``ALL``. Names aren't
|
|
31
|
+
checked against the registry here.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
UsageError: For mixed separators, empty or repeated names, or ``all`` inside a list.
|
|
35
|
+
"""
|
|
36
|
+
if "/" in value and "," in value:
|
|
37
|
+
raise UsageError(f"-n {value}: use '/' (parallel) or ',' (one after another), not both")
|
|
38
|
+
sep = "/" if "/" in value else ","
|
|
39
|
+
names = [n.strip() for n in value.split(sep)]
|
|
40
|
+
if any(not n for n in names):
|
|
41
|
+
raise UsageError(f"-n {value}: empty harness name")
|
|
42
|
+
if len(names) == 1:
|
|
43
|
+
return (list(HARNESS_NAMES), ALL) if names[0] == "all" else (names, SINGLE)
|
|
44
|
+
if "all" in names:
|
|
45
|
+
raise UsageError(f"-n {value}: 'all' can't be combined with other harnesses")
|
|
46
|
+
for name in names:
|
|
47
|
+
if names.count(name) > 1:
|
|
48
|
+
raise UsageError(f"-n {value}: {name} is listed twice")
|
|
49
|
+
return names, PARALLEL if sep == "/" else SEQUENTIAL
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Job:
|
|
54
|
+
"""One harness's part of a fan-out.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
adapter: The harness's adapter.
|
|
58
|
+
req: Its request.
|
|
59
|
+
argv: Its command line (binary already resolved from the config).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
adapter: Adapter
|
|
63
|
+
req: Request
|
|
64
|
+
argv: tuple[str, ...]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Outcome:
|
|
69
|
+
"""How one job ended.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
result: The normalised result (a timeout is a failed result, exit 124).
|
|
73
|
+
completed: The raw process outcome, or ``None`` if the run didn't finish.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
result: Result
|
|
77
|
+
completed: Completed | None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run_jobs(
|
|
81
|
+
jobs: Sequence[Job],
|
|
82
|
+
*,
|
|
83
|
+
concurrent: bool,
|
|
84
|
+
verbose: bool = False,
|
|
85
|
+
event_handler: Callable[[Job], EventHandler | None] = lambda job: None,
|
|
86
|
+
) -> list[Outcome]:
|
|
87
|
+
"""Run every job, at the same time or one after another; one failure doesn't stop the rest.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
jobs: The jobs, in output order.
|
|
91
|
+
concurrent: Run them all at once (one thread each) instead of in order.
|
|
92
|
+
verbose: Copy each harness's stderr to ours as it arrives.
|
|
93
|
+
event_handler: Gives each job its ``-o stream-json`` event callback, or ``None``.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
One outcome per job, in the same order.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
Interrupted: On Ctrl-C, after every running harness was stopped.
|
|
100
|
+
"""
|
|
101
|
+
if not concurrent:
|
|
102
|
+
return [_run_one(job, verbose, event_handler(job)) for job in jobs]
|
|
103
|
+
outcomes: list[Outcome | None] = [None] * len(jobs)
|
|
104
|
+
errors: list[BaseException] = []
|
|
105
|
+
|
|
106
|
+
def work(i: int) -> None:
|
|
107
|
+
"""Run job ``i`` in its thread, keeping its outcome or unexpected error.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
i: The job's index.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
outcomes[i] = _run_one(jobs[i], verbose, event_handler(jobs[i]))
|
|
114
|
+
except BaseException as exc: # noqa: BLE001 - re-raised in the main thread below
|
|
115
|
+
errors.append(exc)
|
|
116
|
+
|
|
117
|
+
threads = [threading.Thread(target=work, args=(i,), daemon=True) for i in range(len(jobs))]
|
|
118
|
+
for t in threads:
|
|
119
|
+
t.start()
|
|
120
|
+
try:
|
|
121
|
+
for t in threads:
|
|
122
|
+
while t.is_alive():
|
|
123
|
+
t.join(0.1) # short joins, so Ctrl-C reaches this thread
|
|
124
|
+
except KeyboardInterrupt:
|
|
125
|
+
runner.stop_all()
|
|
126
|
+
for t in threads:
|
|
127
|
+
t.join()
|
|
128
|
+
raise Interrupted() from None
|
|
129
|
+
if errors:
|
|
130
|
+
raise errors[0]
|
|
131
|
+
return [o for o in outcomes if o is not None]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _run_one(job: Job, verbose: bool, on_event: EventHandler | None) -> Outcome:
|
|
135
|
+
"""Run one job; a callai error (e.g. a timeout) becomes a failed result instead of stopping the others.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
job: The job.
|
|
139
|
+
verbose: Copy the harness's stderr to ours as it arrives.
|
|
140
|
+
on_event: ``-o stream-json`` event callback, or ``None``.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Its outcome.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
Interrupted: On Ctrl-C while it ran (one after another), so the rest don't start.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
completed = run_harness(job.adapter, job.req, job.argv, verbose=verbose, on_event=on_event)
|
|
150
|
+
except Interrupted:
|
|
151
|
+
raise
|
|
152
|
+
except CallaiError as exc:
|
|
153
|
+
result = Result(
|
|
154
|
+
harness_name=job.adapter.name,
|
|
155
|
+
model=job.req.model,
|
|
156
|
+
text=str(exc),
|
|
157
|
+
exit_code=exc.exit_code,
|
|
158
|
+
is_error=True,
|
|
159
|
+
argv=job.argv,
|
|
160
|
+
)
|
|
161
|
+
completed = None
|
|
162
|
+
else:
|
|
163
|
+
result = job.adapter.parse(completed, job.req)
|
|
164
|
+
if on_event is not None:
|
|
165
|
+
on_event({"type": "result", **result.to_dict()})
|
|
166
|
+
return Outcome(result, completed)
|
callai/py.typed
ADDED
|
File without changes
|
callai/request.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The harness-independent request every adapter turns into a command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .errors import UsageError
|
|
8
|
+
|
|
9
|
+
OUTPUT_FORMATS = ("text", "json", "stream-json", "raw")
|
|
10
|
+
PERMISSIONS = ("default", "read-only", "auto", "bypass")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Request:
|
|
15
|
+
"""One non-interactive prompt to run through a harness.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
harness_name: Harness name, e.g. ``"claude-code"`` (validated by the adapter registry).
|
|
19
|
+
prompt: Final prompt text, after stdin has been merged in.
|
|
20
|
+
model: Model passed to the harness verbatim, or ``None`` for its default.
|
|
21
|
+
output_format: How callai prints the result (not how the harness is invoked).
|
|
22
|
+
cwd: Working directory for the harness, or ``None`` for the current directory.
|
|
23
|
+
timeout: Seconds before the harness is killed, or ``None`` for no limit.
|
|
24
|
+
permission: Harness-neutral permission level (DESIGN.md §7).
|
|
25
|
+
system_prompt: Optional system prompt, where the harness supports one.
|
|
26
|
+
extra_args: Arguments passed through to the harness unchanged.
|
|
27
|
+
resume: Session id to continue (the ``session_id`` a previous run reported), or ``None``.
|
|
28
|
+
continue_session: Continue the harness's most recent session instead of starting a new one.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
harness_name: str
|
|
32
|
+
prompt: str
|
|
33
|
+
model: str | None = None
|
|
34
|
+
output_format: str = "text"
|
|
35
|
+
cwd: str | None = None
|
|
36
|
+
timeout: float | None = None
|
|
37
|
+
permission: str = "default"
|
|
38
|
+
system_prompt: str | None = None
|
|
39
|
+
extra_args: tuple[str, ...] = ()
|
|
40
|
+
resume: str | None = None
|
|
41
|
+
continue_session: bool = False
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
"""Validate fields and normalise ``extra_args`` to a tuple.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
UsageError: If any field has an invalid value.
|
|
48
|
+
"""
|
|
49
|
+
if not isinstance(self.prompt, str):
|
|
50
|
+
raise UsageError("prompt must be a string")
|
|
51
|
+
if self.output_format not in OUTPUT_FORMATS:
|
|
52
|
+
raise UsageError(f"invalid output format {self.output_format!r}; choose from {', '.join(OUTPUT_FORMATS)}")
|
|
53
|
+
if self.permission not in PERMISSIONS:
|
|
54
|
+
raise UsageError(f"invalid permission {self.permission!r}; choose from {', '.join(PERMISSIONS)}")
|
|
55
|
+
if self.timeout is not None and (
|
|
56
|
+
isinstance(self.timeout, bool) or not isinstance(self.timeout, int | float) or self.timeout <= 0
|
|
57
|
+
):
|
|
58
|
+
raise UsageError(f"timeout must be a positive number of seconds, got {self.timeout!r}")
|
|
59
|
+
if isinstance(self.extra_args, str) or not all(isinstance(a, str) for a in self.extra_args):
|
|
60
|
+
raise UsageError("extra_args must be a sequence of strings")
|
|
61
|
+
object.__setattr__(self, "extra_args", tuple(self.extra_args))
|
|
62
|
+
if self.resume is not None:
|
|
63
|
+
if not isinstance(self.resume, str):
|
|
64
|
+
raise UsageError("resume must be a session id string")
|
|
65
|
+
if not self.resume:
|
|
66
|
+
raise UsageError("--resume: empty session id")
|
|
67
|
+
if self.resume.startswith("-"):
|
|
68
|
+
raise UsageError("--resume: a session id can't start with '-' (the harness would read it as an option)")
|
|
69
|
+
if self.continue_session:
|
|
70
|
+
raise UsageError("use --resume or --continue, not both")
|
callai/result.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""What a harness run produced: the raw process outcome and the normalised result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import dataclasses
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Completed:
|
|
14
|
+
"""Raw outcome of running a harness process (filled in by the runner).
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
argv: The exact command line that ran.
|
|
18
|
+
exit_code: The harness's exit status.
|
|
19
|
+
stdout: Captured standard output.
|
|
20
|
+
stderr: Captured standard error.
|
|
21
|
+
duration_ms: Wall-clock run time in milliseconds, if measured.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
argv: Sequence[str]
|
|
25
|
+
exit_code: int
|
|
26
|
+
stdout: str
|
|
27
|
+
stderr: str
|
|
28
|
+
duration_ms: int | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Result:
|
|
33
|
+
"""Harness-independent result of one run; ``to_dict()`` is the ``-o json`` schema (DESIGN.md §5).
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
harness_name: Harness name.
|
|
37
|
+
text: The final assistant answer.
|
|
38
|
+
exit_code: The harness's exit status.
|
|
39
|
+
is_error: Whether the run failed, per the harness's own error signal.
|
|
40
|
+
model: Model the harness reports it used, else the requested one.
|
|
41
|
+
duration_ms: Wall-clock run time in milliseconds.
|
|
42
|
+
session_id: Harness session id, if reported.
|
|
43
|
+
usage: ``{"input_tokens": ..., "output_tokens": ...}``; input includes cached tokens.
|
|
44
|
+
cost_usd: Reported cost in US dollars.
|
|
45
|
+
argv: The command line that ran.
|
|
46
|
+
raw_stdout: Unparsed harness stdout (for ``-o raw``; not part of ``to_dict()``).
|
|
47
|
+
raw_stderr: Unparsed harness stderr (not part of ``to_dict()``).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
harness_name: str
|
|
51
|
+
text: str
|
|
52
|
+
exit_code: int
|
|
53
|
+
is_error: bool
|
|
54
|
+
model: str | None = None
|
|
55
|
+
duration_ms: int | None = None
|
|
56
|
+
session_id: str | None = None
|
|
57
|
+
usage: dict[str, int] | None = None
|
|
58
|
+
cost_usd: float | None = None
|
|
59
|
+
argv: tuple[str, ...] = ()
|
|
60
|
+
raw_stdout: str = field(default="", repr=False)
|
|
61
|
+
raw_stderr: str = field(default="", repr=False)
|
|
62
|
+
|
|
63
|
+
def __post_init__(self) -> None:
|
|
64
|
+
"""Normalise ``argv`` to a tuple."""
|
|
65
|
+
object.__setattr__(self, "argv", tuple(self.argv))
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict[str, Any]:
|
|
68
|
+
"""Return the JSON-serialisable result, in the stable key order of DESIGN.md §5.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
A new dict; mutating it does not affect this result.
|
|
72
|
+
"""
|
|
73
|
+
return {
|
|
74
|
+
"harness_name": self.harness_name,
|
|
75
|
+
"model": self.model,
|
|
76
|
+
"text": self.text,
|
|
77
|
+
"exit_code": self.exit_code,
|
|
78
|
+
"is_error": self.is_error,
|
|
79
|
+
"duration_ms": self.duration_ms,
|
|
80
|
+
"session_id": self.session_id,
|
|
81
|
+
"usage": copy.deepcopy(self.usage),
|
|
82
|
+
"cost_usd": self.cost_usd,
|
|
83
|
+
"argv": list(self.argv),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
def with_updates(self, **changes: Any) -> Result:
|
|
87
|
+
"""Return a copy with some fields replaced.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
**changes: Field names and their new values.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The updated copy.
|
|
94
|
+
"""
|
|
95
|
+
return dataclasses.replace(self, **changes)
|