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 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,5 @@
1
+ import sys
2
+
3
+ from cli_relay.cli import main
4
+
5
+ sys.exit(main())
@@ -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 []