coderouter-cli 2.7.6__py3-none-any.whl → 2.7.8__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.
@@ -0,0 +1,780 @@
1
+ """External coding-agent CLI adapter (``kind="agent_cli"``).
2
+
3
+ This adapter invokes an external coding-agent CLI (Claude Code / Codex /
4
+ Gemini / Grok) as a single one-shot ``exec`` and returns the agent's final
5
+ answer as one ``prompt in → text out`` transformation. It is the in-core
6
+ implementation of the external-agents-adapter design
7
+ (``docs/designs/external-agents-adapter.md``).
8
+
9
+ Design in one paragraph
10
+ =======================
11
+
12
+ A coding-agent CLI is normally a stateful, multi-turn, filesystem-editing
13
+ control loop — at odds with CodeRouter's "one request = one stateless
14
+ transformation" ethos. Restricting it to a single non-interactive
15
+ one-shot ``exec`` collapses it back into a single transformation, which
16
+ keeps the ethos intact: orchestration stays on the *client* side, and
17
+ CodeRouter merely performs the one conversion. Following the
18
+ ``openai_compat`` precedent, a *single* adapter class fronts *multiple*
19
+ target agents, dispatched on the ``agent`` field.
20
+
21
+ Implemented agents (Phase 1a + 1d)
22
+ ==================================
23
+
24
+ Two 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
+ * ``grok`` (grok CLI, Phase 1d) — headless one-shot via ``--prompt-file`` +
31
+ ``--output-format json``. The CLI emits no token/cost figures, so usage
32
+ is reported as zeros (cost stays 0 unless the operator sets
33
+ ``ProviderConfig.cost``, design §5.1.6). ``--no-memory`` is always passed
34
+ so a user-level cross-session memory setting cannot leak state between
35
+ requests.
36
+
37
+ The remaining agents (codex / gemini) are declared in the config schema so
38
+ providers.yaml is forward-compatible, but constructing an adapter for them
39
+ raises a clear ``AdapterError`` until their phase (1b / 1c) lands.
40
+
41
+ Security (design §6, non-negotiable)
42
+ ====================================
43
+
44
+ * **allowlist argv only** — the child is launched with
45
+ :func:`asyncio.create_subprocess_exec` and a list argv. ``shell=True`` is
46
+ never used; the prompt is never subject to shell interpretation (claude
47
+ reads it from stdin; grok reads it from a private ``0600`` prompt file
48
+ inside the resolved workdir — its ``-p`` requires the prompt as an argv
49
+ value, and argv would both hit Linux's ~128KiB ``MAX_ARG_STRLEN`` on huge
50
+ prompts and leak the text into ``ps`` output).
51
+ * **default read-only** — ``allow_file_writes=False`` /
52
+ ``sandbox_mode="read_only"`` are the defaults, mapped to claude's
53
+ ``--permission-mode plan``. Writes require explicit opt-in and the sandbox
54
+ mapping is clamped to read-only whenever ``allow_file_writes`` is False.
55
+ * **workdir boundary** — the working directory is expanded, resolved to an
56
+ absolute path and created; a literal ``..`` escape is rejected.
57
+ * **timeout with process-group kill** — ``exec_timeout_s`` is enforced with
58
+ :func:`asyncio.wait_for`; on expiry the whole process *group* is
59
+ ``SIGKILL``ed (the CLI hangs a real LLM call off a child, so killing only
60
+ the parent would orphan it).
61
+ * **env allowlist** — the child does NOT inherit the parent environment. A
62
+ minimal env is built explicitly; ``ANTHROPIC_API_KEY`` is never forwarded
63
+ unless the operator lists it in ``passthrough_env`` (this prevents a
64
+ stray key from silently overriding subscription OAuth).
65
+ * **recursion cap** — ``CODEROUTER_AGENT_DEPTH`` is propagated (incremented)
66
+ into the child and refused at or above ``agent_depth_limit``.
67
+ """
68
+
69
+ from __future__ import annotations
70
+
71
+ import asyncio
72
+ import contextlib
73
+ import json
74
+ import os
75
+ import shutil
76
+ import signal
77
+ import time
78
+ import uuid
79
+ from collections.abc import AsyncIterator, Iterator
80
+ from pathlib import Path
81
+ from typing import Any
82
+
83
+ from coderouter.adapters.base import (
84
+ AdapterError,
85
+ BaseAdapter,
86
+ ChatRequest,
87
+ ChatResponse,
88
+ ProviderCallOverrides,
89
+ StreamChunk,
90
+ )
91
+ from coderouter.config.schemas import AgentCliConfig, ProviderConfig
92
+ from coderouter.logging import get_logger
93
+
94
+ logger = get_logger(__name__)
95
+
96
+ # Fixed, minimal PATH injected into the child (design §5.3.1). The adapter
97
+ # resolves the CLI executable to an absolute path itself (see ``generate``),
98
+ # so this PATH only governs any helper binaries the CLI spawns.
99
+ _SAFE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
100
+
101
+ # Environment variable that carries the recursion depth across nested agent
102
+ # invocations (design §5.5).
103
+ _DEPTH_ENV = "CODEROUTER_AGENT_DEPTH"
104
+
105
+ # How many trailing stderr bytes to attach to a non-zero-exit error message.
106
+ _MAX_STDERR_TAIL = 2000
107
+
108
+ # Coarse chunk size (characters) for the pseudo-stream splitter (design §5.1.4).
109
+ _STREAM_CHUNK_CHARS = 512
110
+
111
+ # sandbox_mode → claude ``--permission-mode`` value (design §5.4). ``edit``
112
+ # and ``full_auto`` both map to ``acceptEdits`` in Phase 1a.
113
+ _CLAUDE_PERMISSION_MODE = {
114
+ "read_only": "plan",
115
+ "edit": "acceptEdits",
116
+ "full_auto": "acceptEdits",
117
+ }
118
+
119
+ # sandbox_mode → grok sandbox/approval flags (design §5.4, grok CLI v0.2.93).
120
+ # grok's ``--sandbox`` takes a built-in profile VALUE (off|workspace|
121
+ # read-only|strict) and its ``--permission-mode`` shares Claude Code's value
122
+ # set; ``full_auto`` swaps the permission mode for ``--always-approve``
123
+ # (auto-approve all tool executions).
124
+ _GROK_SANDBOX_ARGS = {
125
+ "read_only": ["--sandbox", "read-only", "--permission-mode", "plan"],
126
+ "edit": ["--sandbox", "workspace", "--permission-mode", "acceptEdits"],
127
+ "full_auto": ["--sandbox", "workspace", "--always-approve"],
128
+ }
129
+
130
+
131
+ def _chunk_text(text: str, size: int = _STREAM_CHUNK_CHARS) -> Iterator[str]:
132
+ """Split ``text`` into ``size``-char pieces for the pseudo-stream."""
133
+ for start in range(0, len(text), size):
134
+ yield text[start : start + size]
135
+
136
+
137
+ class AgentCliAdapter(BaseAdapter):
138
+ """Invoke an external coding-agent CLI one-shot (claude + grok).
139
+
140
+ The ``agent`` field selects the argv builder / output parser via the
141
+ dispatch tables built in :meth:`__init__`, mirroring how
142
+ ``openai_compat`` fronts many HTTP backends from one class.
143
+ """
144
+
145
+ def __init__(self, config: ProviderConfig) -> None:
146
+ """Bind to a ``ProviderConfig`` and reject unsupported agents.
147
+
148
+ Constructing an adapter for an agent other than ``claude`` / ``grok``
149
+ raises a non-retryable :class:`AdapterError` — the other targets are
150
+ declared in the schema but not implemented until their phase
151
+ (design §9).
152
+ """
153
+ super().__init__(config)
154
+ if config.agent_cli is None: # pragma: no cover - schema enforces this
155
+ raise AdapterError(
156
+ "agent_cli provider is missing its agent_cli sub-config",
157
+ provider=config.name,
158
+ retryable=False,
159
+ )
160
+ self.acfg: AgentCliConfig = config.agent_cli
161
+ if self.acfg.agent not in ("claude", "grok"):
162
+ raise AdapterError(
163
+ f"agent {self.acfg.agent!r} is not implemented yet "
164
+ f"(implemented: claude, grok). Wait for Phase 1b/1c.",
165
+ provider=config.name,
166
+ retryable=False,
167
+ )
168
+ # agent → argv builder / output parser dispatch tables. claude landed
169
+ # in Phase 1a, grok in Phase 1d; Phase 1b/1c add codex / gemini.
170
+ self._builders = {
171
+ "claude": self._build_claude_argv,
172
+ "grok": self._build_grok_argv,
173
+ }
174
+ self._parsers = {
175
+ "claude": self._parse_claude,
176
+ "grok": self._parse_grok,
177
+ }
178
+ # Prompt delivery is per-agent: claude reads its print-mode prompt
179
+ # from stdin (10MB cap), which keeps argv free of the (potentially
180
+ # huge) prompt text. grok's ``-p`` REQUIRES the prompt as its argv
181
+ # value (piped stdin is only appended as extra context, verified on
182
+ # v0.2.93), so grok gets the prompt via ``--prompt-file`` instead —
183
+ # see ``_write_prompt_file`` for the rationale.
184
+ self._uses_stdin = self.acfg.agent == "claude"
185
+
186
+ # ------------------------------------------------------------------
187
+ # BaseAdapter contract
188
+ # ------------------------------------------------------------------
189
+
190
+ async def healthcheck(self) -> bool:
191
+ """Lightweight check: the CLI binary exists on PATH (design §5.1.2)."""
192
+ return shutil.which(self.acfg.command) is not None
193
+
194
+ async def generate(
195
+ self,
196
+ request: ChatRequest,
197
+ *,
198
+ overrides: ProviderCallOverrides | None = None,
199
+ ) -> ChatResponse:
200
+ """Run the CLI once and shape the final answer into a ChatResponse.
201
+
202
+ Raises :class:`AdapterError` on every failure path, with
203
+ ``retryable`` set so the fallback engine can decide whether to try
204
+ the next provider (transient failures) or stop (config / recursion
205
+ errors).
206
+ """
207
+ # Recursion guard (design §5.5): refuse when we are already nested at
208
+ # or beyond the configured depth. Non-retryable — retrying the same
209
+ # chain would recurse again.
210
+ depth = self._current_depth()
211
+ if depth >= self.acfg.agent_depth_limit:
212
+ raise AdapterError(
213
+ f"agent recursion depth {depth} >= limit {self.acfg.agent_depth_limit}",
214
+ provider=self.name,
215
+ retryable=False,
216
+ )
217
+
218
+ # exec_timeout_s is the base; a profile-level override wins when set.
219
+ # NOTE: the inherited ``effective_timeout`` falls back to
220
+ # ``ProviderConfig.timeout_s``, which is the wrong knob here, so we
221
+ # resolve against ``exec_timeout_s`` explicitly (design §5.1.3).
222
+ timeout = (
223
+ overrides.timeout_s
224
+ if overrides is not None and overrides.timeout_s is not None
225
+ else self.acfg.exec_timeout_s
226
+ )
227
+
228
+ prompt = self._render_prompt(request, overrides)
229
+ workdir = self._resolve_workdir()
230
+
231
+ # Agents that cannot take the prompt on stdin (grok) get it through a
232
+ # private temp file inside the workdir; it is ALWAYS removed in the
233
+ # ``finally`` below, including on timeout / exception paths.
234
+ prompt_file = None if self._uses_stdin else self._write_prompt_file(prompt, workdir)
235
+ try:
236
+ argv = self._builders[self.acfg.agent](workdir, prompt_file)
237
+ # Resolve the executable to an absolute path so argv[0] is a
238
+ # concrete binary independent of the child's minimal PATH
239
+ # (design §6 allowlist).
240
+ resolved = shutil.which(argv[0])
241
+ if resolved is not None:
242
+ argv = [resolved, *argv[1:]]
243
+ env = self._build_child_env()
244
+
245
+ logger.info(
246
+ "agent-cli-exec",
247
+ extra={
248
+ "provider": self.name,
249
+ "agent": self.acfg.agent,
250
+ "argv0": argv[0],
251
+ "timeout_s": timeout,
252
+ },
253
+ )
254
+
255
+ try:
256
+ proc = await asyncio.create_subprocess_exec(
257
+ *argv,
258
+ stdin=asyncio.subprocess.PIPE,
259
+ stdout=asyncio.subprocess.PIPE,
260
+ stderr=asyncio.subprocess.PIPE,
261
+ cwd=workdir,
262
+ env=env,
263
+ # New session/process group so a timeout can SIGKILL the
264
+ # whole group (the CLI hangs its real LLM call off a
265
+ # child).
266
+ start_new_session=True,
267
+ )
268
+ except (FileNotFoundError, OSError) as exc:
269
+ raise AdapterError(
270
+ f"failed to launch {self.acfg.command!r}: {exc}",
271
+ provider=self.name,
272
+ retryable=False,
273
+ ) from exc
274
+
275
+ stdin_bytes = prompt.encode("utf-8") if self._uses_stdin else None
276
+ try:
277
+ stdout, stderr = await asyncio.wait_for(
278
+ proc.communicate(input=stdin_bytes), timeout=timeout
279
+ )
280
+ except TimeoutError as exc:
281
+ self._kill_process_group(proc)
282
+ with contextlib.suppress(Exception):
283
+ await proc.wait()
284
+ raise AdapterError(
285
+ f"{self.acfg.agent} exec timed out after {timeout}s",
286
+ provider=self.name,
287
+ retryable=True,
288
+ ) from exc
289
+
290
+ if proc.returncode != 0:
291
+ detail = self._error_detail(stdout, stderr)
292
+ raise AdapterError(
293
+ f"{self.acfg.agent} exited {proc.returncode}: {detail}",
294
+ provider=self.name,
295
+ status_code=None,
296
+ retryable=self._is_retryable_exit(proc.returncode),
297
+ )
298
+
299
+ final_text, usage, meta = self._parsers[self.acfg.agent](stdout, stderr)
300
+ finally:
301
+ if prompt_file is not None:
302
+ # Best-effort cleanup — the child may already have exited and
303
+ # a vanished file is not an error worth surfacing.
304
+ with contextlib.suppress(OSError):
305
+ os.unlink(prompt_file)
306
+ return self._to_chat_response(final_text, usage, meta)
307
+
308
+ async def stream(
309
+ self,
310
+ request: ChatRequest,
311
+ *,
312
+ overrides: ProviderCallOverrides | None = None,
313
+ ) -> AsyncIterator[StreamChunk]:
314
+ """Pseudo-stream (design §5.1.4): run once, then chunk the answer.
315
+
316
+ No CLI in Phase 1a exposes a stable token stream, so the final text
317
+ from :meth:`generate` is split into content chunks followed by a
318
+ terminal ``finish_reason="stop"`` chunk carrying usage.
319
+ """
320
+ resp = await self.generate(request, overrides=overrides)
321
+ try:
322
+ content = resp.choices[0]["message"]["content"] or ""
323
+ except (IndexError, KeyError, TypeError): # pragma: no cover - defensive
324
+ content = ""
325
+ for piece in _chunk_text(content):
326
+ yield StreamChunk(
327
+ id=resp.id,
328
+ created=resp.created,
329
+ model=resp.model,
330
+ choices=[{"index": 0, "delta": {"content": piece}, "finish_reason": None}],
331
+ )
332
+ yield StreamChunk(
333
+ id=resp.id,
334
+ created=resp.created,
335
+ model=resp.model,
336
+ choices=[{"index": 0, "delta": {}, "finish_reason": "stop"}],
337
+ usage=resp.usage,
338
+ )
339
+
340
+ # ------------------------------------------------------------------
341
+ # claude argv builder + output parser
342
+ # ------------------------------------------------------------------
343
+
344
+ def _build_claude_argv(self, workdir: str, prompt_file: str | None = None) -> list[str]:
345
+ """Assemble the ``claude -p`` argv (design §5.1.5 / §5.4).
346
+
347
+ Shape::
348
+
349
+ claude -p --output-format json --model <m> --max-turns <n>
350
+ --permission-mode <plan|acceptEdits> --add-dir <workdir>
351
+
352
+ The prompt is fed on stdin (not argv), so it never appears here and
353
+ ``prompt_file`` is ignored (it exists only to keep the builder
354
+ signature uniform across agents). ``--bare`` is deliberately NOT
355
+ added — it would skip OAuth/keychain reads and break subscription
356
+ auth (design §5.3.4).
357
+ """
358
+ del prompt_file # claude takes the prompt on stdin, not from a file.
359
+ model = self.acfg.model or self.config.model
360
+ argv = [self.acfg.command, "-p", "--output-format", "json", "--model", model]
361
+ if self.acfg.max_turns is not None:
362
+ argv += ["--max-turns", str(self.acfg.max_turns)]
363
+ argv += ["--permission-mode", self._claude_permission_mode()]
364
+ argv += ["--add-dir", workdir]
365
+ return argv
366
+
367
+ def _claude_permission_mode(self) -> str:
368
+ """Map ``sandbox_mode`` → claude ``--permission-mode``, clamped.
369
+
370
+ When ``allow_file_writes`` is False the effective mode is clamped to
371
+ ``read_only`` regardless of ``sandbox_mode`` — defense in depth so an
372
+ ``edit`` / ``full_auto`` request cannot grant writes without the
373
+ explicit ``allow_file_writes`` opt-in (design §5.4).
374
+ """
375
+ mode = self.acfg.sandbox_mode if self.acfg.allow_file_writes else "read_only"
376
+ return _CLAUDE_PERMISSION_MODE[mode]
377
+
378
+ def _parse_claude(
379
+ self, stdout: bytes, stderr: bytes
380
+ ) -> tuple[str, dict[str, Any], dict[str, Any]]:
381
+ """Parse claude ``--output-format json`` output (design §5.1.6).
382
+
383
+ Returns ``(final_text, usage, meta)``. Parsing is deliberately
384
+ defensive: any missing/blank field or a reported ``is_error`` raises a
385
+ retryable :class:`AdapterError` so the chain can fall through.
386
+ """
387
+ text = stdout.decode("utf-8", "replace").strip()
388
+ if not text:
389
+ raise AdapterError(
390
+ "claude produced no stdout to parse",
391
+ provider=self.name,
392
+ retryable=True,
393
+ )
394
+ try:
395
+ data = json.loads(text)
396
+ except json.JSONDecodeError as exc:
397
+ raise AdapterError(
398
+ f"claude emitted non-JSON output: {exc}",
399
+ provider=self.name,
400
+ retryable=True,
401
+ ) from exc
402
+ if not isinstance(data, dict):
403
+ raise AdapterError(
404
+ "claude JSON output was not an object",
405
+ provider=self.name,
406
+ retryable=True,
407
+ )
408
+ if data.get("is_error"):
409
+ raise AdapterError(
410
+ f"claude reported is_error=true: {str(data.get('result'))[:500]!r}",
411
+ provider=self.name,
412
+ retryable=True,
413
+ )
414
+ result = data.get("result")
415
+ if not isinstance(result, str):
416
+ raise AdapterError(
417
+ "claude JSON output missing string 'result' field",
418
+ provider=self.name,
419
+ retryable=True,
420
+ )
421
+
422
+ usage = self._claude_usage(data)
423
+ meta: dict[str, Any] = {}
424
+ cost = data.get("total_cost_usd")
425
+ if isinstance(cost, (int, float)):
426
+ # claude is the only CLI that emits a dollar figure directly;
427
+ # surface it as response metadata for the cost dashboard.
428
+ meta["coderouter_cost_usd"] = float(cost)
429
+ session_id = data.get("session_id")
430
+ if isinstance(session_id, str):
431
+ meta["coderouter_session_id"] = session_id
432
+ return result, usage, meta
433
+
434
+ def _claude_usage(self, data: dict[str, Any]) -> dict[str, Any]:
435
+ """Normalize claude token usage into the OpenAI usage shape.
436
+
437
+ ``input_tokens`` plus the two cache buckets fold into
438
+ ``prompt_tokens``; ``cache_read_input_tokens`` is preserved under
439
+ ``prompt_tokens_details.cached_tokens``. ``num_turns`` / ``duration_ms``
440
+ ride along as extra keys (design §5.1.6).
441
+ """
442
+ raw = data.get("usage")
443
+ raw = raw if isinstance(raw, dict) else {}
444
+
445
+ def _int(key: str) -> int:
446
+ value = raw.get(key)
447
+ return int(value) if isinstance(value, (int, float)) else 0
448
+
449
+ input_tokens = _int("input_tokens")
450
+ output_tokens = _int("output_tokens")
451
+ cache_read = _int("cache_read_input_tokens")
452
+ cache_creation = _int("cache_creation_input_tokens")
453
+ prompt_tokens = input_tokens + cache_read + cache_creation
454
+
455
+ usage: dict[str, Any] = {
456
+ "prompt_tokens": prompt_tokens,
457
+ "completion_tokens": output_tokens,
458
+ "total_tokens": prompt_tokens + output_tokens,
459
+ }
460
+ if cache_read:
461
+ usage["prompt_tokens_details"] = {"cached_tokens": cache_read}
462
+ if isinstance(data.get("num_turns"), int):
463
+ usage["num_turns"] = data["num_turns"]
464
+ if isinstance(data.get("duration_ms"), (int, float)):
465
+ usage["duration_ms"] = data["duration_ms"]
466
+ return usage
467
+
468
+ # ------------------------------------------------------------------
469
+ # grok argv builder + output parser (Phase 1d)
470
+ # ------------------------------------------------------------------
471
+
472
+ def _build_grok_argv(self, workdir: str, prompt_file: str | None = None) -> list[str]:
473
+ """Assemble the grok headless argv (design §5, grok CLI v0.2.93).
474
+
475
+ Shape::
476
+
477
+ grok --prompt-file <f> --output-format json -m <m> --cwd <w>
478
+ --max-turns <n> --no-memory --sandbox <profile>
479
+ [--permission-mode <mode> | --always-approve]
480
+
481
+ The prompt travels via ``--prompt-file`` (never argv / stdin): grok's
482
+ ``-p`` requires the prompt as its argv value, and putting it there
483
+ would hit Linux's ~128KiB ``MAX_ARG_STRLEN`` on large prompts and
484
+ leak the text into ``ps`` output. ``--no-memory`` is deliberate: it
485
+ enforces the one-request-one-transformation statelessness even if
486
+ the user's grok config enables cross-session memory.
487
+ """
488
+ if prompt_file is None: # pragma: no cover - generate() always supplies it
489
+ raise AdapterError(
490
+ "grok argv requires a prompt file",
491
+ provider=self.name,
492
+ retryable=False,
493
+ )
494
+ model = self.acfg.model or self.config.model
495
+ argv = [
496
+ self.acfg.command,
497
+ "--prompt-file",
498
+ prompt_file,
499
+ "--output-format",
500
+ "json",
501
+ "-m",
502
+ model,
503
+ "--cwd",
504
+ workdir,
505
+ ]
506
+ if self.acfg.max_turns is not None:
507
+ argv += ["--max-turns", str(self.acfg.max_turns)]
508
+ argv += ["--no-memory"]
509
+ argv += self._grok_sandbox_args()
510
+ return argv
511
+
512
+ def _grok_sandbox_args(self) -> list[str]:
513
+ """Map ``sandbox_mode`` → grok sandbox/approval flags, clamped.
514
+
515
+ Same clamp as claude (design §5.4): when ``allow_file_writes`` is
516
+ False the effective mode is forced to ``read_only`` regardless of
517
+ ``sandbox_mode``, so writes always require the explicit opt-in.
518
+ """
519
+ mode = self.acfg.sandbox_mode if self.acfg.allow_file_writes else "read_only"
520
+ return list(_GROK_SANDBOX_ARGS[mode])
521
+
522
+ def _parse_grok(
523
+ self, stdout: bytes, stderr: bytes
524
+ ) -> tuple[str, dict[str, Any], dict[str, Any]]:
525
+ """Parse grok ``--output-format json`` output (verified v0.2.93).
526
+
527
+ Real-run shape::
528
+
529
+ {"text": "...", "stopReason": "EndTurn", "sessionId": "<uuid>",
530
+ "requestId": "<uuid>", "thought": "..."}
531
+
532
+ The CLI is early beta, so parsing is deliberately defensive: empty /
533
+ non-JSON / non-object stdout and a missing ``text`` field all raise
534
+ a retryable :class:`AdapterError` so the chain can fall through.
535
+ ``thought`` is ignored — only the final ``text`` is the answer.
536
+ """
537
+ text = stdout.decode("utf-8", "replace").strip()
538
+ if not text:
539
+ raise AdapterError(
540
+ "grok produced no stdout to parse",
541
+ provider=self.name,
542
+ retryable=True,
543
+ )
544
+ try:
545
+ data = json.loads(text)
546
+ except json.JSONDecodeError as exc:
547
+ raise AdapterError(
548
+ f"grok emitted non-JSON output: {exc}",
549
+ provider=self.name,
550
+ retryable=True,
551
+ ) from exc
552
+ if not isinstance(data, dict):
553
+ raise AdapterError(
554
+ "grok JSON output was not an object",
555
+ provider=self.name,
556
+ retryable=True,
557
+ )
558
+ result = data.get("text")
559
+ if not isinstance(result, str):
560
+ raise AdapterError(
561
+ "grok JSON output missing string 'text' field",
562
+ provider=self.name,
563
+ retryable=True,
564
+ )
565
+
566
+ # grok emits NO token usage / cost fields (verified), so usage is
567
+ # all-zeros; the cost dashboard shows 0 for this provider unless the
568
+ # operator sets ``ProviderConfig.cost`` rates (design §5.1.6).
569
+ usage: dict[str, Any] = {
570
+ "prompt_tokens": 0,
571
+ "completion_tokens": 0,
572
+ "total_tokens": 0,
573
+ }
574
+ meta: dict[str, Any] = {}
575
+ session_id = data.get("sessionId")
576
+ if isinstance(session_id, str):
577
+ meta["coderouter_session_id"] = session_id
578
+ return result, usage, meta
579
+
580
+ # ------------------------------------------------------------------
581
+ # helpers: prompt rendering, response shaping, env, workdir, kill
582
+ # ------------------------------------------------------------------
583
+
584
+ def _write_prompt_file(self, prompt: str, workdir: str) -> str:
585
+ """Write the prompt to a private ``0600`` temp file in ``workdir``.
586
+
587
+ Rationale: grok's ``-p`` requires the prompt as an argv value, but a
588
+ huge prompt would hit Linux's ~128KiB ``MAX_ARG_STRLEN`` and argv
589
+ leaks into ``ps`` output; file delivery keeps argv small and private,
590
+ and ``0600`` + the isolated workdir bounds exposure. ``O_EXCL`` with
591
+ a uuid4 name makes creation race-free; the caller (``generate``)
592
+ deletes the file in a ``finally`` block on every path.
593
+ """
594
+ path = os.path.join(workdir, f".coderouter-prompt-{uuid.uuid4().hex}.txt")
595
+ try:
596
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
597
+ except OSError as exc:
598
+ raise AdapterError(
599
+ f"failed to create prompt file in {workdir}: {exc}",
600
+ provider=self.name,
601
+ retryable=False,
602
+ ) from exc
603
+ try:
604
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
605
+ handle.write(prompt)
606
+ except OSError as exc:
607
+ with contextlib.suppress(OSError):
608
+ os.unlink(path)
609
+ raise AdapterError(
610
+ f"failed to write prompt file {path}: {exc}",
611
+ provider=self.name,
612
+ retryable=False,
613
+ ) from exc
614
+ return path
615
+
616
+ def _to_chat_response(
617
+ self, final_text: str, usage: dict[str, Any], meta: dict[str, Any]
618
+ ) -> ChatResponse:
619
+ """Wrap the agent's final text in an OpenAI ChatResponse."""
620
+ return ChatResponse(
621
+ id=f"chatcmpl-{uuid.uuid4().hex}",
622
+ created=int(time.time()),
623
+ model=self.config.model,
624
+ choices=[
625
+ {
626
+ "index": 0,
627
+ "message": {"role": "assistant", "content": final_text},
628
+ "finish_reason": "stop",
629
+ }
630
+ ],
631
+ usage=usage,
632
+ coderouter_provider=self.name,
633
+ **meta,
634
+ )
635
+
636
+ def _render_prompt(self, request: ChatRequest, overrides: ProviderCallOverrides | None) -> str:
637
+ """Flatten the chat messages into a single role-tagged prompt string.
638
+
639
+ The profile-level ``append_system_prompt`` (if any) is prepended as a
640
+ leading system block, matching the openai_compat directive semantics.
641
+ """
642
+ parts: list[str] = []
643
+ directive = self.effective_append_system_prompt(overrides)
644
+ if directive:
645
+ parts.append(f"[system]\n{directive}")
646
+ for message in request.messages:
647
+ text = self._message_text(message.content)
648
+ if text:
649
+ parts.append(f"[{message.role}]\n{text}")
650
+ return "\n\n".join(parts).strip()
651
+
652
+ @staticmethod
653
+ def _message_text(content: str | list[dict[str, Any]] | None) -> str:
654
+ """Extract plain text from a message's ``content`` (str or blocks)."""
655
+ if content is None:
656
+ return ""
657
+ if isinstance(content, str):
658
+ return content
659
+ chunks: list[str] = []
660
+ for part in content:
661
+ if isinstance(part, dict) and part.get("type") == "text":
662
+ text = part.get("text")
663
+ if isinstance(text, str):
664
+ chunks.append(text)
665
+ return "\n".join(chunks)
666
+
667
+ def _current_depth(self) -> int:
668
+ """Read the current recursion depth from the environment (0 default)."""
669
+ raw = os.environ.get(_DEPTH_ENV, "0")
670
+ try:
671
+ return int(raw)
672
+ except ValueError:
673
+ return 0
674
+
675
+ def _build_child_env(self) -> dict[str, str]:
676
+ """Build the minimal child environment (design §5.3).
677
+
678
+ The child does NOT inherit the parent environment. Only a fixed base
679
+ (PATH / NO_COLOR / TERM), the inherited HOME / USER / LOGNAME (for
680
+ credential discovery — on macOS the Claude Code CLI resolves its
681
+ Keychain entry via ``USER``; without it headless runs fail with
682
+ "Not logged in"), the incremented recursion depth, and the operator's
683
+ ``passthrough_env`` allowlist are injected. ``ANTHROPIC_API_KEY`` is
684
+ therefore excluded unless explicitly allowlisted.
685
+ """
686
+ parent = os.environ
687
+ env: dict[str, str] = {
688
+ "PATH": _SAFE_PATH,
689
+ "NO_COLOR": "1",
690
+ "TERM": "dumb",
691
+ _DEPTH_ENV: str(self._current_depth() + 1),
692
+ }
693
+ for name in ("HOME", "USER", "LOGNAME"):
694
+ value = parent.get(name)
695
+ if value:
696
+ env[name] = value
697
+ for name in self.acfg.passthrough_env:
698
+ value = parent.get(name)
699
+ if value is not None:
700
+ env[name] = value
701
+ return env
702
+
703
+ @staticmethod
704
+ def _error_detail(stdout: bytes, stderr: bytes) -> str:
705
+ """Extract the most useful error text from a failed CLI run.
706
+
707
+ The Claude Code CLI reports auth / API failures as an ``is_error:
708
+ true`` result JSON on **stdout** with exit code 1 (stderr stays
709
+ empty), so a stderr-only tail hides the actual cause (e.g. ``Not
710
+ logged in · Please run /login``). Preference order: the ``result``
711
+ field of an ``is_error`` stdout JSON → stderr tail → stdout tail.
712
+ """
713
+ if stdout:
714
+ with contextlib.suppress(ValueError, TypeError):
715
+ doc = json.loads(stdout)
716
+ if isinstance(doc, dict) and doc.get("is_error"):
717
+ result = doc.get("result")
718
+ if isinstance(result, str) and result.strip():
719
+ return result.strip()[:_MAX_STDERR_TAIL]
720
+ if stderr:
721
+ return repr(stderr[-_MAX_STDERR_TAIL:].decode("utf-8", "replace"))
722
+ if stdout:
723
+ return repr(stdout[-_MAX_STDERR_TAIL:].decode("utf-8", "replace"))
724
+ return "''"
725
+
726
+ def _resolve_workdir(self) -> str:
727
+ """Resolve + create the working directory, rejecting ``..`` escapes.
728
+
729
+ A configured ``workdir`` is ``~`` / env-var expanded and resolved to
730
+ an absolute path; a literal ``..`` component is rejected as an escape
731
+ attempt. When unset, a dedicated isolated directory
732
+ (``~/.coderouter/agents/<name>``) is used (design §6).
733
+ """
734
+ raw = self.acfg.workdir
735
+ if raw:
736
+ if ".." in Path(raw).parts:
737
+ raise AdapterError(
738
+ f"workdir {raw!r} must not contain '..' (escape attempt)",
739
+ provider=self.name,
740
+ retryable=False,
741
+ )
742
+ expanded = os.path.expanduser(os.path.expandvars(raw))
743
+ path = Path(expanded).resolve()
744
+ else:
745
+ path = (Path.home() / ".coderouter" / "agents" / self.name).resolve()
746
+ try:
747
+ path.mkdir(parents=True, exist_ok=True)
748
+ except OSError as exc:
749
+ raise AdapterError(
750
+ f"failed to prepare workdir {path}: {exc}",
751
+ provider=self.name,
752
+ retryable=False,
753
+ ) from exc
754
+ return str(path)
755
+
756
+ def _kill_process_group(self, proc: asyncio.subprocess.Process) -> None:
757
+ """SIGKILL the child's whole process group (best effort)."""
758
+ pid = proc.pid
759
+ try:
760
+ os.killpg(os.getpgid(pid), signal.SIGKILL)
761
+ except (ProcessLookupError, PermissionError, OSError):
762
+ # Group already gone / no permission — fall back to a direct kill.
763
+ with contextlib.suppress(ProcessLookupError, OSError):
764
+ proc.kill()
765
+
766
+ def _is_retryable_exit(self, returncode: int | None) -> bool:
767
+ """Whether a non-zero exit should let the chain fall through.
768
+
769
+ Phase 1a treats any non-zero exit as transient (rate-limit / OAuth
770
+ expiry / network), so the fallback engine advances to the next
771
+ provider rather than surfacing a terminal failure.
772
+ """
773
+ return True
774
+
775
+ # ``BaseAdapter`` (HTTP-oriented) lazily builds an httpx client; the agent
776
+ # adapter never touches it, but ``aclose`` on the inherited base is a safe
777
+ # no-op, so nothing extra is needed here.
778
+
779
+
780
+ __all__ = ["AgentCliAdapter"]
@@ -14,4 +14,10 @@ def build_adapter(provider: ProviderConfig) -> BaseAdapter:
14
14
  return OpenAICompatAdapter(provider)
15
15
  if provider.kind == "anthropic":
16
16
  return AnthropicAdapter(provider)
17
+ if provider.kind == "agent_cli":
18
+ # Imported lazily so the external-agent adapter (and its subprocess /
19
+ # os plumbing) is only pulled in when a config actually uses it.
20
+ from coderouter.adapters.agent_cli import AgentCliAdapter
21
+
22
+ return AgentCliAdapter(provider)
17
23
  raise ValueError(f"Unknown adapter kind: {provider.kind!r}")
@@ -163,6 +163,137 @@ class CostConfig(BaseModel):
163
163
  )
164
164
 
165
165
 
166
+ class AgentCliConfig(BaseModel):
167
+ """External coding-agent CLI settings for ``kind="agent_cli"`` providers.
168
+
169
+ Introduced by the external-agents-adapter design (Phase 1). One
170
+ ``agent_cli`` sub-config drives the :class:`AgentCliAdapter`, which
171
+ invokes an external coding-agent CLI (codex / gemini / grok / claude)
172
+ in a single one-shot ``exec`` and returns the final answer as one
173
+ ``prompt in → text out`` transformation. ``claude`` (Claude Code CLI,
174
+ Phase 1a) and ``grok`` (grok CLI, Phase 1d) are implemented;
175
+ codex / gemini are declared at the schema level so configs are
176
+ forward-compatible, but the adapter rejects them until their phase
177
+ 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
+ Follows the ``extra="forbid"`` convention used across this module so a
186
+ typo'd key fails at config-load rather than being silently ignored.
187
+ """
188
+
189
+ model_config = ConfigDict(extra="forbid")
190
+
191
+ agent: Literal["codex", "gemini", "grok", "claude"] = Field(
192
+ ...,
193
+ description=(
194
+ "External coding-agent CLI to invoke. 'claude' (Phase 1a) and "
195
+ "'grok' (Phase 1d) are implemented; 'codex' / 'gemini' pending."
196
+ ),
197
+ )
198
+ command: str | None = Field(
199
+ default=None,
200
+ description=(
201
+ "CLI executable name or absolute path (resolved via PATH). "
202
+ "When unset, defaults to the ``agent`` name."
203
+ ),
204
+ )
205
+ workdir: str | None = Field(
206
+ default=None,
207
+ description=(
208
+ "Working directory for the one-shot exec. ``~`` / env-var "
209
+ "expansion is applied. When unset, a dedicated isolated "
210
+ "directory (``~/.coderouter/agents/<name>``) is used."
211
+ ),
212
+ )
213
+ exec_timeout_s: float = Field(
214
+ default=600.0,
215
+ ge=1.0,
216
+ le=1800.0,
217
+ description=(
218
+ "Forced timeout (seconds) for the one-shot exec. Independent "
219
+ "of ``ProviderConfig.timeout_s`` — the CLI has no built-in "
220
+ "wall clock so this is enforced with an asyncio watchdog + "
221
+ "process-group SIGKILL."
222
+ ),
223
+ )
224
+ allow_file_writes: bool = Field(
225
+ default=False,
226
+ description=(
227
+ "Allow the agent to write to the filesystem. Default False "
228
+ "(read-only). When False the sandbox mapping is clamped to "
229
+ "read-only regardless of ``sandbox_mode`` (defense in depth)."
230
+ ),
231
+ )
232
+ sandbox_mode: Literal["read_only", "edit", "full_auto"] = Field(
233
+ default="read_only",
234
+ description=(
235
+ "Source mode mapped onto each CLI's sandbox / approval flags. "
236
+ "Default read_only maps to claude ``--permission-mode plan``."
237
+ ),
238
+ )
239
+ model: str | None = Field(
240
+ default=None,
241
+ description=(
242
+ "Model name passed to the CLI's ``--model``. When unset, "
243
+ "``ProviderConfig.model`` is used."
244
+ ),
245
+ )
246
+ max_turns: int | None = Field(
247
+ default=8,
248
+ ge=1,
249
+ le=50,
250
+ description=(
251
+ "Turn cap. Passed to the CLI's ``--max-turns`` where supported "
252
+ "(codex ignores it — the CLI has no such flag)."
253
+ ),
254
+ )
255
+ passthrough_env: list[str] = Field(
256
+ default_factory=list,
257
+ description=(
258
+ "Allowlist of environment variable NAMES forwarded from the "
259
+ "parent process into the child. The child otherwise inherits "
260
+ "no parent environment (subscription-first auth policy). "
261
+ "``ANTHROPIC_API_KEY`` is NOT forwarded unless listed here. "
262
+ "For grok in CI, list ``GROK_CODE_XAI_API_KEY`` here; OAuth "
263
+ "logins under ``~/.grok`` work without it (HOME is inherited)."
264
+ ),
265
+ )
266
+ agent_depth_limit: int = Field(
267
+ default=2,
268
+ ge=1,
269
+ le=4,
270
+ description=(
271
+ "Recursion nesting cap. ``CODEROUTER_AGENT_DEPTH`` is "
272
+ "propagated (incremented) into the child; ``generate()`` "
273
+ "refuses when the current depth is at or above this limit."
274
+ ),
275
+ )
276
+
277
+ @model_validator(mode="after")
278
+ def _resolve_and_check(self) -> AgentCliConfig:
279
+ """Default ``command`` to ``agent`` and reject contradictory sandbox.
280
+
281
+ Rule (design §5.2.1 #2): ``allow_file_writes=True`` together with
282
+ ``sandbox_mode="read_only"`` is contradictory — the operator asked
283
+ for writes while pinning a read-only sandbox. Fail fast at load,
284
+ matching the module's other cross-field validators.
285
+ """
286
+ if self.command is None:
287
+ self.command = self.agent
288
+ if self.allow_file_writes and self.sandbox_mode == "read_only":
289
+ raise ValueError(
290
+ "agent_cli: allow_file_writes=True conflicts with "
291
+ "sandbox_mode='read_only'. Set sandbox_mode to 'edit' or "
292
+ "'full_auto' to permit writes, or keep allow_file_writes=False."
293
+ )
294
+ return self
295
+
296
+
166
297
  class ProviderConfig(BaseModel):
167
298
  """A single provider entry from providers.yaml.
168
299
 
@@ -170,20 +301,26 @@ class ProviderConfig(BaseModel):
170
301
  - Local llama.cpp server: kind=openai_compat, base_url=http://localhost:8080/v1
171
302
  - OpenRouter free: kind=openai_compat, base_url=https://openrouter.ai/api/v1
172
303
  - (future) Anthropic: kind=anthropic, base_url=https://api.anthropic.com
304
+ - External agent CLI: kind=agent_cli, agent_cli={agent: claude, ...}
173
305
  """
174
306
 
175
307
  model_config = ConfigDict(extra="forbid")
176
308
 
177
309
  name: str = Field(..., description="Unique identifier used in profiles.yaml")
178
- kind: Literal["openai_compat", "anthropic"] = Field(
310
+ kind: Literal["openai_compat", "anthropic", "agent_cli"] = Field(
179
311
  default="openai_compat",
180
312
  description=(
181
313
  "Adapter type. 'openai_compat' covers llama.cpp / Ollama / "
182
314
  "OpenRouter / LM Studio / Together / Groq. 'anthropic' is the "
183
- "native Anthropic Messages API passthrough (v0.3.x)."
315
+ "native Anthropic Messages API passthrough (v0.3.x). 'agent_cli' "
316
+ "invokes an external coding-agent CLI one-shot (see AgentCliConfig)."
184
317
  ),
185
318
  )
186
- base_url: HttpUrl
319
+ # base_url is required for HTTP-backed adapters (openai_compat / anthropic)
320
+ # but meaningless for agent_cli (which shells out to a local CLI rather
321
+ # than calling a URL). It is optional at the field level and enforced per
322
+ # kind by ``_check_kind_requirements`` below.
323
+ base_url: HttpUrl | None = None
187
324
  model: str = Field(..., description="Upstream model id sent in the request body")
188
325
  api_key_env: str | None = Field(
189
326
  default=None,
@@ -243,6 +380,14 @@ class ProviderConfig(BaseModel):
243
380
 
244
381
  capabilities: Capabilities = Field(default_factory=Capabilities)
245
382
 
383
+ # kind="agent_cli": required external coding-agent CLI settings. Opt-in
384
+ # (default None) exactly like ``restart_command`` — only meaningful when
385
+ # ``kind == "agent_cli"``, and enforced by ``_check_kind_requirements``.
386
+ agent_cli: AgentCliConfig | None = Field(
387
+ default=None,
388
+ description="Required when kind='agent_cli': external agent CLI settings.",
389
+ )
390
+
246
391
  cost: CostConfig | None = Field(
247
392
  default=None,
248
393
  description=(
@@ -298,6 +443,32 @@ class ProviderConfig(BaseModel):
298
443
  validate_output_filters(self.output_filters)
299
444
  return self
300
445
 
446
+ @model_validator(mode="after")
447
+ def _check_kind_requirements(self) -> ProviderConfig:
448
+ """Enforce per-``kind`` field requirements (external-agents design §5.2.2).
449
+
450
+ - HTTP-backed adapters (``openai_compat`` / ``anthropic``) require a
451
+ ``base_url`` — they have nowhere to send the request otherwise.
452
+ ``base_url`` was relaxed to Optional so ``agent_cli`` providers can
453
+ omit it; this validator restores the required-ness for the HTTP kinds.
454
+ - ``agent_cli`` requires the ``agent_cli`` sub-config (the adapter has
455
+ no CLI to invoke without it).
456
+
457
+ Same fast-fail philosophy as ``_check_output_filters_known`` — a
458
+ misconfigured provider surfaces at config-load, not at first request.
459
+ """
460
+ if self.kind in ("openai_compat", "anthropic") and self.base_url is None:
461
+ raise ValueError(
462
+ f"provider {self.name!r}: base_url is required for "
463
+ f"kind={self.kind!r}."
464
+ )
465
+ if self.kind == "agent_cli" and self.agent_cli is None:
466
+ raise ValueError(
467
+ f"provider {self.name!r}: agent_cli sub-config is required "
468
+ f"for kind='agent_cli'."
469
+ )
470
+ return self
471
+
301
472
 
302
473
  class FallbackChain(BaseModel):
303
474
  """An ordered list of provider names to try in sequence.
@@ -468,7 +639,7 @@ class FallbackChain(BaseModel):
468
639
  # Distinct from the v1.9-C ``adaptive`` gradient (continuous
469
640
  # latency / error-rate buffer with debounce) which handles the
470
641
  # "slow but alive" case; L5 handles the "hard crash" case.
471
- backend_health_action: Literal["off", "warn", "demote", "exclude"] = Field(
642
+ backend_health_action: Literal["off", "warn", "demote", "exclude", "skip"] = Field(
472
643
  default="warn",
473
644
  description=(
474
645
  "v1.9-E (L5 phase 2): action when a provider transitions "
@@ -483,11 +654,35 @@ class FallbackChain(BaseModel):
483
654
  "self-healing (restart helper if configured, recovery "
484
655
  "probe with exponential backoff). On recovery, the "
485
656
  "provider is automatically restored to its original "
486
- "chain position. ``off`` disables the monitor "
657
+ "chain position. ``skip`` (v2.x) filters UNHEALTHY providers "
658
+ "out of the chain like ``exclude`` but with a self-contained "
659
+ "half-open circuit breaker — no self-healing orchestrator "
660
+ "required: every ``backend_health_half_open_s`` window one "
661
+ "trial request is let through, and a single success snaps the "
662
+ "provider back into rotation. If skipping would empty the "
663
+ "chain, the unfiltered chain is used as a last resort so a "
664
+ "uniformly-UNHEALTHY chain still attempts every provider "
665
+ "rather than 502-ing outright. ``off`` disables the monitor "
487
666
  "entirely (zero observation overhead, identical to "
488
667
  "v1.9.x behavior)."
489
668
  ),
490
669
  )
670
+ backend_health_half_open_s: float = Field(
671
+ default=30.0,
672
+ ge=5.0,
673
+ le=600.0,
674
+ description=(
675
+ "v2.x: half-open interval (seconds) for the ``skip`` "
676
+ "backend-health action. While a provider is UNHEALTHY, at most "
677
+ "one trial request is let through per interval — the built-in "
678
+ "circuit breaker's half-open probe. A successful trial resets "
679
+ "the provider to HEALTHY immediately; a failed trial keeps it "
680
+ "skipped until the next interval elapses. Ignored for every "
681
+ "other ``backend_health_action``. Default 30 s balances quick "
682
+ "recovery against hammering a still-down backend; capped at "
683
+ "600 s (10 min)."
684
+ ),
685
+ )
491
686
  backend_health_threshold: int = Field(
492
687
  default=3,
493
688
  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
  # ---------------------------------------------------------------------------
@@ -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.6
3
+ Version: 2.7.8
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,25 +11,26 @@ 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=91cveN8SCJEMNz9DGi6TrFSlc8cx0wdUkOdSLWLA-z8,56144
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=_-9DsSO-zQJI-bEldBhFgIBbAQd6kqMCqX7xNy8WqtQ,32992
19
20
  coderouter/adapters/anthropic_native.py,sha256=CT9Hitun-c3z83YHT2zZXgwZpY3_t6eRnOfAmNud2aw,23610
20
21
  coderouter/adapters/base.py,sha256=ykKMaiIVVGr23oFFXilK2iP_YhI8-CtsEiJyFXaC9bE,10396
21
22
  coderouter/adapters/openai_compat.py,sha256=wcrd_UpV7N6ISpUyKvdFPGU-YjCrX5oMqGpjAFEeWfg,20356
22
- coderouter/adapters/registry.py,sha256=Syt3eDljWZAK5mfiJGvUMKaZYAfCRScp7PvV6pYt7mc,683
23
+ coderouter/adapters/registry.py,sha256=A2LNhp70LOSNodM_iKO6EL-MBadB3kli-YPWRe1U7pQ,979
23
24
  coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g,274
24
25
  coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
25
26
  coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
26
27
  coderouter/config/loader.py,sha256=FUEe8m4Tnmj_aul0vSctD8vKvNW-oLRoMRbTpSKqSmc,4077
27
- coderouter/config/schemas.py,sha256=f46bpmSzZ_zJRhHbo9-inp-mAW86WZAg6hfSxgPaEcc,77741
28
+ coderouter/config/schemas.py,sha256=c5VJkcsuPHIKgTtcRQnXL-q52ollsWOISnDC8RuOX70,86528
28
29
  coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
29
30
  coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
30
31
  coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
31
32
  coderouter/guards/_fingerprint.py,sha256=qsgNzIq9jv3FHrKL39nGJARp0cMenpN_QmWoJu87vU4,4835
32
- coderouter/guards/backend_health.py,sha256=Xx5OpX1x7atxghmBNDVxtwGg62zQIOsk6FmrQV4ILa4,9113
33
+ coderouter/guards/backend_health.py,sha256=GLUVQ5BhU6i8Cxz_FrgIMs5AZ-YSKAW8veFWgvn9GFI,12565
33
34
  coderouter/guards/context_budget.py,sha256=6u08JqBdRQkDz9NIQ-aISXo3w3L804oYFg027s04IwY,17202
34
35
  coderouter/guards/continuous_probe.py,sha256=WIfS-apVMWXGv7bPBxxkJssePa95I4fT0mi4PNqK5iE,12181
35
36
  coderouter/guards/drift_actions.py,sha256=A6pY5CR480Ct5rCVyjlBvjPFVc93eu_r5qcUpK9mWKc,3602
@@ -57,7 +58,7 @@ coderouter/routing/adaptive.py,sha256=G2o377twGSjbUh65wiIFx6klnpFGjsD_nI3oDvcBwh
57
58
  coderouter/routing/auto_router.py,sha256=y4v0c8u5F9f98Vmhx1vRcKPiOgAvpzbFqr6TIh058h0,13341
58
59
  coderouter/routing/budget.py,sha256=PblmVKJGs_BwNa9uDHAA8hmZ4XIVKv38mHAeU0V3OMs,8451
59
60
  coderouter/routing/capability.py,sha256=rRQhzTgQYFTFDNYcSLKvq3xjdw6B1w7T-3jS7PTMI14,27864
60
- coderouter/routing/fallback.py,sha256=L5hwe76DRlmHTR39dXafi27T5zRrgLlgX9uINddcSGk,142983
61
+ coderouter/routing/fallback.py,sha256=WNfDT9XDfKk584rQYHwSesq8-n3Axvp4j_e7qQ5ghuQ,144789
61
62
  coderouter/state/__init__.py,sha256=XoGcPmmBQSiZWML2S0juSveQ78xfhtdeCliNnVyzu7E,1088
62
63
  coderouter/state/audit_log.py,sha256=n7vuDsTfd5iuNUJhlRQPakJ2tMSsT9EfgPCzs1GEaac,10941
63
64
  coderouter/state/replay.py,sha256=Z_YHKroTKZdrL8qObFxcoLOAQWWXZvXFdLfxzvBhEJg,11230
@@ -68,8 +69,8 @@ coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8I
68
69
  coderouter/translation/anthropic.py,sha256=aZkcYH4x82b0x7efJgJb9RWn9Hbyc9pEOthXe4vjUdU,11113
69
70
  coderouter/translation/convert.py,sha256=VV4kpu4dvLmGBmJfQnCLY5ryy_AnQcV2NuwjqRtaR8Q,52633
70
71
  coderouter/translation/tool_repair.py,sha256=DGOKArM53yfVbIfiAveoul1vGq9NEKmjagf43Qh4IkM,57630
71
- coderouter_cli-2.7.6.dist-info/METADATA,sha256=VNl2YqpfRQDN5Ww8ukg21mjL5JVvgAOtPj6HKTZKP6Y,15546
72
- coderouter_cli-2.7.6.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
73
- coderouter_cli-2.7.6.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
74
- coderouter_cli-2.7.6.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
75
- coderouter_cli-2.7.6.dist-info/RECORD,,
72
+ coderouter_cli-2.7.8.dist-info/METADATA,sha256=S3XO80V0pWn7ifsjuoMOEYcU0Cnru5WIADYIq9-PQb4,15546
73
+ coderouter_cli-2.7.8.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
74
+ coderouter_cli-2.7.8.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
75
+ coderouter_cli-2.7.8.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
76
+ coderouter_cli-2.7.8.dist-info/RECORD,,