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