coderouter-cli 2.7.7__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.
@@ -18,24 +18,36 @@ 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 scope
22
- ==============
23
-
24
- Only the ``claude`` (Claude Code CLI) target is implemented here — it is
25
- the most stable CLI, has the most fine-grained safety controls, and is the
26
- only one that emits ``total_cost_usd`` directly (making it the reference
27
- implementation for the parser / cost path). The other agents
28
- (codex / gemini / grok) are declared in the config schema so providers.yaml
29
- is forward-compatible, but constructing an adapter for them raises a clear
30
- ``AdapterError`` until their phase lands.
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.
31
40
 
32
41
  Security (design §6, non-negotiable)
33
42
  ====================================
34
43
 
35
44
  * **allowlist argv only** — the child is launched with
36
45
  :func:`asyncio.create_subprocess_exec` and a list argv. ``shell=True`` is
37
- never used; the prompt is fed on stdin, so it is never subject to shell
38
- interpretation.
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).
39
51
  * **default read-only** — ``allow_file_writes=False`` /
40
52
  ``sandbox_mode="read_only"`` are the defaults, mapped to claude's
41
53
  ``--permission-mode plan``. Writes require explicit opt-in and the sandbox
@@ -104,6 +116,17 @@ _CLAUDE_PERMISSION_MODE = {
104
116
  "full_auto": "acceptEdits",
105
117
  }
106
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
+
107
130
 
108
131
  def _chunk_text(text: str, size: int = _STREAM_CHUNK_CHARS) -> Iterator[str]:
109
132
  """Split ``text`` into ``size``-char pieces for the pseudo-stream."""
@@ -112,7 +135,7 @@ def _chunk_text(text: str, size: int = _STREAM_CHUNK_CHARS) -> Iterator[str]:
112
135
 
113
136
 
114
137
  class AgentCliAdapter(BaseAdapter):
115
- """Invoke an external coding-agent CLI one-shot (Phase 1a: claude only).
138
+ """Invoke an external coding-agent CLI one-shot (claude + grok).
116
139
 
117
140
  The ``agent`` field selects the argv builder / output parser via the
118
141
  dispatch tables built in :meth:`__init__`, mirroring how
@@ -122,9 +145,10 @@ class AgentCliAdapter(BaseAdapter):
122
145
  def __init__(self, config: ProviderConfig) -> None:
123
146
  """Bind to a ``ProviderConfig`` and reject unsupported agents.
124
147
 
125
- Constructing an adapter for an agent other than ``claude`` raises a
126
- non-retryable :class:`AdapterError` — the other targets are declared
127
- in the schema but not implemented until their phase (design §9).
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).
128
152
  """
129
153
  super().__init__(config)
130
154
  if config.agent_cli is None: # pragma: no cover - schema enforces this
@@ -134,21 +158,30 @@ class AgentCliAdapter(BaseAdapter):
134
158
  retryable=False,
135
159
  )
136
160
  self.acfg: AgentCliConfig = config.agent_cli
137
- if self.acfg.agent != "claude":
161
+ if self.acfg.agent not in ("claude", "grok"):
138
162
  raise AdapterError(
139
- f"agent {self.acfg.agent!r} is not implemented in Phase 1a "
140
- f"(claude only). Configure agent='claude' or wait for the "
141
- f"agent's phase.",
163
+ f"agent {self.acfg.agent!r} is not implemented yet "
164
+ f"(implemented: claude, grok). Wait for Phase 1b/1c.",
142
165
  provider=config.name,
143
166
  retryable=False,
144
167
  )
145
- # agent → argv builder / output parser dispatch tables. Phase 1a
146
- # registers only claude; later phases add codex / gemini / grok.
147
- self._builders = {"claude": self._build_claude_argv}
148
- self._parsers = {"claude": self._parse_claude}
149
- # claude reads its print-mode prompt from stdin (10MB cap), which
150
- # keeps argv free of the (potentially huge) prompt text.
151
- self._uses_stdin = True
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"
152
185
 
153
186
  # ------------------------------------------------------------------
154
187
  # BaseAdapter contract
@@ -177,8 +210,7 @@ class AgentCliAdapter(BaseAdapter):
177
210
  depth = self._current_depth()
178
211
  if depth >= self.acfg.agent_depth_limit:
179
212
  raise AdapterError(
180
- f"agent recursion depth {depth} >= limit "
181
- f"{self.acfg.agent_depth_limit}",
213
+ f"agent recursion depth {depth} >= limit {self.acfg.agent_depth_limit}",
182
214
  provider=self.name,
183
215
  retryable=False,
184
216
  )
@@ -195,68 +227,82 @@ class AgentCliAdapter(BaseAdapter):
195
227
 
196
228
  prompt = self._render_prompt(request, overrides)
197
229
  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
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)
216
235
  try:
217
- proc = await asyncio.create_subprocess_exec(
218
- *argv,
219
- stdin=asyncio.subprocess.PIPE,
220
- stdout=asyncio.subprocess.PIPE,
221
- stderr=asyncio.subprocess.PIPE,
222
- cwd=workdir,
223
- env=env,
224
- # New session/process group so a timeout can SIGKILL the whole
225
- # group (the CLI hangs its real LLM call off a child).
226
- start_new_session=True,
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
+ },
227
253
  )
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
254
 
235
- stdin_bytes = prompt.encode("utf-8") if self._uses_stdin else None
236
- try:
237
- stdout, stderr = await asyncio.wait_for(
238
- proc.communicate(input=stdin_bytes), timeout=timeout
239
- )
240
- except TimeoutError as exc:
241
- self._kill_process_group(proc)
242
- with contextlib.suppress(Exception):
243
- await proc.wait()
244
- raise AdapterError(
245
- f"{self.acfg.agent} exec timed out after {timeout}s",
246
- provider=self.name,
247
- retryable=True,
248
- ) from exc
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
249
274
 
250
- if proc.returncode != 0:
251
- detail = self._error_detail(stdout, stderr)
252
- raise AdapterError(
253
- f"{self.acfg.agent} exited {proc.returncode}: {detail}",
254
- provider=self.name,
255
- status_code=None,
256
- retryable=self._is_retryable_exit(proc.returncode),
257
- )
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
258
289
 
259
- final_text, usage, meta = self._parsers[self.acfg.agent](stdout, stderr)
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)
260
306
  return self._to_chat_response(final_text, usage, meta)
261
307
 
262
308
  async def stream(
@@ -281,9 +327,7 @@ class AgentCliAdapter(BaseAdapter):
281
327
  id=resp.id,
282
328
  created=resp.created,
283
329
  model=resp.model,
284
- choices=[
285
- {"index": 0, "delta": {"content": piece}, "finish_reason": None}
286
- ],
330
+ choices=[{"index": 0, "delta": {"content": piece}, "finish_reason": None}],
287
331
  )
288
332
  yield StreamChunk(
289
333
  id=resp.id,
@@ -297,7 +341,7 @@ class AgentCliAdapter(BaseAdapter):
297
341
  # claude argv builder + output parser
298
342
  # ------------------------------------------------------------------
299
343
 
300
- def _build_claude_argv(self, workdir: str) -> list[str]:
344
+ def _build_claude_argv(self, workdir: str, prompt_file: str | None = None) -> list[str]:
301
345
  """Assemble the ``claude -p`` argv (design §5.1.5 / §5.4).
302
346
 
303
347
  Shape::
@@ -305,10 +349,13 @@ class AgentCliAdapter(BaseAdapter):
305
349
  claude -p --output-format json --model <m> --max-turns <n>
306
350
  --permission-mode <plan|acceptEdits> --add-dir <workdir>
307
351
 
308
- The prompt is fed on stdin (not argv), so it never appears here.
309
- ``--bare`` is deliberately NOT added it would skip OAuth/keychain
310
- reads and break subscription auth (design §5.3.4).
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).
311
357
  """
358
+ del prompt_file # claude takes the prompt on stdin, not from a file.
312
359
  model = self.acfg.model or self.config.model
313
360
  argv = [self.acfg.command, "-p", "--output-format", "json", "--model", model]
314
361
  if self.acfg.max_turns is not None:
@@ -418,10 +465,154 @@ class AgentCliAdapter(BaseAdapter):
418
465
  usage["duration_ms"] = data["duration_ms"]
419
466
  return usage
420
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
+
421
580
  # ------------------------------------------------------------------
422
581
  # helpers: prompt rendering, response shaping, env, workdir, kill
423
582
  # ------------------------------------------------------------------
424
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
+
425
616
  def _to_chat_response(
426
617
  self, final_text: str, usage: dict[str, Any], meta: dict[str, Any]
427
618
  ) -> ChatResponse:
@@ -442,9 +633,7 @@ class AgentCliAdapter(BaseAdapter):
442
633
  **meta,
443
634
  )
444
635
 
445
- def _render_prompt(
446
- self, request: ChatRequest, overrides: ProviderCallOverrides | None
447
- ) -> str:
636
+ def _render_prompt(self, request: ChatRequest, overrides: ProviderCallOverrides | None) -> str:
448
637
  """Flatten the chat messages into a single role-tagged prompt string.
449
638
 
450
639
  The profile-level ``append_system_prompt`` (if any) is prepended as a
@@ -170,10 +170,17 @@ 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. Phase 1a implements the
174
- ``claude`` (Claude Code CLI) target only; the other agents are declared
175
- at the schema level so configs are forward-compatible, but the adapter
176
- rejects them until their phase lands.
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``).
177
184
 
178
185
  Follows the ``extra="forbid"`` convention used across this module so a
179
186
  typo'd key fails at config-load rather than being silently ignored.
@@ -183,7 +190,10 @@ class AgentCliConfig(BaseModel):
183
190
 
184
191
  agent: Literal["codex", "gemini", "grok", "claude"] = Field(
185
192
  ...,
186
- description="External coding-agent CLI to invoke. Phase 1a: 'claude' only.",
193
+ description=(
194
+ "External coding-agent CLI to invoke. 'claude' (Phase 1a) and "
195
+ "'grok' (Phase 1d) are implemented; 'codex' / 'gemini' pending."
196
+ ),
187
197
  )
188
198
  command: str | None = Field(
189
199
  default=None,
@@ -248,7 +258,9 @@ class AgentCliConfig(BaseModel):
248
258
  "Allowlist of environment variable NAMES forwarded from the "
249
259
  "parent process into the child. The child otherwise inherits "
250
260
  "no parent environment (subscription-first auth policy). "
251
- "``ANTHROPIC_API_KEY`` is NOT forwarded unless listed here."
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)."
252
264
  ),
253
265
  )
254
266
  agent_depth_limit: int = Field(
@@ -627,7 +639,7 @@ class FallbackChain(BaseModel):
627
639
  # Distinct from the v1.9-C ``adaptive`` gradient (continuous
628
640
  # latency / error-rate buffer with debounce) which handles the
629
641
  # "slow but alive" case; L5 handles the "hard crash" case.
630
- backend_health_action: Literal["off", "warn", "demote", "exclude"] = Field(
642
+ backend_health_action: Literal["off", "warn", "demote", "exclude", "skip"] = Field(
631
643
  default="warn",
632
644
  description=(
633
645
  "v1.9-E (L5 phase 2): action when a provider transitions "
@@ -642,11 +654,35 @@ class FallbackChain(BaseModel):
642
654
  "self-healing (restart helper if configured, recovery "
643
655
  "probe with exponential backoff). On recovery, the "
644
656
  "provider is automatically restored to its original "
645
- "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 "
646
666
  "entirely (zero observation overhead, identical to "
647
667
  "v1.9.x behavior)."
648
668
  ),
649
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
+ )
650
686
  backend_health_threshold: int = Field(
651
687
  default=3,
652
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.7
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,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=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=jy8u5aNacQmKMH698P_4aZYNIhjGA6xFgynGquNOzOM,24335
19
+ coderouter/adapters/agent_cli.py,sha256=_-9DsSO-zQJI-bEldBhFgIBbAQd6kqMCqX7xNy8WqtQ,32992
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=-HrlEN1BkfO_Euo0m-mszQIaLupg3en6vHkqpPKTvAw,84538
28
+ coderouter/config/schemas.py,sha256=c5VJkcsuPHIKgTtcRQnXL-q52ollsWOISnDC8RuOX70,86528
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=Xx5OpX1x7atxghmBNDVxtwGg62zQIOsk6FmrQV4ILa4,9113
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=L5hwe76DRlmHTR39dXafi27T5zRrgLlgX9uINddcSGk,142983
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.7.dist-info/METADATA,sha256=BfuchYkkpXZtvCOcok0FISgazhd7eSFKD9f8qID58dI,15546
73
- coderouter_cli-2.7.7.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
74
- coderouter_cli-2.7.7.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
75
- coderouter_cli-2.7.7.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
76
- coderouter_cli-2.7.7.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,,