coderouter-cli 2.7.7__py3-none-any.whl → 2.7.9__py3-none-any.whl

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