coderouter-cli 2.7.7__py3-none-any.whl → 2.7.9__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.
- coderouter/adapters/agent_cli.py +481 -95
- coderouter/config/schemas.py +53 -8
- coderouter/guards/backend_health.py +63 -1
- coderouter/logging.py +60 -0
- coderouter/routing/fallback.py +37 -0
- {coderouter_cli-2.7.7.dist-info → coderouter_cli-2.7.9.dist-info}/METADATA +1 -1
- {coderouter_cli-2.7.7.dist-info → coderouter_cli-2.7.9.dist-info}/RECORD +10 -10
- {coderouter_cli-2.7.7.dist-info → coderouter_cli-2.7.9.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.7.7.dist-info → coderouter_cli-2.7.9.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.7.7.dist-info → coderouter_cli-2.7.9.dist-info}/licenses/LICENSE +0 -0
coderouter/adapters/agent_cli.py
CHANGED
|
@@ -18,24 +18,46 @@ CodeRouter merely performs the one conversion. Following the
|
|
|
18
18
|
``openai_compat`` precedent, a *single* adapter class fronts *multiple*
|
|
19
19
|
target agents, dispatched on the ``agent`` field.
|
|
20
20
|
|
|
21
|
-
Phase 1a
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
``
|
|
21
|
+
Implemented agents (Phase 1a + 1b + 1d)
|
|
22
|
+
========================================
|
|
23
|
+
|
|
24
|
+
Three targets are implemented here:
|
|
25
|
+
|
|
26
|
+
* ``claude`` (Claude Code CLI, Phase 1a) — the most stable CLI, the most
|
|
27
|
+
fine-grained safety controls, and the only one that emits
|
|
28
|
+
``total_cost_usd`` directly (making it the reference implementation for
|
|
29
|
+
the parser / cost path).
|
|
30
|
+
* ``codex`` (codex CLI, Phase 1b) — headless one-shot via ``codex exec
|
|
31
|
+
--json``, prompt on stdin (like claude). The CLI is pre-1.0 and emits
|
|
32
|
+
defensively-parsed JSONL (one event per line); usage comes from
|
|
33
|
+
``turn.completed`` events, normalized per design §5.1.6 with
|
|
34
|
+
``cached_input_tokens`` kept as a *subset* of ``input_tokens`` (unlike
|
|
35
|
+
claude, which folds its cache buckets into ``prompt_tokens``).
|
|
36
|
+
``--skip-git-repo-check`` and ``--ephemeral`` are always passed — the
|
|
37
|
+
isolated workdir is not a git repo, and the adapter never wants
|
|
38
|
+
session persistence.
|
|
39
|
+
* ``grok`` (grok CLI, Phase 1d) — headless one-shot via ``--prompt-file`` +
|
|
40
|
+
``--output-format json``. The CLI emits no token/cost figures, so usage
|
|
41
|
+
is reported as zeros (cost stays 0 unless the operator sets
|
|
42
|
+
``ProviderConfig.cost``, design §5.1.6). ``--no-memory`` is always passed
|
|
43
|
+
so a user-level cross-session memory setting cannot leak state between
|
|
44
|
+
requests.
|
|
45
|
+
|
|
46
|
+
The remaining agent (gemini) is declared in the config schema so
|
|
47
|
+
providers.yaml is forward-compatible, but constructing an adapter for it
|
|
48
|
+
raises a clear ``AdapterError`` until its phase (1c) lands.
|
|
31
49
|
|
|
32
50
|
Security (design §6, non-negotiable)
|
|
33
51
|
====================================
|
|
34
52
|
|
|
35
53
|
* **allowlist argv only** — the child is launched with
|
|
36
54
|
:func:`asyncio.create_subprocess_exec` and a list argv. ``shell=True`` is
|
|
37
|
-
never used; the prompt is
|
|
38
|
-
|
|
55
|
+
never used; the prompt is never subject to shell interpretation (claude
|
|
56
|
+
and codex read it from stdin — codex's argv carries a trailing ``-``
|
|
57
|
+
sentinel making that explicit; grok reads it from a private ``0600``
|
|
58
|
+
prompt file inside the resolved workdir — its ``-p`` requires the prompt
|
|
59
|
+
as an argv value, and argv would both hit Linux's ~128KiB
|
|
60
|
+
``MAX_ARG_STRLEN`` on huge prompts and leak the text into ``ps`` output).
|
|
39
61
|
* **default read-only** — ``allow_file_writes=False`` /
|
|
40
62
|
``sandbox_mode="read_only"`` are the defaults, mapped to claude's
|
|
41
63
|
``--permission-mode plan``. Writes require explicit opt-in and the sandbox
|
|
@@ -104,6 +126,28 @@ _CLAUDE_PERMISSION_MODE = {
|
|
|
104
126
|
"full_auto": "acceptEdits",
|
|
105
127
|
}
|
|
106
128
|
|
|
129
|
+
# sandbox_mode → grok sandbox/approval flags (design §5.4, grok CLI v0.2.93).
|
|
130
|
+
# grok's ``--sandbox`` takes a built-in profile VALUE (off|workspace|
|
|
131
|
+
# read-only|strict) and its ``--permission-mode`` shares Claude Code's value
|
|
132
|
+
# set; ``full_auto`` swaps the permission mode for ``--always-approve``
|
|
133
|
+
# (auto-approve all tool executions).
|
|
134
|
+
_GROK_SANDBOX_ARGS = {
|
|
135
|
+
"read_only": ["--sandbox", "read-only", "--permission-mode", "plan"],
|
|
136
|
+
"edit": ["--sandbox", "workspace", "--permission-mode", "acceptEdits"],
|
|
137
|
+
"full_auto": ["--sandbox", "workspace", "--always-approve"],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# sandbox_mode → codex ``-s/--sandbox`` value (design §5.4, codex-cli
|
|
141
|
+
# 0.144.1, verified via facts-codex.md). ``codex exec`` has NO approval
|
|
142
|
+
# flag at all (non-interactive, so there is no prompt to approve/skip), so
|
|
143
|
+
# ``full_auto`` collapses onto the same ``workspace-write`` value as
|
|
144
|
+
# ``edit`` — there is nothing further to "auto" beyond granting writes.
|
|
145
|
+
_CODEX_SANDBOX_ARGS = {
|
|
146
|
+
"read_only": ["-s", "read-only"],
|
|
147
|
+
"edit": ["-s", "workspace-write"],
|
|
148
|
+
"full_auto": ["-s", "workspace-write"],
|
|
149
|
+
}
|
|
150
|
+
|
|
107
151
|
|
|
108
152
|
def _chunk_text(text: str, size: int = _STREAM_CHUNK_CHARS) -> Iterator[str]:
|
|
109
153
|
"""Split ``text`` into ``size``-char pieces for the pseudo-stream."""
|
|
@@ -112,7 +156,7 @@ def _chunk_text(text: str, size: int = _STREAM_CHUNK_CHARS) -> Iterator[str]:
|
|
|
112
156
|
|
|
113
157
|
|
|
114
158
|
class AgentCliAdapter(BaseAdapter):
|
|
115
|
-
"""Invoke an external coding-agent CLI one-shot (
|
|
159
|
+
"""Invoke an external coding-agent CLI one-shot (claude + codex + grok).
|
|
116
160
|
|
|
117
161
|
The ``agent`` field selects the argv builder / output parser via the
|
|
118
162
|
dispatch tables built in :meth:`__init__`, mirroring how
|
|
@@ -122,9 +166,10 @@ class AgentCliAdapter(BaseAdapter):
|
|
|
122
166
|
def __init__(self, config: ProviderConfig) -> None:
|
|
123
167
|
"""Bind to a ``ProviderConfig`` and reject unsupported agents.
|
|
124
168
|
|
|
125
|
-
Constructing an adapter for an agent other than ``claude``
|
|
126
|
-
non-retryable :class:`AdapterError` —
|
|
127
|
-
|
|
169
|
+
Constructing an adapter for an agent other than ``claude`` /
|
|
170
|
+
``codex`` / ``grok`` raises a non-retryable :class:`AdapterError` —
|
|
171
|
+
the remaining target (gemini) is declared in the schema but not
|
|
172
|
+
implemented until its phase (design §9).
|
|
128
173
|
"""
|
|
129
174
|
super().__init__(config)
|
|
130
175
|
if config.agent_cli is None: # pragma: no cover - schema enforces this
|
|
@@ -134,21 +179,34 @@ class AgentCliAdapter(BaseAdapter):
|
|
|
134
179
|
retryable=False,
|
|
135
180
|
)
|
|
136
181
|
self.acfg: AgentCliConfig = config.agent_cli
|
|
137
|
-
if self.acfg.agent
|
|
182
|
+
if self.acfg.agent not in ("claude", "codex", "grok"):
|
|
138
183
|
raise AdapterError(
|
|
139
|
-
f"agent {self.acfg.agent!r} is not implemented
|
|
140
|
-
f"(claude
|
|
141
|
-
f"agent's phase.",
|
|
184
|
+
f"agent {self.acfg.agent!r} is not implemented yet "
|
|
185
|
+
f"(implemented: claude, codex, grok). Wait for Phase 1c.",
|
|
142
186
|
provider=config.name,
|
|
143
187
|
retryable=False,
|
|
144
188
|
)
|
|
145
|
-
# agent → argv builder / output parser dispatch tables.
|
|
146
|
-
#
|
|
147
|
-
|
|
148
|
-
self.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
189
|
+
# agent → argv builder / output parser dispatch tables. claude landed
|
|
190
|
+
# in Phase 1a, codex in Phase 1b, grok in Phase 1d; Phase 1c adds
|
|
191
|
+
# gemini.
|
|
192
|
+
self._builders = {
|
|
193
|
+
"claude": self._build_claude_argv,
|
|
194
|
+
"codex": self._build_codex_argv,
|
|
195
|
+
"grok": self._build_grok_argv,
|
|
196
|
+
}
|
|
197
|
+
self._parsers = {
|
|
198
|
+
"claude": self._parse_claude,
|
|
199
|
+
"codex": self._parse_codex,
|
|
200
|
+
"grok": self._parse_grok,
|
|
201
|
+
}
|
|
202
|
+
# Prompt delivery is per-agent: claude and codex read their prompt
|
|
203
|
+
# from stdin (10MB cap; codex's argv carries a trailing "-" sentinel
|
|
204
|
+
# making that explicit), which keeps argv free of the (potentially
|
|
205
|
+
# huge) prompt text. grok's ``-p`` REQUIRES the prompt as its argv
|
|
206
|
+
# value (piped stdin is only appended as extra context, verified on
|
|
207
|
+
# v0.2.93), so grok gets the prompt via ``--prompt-file`` instead —
|
|
208
|
+
# see ``_write_prompt_file`` for the rationale.
|
|
209
|
+
self._uses_stdin = self.acfg.agent in ("claude", "codex")
|
|
152
210
|
|
|
153
211
|
# ------------------------------------------------------------------
|
|
154
212
|
# BaseAdapter contract
|
|
@@ -177,8 +235,7 @@ class AgentCliAdapter(BaseAdapter):
|
|
|
177
235
|
depth = self._current_depth()
|
|
178
236
|
if depth >= self.acfg.agent_depth_limit:
|
|
179
237
|
raise AdapterError(
|
|
180
|
-
f"agent recursion depth {depth} >= limit "
|
|
181
|
-
f"{self.acfg.agent_depth_limit}",
|
|
238
|
+
f"agent recursion depth {depth} >= limit {self.acfg.agent_depth_limit}",
|
|
182
239
|
provider=self.name,
|
|
183
240
|
retryable=False,
|
|
184
241
|
)
|
|
@@ -195,68 +252,82 @@ class AgentCliAdapter(BaseAdapter):
|
|
|
195
252
|
|
|
196
253
|
prompt = self._render_prompt(request, overrides)
|
|
197
254
|
workdir = self._resolve_workdir()
|
|
198
|
-
argv = self._builders[self.acfg.agent](workdir)
|
|
199
|
-
# Resolve the executable to an absolute path so argv[0] is a concrete
|
|
200
|
-
# binary independent of the child's minimal PATH (design §6 allowlist).
|
|
201
|
-
resolved = shutil.which(argv[0])
|
|
202
|
-
if resolved is not None:
|
|
203
|
-
argv = [resolved, *argv[1:]]
|
|
204
|
-
env = self._build_child_env()
|
|
205
|
-
|
|
206
|
-
logger.info(
|
|
207
|
-
"agent-cli-exec",
|
|
208
|
-
extra={
|
|
209
|
-
"provider": self.name,
|
|
210
|
-
"agent": self.acfg.agent,
|
|
211
|
-
"argv0": argv[0],
|
|
212
|
-
"timeout_s": timeout,
|
|
213
|
-
},
|
|
214
|
-
)
|
|
215
255
|
|
|
256
|
+
# Agents that cannot take the prompt on stdin (grok) get it through a
|
|
257
|
+
# private temp file inside the workdir; it is ALWAYS removed in the
|
|
258
|
+
# ``finally`` below, including on timeout / exception paths.
|
|
259
|
+
prompt_file = None if self._uses_stdin else self._write_prompt_file(prompt, workdir)
|
|
216
260
|
try:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
261
|
+
argv = self._builders[self.acfg.agent](workdir, prompt_file)
|
|
262
|
+
# Resolve the executable to an absolute path so argv[0] is a
|
|
263
|
+
# concrete binary independent of the child's minimal PATH
|
|
264
|
+
# (design §6 allowlist).
|
|
265
|
+
resolved = shutil.which(argv[0])
|
|
266
|
+
if resolved is not None:
|
|
267
|
+
argv = [resolved, *argv[1:]]
|
|
268
|
+
env = self._build_child_env()
|
|
269
|
+
|
|
270
|
+
logger.info(
|
|
271
|
+
"agent-cli-exec",
|
|
272
|
+
extra={
|
|
273
|
+
"provider": self.name,
|
|
274
|
+
"agent": self.acfg.agent,
|
|
275
|
+
"argv0": argv[0],
|
|
276
|
+
"timeout_s": timeout,
|
|
277
|
+
},
|
|
227
278
|
)
|
|
228
|
-
except (FileNotFoundError, OSError) as exc:
|
|
229
|
-
raise AdapterError(
|
|
230
|
-
f"failed to launch {self.acfg.command!r}: {exc}",
|
|
231
|
-
provider=self.name,
|
|
232
|
-
retryable=False,
|
|
233
|
-
) from exc
|
|
234
279
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
)
|
|
280
|
+
try:
|
|
281
|
+
proc = await asyncio.create_subprocess_exec(
|
|
282
|
+
*argv,
|
|
283
|
+
stdin=asyncio.subprocess.PIPE,
|
|
284
|
+
stdout=asyncio.subprocess.PIPE,
|
|
285
|
+
stderr=asyncio.subprocess.PIPE,
|
|
286
|
+
cwd=workdir,
|
|
287
|
+
env=env,
|
|
288
|
+
# New session/process group so a timeout can SIGKILL the
|
|
289
|
+
# whole group (the CLI hangs its real LLM call off a
|
|
290
|
+
# child).
|
|
291
|
+
start_new_session=True,
|
|
292
|
+
)
|
|
293
|
+
except (FileNotFoundError, OSError) as exc:
|
|
294
|
+
raise AdapterError(
|
|
295
|
+
f"failed to launch {self.acfg.command!r}: {exc}",
|
|
296
|
+
provider=self.name,
|
|
297
|
+
retryable=False,
|
|
298
|
+
) from exc
|
|
249
299
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
300
|
+
stdin_bytes = prompt.encode("utf-8") if self._uses_stdin else None
|
|
301
|
+
try:
|
|
302
|
+
stdout, stderr = await asyncio.wait_for(
|
|
303
|
+
proc.communicate(input=stdin_bytes), timeout=timeout
|
|
304
|
+
)
|
|
305
|
+
except TimeoutError as exc:
|
|
306
|
+
self._kill_process_group(proc)
|
|
307
|
+
with contextlib.suppress(Exception):
|
|
308
|
+
await proc.wait()
|
|
309
|
+
raise AdapterError(
|
|
310
|
+
f"{self.acfg.agent} exec timed out after {timeout}s",
|
|
311
|
+
provider=self.name,
|
|
312
|
+
retryable=True,
|
|
313
|
+
) from exc
|
|
314
|
+
|
|
315
|
+
if proc.returncode != 0:
|
|
316
|
+
detail = self._error_detail(stdout, stderr)
|
|
317
|
+
raise AdapterError(
|
|
318
|
+
f"{self.acfg.agent} exited {proc.returncode}: {detail}",
|
|
319
|
+
provider=self.name,
|
|
320
|
+
status_code=None,
|
|
321
|
+
retryable=self._is_retryable_exit(proc.returncode),
|
|
322
|
+
)
|
|
258
323
|
|
|
259
|
-
|
|
324
|
+
final_text, usage, meta = self._parsers[self.acfg.agent](stdout, stderr)
|
|
325
|
+
finally:
|
|
326
|
+
if prompt_file is not None:
|
|
327
|
+
# Best-effort cleanup — the child may already have exited and
|
|
328
|
+
# a vanished file is not an error worth surfacing.
|
|
329
|
+
with contextlib.suppress(OSError):
|
|
330
|
+
os.unlink(prompt_file)
|
|
260
331
|
return self._to_chat_response(final_text, usage, meta)
|
|
261
332
|
|
|
262
333
|
async def stream(
|
|
@@ -281,9 +352,7 @@ class AgentCliAdapter(BaseAdapter):
|
|
|
281
352
|
id=resp.id,
|
|
282
353
|
created=resp.created,
|
|
283
354
|
model=resp.model,
|
|
284
|
-
choices=[
|
|
285
|
-
{"index": 0, "delta": {"content": piece}, "finish_reason": None}
|
|
286
|
-
],
|
|
355
|
+
choices=[{"index": 0, "delta": {"content": piece}, "finish_reason": None}],
|
|
287
356
|
)
|
|
288
357
|
yield StreamChunk(
|
|
289
358
|
id=resp.id,
|
|
@@ -297,7 +366,7 @@ class AgentCliAdapter(BaseAdapter):
|
|
|
297
366
|
# claude argv builder + output parser
|
|
298
367
|
# ------------------------------------------------------------------
|
|
299
368
|
|
|
300
|
-
def _build_claude_argv(self, workdir: str) -> list[str]:
|
|
369
|
+
def _build_claude_argv(self, workdir: str, prompt_file: str | None = None) -> list[str]:
|
|
301
370
|
"""Assemble the ``claude -p`` argv (design §5.1.5 / §5.4).
|
|
302
371
|
|
|
303
372
|
Shape::
|
|
@@ -305,10 +374,13 @@ class AgentCliAdapter(BaseAdapter):
|
|
|
305
374
|
claude -p --output-format json --model <m> --max-turns <n>
|
|
306
375
|
--permission-mode <plan|acceptEdits> --add-dir <workdir>
|
|
307
376
|
|
|
308
|
-
The prompt is fed on stdin (not argv), so it never appears here
|
|
309
|
-
|
|
310
|
-
|
|
377
|
+
The prompt is fed on stdin (not argv), so it never appears here and
|
|
378
|
+
``prompt_file`` is ignored (it exists only to keep the builder
|
|
379
|
+
signature uniform across agents). ``--bare`` is deliberately NOT
|
|
380
|
+
added — it would skip OAuth/keychain reads and break subscription
|
|
381
|
+
auth (design §5.3.4).
|
|
311
382
|
"""
|
|
383
|
+
del prompt_file # claude takes the prompt on stdin, not from a file.
|
|
312
384
|
model = self.acfg.model or self.config.model
|
|
313
385
|
argv = [self.acfg.command, "-p", "--output-format", "json", "--model", model]
|
|
314
386
|
if self.acfg.max_turns is not None:
|
|
@@ -418,10 +490,326 @@ class AgentCliAdapter(BaseAdapter):
|
|
|
418
490
|
usage["duration_ms"] = data["duration_ms"]
|
|
419
491
|
return usage
|
|
420
492
|
|
|
493
|
+
# ------------------------------------------------------------------
|
|
494
|
+
# codex argv builder + output parser (Phase 1b)
|
|
495
|
+
# ------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
def _build_codex_argv(self, workdir: str, prompt_file: str | None = None) -> list[str]:
|
|
498
|
+
"""Assemble the ``codex exec`` argv (design §5.1.5 / §5.4, verified
|
|
499
|
+
against codex-cli 0.144.1 — see ``_codex/facts-codex.md``).
|
|
500
|
+
|
|
501
|
+
Shape::
|
|
502
|
+
|
|
503
|
+
codex exec --json --skip-git-repo-check --ephemeral
|
|
504
|
+
-m <model> -C <workdir> -s <read-only|workspace-write> -
|
|
505
|
+
|
|
506
|
+
The prompt is fed on stdin (not argv), so it never appears here and
|
|
507
|
+
``prompt_file`` is ignored (it exists only to keep the builder
|
|
508
|
+
signature uniform across agents) — the trailing ``-`` makes the
|
|
509
|
+
stdin intent explicit to ``codex exec``, which otherwise treats a
|
|
510
|
+
bare invocation with no PROMPT arg the same way but reads more
|
|
511
|
+
ambiguously in a fixed argv list. ``--skip-git-repo-check`` is
|
|
512
|
+
ALWAYS passed because the isolated workdir is not a git repository
|
|
513
|
+
(without it the CLI exits 1). ``--ephemeral`` is ALWAYS passed so no
|
|
514
|
+
session state persists to disk, matching the adapter's stateless
|
|
515
|
+
one-shot ethos (the same rationale as grok's ``--no-memory``). codex
|
|
516
|
+
has no ``--max-turns`` equivalent, so ``AgentCliConfig.max_turns`` is
|
|
517
|
+
silently ignored here (documented in the schema).
|
|
518
|
+
"""
|
|
519
|
+
del prompt_file # codex takes the prompt on stdin, not from a file.
|
|
520
|
+
model = self.acfg.model or self.config.model
|
|
521
|
+
argv = [
|
|
522
|
+
self.acfg.command,
|
|
523
|
+
"exec",
|
|
524
|
+
"--json",
|
|
525
|
+
"--skip-git-repo-check",
|
|
526
|
+
"--ephemeral",
|
|
527
|
+
"-m",
|
|
528
|
+
model,
|
|
529
|
+
"-C",
|
|
530
|
+
workdir,
|
|
531
|
+
]
|
|
532
|
+
argv += self._codex_sandbox_args()
|
|
533
|
+
argv += ["-"]
|
|
534
|
+
return argv
|
|
535
|
+
|
|
536
|
+
def _codex_sandbox_args(self) -> list[str]:
|
|
537
|
+
"""Map ``sandbox_mode`` → codex ``-s/--sandbox`` flags, clamped.
|
|
538
|
+
|
|
539
|
+
Same clamp as claude/grok (design §5.4): when ``allow_file_writes``
|
|
540
|
+
is False the effective mode is forced to ``read_only`` regardless of
|
|
541
|
+
``sandbox_mode``, so writes always require the explicit opt-in.
|
|
542
|
+
``codex exec`` has no approval flag in 0.144.1 (non-interactive, so
|
|
543
|
+
there is nothing to approve), so ``full_auto`` maps onto the same
|
|
544
|
+
``workspace-write`` value as ``edit`` —
|
|
545
|
+
``--dangerously-bypass-approvals-and-sandbox`` is never used.
|
|
546
|
+
"""
|
|
547
|
+
mode = self.acfg.sandbox_mode if self.acfg.allow_file_writes else "read_only"
|
|
548
|
+
return list(_CODEX_SANDBOX_ARGS[mode])
|
|
549
|
+
|
|
550
|
+
def _parse_codex(
|
|
551
|
+
self, stdout: bytes, stderr: bytes
|
|
552
|
+
) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
|
553
|
+
"""Parse codex ``exec --json`` JSONL output (verified codex-cli
|
|
554
|
+
0.144.1, ``_codex/facts-codex.md``).
|
|
555
|
+
|
|
556
|
+
Real-run shape (one JSON object per line, newline-delimited)::
|
|
557
|
+
|
|
558
|
+
{"type":"thread.started","thread_id":"<uuid>"}
|
|
559
|
+
{"type":"turn.started"}
|
|
560
|
+
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"2"}}
|
|
561
|
+
{"type":"turn.completed","usage":{"input_tokens":13810,"cached_input_tokens":9984,"output_tokens":5,"reasoning_output_tokens":0}}
|
|
562
|
+
|
|
563
|
+
The CLI is pre-1.0 and its JSON schema is not frozen, so parsing is
|
|
564
|
+
deliberately defensive at every level: individual lines that fail to
|
|
565
|
+
parse (or parse to something other than a JSON object) are SKIPPED
|
|
566
|
+
rather than aborting the whole parse — stray non-JSON noise on
|
|
567
|
+
stdout should not sink an otherwise-valid answer. The final answer
|
|
568
|
+
is the LAST ``item.completed`` event whose ``item`` is an
|
|
569
|
+
``agent_message`` with a string ``text``. If a completed answer was
|
|
570
|
+
found, it is returned even when a later ``error`` / ``turn.failed``
|
|
571
|
+
event also appears (a completed answer beats a trailing error); if
|
|
572
|
+
no answer was found, an ``error`` / ``turn.failed`` event (or the
|
|
573
|
+
total absence of any agent_message) raises a retryable
|
|
574
|
+
:class:`AdapterError`.
|
|
575
|
+
"""
|
|
576
|
+
text = stdout.decode("utf-8", "replace")
|
|
577
|
+
if not text.strip():
|
|
578
|
+
raise AdapterError(
|
|
579
|
+
"codex produced no stdout to parse",
|
|
580
|
+
provider=self.name,
|
|
581
|
+
retryable=True,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
final_text: str | None = None
|
|
585
|
+
thread_id: str | None = None
|
|
586
|
+
failure_event: dict[str, Any] | None = None
|
|
587
|
+
prompt_tokens = 0
|
|
588
|
+
completion_tokens = 0
|
|
589
|
+
cached_tokens = 0
|
|
590
|
+
reasoning_tokens = 0
|
|
591
|
+
|
|
592
|
+
for line in text.splitlines():
|
|
593
|
+
line = line.strip()
|
|
594
|
+
if not line:
|
|
595
|
+
continue
|
|
596
|
+
try:
|
|
597
|
+
event = json.loads(line)
|
|
598
|
+
except json.JSONDecodeError:
|
|
599
|
+
# Defensive against stray non-JSON noise (progress text that
|
|
600
|
+
# leaked onto stdout, partial writes, etc.) — skip the line.
|
|
601
|
+
continue
|
|
602
|
+
if not isinstance(event, dict):
|
|
603
|
+
continue
|
|
604
|
+
|
|
605
|
+
etype = event.get("type")
|
|
606
|
+
if etype == "thread.started":
|
|
607
|
+
tid = event.get("thread_id")
|
|
608
|
+
if isinstance(tid, str):
|
|
609
|
+
thread_id = tid
|
|
610
|
+
elif etype == "item.completed":
|
|
611
|
+
item = event.get("item")
|
|
612
|
+
if (
|
|
613
|
+
isinstance(item, dict)
|
|
614
|
+
and item.get("type") == "agent_message"
|
|
615
|
+
and isinstance(item.get("text"), str)
|
|
616
|
+
):
|
|
617
|
+
final_text = item["text"]
|
|
618
|
+
elif etype == "turn.completed":
|
|
619
|
+
usage = event.get("usage")
|
|
620
|
+
usage = usage if isinstance(usage, dict) else {}
|
|
621
|
+
|
|
622
|
+
def _int(key: str, _usage: dict[str, Any] = usage) -> int:
|
|
623
|
+
value = _usage.get(key)
|
|
624
|
+
return int(value) if isinstance(value, (int, float)) else 0
|
|
625
|
+
|
|
626
|
+
prompt_tokens += _int("input_tokens")
|
|
627
|
+
completion_tokens += _int("output_tokens")
|
|
628
|
+
cached_tokens += _int("cached_input_tokens")
|
|
629
|
+
reasoning_tokens += _int("reasoning_output_tokens")
|
|
630
|
+
elif etype in ("error", "turn.failed"):
|
|
631
|
+
failure_event = event
|
|
632
|
+
|
|
633
|
+
if final_text is None:
|
|
634
|
+
if failure_event is not None:
|
|
635
|
+
raise AdapterError(
|
|
636
|
+
f"codex reported {failure_event.get('type')}: {failure_event!r}"[:500],
|
|
637
|
+
provider=self.name,
|
|
638
|
+
retryable=True,
|
|
639
|
+
)
|
|
640
|
+
raise AdapterError(
|
|
641
|
+
"codex JSONL output contained no agent_message",
|
|
642
|
+
provider=self.name,
|
|
643
|
+
retryable=True,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
# cached_input_tokens is a SUBSET of input_tokens (not additive) —
|
|
647
|
+
# verified sample: input 13810 ⊇ cached 9984 — so it is preserved
|
|
648
|
+
# under prompt_tokens_details rather than folded into prompt_tokens
|
|
649
|
+
# (this differs from claude's normalization, design §5.1.6).
|
|
650
|
+
usage_out: dict[str, Any] = {
|
|
651
|
+
"prompt_tokens": prompt_tokens,
|
|
652
|
+
"completion_tokens": completion_tokens,
|
|
653
|
+
"total_tokens": prompt_tokens + completion_tokens,
|
|
654
|
+
}
|
|
655
|
+
if cached_tokens > 0:
|
|
656
|
+
usage_out["prompt_tokens_details"] = {"cached_tokens": cached_tokens}
|
|
657
|
+
if reasoning_tokens > 0:
|
|
658
|
+
usage_out["completion_tokens_details"] = {"reasoning_tokens": reasoning_tokens}
|
|
659
|
+
|
|
660
|
+
meta: dict[str, Any] = {}
|
|
661
|
+
if thread_id is not None:
|
|
662
|
+
meta["coderouter_session_id"] = thread_id
|
|
663
|
+
return final_text, usage_out, meta
|
|
664
|
+
|
|
665
|
+
# ------------------------------------------------------------------
|
|
666
|
+
# grok argv builder + output parser (Phase 1d)
|
|
667
|
+
# ------------------------------------------------------------------
|
|
668
|
+
|
|
669
|
+
def _build_grok_argv(self, workdir: str, prompt_file: str | None = None) -> list[str]:
|
|
670
|
+
"""Assemble the grok headless argv (design §5, grok CLI v0.2.93).
|
|
671
|
+
|
|
672
|
+
Shape::
|
|
673
|
+
|
|
674
|
+
grok --prompt-file <f> --output-format json -m <m> --cwd <w>
|
|
675
|
+
--max-turns <n> --no-memory --sandbox <profile>
|
|
676
|
+
[--permission-mode <mode> | --always-approve]
|
|
677
|
+
|
|
678
|
+
The prompt travels via ``--prompt-file`` (never argv / stdin): grok's
|
|
679
|
+
``-p`` requires the prompt as its argv value, and putting it there
|
|
680
|
+
would hit Linux's ~128KiB ``MAX_ARG_STRLEN`` on large prompts and
|
|
681
|
+
leak the text into ``ps`` output. ``--no-memory`` is deliberate: it
|
|
682
|
+
enforces the one-request-one-transformation statelessness even if
|
|
683
|
+
the user's grok config enables cross-session memory.
|
|
684
|
+
"""
|
|
685
|
+
if prompt_file is None: # pragma: no cover - generate() always supplies it
|
|
686
|
+
raise AdapterError(
|
|
687
|
+
"grok argv requires a prompt file",
|
|
688
|
+
provider=self.name,
|
|
689
|
+
retryable=False,
|
|
690
|
+
)
|
|
691
|
+
model = self.acfg.model or self.config.model
|
|
692
|
+
argv = [
|
|
693
|
+
self.acfg.command,
|
|
694
|
+
"--prompt-file",
|
|
695
|
+
prompt_file,
|
|
696
|
+
"--output-format",
|
|
697
|
+
"json",
|
|
698
|
+
"-m",
|
|
699
|
+
model,
|
|
700
|
+
"--cwd",
|
|
701
|
+
workdir,
|
|
702
|
+
]
|
|
703
|
+
if self.acfg.max_turns is not None:
|
|
704
|
+
argv += ["--max-turns", str(self.acfg.max_turns)]
|
|
705
|
+
argv += ["--no-memory"]
|
|
706
|
+
argv += self._grok_sandbox_args()
|
|
707
|
+
return argv
|
|
708
|
+
|
|
709
|
+
def _grok_sandbox_args(self) -> list[str]:
|
|
710
|
+
"""Map ``sandbox_mode`` → grok sandbox/approval flags, clamped.
|
|
711
|
+
|
|
712
|
+
Same clamp as claude (design §5.4): when ``allow_file_writes`` is
|
|
713
|
+
False the effective mode is forced to ``read_only`` regardless of
|
|
714
|
+
``sandbox_mode``, so writes always require the explicit opt-in.
|
|
715
|
+
"""
|
|
716
|
+
mode = self.acfg.sandbox_mode if self.acfg.allow_file_writes else "read_only"
|
|
717
|
+
return list(_GROK_SANDBOX_ARGS[mode])
|
|
718
|
+
|
|
719
|
+
def _parse_grok(
|
|
720
|
+
self, stdout: bytes, stderr: bytes
|
|
721
|
+
) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
|
722
|
+
"""Parse grok ``--output-format json`` output (verified v0.2.93).
|
|
723
|
+
|
|
724
|
+
Real-run shape::
|
|
725
|
+
|
|
726
|
+
{"text": "...", "stopReason": "EndTurn", "sessionId": "<uuid>",
|
|
727
|
+
"requestId": "<uuid>", "thought": "..."}
|
|
728
|
+
|
|
729
|
+
The CLI is early beta, so parsing is deliberately defensive: empty /
|
|
730
|
+
non-JSON / non-object stdout and a missing ``text`` field all raise
|
|
731
|
+
a retryable :class:`AdapterError` so the chain can fall through.
|
|
732
|
+
``thought`` is ignored — only the final ``text`` is the answer.
|
|
733
|
+
"""
|
|
734
|
+
text = stdout.decode("utf-8", "replace").strip()
|
|
735
|
+
if not text:
|
|
736
|
+
raise AdapterError(
|
|
737
|
+
"grok produced no stdout to parse",
|
|
738
|
+
provider=self.name,
|
|
739
|
+
retryable=True,
|
|
740
|
+
)
|
|
741
|
+
try:
|
|
742
|
+
data = json.loads(text)
|
|
743
|
+
except json.JSONDecodeError as exc:
|
|
744
|
+
raise AdapterError(
|
|
745
|
+
f"grok emitted non-JSON output: {exc}",
|
|
746
|
+
provider=self.name,
|
|
747
|
+
retryable=True,
|
|
748
|
+
) from exc
|
|
749
|
+
if not isinstance(data, dict):
|
|
750
|
+
raise AdapterError(
|
|
751
|
+
"grok JSON output was not an object",
|
|
752
|
+
provider=self.name,
|
|
753
|
+
retryable=True,
|
|
754
|
+
)
|
|
755
|
+
result = data.get("text")
|
|
756
|
+
if not isinstance(result, str):
|
|
757
|
+
raise AdapterError(
|
|
758
|
+
"grok JSON output missing string 'text' field",
|
|
759
|
+
provider=self.name,
|
|
760
|
+
retryable=True,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
# grok emits NO token usage / cost fields (verified), so usage is
|
|
764
|
+
# all-zeros; the cost dashboard shows 0 for this provider unless the
|
|
765
|
+
# operator sets ``ProviderConfig.cost`` rates (design §5.1.6).
|
|
766
|
+
usage: dict[str, Any] = {
|
|
767
|
+
"prompt_tokens": 0,
|
|
768
|
+
"completion_tokens": 0,
|
|
769
|
+
"total_tokens": 0,
|
|
770
|
+
}
|
|
771
|
+
meta: dict[str, Any] = {}
|
|
772
|
+
session_id = data.get("sessionId")
|
|
773
|
+
if isinstance(session_id, str):
|
|
774
|
+
meta["coderouter_session_id"] = session_id
|
|
775
|
+
return result, usage, meta
|
|
776
|
+
|
|
421
777
|
# ------------------------------------------------------------------
|
|
422
778
|
# helpers: prompt rendering, response shaping, env, workdir, kill
|
|
423
779
|
# ------------------------------------------------------------------
|
|
424
780
|
|
|
781
|
+
def _write_prompt_file(self, prompt: str, workdir: str) -> str:
|
|
782
|
+
"""Write the prompt to a private ``0600`` temp file in ``workdir``.
|
|
783
|
+
|
|
784
|
+
Rationale: grok's ``-p`` requires the prompt as an argv value, but a
|
|
785
|
+
huge prompt would hit Linux's ~128KiB ``MAX_ARG_STRLEN`` and argv
|
|
786
|
+
leaks into ``ps`` output; file delivery keeps argv small and private,
|
|
787
|
+
and ``0600`` + the isolated workdir bounds exposure. ``O_EXCL`` with
|
|
788
|
+
a uuid4 name makes creation race-free; the caller (``generate``)
|
|
789
|
+
deletes the file in a ``finally`` block on every path.
|
|
790
|
+
"""
|
|
791
|
+
path = os.path.join(workdir, f".coderouter-prompt-{uuid.uuid4().hex}.txt")
|
|
792
|
+
try:
|
|
793
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
794
|
+
except OSError as exc:
|
|
795
|
+
raise AdapterError(
|
|
796
|
+
f"failed to create prompt file in {workdir}: {exc}",
|
|
797
|
+
provider=self.name,
|
|
798
|
+
retryable=False,
|
|
799
|
+
) from exc
|
|
800
|
+
try:
|
|
801
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
802
|
+
handle.write(prompt)
|
|
803
|
+
except OSError as exc:
|
|
804
|
+
with contextlib.suppress(OSError):
|
|
805
|
+
os.unlink(path)
|
|
806
|
+
raise AdapterError(
|
|
807
|
+
f"failed to write prompt file {path}: {exc}",
|
|
808
|
+
provider=self.name,
|
|
809
|
+
retryable=False,
|
|
810
|
+
) from exc
|
|
811
|
+
return path
|
|
812
|
+
|
|
425
813
|
def _to_chat_response(
|
|
426
814
|
self, final_text: str, usage: dict[str, Any], meta: dict[str, Any]
|
|
427
815
|
) -> ChatResponse:
|
|
@@ -442,9 +830,7 @@ class AgentCliAdapter(BaseAdapter):
|
|
|
442
830
|
**meta,
|
|
443
831
|
)
|
|
444
832
|
|
|
445
|
-
def _render_prompt(
|
|
446
|
-
self, request: ChatRequest, overrides: ProviderCallOverrides | None
|
|
447
|
-
) -> str:
|
|
833
|
+
def _render_prompt(self, request: ChatRequest, overrides: ProviderCallOverrides | None) -> str:
|
|
448
834
|
"""Flatten the chat messages into a single role-tagged prompt string.
|
|
449
835
|
|
|
450
836
|
The profile-level ``append_system_prompt`` (if any) is prepended as a
|
coderouter/config/schemas.py
CHANGED
|
@@ -170,10 +170,25 @@ class AgentCliConfig(BaseModel):
|
|
|
170
170
|
``agent_cli`` sub-config drives the :class:`AgentCliAdapter`, which
|
|
171
171
|
invokes an external coding-agent CLI (codex / gemini / grok / claude)
|
|
172
172
|
in a single one-shot ``exec`` and returns the final answer as one
|
|
173
|
-
``prompt in → text out`` transformation.
|
|
174
|
-
``
|
|
175
|
-
|
|
176
|
-
rejects
|
|
173
|
+
``prompt in → text out`` transformation. ``claude`` (Claude Code CLI,
|
|
174
|
+
Phase 1a), ``codex`` (codex CLI, Phase 1b) and ``grok`` (grok CLI,
|
|
175
|
+
Phase 1d) are implemented; ``gemini`` is declared at the schema level
|
|
176
|
+
so configs are forward-compatible, but the adapter rejects it until its
|
|
177
|
+
phase (1c) lands.
|
|
178
|
+
|
|
179
|
+
Auth note (grok): the grok CLI uses OAuth credentials stored under
|
|
180
|
+
``~/.grok`` (``grok login``), which the adapter's HOME inheritance
|
|
181
|
+
already covers — no extra config needed. For CI / API-key setups, list
|
|
182
|
+
``GROK_CODE_XAI_API_KEY`` in ``passthrough_env`` (this is grok's key
|
|
183
|
+
env var — NOT ``XAI_API_KEY``).
|
|
184
|
+
|
|
185
|
+
Auth note (codex): the codex CLI uses a ChatGPT-plan OAuth login stored
|
|
186
|
+
under ``~/.codex`` (``codex login``), which the adapter's HOME
|
|
187
|
+
inheritance already covers — the credentials go stale after roughly 8
|
|
188
|
+
days and are auto-refreshed on use, so no extra config is needed for
|
|
189
|
+
interactive/subscription setups. For CI / API-key setups, list
|
|
190
|
+
``CODEX_API_KEY`` (exec-only) or ``OPENAI_API_KEY`` (general) in
|
|
191
|
+
``passthrough_env``.
|
|
177
192
|
|
|
178
193
|
Follows the ``extra="forbid"`` convention used across this module so a
|
|
179
194
|
typo'd key fails at config-load rather than being silently ignored.
|
|
@@ -183,7 +198,11 @@ class AgentCliConfig(BaseModel):
|
|
|
183
198
|
|
|
184
199
|
agent: Literal["codex", "gemini", "grok", "claude"] = Field(
|
|
185
200
|
...,
|
|
186
|
-
description=
|
|
201
|
+
description=(
|
|
202
|
+
"External coding-agent CLI to invoke. 'claude' (Phase 1a), "
|
|
203
|
+
"'codex' (Phase 1b) and 'grok' (Phase 1d) are implemented; "
|
|
204
|
+
"'gemini' (Phase 1c) pending."
|
|
205
|
+
),
|
|
187
206
|
)
|
|
188
207
|
command: str | None = Field(
|
|
189
208
|
default=None,
|
|
@@ -248,7 +267,9 @@ class AgentCliConfig(BaseModel):
|
|
|
248
267
|
"Allowlist of environment variable NAMES forwarded from the "
|
|
249
268
|
"parent process into the child. The child otherwise inherits "
|
|
250
269
|
"no parent environment (subscription-first auth policy). "
|
|
251
|
-
"``ANTHROPIC_API_KEY`` is NOT forwarded unless listed here."
|
|
270
|
+
"``ANTHROPIC_API_KEY`` is NOT forwarded unless listed here. "
|
|
271
|
+
"For grok in CI, list ``GROK_CODE_XAI_API_KEY`` here; OAuth "
|
|
272
|
+
"logins under ``~/.grok`` work without it (HOME is inherited)."
|
|
252
273
|
),
|
|
253
274
|
)
|
|
254
275
|
agent_depth_limit: int = Field(
|
|
@@ -627,7 +648,7 @@ class FallbackChain(BaseModel):
|
|
|
627
648
|
# Distinct from the v1.9-C ``adaptive`` gradient (continuous
|
|
628
649
|
# latency / error-rate buffer with debounce) which handles the
|
|
629
650
|
# "slow but alive" case; L5 handles the "hard crash" case.
|
|
630
|
-
backend_health_action: Literal["off", "warn", "demote", "exclude"] = Field(
|
|
651
|
+
backend_health_action: Literal["off", "warn", "demote", "exclude", "skip"] = Field(
|
|
631
652
|
default="warn",
|
|
632
653
|
description=(
|
|
633
654
|
"v1.9-E (L5 phase 2): action when a provider transitions "
|
|
@@ -642,11 +663,35 @@ class FallbackChain(BaseModel):
|
|
|
642
663
|
"self-healing (restart helper if configured, recovery "
|
|
643
664
|
"probe with exponential backoff). On recovery, the "
|
|
644
665
|
"provider is automatically restored to its original "
|
|
645
|
-
"chain position. ``
|
|
666
|
+
"chain position. ``skip`` (v2.x) filters UNHEALTHY providers "
|
|
667
|
+
"out of the chain like ``exclude`` but with a self-contained "
|
|
668
|
+
"half-open circuit breaker — no self-healing orchestrator "
|
|
669
|
+
"required: every ``backend_health_half_open_s`` window one "
|
|
670
|
+
"trial request is let through, and a single success snaps the "
|
|
671
|
+
"provider back into rotation. If skipping would empty the "
|
|
672
|
+
"chain, the unfiltered chain is used as a last resort so a "
|
|
673
|
+
"uniformly-UNHEALTHY chain still attempts every provider "
|
|
674
|
+
"rather than 502-ing outright. ``off`` disables the monitor "
|
|
646
675
|
"entirely (zero observation overhead, identical to "
|
|
647
676
|
"v1.9.x behavior)."
|
|
648
677
|
),
|
|
649
678
|
)
|
|
679
|
+
backend_health_half_open_s: float = Field(
|
|
680
|
+
default=30.0,
|
|
681
|
+
ge=5.0,
|
|
682
|
+
le=600.0,
|
|
683
|
+
description=(
|
|
684
|
+
"v2.x: half-open interval (seconds) for the ``skip`` "
|
|
685
|
+
"backend-health action. While a provider is UNHEALTHY, at most "
|
|
686
|
+
"one trial request is let through per interval — the built-in "
|
|
687
|
+
"circuit breaker's half-open probe. A successful trial resets "
|
|
688
|
+
"the provider to HEALTHY immediately; a failed trial keeps it "
|
|
689
|
+
"skipped until the next interval elapses. Ignored for every "
|
|
690
|
+
"other ``backend_health_action``. Default 30 s balances quick "
|
|
691
|
+
"recovery against hammering a still-down backend; capped at "
|
|
692
|
+
"600 s (10 min)."
|
|
693
|
+
),
|
|
694
|
+
)
|
|
650
695
|
backend_health_threshold: int = Field(
|
|
651
696
|
default=3,
|
|
652
697
|
ge=2,
|
|
@@ -46,6 +46,7 @@ record can't observe a torn state.
|
|
|
46
46
|
from __future__ import annotations
|
|
47
47
|
|
|
48
48
|
import threading
|
|
49
|
+
import time
|
|
49
50
|
from dataclasses import dataclass
|
|
50
51
|
from typing import Literal
|
|
51
52
|
|
|
@@ -84,6 +85,15 @@ class HealthTransition:
|
|
|
84
85
|
class _ProviderHealth:
|
|
85
86
|
state: HealthState = "HEALTHY"
|
|
86
87
|
consecutive_failures: int = 0
|
|
88
|
+
# v2.x (``skip`` action): monotonic timestamps driving the built-in
|
|
89
|
+
# half-open trial. ``unhealthy_since`` is stamped when the provider
|
|
90
|
+
# crosses into UNHEALTHY (diagnostic — "how long has it been down");
|
|
91
|
+
# ``last_half_open_at`` records the last time :meth:`should_skip` let a
|
|
92
|
+
# single trial request through. Both are cleared on any success. These
|
|
93
|
+
# are ``time.monotonic`` values, so they are intentionally NOT persisted
|
|
94
|
+
# across restarts (see :meth:`save_state`).
|
|
95
|
+
unhealthy_since: float | None = None
|
|
96
|
+
last_half_open_at: float | None = None
|
|
87
97
|
|
|
88
98
|
|
|
89
99
|
class BackendHealthMonitor:
|
|
@@ -140,6 +150,10 @@ class BackendHealthMonitor:
|
|
|
140
150
|
|
|
141
151
|
if success:
|
|
142
152
|
entry.consecutive_failures = 0
|
|
153
|
+
# v2.x: a single success snaps back to HEALTHY and clears the
|
|
154
|
+
# half-open bookkeeping so a future crash starts a fresh cycle.
|
|
155
|
+
entry.unhealthy_since = None
|
|
156
|
+
entry.last_half_open_at = None
|
|
143
157
|
if old_state != "HEALTHY":
|
|
144
158
|
entry.state = "HEALTHY"
|
|
145
159
|
return HealthTransition(
|
|
@@ -168,6 +182,12 @@ class BackendHealthMonitor:
|
|
|
168
182
|
return None
|
|
169
183
|
|
|
170
184
|
entry.state = new_state
|
|
185
|
+
if new_state == "UNHEALTHY":
|
|
186
|
+
# v2.x: record the moment we went down (diagnostic). The
|
|
187
|
+
# half-open trial itself is keyed off ``last_half_open_at``,
|
|
188
|
+
# which starts unset so the first resolve after this
|
|
189
|
+
# transition is allowed straight through as a trial.
|
|
190
|
+
entry.unhealthy_since = time.monotonic()
|
|
171
191
|
return HealthTransition(
|
|
172
192
|
provider=provider,
|
|
173
193
|
old_state=old_state,
|
|
@@ -200,12 +220,54 @@ class BackendHealthMonitor:
|
|
|
200
220
|
"""True iff ``provider``'s current state is ``UNHEALTHY``."""
|
|
201
221
|
return self.state_for(provider) == "UNHEALTHY"
|
|
202
222
|
|
|
223
|
+
def should_skip(self, provider: str, *, half_open_interval_s: float) -> bool:
|
|
224
|
+
"""v2.x: decide whether to skip ``provider`` at chain-resolve time.
|
|
225
|
+
|
|
226
|
+
Drives the ``skip`` backend-health action's built-in half-open
|
|
227
|
+
circuit breaker. Semantics:
|
|
228
|
+
|
|
229
|
+
* Not UNHEALTHY → ``False`` (never skip a HEALTHY/DEGRADED provider).
|
|
230
|
+
* UNHEALTHY, but either never trialed (``last_half_open_at is None``)
|
|
231
|
+
or the half-open interval has elapsed since the last trial → stamp
|
|
232
|
+
``last_half_open_at = now`` and return ``False`` — exactly one
|
|
233
|
+
request is let through as a probe. If it succeeds,
|
|
234
|
+
:meth:`record_attempt` snaps the provider back to HEALTHY; if it
|
|
235
|
+
fails, the counter stays UNHEALTHY and the freshly-stamped trial
|
|
236
|
+
time keeps the provider skipped until the interval elapses again.
|
|
237
|
+
* UNHEALTHY and inside the current interval → ``True`` (skip).
|
|
238
|
+
|
|
239
|
+
This is a mutating read: the ``last_half_open_at`` stamp is the
|
|
240
|
+
side effect that makes the half-open trial fire at most once per
|
|
241
|
+
``half_open_interval_s`` window. Held under the lock so concurrent
|
|
242
|
+
resolves can't both slip a trial through the same window.
|
|
243
|
+
"""
|
|
244
|
+
with self._lock:
|
|
245
|
+
entry = self._state.get(provider)
|
|
246
|
+
if entry is None or entry.state != "UNHEALTHY":
|
|
247
|
+
return False
|
|
248
|
+
now = time.monotonic()
|
|
249
|
+
last = entry.last_half_open_at
|
|
250
|
+
if last is None or (now - last) >= half_open_interval_s:
|
|
251
|
+
entry.last_half_open_at = now
|
|
252
|
+
return False
|
|
253
|
+
return True
|
|
254
|
+
|
|
203
255
|
# ------------------------------------------------------------------
|
|
204
256
|
# v2.0-K: Persistence
|
|
205
257
|
# ------------------------------------------------------------------
|
|
206
258
|
|
|
207
259
|
def save_state(self) -> dict[str, object]:
|
|
208
|
-
"""Export the current per-provider health state for persistence.
|
|
260
|
+
"""Export the current per-provider health state for persistence.
|
|
261
|
+
|
|
262
|
+
v2.x: only ``state`` and ``consecutive_failures`` are persisted.
|
|
263
|
+
``unhealthy_since`` / ``last_half_open_at`` are ``time.monotonic``
|
|
264
|
+
values whose zero point is per-process — persisting them across a
|
|
265
|
+
restart would be meaningless (and could compare against a future
|
|
266
|
+
process's clock). They are dropped here and default to ``None`` on
|
|
267
|
+
:meth:`load_state`, which simply means the first resolve after a
|
|
268
|
+
restart is treated as a fresh half-open trial — safe, since a
|
|
269
|
+
successful trial resets the provider anyway.
|
|
270
|
+
"""
|
|
209
271
|
with self._lock:
|
|
210
272
|
return {
|
|
211
273
|
name: {
|
coderouter/logging.py
CHANGED
|
@@ -621,6 +621,66 @@ def log_demote_unhealthy_provider(
|
|
|
621
621
|
logger.info("demote-unhealthy-provider", extra=payload)
|
|
622
622
|
|
|
623
623
|
|
|
624
|
+
# ---------------------------------------------------------------------------
|
|
625
|
+
# v2.x: ``skip`` backend-health action log shapes
|
|
626
|
+
# ---------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class SkipUnhealthyProviderPayload(TypedDict):
|
|
630
|
+
"""Structured shape of the ``skip-unhealthy-provider`` log record."""
|
|
631
|
+
|
|
632
|
+
provider: str
|
|
633
|
+
profile: str
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class ChainAllUnhealthyLastResortPayload(TypedDict):
|
|
637
|
+
"""Structured shape of the ``chain-all-unhealthy-last-resort`` log record."""
|
|
638
|
+
|
|
639
|
+
profile: str
|
|
640
|
+
providers: list[str]
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def log_skip_unhealthy_provider(
|
|
644
|
+
logger: logging.Logger,
|
|
645
|
+
*,
|
|
646
|
+
provider: str,
|
|
647
|
+
profile: str,
|
|
648
|
+
) -> None:
|
|
649
|
+
"""Emit a ``skip-unhealthy-provider`` info line.
|
|
650
|
+
|
|
651
|
+
Fires per chain-resolve when the ``skip`` action filters an UNHEALTHY
|
|
652
|
+
provider out of the chain (and the half-open trial is not currently
|
|
653
|
+
allowed through). Quiet when a half-open trial lets the provider through
|
|
654
|
+
— that resolve looks identical to a healthy one.
|
|
655
|
+
"""
|
|
656
|
+
payload: SkipUnhealthyProviderPayload = {
|
|
657
|
+
"provider": provider,
|
|
658
|
+
"profile": profile,
|
|
659
|
+
}
|
|
660
|
+
logger.info("skip-unhealthy-provider", extra=payload)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def log_chain_all_unhealthy_last_resort(
|
|
664
|
+
logger: logging.Logger,
|
|
665
|
+
*,
|
|
666
|
+
profile: str,
|
|
667
|
+
providers: list[str],
|
|
668
|
+
) -> None:
|
|
669
|
+
"""Emit a ``chain-all-unhealthy-last-resort`` info line.
|
|
670
|
+
|
|
671
|
+
Fires once per chain-resolve when the ``skip`` action would have filtered
|
|
672
|
+
out every provider — rather than fail the request outright, the engine
|
|
673
|
+
falls back to the unfiltered chain and attempts everyone. ``providers`` is
|
|
674
|
+
the last-resort chain (original order) so the operator can see exactly what
|
|
675
|
+
is being tried despite all members being UNHEALTHY.
|
|
676
|
+
"""
|
|
677
|
+
payload: ChainAllUnhealthyLastResortPayload = {
|
|
678
|
+
"profile": profile,
|
|
679
|
+
"providers": providers,
|
|
680
|
+
}
|
|
681
|
+
logger.info("chain-all-unhealthy-last-resort", extra=payload)
|
|
682
|
+
|
|
683
|
+
|
|
624
684
|
# ---------------------------------------------------------------------------
|
|
625
685
|
# v2.0-J: self-healing log shapes
|
|
626
686
|
# ---------------------------------------------------------------------------
|
coderouter/routing/fallback.py
CHANGED
|
@@ -72,6 +72,7 @@ from coderouter.logging import (
|
|
|
72
72
|
get_logger,
|
|
73
73
|
log_backend_health_changed,
|
|
74
74
|
log_cache_observed,
|
|
75
|
+
log_chain_all_unhealthy_last_resort,
|
|
75
76
|
log_chain_budget_exceeded,
|
|
76
77
|
log_chain_memory_pressure_blocked,
|
|
77
78
|
log_chain_paid_gate_blocked,
|
|
@@ -80,6 +81,7 @@ from coderouter.logging import (
|
|
|
80
81
|
log_memory_pressure_detected,
|
|
81
82
|
log_skip_budget_exceeded,
|
|
82
83
|
log_skip_memory_pressure,
|
|
84
|
+
log_skip_unhealthy_provider,
|
|
83
85
|
log_tool_loop_detected,
|
|
84
86
|
)
|
|
85
87
|
from coderouter.plugins.registry import PluginRegistry
|
|
@@ -2063,6 +2065,41 @@ class FallbackEngine:
|
|
|
2063
2065
|
if excluded:
|
|
2064
2066
|
adapters = [a for a in adapters if a.name not in excluded]
|
|
2065
2067
|
|
|
2068
|
+
# Pass 4c: v2.x ``skip`` action — self-contained half-open circuit
|
|
2069
|
+
# breaker. Unlike ``exclude`` (which relies on the self-healing
|
|
2070
|
+
# orchestrator's excluded set + background recovery probes), ``skip``
|
|
2071
|
+
# asks the backend-health monitor directly: an UNHEALTHY provider is
|
|
2072
|
+
# filtered out unless its half-open interval has elapsed, in which
|
|
2073
|
+
# case ``should_skip`` lets exactly one trial request through (a
|
|
2074
|
+
# success snaps it back to HEALTHY). If skipping would empty the
|
|
2075
|
+
# chain, fall back to the unfiltered list as a last resort — a
|
|
2076
|
+
# uniformly-UNHEALTHY chain still attempts everyone rather than
|
|
2077
|
+
# failing with NoProvidersAvailableError before trying anything.
|
|
2078
|
+
if chain.backend_health_action == "skip":
|
|
2079
|
+
kept: list[BaseAdapter] = []
|
|
2080
|
+
for adapter in adapters:
|
|
2081
|
+
if self._backend_health.should_skip(
|
|
2082
|
+
adapter.name,
|
|
2083
|
+
half_open_interval_s=chain.backend_health_half_open_s,
|
|
2084
|
+
):
|
|
2085
|
+
log_skip_unhealthy_provider(
|
|
2086
|
+
logger,
|
|
2087
|
+
provider=adapter.name,
|
|
2088
|
+
profile=chosen,
|
|
2089
|
+
)
|
|
2090
|
+
else:
|
|
2091
|
+
kept.append(adapter)
|
|
2092
|
+
if kept:
|
|
2093
|
+
adapters = kept
|
|
2094
|
+
elif adapters:
|
|
2095
|
+
# Everyone was skipped — last-resort: try the unfiltered
|
|
2096
|
+
# chain rather than 502 without attempting a single provider.
|
|
2097
|
+
log_chain_all_unhealthy_last_resort(
|
|
2098
|
+
logger,
|
|
2099
|
+
profile=chosen,
|
|
2100
|
+
providers=[a.name for a in adapters],
|
|
2101
|
+
)
|
|
2102
|
+
|
|
2066
2103
|
return adapters
|
|
2067
2104
|
|
|
2068
2105
|
def _resolve_anthropic_chain(self, request: AnthropicRequest) -> list[tuple[BaseAdapter, bool]]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coderouter-cli
|
|
3
|
-
Version: 2.7.
|
|
3
|
+
Version: 2.7.9
|
|
4
4
|
Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
|
|
5
5
|
Project-URL: Homepage, https://github.com/zephel01/CodeRouter
|
|
6
6
|
Project-URL: Repository, https://github.com/zephel01/CodeRouter
|
|
@@ -11,12 +11,12 @@ coderouter/gguf_introspect.py,sha256=KeE00CfbYRa4gSTrzuwC6zVuHi20IzLC4nUQa98BEXI
|
|
|
11
11
|
coderouter/hardware.py,sha256=gn3_9qbVcGRR81yKMn1lJE_8-YDRau0LxIH_M-f7pxE,8356
|
|
12
12
|
coderouter/language_tax.py,sha256=LTbE3tIfoJuV2O3T0NixRKhzq_dEOTUuPEerJv2q9uk,9360
|
|
13
13
|
coderouter/launcher_speculative.py,sha256=kWaHNmhzBsbWuM_lRGDlbWkgJ1suuxBWQNTKhJOC-yg,12644
|
|
14
|
-
coderouter/logging.py,sha256=
|
|
14
|
+
coderouter/logging.py,sha256=5rfi4_R4aoeeUYDc642dgs61wA0aBX7wvubyB-k3Uyo,58074
|
|
15
15
|
coderouter/output_filters.py,sha256=0ry_rPiS_kC-FnHgaNVP6v7e6Al2djxzu9vBzZ8kEkE,25314
|
|
16
16
|
coderouter/token_estimation.py,sha256=iz22vZEEW2P7uKLB2pYvPNpIbZGbgXRO5MtfkS_-9Sk,7531
|
|
17
17
|
coderouter/token_estimation_accurate.py,sha256=GTfzrBVnvAGjeVzmzAeUdOYZvWZKLAxcxPpFiJGlzjk,4609
|
|
18
18
|
coderouter/adapters/__init__.py,sha256=7dIDSZ-FE_0iSqLSDc_lK1idRdLTKcM2hP9tCJipgPI,463
|
|
19
|
-
coderouter/adapters/agent_cli.py,sha256=
|
|
19
|
+
coderouter/adapters/agent_cli.py,sha256=ChTheoyDz9SE86Htr-ZOwSW-M7XrbuUfed1daAlvXBI,42154
|
|
20
20
|
coderouter/adapters/anthropic_native.py,sha256=CT9Hitun-c3z83YHT2zZXgwZpY3_t6eRnOfAmNud2aw,23610
|
|
21
21
|
coderouter/adapters/base.py,sha256=ykKMaiIVVGr23oFFXilK2iP_YhI8-CtsEiJyFXaC9bE,10396
|
|
22
22
|
coderouter/adapters/openai_compat.py,sha256=wcrd_UpV7N6ISpUyKvdFPGU-YjCrX5oMqGpjAFEeWfg,20356
|
|
@@ -25,12 +25,12 @@ coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g
|
|
|
25
25
|
coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
|
|
26
26
|
coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
|
|
27
27
|
coderouter/config/loader.py,sha256=FUEe8m4Tnmj_aul0vSctD8vKvNW-oLRoMRbTpSKqSmc,4077
|
|
28
|
-
coderouter/config/schemas.py,sha256
|
|
28
|
+
coderouter/config/schemas.py,sha256=fuqMuO9bafAhztUMtA6CwEBjSKNFyW-CIeen3Q0pRlg,87047
|
|
29
29
|
coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
|
|
30
30
|
coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
|
|
31
31
|
coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
|
|
32
32
|
coderouter/guards/_fingerprint.py,sha256=qsgNzIq9jv3FHrKL39nGJARp0cMenpN_QmWoJu87vU4,4835
|
|
33
|
-
coderouter/guards/backend_health.py,sha256=
|
|
33
|
+
coderouter/guards/backend_health.py,sha256=GLUVQ5BhU6i8Cxz_FrgIMs5AZ-YSKAW8veFWgvn9GFI,12565
|
|
34
34
|
coderouter/guards/context_budget.py,sha256=6u08JqBdRQkDz9NIQ-aISXo3w3L804oYFg027s04IwY,17202
|
|
35
35
|
coderouter/guards/continuous_probe.py,sha256=WIfS-apVMWXGv7bPBxxkJssePa95I4fT0mi4PNqK5iE,12181
|
|
36
36
|
coderouter/guards/drift_actions.py,sha256=A6pY5CR480Ct5rCVyjlBvjPFVc93eu_r5qcUpK9mWKc,3602
|
|
@@ -58,7 +58,7 @@ coderouter/routing/adaptive.py,sha256=G2o377twGSjbUh65wiIFx6klnpFGjsD_nI3oDvcBwh
|
|
|
58
58
|
coderouter/routing/auto_router.py,sha256=y4v0c8u5F9f98Vmhx1vRcKPiOgAvpzbFqr6TIh058h0,13341
|
|
59
59
|
coderouter/routing/budget.py,sha256=PblmVKJGs_BwNa9uDHAA8hmZ4XIVKv38mHAeU0V3OMs,8451
|
|
60
60
|
coderouter/routing/capability.py,sha256=rRQhzTgQYFTFDNYcSLKvq3xjdw6B1w7T-3jS7PTMI14,27864
|
|
61
|
-
coderouter/routing/fallback.py,sha256=
|
|
61
|
+
coderouter/routing/fallback.py,sha256=WNfDT9XDfKk584rQYHwSesq8-n3Axvp4j_e7qQ5ghuQ,144789
|
|
62
62
|
coderouter/state/__init__.py,sha256=XoGcPmmBQSiZWML2S0juSveQ78xfhtdeCliNnVyzu7E,1088
|
|
63
63
|
coderouter/state/audit_log.py,sha256=n7vuDsTfd5iuNUJhlRQPakJ2tMSsT9EfgPCzs1GEaac,10941
|
|
64
64
|
coderouter/state/replay.py,sha256=Z_YHKroTKZdrL8qObFxcoLOAQWWXZvXFdLfxzvBhEJg,11230
|
|
@@ -69,8 +69,8 @@ coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8I
|
|
|
69
69
|
coderouter/translation/anthropic.py,sha256=aZkcYH4x82b0x7efJgJb9RWn9Hbyc9pEOthXe4vjUdU,11113
|
|
70
70
|
coderouter/translation/convert.py,sha256=VV4kpu4dvLmGBmJfQnCLY5ryy_AnQcV2NuwjqRtaR8Q,52633
|
|
71
71
|
coderouter/translation/tool_repair.py,sha256=DGOKArM53yfVbIfiAveoul1vGq9NEKmjagf43Qh4IkM,57630
|
|
72
|
-
coderouter_cli-2.7.
|
|
73
|
-
coderouter_cli-2.7.
|
|
74
|
-
coderouter_cli-2.7.
|
|
75
|
-
coderouter_cli-2.7.
|
|
76
|
-
coderouter_cli-2.7.
|
|
72
|
+
coderouter_cli-2.7.9.dist-info/METADATA,sha256=foZQpq3qpofluaktg5dWZBeLH9j7RUrD08n3wNS33p4,15546
|
|
73
|
+
coderouter_cli-2.7.9.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
74
|
+
coderouter_cli-2.7.9.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
|
|
75
|
+
coderouter_cli-2.7.9.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
|
|
76
|
+
coderouter_cli-2.7.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|