coderouter-cli 2.7.6__py3-none-any.whl → 2.7.7__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,591 @@
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
+ 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.
31
+
32
+ Security (design §6, non-negotiable)
33
+ ====================================
34
+
35
+ * **allowlist argv only** — the child is launched with
36
+ :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.
39
+ * **default read-only** — ``allow_file_writes=False`` /
40
+ ``sandbox_mode="read_only"`` are the defaults, mapped to claude's
41
+ ``--permission-mode plan``. Writes require explicit opt-in and the sandbox
42
+ mapping is clamped to read-only whenever ``allow_file_writes`` is False.
43
+ * **workdir boundary** — the working directory is expanded, resolved to an
44
+ absolute path and created; a literal ``..`` escape is rejected.
45
+ * **timeout with process-group kill** — ``exec_timeout_s`` is enforced with
46
+ :func:`asyncio.wait_for`; on expiry the whole process *group* is
47
+ ``SIGKILL``ed (the CLI hangs a real LLM call off a child, so killing only
48
+ the parent would orphan it).
49
+ * **env allowlist** — the child does NOT inherit the parent environment. A
50
+ minimal env is built explicitly; ``ANTHROPIC_API_KEY`` is never forwarded
51
+ unless the operator lists it in ``passthrough_env`` (this prevents a
52
+ stray key from silently overriding subscription OAuth).
53
+ * **recursion cap** — ``CODEROUTER_AGENT_DEPTH`` is propagated (incremented)
54
+ into the child and refused at or above ``agent_depth_limit``.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import asyncio
60
+ import contextlib
61
+ import json
62
+ import os
63
+ import shutil
64
+ import signal
65
+ import time
66
+ import uuid
67
+ from collections.abc import AsyncIterator, Iterator
68
+ from pathlib import Path
69
+ from typing import Any
70
+
71
+ from coderouter.adapters.base import (
72
+ AdapterError,
73
+ BaseAdapter,
74
+ ChatRequest,
75
+ ChatResponse,
76
+ ProviderCallOverrides,
77
+ StreamChunk,
78
+ )
79
+ from coderouter.config.schemas import AgentCliConfig, ProviderConfig
80
+ from coderouter.logging import get_logger
81
+
82
+ logger = get_logger(__name__)
83
+
84
+ # Fixed, minimal PATH injected into the child (design §5.3.1). The adapter
85
+ # resolves the CLI executable to an absolute path itself (see ``generate``),
86
+ # so this PATH only governs any helper binaries the CLI spawns.
87
+ _SAFE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
88
+
89
+ # Environment variable that carries the recursion depth across nested agent
90
+ # invocations (design §5.5).
91
+ _DEPTH_ENV = "CODEROUTER_AGENT_DEPTH"
92
+
93
+ # How many trailing stderr bytes to attach to a non-zero-exit error message.
94
+ _MAX_STDERR_TAIL = 2000
95
+
96
+ # Coarse chunk size (characters) for the pseudo-stream splitter (design §5.1.4).
97
+ _STREAM_CHUNK_CHARS = 512
98
+
99
+ # sandbox_mode → claude ``--permission-mode`` value (design §5.4). ``edit``
100
+ # and ``full_auto`` both map to ``acceptEdits`` in Phase 1a.
101
+ _CLAUDE_PERMISSION_MODE = {
102
+ "read_only": "plan",
103
+ "edit": "acceptEdits",
104
+ "full_auto": "acceptEdits",
105
+ }
106
+
107
+
108
+ def _chunk_text(text: str, size: int = _STREAM_CHUNK_CHARS) -> Iterator[str]:
109
+ """Split ``text`` into ``size``-char pieces for the pseudo-stream."""
110
+ for start in range(0, len(text), size):
111
+ yield text[start : start + size]
112
+
113
+
114
+ class AgentCliAdapter(BaseAdapter):
115
+ """Invoke an external coding-agent CLI one-shot (Phase 1a: claude only).
116
+
117
+ The ``agent`` field selects the argv builder / output parser via the
118
+ dispatch tables built in :meth:`__init__`, mirroring how
119
+ ``openai_compat`` fronts many HTTP backends from one class.
120
+ """
121
+
122
+ def __init__(self, config: ProviderConfig) -> None:
123
+ """Bind to a ``ProviderConfig`` and reject unsupported agents.
124
+
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).
128
+ """
129
+ super().__init__(config)
130
+ if config.agent_cli is None: # pragma: no cover - schema enforces this
131
+ raise AdapterError(
132
+ "agent_cli provider is missing its agent_cli sub-config",
133
+ provider=config.name,
134
+ retryable=False,
135
+ )
136
+ self.acfg: AgentCliConfig = config.agent_cli
137
+ if self.acfg.agent != "claude":
138
+ 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.",
142
+ provider=config.name,
143
+ retryable=False,
144
+ )
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
152
+
153
+ # ------------------------------------------------------------------
154
+ # BaseAdapter contract
155
+ # ------------------------------------------------------------------
156
+
157
+ async def healthcheck(self) -> bool:
158
+ """Lightweight check: the CLI binary exists on PATH (design §5.1.2)."""
159
+ return shutil.which(self.acfg.command) is not None
160
+
161
+ async def generate(
162
+ self,
163
+ request: ChatRequest,
164
+ *,
165
+ overrides: ProviderCallOverrides | None = None,
166
+ ) -> ChatResponse:
167
+ """Run the CLI once and shape the final answer into a ChatResponse.
168
+
169
+ Raises :class:`AdapterError` on every failure path, with
170
+ ``retryable`` set so the fallback engine can decide whether to try
171
+ the next provider (transient failures) or stop (config / recursion
172
+ errors).
173
+ """
174
+ # Recursion guard (design §5.5): refuse when we are already nested at
175
+ # or beyond the configured depth. Non-retryable — retrying the same
176
+ # chain would recurse again.
177
+ depth = self._current_depth()
178
+ if depth >= self.acfg.agent_depth_limit:
179
+ raise AdapterError(
180
+ f"agent recursion depth {depth} >= limit "
181
+ f"{self.acfg.agent_depth_limit}",
182
+ provider=self.name,
183
+ retryable=False,
184
+ )
185
+
186
+ # exec_timeout_s is the base; a profile-level override wins when set.
187
+ # NOTE: the inherited ``effective_timeout`` falls back to
188
+ # ``ProviderConfig.timeout_s``, which is the wrong knob here, so we
189
+ # resolve against ``exec_timeout_s`` explicitly (design §5.1.3).
190
+ timeout = (
191
+ overrides.timeout_s
192
+ if overrides is not None and overrides.timeout_s is not None
193
+ else self.acfg.exec_timeout_s
194
+ )
195
+
196
+ prompt = self._render_prompt(request, overrides)
197
+ 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
+
216
+ 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,
227
+ )
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
+
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
249
+
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
+ )
258
+
259
+ final_text, usage, meta = self._parsers[self.acfg.agent](stdout, stderr)
260
+ return self._to_chat_response(final_text, usage, meta)
261
+
262
+ async def stream(
263
+ self,
264
+ request: ChatRequest,
265
+ *,
266
+ overrides: ProviderCallOverrides | None = None,
267
+ ) -> AsyncIterator[StreamChunk]:
268
+ """Pseudo-stream (design §5.1.4): run once, then chunk the answer.
269
+
270
+ No CLI in Phase 1a exposes a stable token stream, so the final text
271
+ from :meth:`generate` is split into content chunks followed by a
272
+ terminal ``finish_reason="stop"`` chunk carrying usage.
273
+ """
274
+ resp = await self.generate(request, overrides=overrides)
275
+ try:
276
+ content = resp.choices[0]["message"]["content"] or ""
277
+ except (IndexError, KeyError, TypeError): # pragma: no cover - defensive
278
+ content = ""
279
+ for piece in _chunk_text(content):
280
+ yield StreamChunk(
281
+ id=resp.id,
282
+ created=resp.created,
283
+ model=resp.model,
284
+ choices=[
285
+ {"index": 0, "delta": {"content": piece}, "finish_reason": None}
286
+ ],
287
+ )
288
+ yield StreamChunk(
289
+ id=resp.id,
290
+ created=resp.created,
291
+ model=resp.model,
292
+ choices=[{"index": 0, "delta": {}, "finish_reason": "stop"}],
293
+ usage=resp.usage,
294
+ )
295
+
296
+ # ------------------------------------------------------------------
297
+ # claude argv builder + output parser
298
+ # ------------------------------------------------------------------
299
+
300
+ def _build_claude_argv(self, workdir: str) -> list[str]:
301
+ """Assemble the ``claude -p`` argv (design §5.1.5 / §5.4).
302
+
303
+ Shape::
304
+
305
+ claude -p --output-format json --model <m> --max-turns <n>
306
+ --permission-mode <plan|acceptEdits> --add-dir <workdir>
307
+
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).
311
+ """
312
+ model = self.acfg.model or self.config.model
313
+ argv = [self.acfg.command, "-p", "--output-format", "json", "--model", model]
314
+ if self.acfg.max_turns is not None:
315
+ argv += ["--max-turns", str(self.acfg.max_turns)]
316
+ argv += ["--permission-mode", self._claude_permission_mode()]
317
+ argv += ["--add-dir", workdir]
318
+ return argv
319
+
320
+ def _claude_permission_mode(self) -> str:
321
+ """Map ``sandbox_mode`` → claude ``--permission-mode``, clamped.
322
+
323
+ When ``allow_file_writes`` is False the effective mode is clamped to
324
+ ``read_only`` regardless of ``sandbox_mode`` — defense in depth so an
325
+ ``edit`` / ``full_auto`` request cannot grant writes without the
326
+ explicit ``allow_file_writes`` opt-in (design §5.4).
327
+ """
328
+ mode = self.acfg.sandbox_mode if self.acfg.allow_file_writes else "read_only"
329
+ return _CLAUDE_PERMISSION_MODE[mode]
330
+
331
+ def _parse_claude(
332
+ self, stdout: bytes, stderr: bytes
333
+ ) -> tuple[str, dict[str, Any], dict[str, Any]]:
334
+ """Parse claude ``--output-format json`` output (design §5.1.6).
335
+
336
+ Returns ``(final_text, usage, meta)``. Parsing is deliberately
337
+ defensive: any missing/blank field or a reported ``is_error`` raises a
338
+ retryable :class:`AdapterError` so the chain can fall through.
339
+ """
340
+ text = stdout.decode("utf-8", "replace").strip()
341
+ if not text:
342
+ raise AdapterError(
343
+ "claude produced no stdout to parse",
344
+ provider=self.name,
345
+ retryable=True,
346
+ )
347
+ try:
348
+ data = json.loads(text)
349
+ except json.JSONDecodeError as exc:
350
+ raise AdapterError(
351
+ f"claude emitted non-JSON output: {exc}",
352
+ provider=self.name,
353
+ retryable=True,
354
+ ) from exc
355
+ if not isinstance(data, dict):
356
+ raise AdapterError(
357
+ "claude JSON output was not an object",
358
+ provider=self.name,
359
+ retryable=True,
360
+ )
361
+ if data.get("is_error"):
362
+ raise AdapterError(
363
+ f"claude reported is_error=true: {str(data.get('result'))[:500]!r}",
364
+ provider=self.name,
365
+ retryable=True,
366
+ )
367
+ result = data.get("result")
368
+ if not isinstance(result, str):
369
+ raise AdapterError(
370
+ "claude JSON output missing string 'result' field",
371
+ provider=self.name,
372
+ retryable=True,
373
+ )
374
+
375
+ usage = self._claude_usage(data)
376
+ meta: dict[str, Any] = {}
377
+ cost = data.get("total_cost_usd")
378
+ if isinstance(cost, (int, float)):
379
+ # claude is the only CLI that emits a dollar figure directly;
380
+ # surface it as response metadata for the cost dashboard.
381
+ meta["coderouter_cost_usd"] = float(cost)
382
+ session_id = data.get("session_id")
383
+ if isinstance(session_id, str):
384
+ meta["coderouter_session_id"] = session_id
385
+ return result, usage, meta
386
+
387
+ def _claude_usage(self, data: dict[str, Any]) -> dict[str, Any]:
388
+ """Normalize claude token usage into the OpenAI usage shape.
389
+
390
+ ``input_tokens`` plus the two cache buckets fold into
391
+ ``prompt_tokens``; ``cache_read_input_tokens`` is preserved under
392
+ ``prompt_tokens_details.cached_tokens``. ``num_turns`` / ``duration_ms``
393
+ ride along as extra keys (design §5.1.6).
394
+ """
395
+ raw = data.get("usage")
396
+ raw = raw if isinstance(raw, dict) else {}
397
+
398
+ def _int(key: str) -> int:
399
+ value = raw.get(key)
400
+ return int(value) if isinstance(value, (int, float)) else 0
401
+
402
+ input_tokens = _int("input_tokens")
403
+ output_tokens = _int("output_tokens")
404
+ cache_read = _int("cache_read_input_tokens")
405
+ cache_creation = _int("cache_creation_input_tokens")
406
+ prompt_tokens = input_tokens + cache_read + cache_creation
407
+
408
+ usage: dict[str, Any] = {
409
+ "prompt_tokens": prompt_tokens,
410
+ "completion_tokens": output_tokens,
411
+ "total_tokens": prompt_tokens + output_tokens,
412
+ }
413
+ if cache_read:
414
+ usage["prompt_tokens_details"] = {"cached_tokens": cache_read}
415
+ if isinstance(data.get("num_turns"), int):
416
+ usage["num_turns"] = data["num_turns"]
417
+ if isinstance(data.get("duration_ms"), (int, float)):
418
+ usage["duration_ms"] = data["duration_ms"]
419
+ return usage
420
+
421
+ # ------------------------------------------------------------------
422
+ # helpers: prompt rendering, response shaping, env, workdir, kill
423
+ # ------------------------------------------------------------------
424
+
425
+ def _to_chat_response(
426
+ self, final_text: str, usage: dict[str, Any], meta: dict[str, Any]
427
+ ) -> ChatResponse:
428
+ """Wrap the agent's final text in an OpenAI ChatResponse."""
429
+ return ChatResponse(
430
+ id=f"chatcmpl-{uuid.uuid4().hex}",
431
+ created=int(time.time()),
432
+ model=self.config.model,
433
+ choices=[
434
+ {
435
+ "index": 0,
436
+ "message": {"role": "assistant", "content": final_text},
437
+ "finish_reason": "stop",
438
+ }
439
+ ],
440
+ usage=usage,
441
+ coderouter_provider=self.name,
442
+ **meta,
443
+ )
444
+
445
+ def _render_prompt(
446
+ self, request: ChatRequest, overrides: ProviderCallOverrides | None
447
+ ) -> str:
448
+ """Flatten the chat messages into a single role-tagged prompt string.
449
+
450
+ The profile-level ``append_system_prompt`` (if any) is prepended as a
451
+ leading system block, matching the openai_compat directive semantics.
452
+ """
453
+ parts: list[str] = []
454
+ directive = self.effective_append_system_prompt(overrides)
455
+ if directive:
456
+ parts.append(f"[system]\n{directive}")
457
+ for message in request.messages:
458
+ text = self._message_text(message.content)
459
+ if text:
460
+ parts.append(f"[{message.role}]\n{text}")
461
+ return "\n\n".join(parts).strip()
462
+
463
+ @staticmethod
464
+ def _message_text(content: str | list[dict[str, Any]] | None) -> str:
465
+ """Extract plain text from a message's ``content`` (str or blocks)."""
466
+ if content is None:
467
+ return ""
468
+ if isinstance(content, str):
469
+ return content
470
+ chunks: list[str] = []
471
+ for part in content:
472
+ if isinstance(part, dict) and part.get("type") == "text":
473
+ text = part.get("text")
474
+ if isinstance(text, str):
475
+ chunks.append(text)
476
+ return "\n".join(chunks)
477
+
478
+ def _current_depth(self) -> int:
479
+ """Read the current recursion depth from the environment (0 default)."""
480
+ raw = os.environ.get(_DEPTH_ENV, "0")
481
+ try:
482
+ return int(raw)
483
+ except ValueError:
484
+ return 0
485
+
486
+ def _build_child_env(self) -> dict[str, str]:
487
+ """Build the minimal child environment (design §5.3).
488
+
489
+ The child does NOT inherit the parent environment. Only a fixed base
490
+ (PATH / NO_COLOR / TERM), the inherited HOME / USER / LOGNAME (for
491
+ credential discovery — on macOS the Claude Code CLI resolves its
492
+ Keychain entry via ``USER``; without it headless runs fail with
493
+ "Not logged in"), the incremented recursion depth, and the operator's
494
+ ``passthrough_env`` allowlist are injected. ``ANTHROPIC_API_KEY`` is
495
+ therefore excluded unless explicitly allowlisted.
496
+ """
497
+ parent = os.environ
498
+ env: dict[str, str] = {
499
+ "PATH": _SAFE_PATH,
500
+ "NO_COLOR": "1",
501
+ "TERM": "dumb",
502
+ _DEPTH_ENV: str(self._current_depth() + 1),
503
+ }
504
+ for name in ("HOME", "USER", "LOGNAME"):
505
+ value = parent.get(name)
506
+ if value:
507
+ env[name] = value
508
+ for name in self.acfg.passthrough_env:
509
+ value = parent.get(name)
510
+ if value is not None:
511
+ env[name] = value
512
+ return env
513
+
514
+ @staticmethod
515
+ def _error_detail(stdout: bytes, stderr: bytes) -> str:
516
+ """Extract the most useful error text from a failed CLI run.
517
+
518
+ The Claude Code CLI reports auth / API failures as an ``is_error:
519
+ true`` result JSON on **stdout** with exit code 1 (stderr stays
520
+ empty), so a stderr-only tail hides the actual cause (e.g. ``Not
521
+ logged in · Please run /login``). Preference order: the ``result``
522
+ field of an ``is_error`` stdout JSON → stderr tail → stdout tail.
523
+ """
524
+ if stdout:
525
+ with contextlib.suppress(ValueError, TypeError):
526
+ doc = json.loads(stdout)
527
+ if isinstance(doc, dict) and doc.get("is_error"):
528
+ result = doc.get("result")
529
+ if isinstance(result, str) and result.strip():
530
+ return result.strip()[:_MAX_STDERR_TAIL]
531
+ if stderr:
532
+ return repr(stderr[-_MAX_STDERR_TAIL:].decode("utf-8", "replace"))
533
+ if stdout:
534
+ return repr(stdout[-_MAX_STDERR_TAIL:].decode("utf-8", "replace"))
535
+ return "''"
536
+
537
+ def _resolve_workdir(self) -> str:
538
+ """Resolve + create the working directory, rejecting ``..`` escapes.
539
+
540
+ A configured ``workdir`` is ``~`` / env-var expanded and resolved to
541
+ an absolute path; a literal ``..`` component is rejected as an escape
542
+ attempt. When unset, a dedicated isolated directory
543
+ (``~/.coderouter/agents/<name>``) is used (design §6).
544
+ """
545
+ raw = self.acfg.workdir
546
+ if raw:
547
+ if ".." in Path(raw).parts:
548
+ raise AdapterError(
549
+ f"workdir {raw!r} must not contain '..' (escape attempt)",
550
+ provider=self.name,
551
+ retryable=False,
552
+ )
553
+ expanded = os.path.expanduser(os.path.expandvars(raw))
554
+ path = Path(expanded).resolve()
555
+ else:
556
+ path = (Path.home() / ".coderouter" / "agents" / self.name).resolve()
557
+ try:
558
+ path.mkdir(parents=True, exist_ok=True)
559
+ except OSError as exc:
560
+ raise AdapterError(
561
+ f"failed to prepare workdir {path}: {exc}",
562
+ provider=self.name,
563
+ retryable=False,
564
+ ) from exc
565
+ return str(path)
566
+
567
+ def _kill_process_group(self, proc: asyncio.subprocess.Process) -> None:
568
+ """SIGKILL the child's whole process group (best effort)."""
569
+ pid = proc.pid
570
+ try:
571
+ os.killpg(os.getpgid(pid), signal.SIGKILL)
572
+ except (ProcessLookupError, PermissionError, OSError):
573
+ # Group already gone / no permission — fall back to a direct kill.
574
+ with contextlib.suppress(ProcessLookupError, OSError):
575
+ proc.kill()
576
+
577
+ def _is_retryable_exit(self, returncode: int | None) -> bool:
578
+ """Whether a non-zero exit should let the chain fall through.
579
+
580
+ Phase 1a treats any non-zero exit as transient (rate-limit / OAuth
581
+ expiry / network), so the fallback engine advances to the next
582
+ provider rather than surfacing a terminal failure.
583
+ """
584
+ return True
585
+
586
+ # ``BaseAdapter`` (HTTP-oriented) lazily builds an httpx client; the agent
587
+ # adapter never touches it, but ``aclose`` on the inherited base is a safe
588
+ # no-op, so nothing extra is needed here.
589
+
590
+
591
+ __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,125 @@ 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. 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.
177
+
178
+ Follows the ``extra="forbid"`` convention used across this module so a
179
+ typo'd key fails at config-load rather than being silently ignored.
180
+ """
181
+
182
+ model_config = ConfigDict(extra="forbid")
183
+
184
+ agent: Literal["codex", "gemini", "grok", "claude"] = Field(
185
+ ...,
186
+ description="External coding-agent CLI to invoke. Phase 1a: 'claude' only.",
187
+ )
188
+ command: str | None = Field(
189
+ default=None,
190
+ description=(
191
+ "CLI executable name or absolute path (resolved via PATH). "
192
+ "When unset, defaults to the ``agent`` name."
193
+ ),
194
+ )
195
+ workdir: str | None = Field(
196
+ default=None,
197
+ description=(
198
+ "Working directory for the one-shot exec. ``~`` / env-var "
199
+ "expansion is applied. When unset, a dedicated isolated "
200
+ "directory (``~/.coderouter/agents/<name>``) is used."
201
+ ),
202
+ )
203
+ exec_timeout_s: float = Field(
204
+ default=600.0,
205
+ ge=1.0,
206
+ le=1800.0,
207
+ description=(
208
+ "Forced timeout (seconds) for the one-shot exec. Independent "
209
+ "of ``ProviderConfig.timeout_s`` — the CLI has no built-in "
210
+ "wall clock so this is enforced with an asyncio watchdog + "
211
+ "process-group SIGKILL."
212
+ ),
213
+ )
214
+ allow_file_writes: bool = Field(
215
+ default=False,
216
+ description=(
217
+ "Allow the agent to write to the filesystem. Default False "
218
+ "(read-only). When False the sandbox mapping is clamped to "
219
+ "read-only regardless of ``sandbox_mode`` (defense in depth)."
220
+ ),
221
+ )
222
+ sandbox_mode: Literal["read_only", "edit", "full_auto"] = Field(
223
+ default="read_only",
224
+ description=(
225
+ "Source mode mapped onto each CLI's sandbox / approval flags. "
226
+ "Default read_only maps to claude ``--permission-mode plan``."
227
+ ),
228
+ )
229
+ model: str | None = Field(
230
+ default=None,
231
+ description=(
232
+ "Model name passed to the CLI's ``--model``. When unset, "
233
+ "``ProviderConfig.model`` is used."
234
+ ),
235
+ )
236
+ max_turns: int | None = Field(
237
+ default=8,
238
+ ge=1,
239
+ le=50,
240
+ description=(
241
+ "Turn cap. Passed to the CLI's ``--max-turns`` where supported "
242
+ "(codex ignores it — the CLI has no such flag)."
243
+ ),
244
+ )
245
+ passthrough_env: list[str] = Field(
246
+ default_factory=list,
247
+ description=(
248
+ "Allowlist of environment variable NAMES forwarded from the "
249
+ "parent process into the child. The child otherwise inherits "
250
+ "no parent environment (subscription-first auth policy). "
251
+ "``ANTHROPIC_API_KEY`` is NOT forwarded unless listed here."
252
+ ),
253
+ )
254
+ agent_depth_limit: int = Field(
255
+ default=2,
256
+ ge=1,
257
+ le=4,
258
+ description=(
259
+ "Recursion nesting cap. ``CODEROUTER_AGENT_DEPTH`` is "
260
+ "propagated (incremented) into the child; ``generate()`` "
261
+ "refuses when the current depth is at or above this limit."
262
+ ),
263
+ )
264
+
265
+ @model_validator(mode="after")
266
+ def _resolve_and_check(self) -> AgentCliConfig:
267
+ """Default ``command`` to ``agent`` and reject contradictory sandbox.
268
+
269
+ Rule (design §5.2.1 #2): ``allow_file_writes=True`` together with
270
+ ``sandbox_mode="read_only"`` is contradictory — the operator asked
271
+ for writes while pinning a read-only sandbox. Fail fast at load,
272
+ matching the module's other cross-field validators.
273
+ """
274
+ if self.command is None:
275
+ self.command = self.agent
276
+ if self.allow_file_writes and self.sandbox_mode == "read_only":
277
+ raise ValueError(
278
+ "agent_cli: allow_file_writes=True conflicts with "
279
+ "sandbox_mode='read_only'. Set sandbox_mode to 'edit' or "
280
+ "'full_auto' to permit writes, or keep allow_file_writes=False."
281
+ )
282
+ return self
283
+
284
+
166
285
  class ProviderConfig(BaseModel):
167
286
  """A single provider entry from providers.yaml.
168
287
 
@@ -170,20 +289,26 @@ class ProviderConfig(BaseModel):
170
289
  - Local llama.cpp server: kind=openai_compat, base_url=http://localhost:8080/v1
171
290
  - OpenRouter free: kind=openai_compat, base_url=https://openrouter.ai/api/v1
172
291
  - (future) Anthropic: kind=anthropic, base_url=https://api.anthropic.com
292
+ - External agent CLI: kind=agent_cli, agent_cli={agent: claude, ...}
173
293
  """
174
294
 
175
295
  model_config = ConfigDict(extra="forbid")
176
296
 
177
297
  name: str = Field(..., description="Unique identifier used in profiles.yaml")
178
- kind: Literal["openai_compat", "anthropic"] = Field(
298
+ kind: Literal["openai_compat", "anthropic", "agent_cli"] = Field(
179
299
  default="openai_compat",
180
300
  description=(
181
301
  "Adapter type. 'openai_compat' covers llama.cpp / Ollama / "
182
302
  "OpenRouter / LM Studio / Together / Groq. 'anthropic' is the "
183
- "native Anthropic Messages API passthrough (v0.3.x)."
303
+ "native Anthropic Messages API passthrough (v0.3.x). 'agent_cli' "
304
+ "invokes an external coding-agent CLI one-shot (see AgentCliConfig)."
184
305
  ),
185
306
  )
186
- base_url: HttpUrl
307
+ # base_url is required for HTTP-backed adapters (openai_compat / anthropic)
308
+ # but meaningless for agent_cli (which shells out to a local CLI rather
309
+ # than calling a URL). It is optional at the field level and enforced per
310
+ # kind by ``_check_kind_requirements`` below.
311
+ base_url: HttpUrl | None = None
187
312
  model: str = Field(..., description="Upstream model id sent in the request body")
188
313
  api_key_env: str | None = Field(
189
314
  default=None,
@@ -243,6 +368,14 @@ class ProviderConfig(BaseModel):
243
368
 
244
369
  capabilities: Capabilities = Field(default_factory=Capabilities)
245
370
 
371
+ # kind="agent_cli": required external coding-agent CLI settings. Opt-in
372
+ # (default None) exactly like ``restart_command`` — only meaningful when
373
+ # ``kind == "agent_cli"``, and enforced by ``_check_kind_requirements``.
374
+ agent_cli: AgentCliConfig | None = Field(
375
+ default=None,
376
+ description="Required when kind='agent_cli': external agent CLI settings.",
377
+ )
378
+
246
379
  cost: CostConfig | None = Field(
247
380
  default=None,
248
381
  description=(
@@ -298,6 +431,32 @@ class ProviderConfig(BaseModel):
298
431
  validate_output_filters(self.output_filters)
299
432
  return self
300
433
 
434
+ @model_validator(mode="after")
435
+ def _check_kind_requirements(self) -> ProviderConfig:
436
+ """Enforce per-``kind`` field requirements (external-agents design §5.2.2).
437
+
438
+ - HTTP-backed adapters (``openai_compat`` / ``anthropic``) require a
439
+ ``base_url`` — they have nowhere to send the request otherwise.
440
+ ``base_url`` was relaxed to Optional so ``agent_cli`` providers can
441
+ omit it; this validator restores the required-ness for the HTTP kinds.
442
+ - ``agent_cli`` requires the ``agent_cli`` sub-config (the adapter has
443
+ no CLI to invoke without it).
444
+
445
+ Same fast-fail philosophy as ``_check_output_filters_known`` — a
446
+ misconfigured provider surfaces at config-load, not at first request.
447
+ """
448
+ if self.kind in ("openai_compat", "anthropic") and self.base_url is None:
449
+ raise ValueError(
450
+ f"provider {self.name!r}: base_url is required for "
451
+ f"kind={self.kind!r}."
452
+ )
453
+ if self.kind == "agent_cli" and self.agent_cli is None:
454
+ raise ValueError(
455
+ f"provider {self.name!r}: agent_cli sub-config is required "
456
+ f"for kind='agent_cli'."
457
+ )
458
+ return self
459
+
301
460
 
302
461
  class FallbackChain(BaseModel):
303
462
  """An ordered list of provider names to try in sequence.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coderouter-cli
3
- Version: 2.7.6
3
+ Version: 2.7.7
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
@@ -16,15 +16,16 @@ coderouter/output_filters.py,sha256=0ry_rPiS_kC-FnHgaNVP6v7e6Al2djxzu9vBzZ8kEkE,
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
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=-HrlEN1BkfO_Euo0m-mszQIaLupg3en6vHkqpPKTvAw,84538
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
@@ -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.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,,