coderouter-cli 2.15.1__py3-none-any.whl → 2.16.0__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.
@@ -458,6 +458,8 @@ class AnthropicAdapter(BaseAdapter):
458
458
  provider=self.name,
459
459
  status_code=resp.status_code,
460
460
  retryable=resp.status_code in _RETRYABLE_STATUSES,
461
+ upstream_body=resp.text,
462
+ retry_after=resp.headers.get("retry-after"),
461
463
  )
462
464
 
463
465
  try:
@@ -570,6 +572,8 @@ class AnthropicAdapter(BaseAdapter):
570
572
  provider=self.name,
571
573
  status_code=resp.status_code,
572
574
  retryable=resp.status_code in _RETRYABLE_STATUSES,
575
+ upstream_body=body.decode("utf-8", errors="replace"),
576
+ retry_after=resp.headers.get("retry-after"),
573
577
  )
574
578
 
575
579
  # Anthropic SSE block shape:
@@ -101,6 +101,12 @@ class StreamChunk(BaseModel):
101
101
  usage: dict[str, Any] | None = None
102
102
 
103
103
 
104
+ #: v2.16.0: max chars of an upstream error body an :class:`AdapterError`
105
+ #: keeps. Error bodies are small; the cap only bounds a misbehaving upstream
106
+ #: that answers an error status with a large HTML page.
107
+ UPSTREAM_BODY_CAP = 4096
108
+
109
+
104
110
  class AdapterError(CodeRouterError):
105
111
  """Raised when a provider call fails in a way the fallback engine should retry on."""
106
112
 
@@ -111,6 +117,8 @@ class AdapterError(CodeRouterError):
111
117
  provider: str,
112
118
  status_code: int | None = None,
113
119
  retryable: bool = True,
120
+ upstream_body: str | None = None,
121
+ retry_after: str | None = None,
114
122
  ) -> None:
115
123
  """Construct an AdapterError.
116
124
 
@@ -125,11 +133,23 @@ class AdapterError(CodeRouterError):
125
133
  retryable: When True, the fallback engine may try the next
126
134
  provider in the chain. When False, the engine stops
127
135
  and surfaces the error as a terminal failure.
136
+ upstream_body: v2.16.0 — the upstream's error body (capped at
137
+ :data:`UPSTREAM_BODY_CAP` chars), kept so the ingress can
138
+ hand the upstream's own wording back to the client.
139
+ Claude Code recovers from several 400s (rejected beta
140
+ headers, tool search, context overflow) only when it can
141
+ read the upstream message. Never logged by this class.
142
+ retry_after: v2.16.0 — the upstream's ``Retry-After`` header
143
+ value, verbatim, when it sent one.
128
144
  """
129
145
  super().__init__(message)
130
146
  self.provider = provider
131
147
  self.status_code = status_code
132
148
  self.retryable = retryable
149
+ self.upstream_body = (
150
+ upstream_body[:UPSTREAM_BODY_CAP] if upstream_body is not None else None
151
+ )
152
+ self.retry_after = retry_after
133
153
 
134
154
  def __str__(self) -> str:
135
155
  """Render as ``[provider status=NNN] message`` for log trails."""
@@ -244,6 +244,8 @@ class OpenAICompatAdapter(BaseAdapter):
244
244
  provider=self.name,
245
245
  status_code=resp.status_code,
246
246
  retryable=resp.status_code in _RETRYABLE_STATUSES,
247
+ upstream_body=resp.text,
248
+ retry_after=resp.headers.get("retry-after"),
247
249
  )
248
250
 
249
251
  try:
@@ -381,6 +383,8 @@ class OpenAICompatAdapter(BaseAdapter):
381
383
  provider=self.name,
382
384
  status_code=resp.status_code,
383
385
  retryable=resp.status_code in _RETRYABLE_STATUSES,
386
+ upstream_body=body.decode("utf-8", errors="replace"),
387
+ retry_after=resp.headers.get("retry-after"),
384
388
  )
385
389
  async for line in resp.aiter_lines():
386
390
  if not line:
coderouter/cli.py CHANGED
@@ -165,6 +165,17 @@ def _build_parser() -> argparse.ArgumentParser:
165
165
  "for live key values. Exit 0 clean / 2 needs attention / 1 leak."
166
166
  ),
167
167
  )
168
+ doctor.add_argument(
169
+ "--check-claude-code",
170
+ action="store_true",
171
+ help=(
172
+ "v2.16.0: check the local Claude Code install for gateway use — "
173
+ "known-bad versions (<2.1.301, 2.1.304) and the env vars that "
174
+ "matter behind a non-Anthropic gateway (ENABLE_TOOL_SEARCH, "
175
+ "CLAUDE_CODE_MAX_CONTEXT_TOKENS, CLAUDE_CODE_AUTO_MODE_SERVER). "
176
+ "No network. Exit 0 OK / 2 needs attention."
177
+ ),
178
+ )
168
179
  doctor.add_argument(
169
180
  "--config",
170
181
  default=None,
@@ -454,6 +465,28 @@ def _build_parser() -> argparse.ArgumentParser:
454
465
  "generation. Tip: keep secrets in a separate .envrc.local."
455
466
  ),
456
467
  )
468
+ vscode_init.add_argument(
469
+ "--max-context-tokens",
470
+ type=int,
471
+ default=None,
472
+ metavar="N",
473
+ help=(
474
+ "v2.16.0: also write CLAUDE_CODE_MAX_CONTEXT_TOKENS=N so Claude "
475
+ "Code compacts before your local model's context fills up "
476
+ "(since 2.1.285 it assumes 1M behind a gateway). Use the "
477
+ "smallest context window in the profile's chain."
478
+ ),
479
+ )
480
+ vscode_init.add_argument(
481
+ "--no-claude-code-env",
482
+ action="store_true",
483
+ help=(
484
+ "v2.16.0: do not write the Claude Code gateway settings "
485
+ "(ENABLE_TOOL_SEARCH=false, CLAUDE_CODE_AUTO_MODE_SERVER=0, "
486
+ "and --max-context-tokens). Use when the chain is "
487
+ "api.anthropic.com only."
488
+ ),
489
+ )
457
490
  vscode_init.add_argument(
458
491
  "--dry-run",
459
492
  action="store_true",
@@ -595,10 +628,16 @@ def _run_doctor(args: argparse.Namespace) -> int:
595
628
  risks AND broken providers can use a single command.
596
629
  """
597
630
  check_secrets = bool(getattr(args, "check_secrets", False))
598
- if args.check_model is None and args.check_env is None and not check_secrets:
631
+ check_claude_code = bool(getattr(args, "check_claude_code", False))
632
+ if (
633
+ args.check_model is None
634
+ and args.check_env is None
635
+ and not check_secrets
636
+ and not check_claude_code
637
+ ):
599
638
  print(
600
639
  "doctor: provide --check-model PROVIDER, --check-env [PATH], "
601
- "and/or --check-secrets",
640
+ "--check-secrets and/or --check-claude-code",
602
641
  file=sys.stderr,
603
642
  )
604
643
  return 1
@@ -614,6 +653,14 @@ def _run_doctor(args: argparse.Namespace) -> int:
614
653
  if check_secrets:
615
654
  worst_exit = max(worst_exit, _run_check_secrets(args.config))
616
655
 
656
+ if check_claude_code:
657
+ from coderouter.doctor_claude_code import format_report as _cc_format
658
+ from coderouter.doctor_claude_code import run_check as _cc_run
659
+
660
+ cc_report = _cc_run()
661
+ print(_cc_format(cc_report))
662
+ worst_exit = max(worst_exit, cc_report.exit_code)
663
+
617
664
  if args.check_model is not None:
618
665
  worst_exit = max(worst_exit, _run_check_model(args))
619
666
 
@@ -976,6 +1023,7 @@ def _run_vscode_init(args: argparse.Namespace) -> int:
976
1023
  """
977
1024
  from coderouter.vscode_init import (
978
1025
  DEFAULT_PORT,
1026
+ claude_code_env,
979
1027
  exit_code_for,
980
1028
  format_result,
981
1029
  run_vscode_init,
@@ -991,6 +1039,22 @@ def _run_vscode_init(args: argparse.Namespace) -> int:
991
1039
 
992
1040
  port = args.port if args.port is not None else DEFAULT_PORT
993
1041
 
1042
+ extra_env: dict[str, str] | None = None
1043
+ max_ctx = getattr(args, "max_context_tokens", None)
1044
+ if getattr(args, "no_claude_code_env", False):
1045
+ if max_ctx is not None:
1046
+ print(
1047
+ "vscode-init: --max-context-tokens has no effect with --no-claude-code-env",
1048
+ file=sys.stderr,
1049
+ )
1050
+ return 2
1051
+ else:
1052
+ try:
1053
+ extra_env = claude_code_env(max_context_tokens=max_ctx)
1054
+ except ValueError as exc:
1055
+ print(f"vscode-init: {exc}", file=sys.stderr)
1056
+ return 2
1057
+
994
1058
  try:
995
1059
  result = run_vscode_init(
996
1060
  target,
@@ -999,6 +1063,7 @@ def _run_vscode_init(args: argparse.Namespace) -> int:
999
1063
  with_envrc=args.with_envrc,
1000
1064
  dry_run=args.dry_run,
1001
1065
  force=args.force,
1066
+ extra_env=extra_env,
1002
1067
  )
1003
1068
  except FileNotFoundError as exc:
1004
1069
  # Raised only when target vanishes between the pre-check above
@@ -1089,7 +1089,13 @@ class FallbackChain(BaseModel):
1089
1089
  # (``context_budget_preserve_last_n``) are always
1090
1090
  # kept, and tool_use / tool_result pairs are preserved
1091
1091
  # atomically to avoid breaking agent loops.
1092
- context_budget_action: Literal["off", "warn", "trim"] = Field(
1092
+ # * ``reject`` — v2.16.0: ``warn`` + at ``context_budget_trim_threshold``
1093
+ # refuse the request with Anthropic's own overflow error
1094
+ # (HTTP 400, ``prompt is too long: ...``). Claude Code
1095
+ # answers that by compacting the conversation itself
1096
+ # (a summary, not a silent cut), so this is the
1097
+ # recommended action for Claude Code sessions.
1098
+ context_budget_action: Literal["off", "warn", "trim", "reject"] = Field(
1093
1099
  default="off",
1094
1100
  description=(
1095
1101
  "v2.0-F (L1): action when estimated request tokens approach "
@@ -1097,7 +1103,10 @@ class FallbackChain(BaseModel):
1097
1103
  "disables the guard entirely. ``warn`` emits a log and "
1098
1104
  "response header at ``context_budget_warn_threshold``. "
1099
1105
  "``trim`` additionally removes old messages at "
1100
- "``context_budget_trim_threshold`` to reclaim context space."
1106
+ "``context_budget_trim_threshold`` to reclaim context space. "
1107
+ "``reject`` (v2.16.0) instead answers HTTP 400 "
1108
+ "``prompt is too long`` at that threshold so the client "
1109
+ "compacts the conversation itself (Claude Code does)."
1101
1110
  ),
1102
1111
  )
1103
1112
  context_budget_warn_threshold: float = Field(
@@ -1222,6 +1231,31 @@ class FallbackChain(BaseModel):
1222
1231
  # --- v2.0-H (L6): Mid-stream partial stitching --------------------------
1223
1232
  # * ``off`` — discard partial content on mid-stream failure (legacy).
1224
1233
  # * ``surface`` — return partial content as a truncated-but-valid response.
1234
+ # v2.16.0 (tool search): Claude Code 2.1.304+ turns tool search on behind
1235
+ # a custom ANTHROPIC_BASE_URL. The request then carries a server-side
1236
+ # tool-search tool (``type: tool_search_tool_*``) and tools marked
1237
+ # ``defer_loading: true``, which only api.anthropic.com executes. A
1238
+ # local model sees the search tool as an ordinary function (and may
1239
+ # "call" it), while every deferred MCP tool lands in its context anyway.
1240
+ # Claude Code retries without tool search when the gateway answers 400.
1241
+ # * ``auto`` — ``passthrough`` when every provider in the chain
1242
+ # is ``kind: anthropic`` on api.anthropic.com,
1243
+ # otherwise ``reject`` (default).
1244
+ # * ``reject`` — answer 400 so the client turns tool search off.
1245
+ # * ``expand`` — drop the search tool and the ``defer_loading``
1246
+ # flags; every tool is sent up front.
1247
+ # * ``passthrough`` — forward unchanged (pre-v2.16.0 behaviour).
1248
+ deferred_tools_action: Literal["auto", "reject", "expand", "passthrough"] = Field(
1249
+ default="auto",
1250
+ description=(
1251
+ "v2.16.0: what to do with Claude Code tool search "
1252
+ "(``defer_loading`` tools / ``tool_search_tool_*``). ``auto`` "
1253
+ "passes it through only for chains made entirely of "
1254
+ "api.anthropic.com providers and rejects it (HTTP 400, which "
1255
+ "makes Claude Code retry without tool search) otherwise."
1256
+ ),
1257
+ )
1258
+
1225
1259
  partial_stitch_action: Literal["off", "surface"] = Field(
1226
1260
  default="off",
1227
1261
  description=(
@@ -0,0 +1,181 @@
1
+ """``coderouter doctor --check-claude-code`` (v2.16.0).
2
+
3
+ Local-only checks for a Claude Code install that talks to CodeRouter:
4
+
5
+ 1. **Version.** Known gateway-relevant regressions:
6
+
7
+ - before 2.1.301: compaction could repeat when the response's ``model``
8
+ name differs from the requested one (every translating gateway);
9
+ - exactly 2.1.304: every request failed behind a gateway that rejects
10
+ tool search (fixed in 2.1.305).
11
+
12
+ 2. **Environment.** Variables that matter behind a non-Anthropic gateway
13
+ (see ``coderouter vscode-init``):
14
+
15
+ - ``ENABLE_TOOL_SEARCH`` unset → tool search is on by default since
16
+ 2.1.304; CodeRouter refuses it (``deferred_tools_action``) and Claude
17
+ Code retries without it — works, but costs a round trip per session;
18
+ - ``CLAUDE_CODE_MAX_CONTEXT_TOKENS`` unset on 2.1.285+ → Claude Code may
19
+ assume a 1M window and compact far too late for a local model;
20
+ - ``CLAUDE_CODE_AUTO_MODE_SERVER`` unset → auto mode asks the gateway
21
+ for a server-side classifier a local backend does not have.
22
+
23
+ No network, no config file. Exit codes follow the other doctor checks:
24
+ 0 = OK, 2 = needs attention, 1 = could not run.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import re
31
+ import shutil
32
+ import subprocess
33
+ from collections.abc import Callable, Mapping
34
+ from dataclasses import dataclass, field
35
+
36
+ _VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
37
+
38
+ Version = tuple[int, int, int]
39
+
40
+
41
+ @dataclass
42
+ class ClaudeCodeFinding:
43
+ level: str # "ok" | "warn" | "info"
44
+ message: str
45
+
46
+
47
+ @dataclass
48
+ class ClaudeCodeReport:
49
+ version: Version | None
50
+ findings: list[ClaudeCodeFinding] = field(default_factory=list)
51
+ error: str | None = None
52
+
53
+ @property
54
+ def exit_code(self) -> int:
55
+ if self.error is not None:
56
+ return 1
57
+ return 2 if any(f.level == "warn" for f in self.findings) else 0
58
+
59
+
60
+ def parse_version(text: str) -> Version | None:
61
+ m = _VERSION_RE.search(text)
62
+ if m is None:
63
+ return None
64
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
65
+
66
+
67
+ def _fmt(v: Version) -> str:
68
+ return ".".join(str(p) for p in v)
69
+
70
+
71
+ def check(version: Version | None, env: Mapping[str, str]) -> list[ClaudeCodeFinding]:
72
+ """Pure check: findings for a Claude Code version and an environment."""
73
+ out: list[ClaudeCodeFinding] = []
74
+ if version is not None:
75
+ if version < (2, 1, 301):
76
+ out.append(
77
+ ClaudeCodeFinding(
78
+ "warn",
79
+ f"Claude Code {_fmt(version)} is older than 2.1.301: compaction can "
80
+ "repeat behind a gateway that reports a different model name. "
81
+ "Update Claude Code.",
82
+ )
83
+ )
84
+ elif version == (2, 1, 304):
85
+ out.append(
86
+ ClaudeCodeFinding(
87
+ "warn",
88
+ "Claude Code 2.1.304 fails every request behind a gateway that "
89
+ "rejects tool search (fixed in 2.1.305). Update Claude Code, or "
90
+ "set ENABLE_TOOL_SEARCH=false.",
91
+ )
92
+ )
93
+ else:
94
+ out.append(ClaudeCodeFinding("ok", f"Claude Code {_fmt(version)}"))
95
+
96
+ if env.get("ENABLE_TOOL_SEARCH", "").lower() in ("false", "0"):
97
+ out.append(ClaudeCodeFinding("ok", "ENABLE_TOOL_SEARCH=false"))
98
+ else:
99
+ out.append(
100
+ ClaudeCodeFinding(
101
+ "warn",
102
+ "ENABLE_TOOL_SEARCH is not false: Claude Code 2.1.304+ starts each "
103
+ "session with tool search, which only api.anthropic.com runs. "
104
+ "CodeRouter refuses it and Claude Code retries without it. Set "
105
+ "ENABLE_TOOL_SEARCH=false (coderouter vscode-init does) to skip "
106
+ "that round trip.",
107
+ )
108
+ )
109
+
110
+ if "CLAUDE_CODE_MAX_CONTEXT_TOKENS" in env:
111
+ out.append(
112
+ ClaudeCodeFinding(
113
+ "ok", f"CLAUDE_CODE_MAX_CONTEXT_TOKENS={env['CLAUDE_CODE_MAX_CONTEXT_TOKENS']}"
114
+ )
115
+ )
116
+ elif version is None or version >= (2, 1, 285):
117
+ out.append(
118
+ ClaudeCodeFinding(
119
+ "warn",
120
+ "CLAUDE_CODE_MAX_CONTEXT_TOKENS is not set: since 2.1.285 Claude Code "
121
+ "may assume a 1M window behind a gateway and compact long after a "
122
+ "local model has overflowed. Set it to your smallest local context "
123
+ "window (coderouter vscode-init --max-context-tokens N), and/or use "
124
+ "context_budget_action: reject in the profile.",
125
+ )
126
+ )
127
+
128
+ if env.get("CLAUDE_CODE_AUTO_MODE_SERVER") == "0":
129
+ out.append(ClaudeCodeFinding("ok", "CLAUDE_CODE_AUTO_MODE_SERVER=0"))
130
+ else:
131
+ out.append(
132
+ ClaudeCodeFinding(
133
+ "warn",
134
+ "CLAUDE_CODE_AUTO_MODE_SERVER is not 0: on a gateway, auto mode "
135
+ "defaults to a server-side classifier that a local backend does not "
136
+ "provide. Set CLAUDE_CODE_AUTO_MODE_SERVER=0.",
137
+ )
138
+ )
139
+ return out
140
+
141
+
142
+ def _claude_version(run: Callable[[list[str]], str] | None = None) -> tuple[Version | None, str | None]:
143
+ if run is None:
144
+ exe = shutil.which("claude")
145
+ if exe is None:
146
+ return None, "`claude` not found on PATH (version checks skipped)"
147
+
148
+ def run(cmd: list[str]) -> str:
149
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15, check=False)
150
+ return proc.stdout + proc.stderr
151
+
152
+ cmd = [exe, "--version"]
153
+ else:
154
+ cmd = ["claude", "--version"]
155
+ try:
156
+ text = run(cmd)
157
+ except (OSError, subprocess.SubprocessError) as exc:
158
+ return None, f"could not run `claude --version`: {exc}"
159
+ version = parse_version(text)
160
+ if version is None:
161
+ return None, f"could not parse `claude --version` output: {text.strip()[:80]!r}"
162
+ return version, None
163
+
164
+
165
+ def run_check(
166
+ env: Mapping[str, str] | None = None,
167
+ run: Callable[[list[str]], str] | None = None,
168
+ ) -> ClaudeCodeReport:
169
+ version, note = _claude_version(run)
170
+ findings = check(version, os.environ if env is None else env)
171
+ if note:
172
+ findings.insert(0, ClaudeCodeFinding("info", note))
173
+ return ClaudeCodeReport(version=version, findings=findings)
174
+
175
+
176
+ def format_report(report: ClaudeCodeReport) -> str:
177
+ lines = ["Claude Code (behind CodeRouter)"]
178
+ for f in report.findings:
179
+ mark = {"ok": "OK ", "warn": "WARN", "info": "INFO"}.get(f.level, f.level.upper())
180
+ lines.append(f" [{mark}] {f.message}")
181
+ return "\n".join(lines)
@@ -87,6 +87,25 @@ if TYPE_CHECKING:
87
87
  # ---------------------------------------------------------------------------
88
88
 
89
89
 
90
+ class ContextBudgetRejectError(Exception):
91
+ """v2.16.0: ``context_budget_action: reject`` refused an oversized request.
92
+
93
+ The ingress answers with Anthropic's overflow error (HTTP 400,
94
+ ``prompt is too long: ...``) so the client compacts the conversation
95
+ itself. Carries the numbers for the message and the log line.
96
+ """
97
+
98
+ def __init__(self, *, estimated_tokens: int, max_context_tokens: int, profile: str) -> None:
99
+ self.estimated_tokens = estimated_tokens
100
+ self.max_context_tokens = max_context_tokens
101
+ self.profile = profile
102
+ super().__init__(
103
+ f"prompt is too long: ~{estimated_tokens} tokens > "
104
+ f"{max_context_tokens} maximum (CodeRouter profile {profile!r}, "
105
+ "estimated)"
106
+ )
107
+
108
+
90
109
  @dataclass(frozen=True, slots=True)
91
110
  class ContextBudgetEstimate:
92
111
  """Result of a context-budget estimation check."""
@@ -0,0 +1,185 @@
1
+ """Anthropic-shaped error responses for the ``/v1/messages`` ingress (v2.16.0).
2
+
3
+ Before v2.16.0 a request whose whole chain failed came back as FastAPI's
4
+ ``{"detail": "profile=...: all providers failed: ..."}`` with HTTP 502,
5
+ whatever the upstreams had said. Claude Code reads Anthropic's error shape
6
+ (``{"type": "error", "error": {"type": ..., "message": ...}}``) and keys
7
+ several recoveries off the upstream's *wording*:
8
+
9
+ - a 400 that names a rejected beta header, ``context_management`` or tool
10
+ search makes it resend without that feature (2.1.288 to 2.1.305);
11
+ - a 400 whose message says ``prompt is too long`` triggers reactive
12
+ compaction instead of ending the turn.
13
+
14
+ None of that can fire when the message is buried in a 502 ``detail`` string.
15
+ This module turns a :class:`NoProvidersAvailableError` into the status code,
16
+ Anthropic error type and message the client should see, keeping the
17
+ upstream's own words (secret-redacted).
18
+
19
+ Mapping (first match wins, judged on the chain's errors):
20
+
21
+ ==================================== ====== ========================
22
+ condition status ``error.type``
23
+ ==================================== ====== ========================
24
+ any error reads as context overflow 400 ``invalid_request_error``
25
+ (message starts with
26
+ ``prompt is too long:``)
27
+ terminal error 400 / 422, not 400 ``invalid_request_error``
28
+ retryable
29
+ terminal error 413 413 ``request_too_large``
30
+ terminal error 429 429 ``rate_limit_error``
31
+ (+ ``retry-after`` passed
32
+ through when sent)
33
+ anything else 502 ``api_error``
34
+ ==================================== ====== ========================
35
+
36
+ Upstream 401 / 403 deliberately fall into the last row: they describe
37
+ CodeRouter's *own* upstream credentials, not the client's, and handing a
38
+ 401 to Claude Code would send the user to ``/login`` for nothing.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import re
45
+ from dataclasses import dataclass, field
46
+ from typing import TYPE_CHECKING, Any
47
+
48
+ from coderouter.secret_redaction import redact
49
+
50
+ if TYPE_CHECKING:
51
+ from coderouter.adapters.base import AdapterError
52
+ from coderouter.routing.fallback import NoProvidersAvailableError
53
+
54
+
55
+ #: Prefix Claude Code matches to start reactive compaction. Anthropic's own
56
+ #: wording is ``prompt is too long: 208000 tokens > 200000 maximum``.
57
+ PROMPT_TOO_LONG_PREFIX = "prompt is too long"
58
+
59
+ # Upstream wordings for "the prompt does not fit the context window".
60
+ # Sources (2026-10): llama.cpp server ``exceed_context_size_error`` /
61
+ # "the request exceeds the available context size"; vLLM / OpenAI
62
+ # "This model's maximum context length is N tokens"; Ollama "prompt too
63
+ # long"; OpenAI ``context_length_exceeded``; Anthropic "prompt is too long".
64
+ _CONTEXT_OVERFLOW_RE = re.compile(
65
+ r"exceed_context_size"
66
+ r"|exceeds? the available context"
67
+ r"|context_length_exceeded"
68
+ r"|maximum context length"
69
+ r"|prompt is too long"
70
+ r"|prompt too long"
71
+ r"|context window (?:is )?(?:exceeded|full)"
72
+ r"|exceeds? (?:the )?(?:model'?s )?context (?:length|window|size)",
73
+ re.IGNORECASE,
74
+ )
75
+
76
+ _MAX_MESSAGE_CHARS = 2000
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class AnthropicErrorResponse:
81
+ """What the ingress should send for a failed chain."""
82
+
83
+ status_code: int
84
+ error_type: str
85
+ message: str
86
+ headers: dict[str, str] = field(default_factory=dict)
87
+
88
+ def body(self) -> dict[str, Any]:
89
+ """Anthropic error envelope."""
90
+ return {
91
+ "type": "error",
92
+ "error": {"type": self.error_type, "message": self.message},
93
+ }
94
+
95
+
96
+ def upstream_message(err: AdapterError) -> str:
97
+ """Best human-readable message for one upstream failure.
98
+
99
+ Pulls ``error.message`` out of Anthropic / OpenAI / llama.cpp JSON
100
+ bodies, falls back to the raw body text, then to the adapter's own
101
+ message. Always secret-redacted and length-capped.
102
+ """
103
+ body = getattr(err, "upstream_body", None)
104
+ text: str | None = None
105
+ if body:
106
+ text = _message_from_body(body)
107
+ if not text:
108
+ text = Exception.__str__(err)
109
+ text = redact(text.strip())
110
+ if len(text) > _MAX_MESSAGE_CHARS:
111
+ text = text[:_MAX_MESSAGE_CHARS] + "…"
112
+ return text
113
+
114
+
115
+ def _message_from_body(body: str) -> str | None:
116
+ try:
117
+ parsed = json.loads(body)
118
+ except (json.JSONDecodeError, ValueError):
119
+ stripped = body.strip()
120
+ return stripped or None
121
+ if isinstance(parsed, dict):
122
+ err = parsed.get("error")
123
+ if isinstance(err, dict):
124
+ msg = err.get("message")
125
+ if isinstance(msg, str) and msg:
126
+ return msg
127
+ if isinstance(err, str) and err:
128
+ return err
129
+ for key in ("message", "detail"):
130
+ val = parsed.get(key)
131
+ if isinstance(val, str) and val:
132
+ return val
133
+ return body.strip() or None
134
+
135
+
136
+ # "prompt + max_tokens > window": the *completion* budget is what is too big.
137
+ # vLLM / OpenAI: "... However, you requested 33000 tokens (1000 in the
138
+ # messages, 32000 in the completion)". Compacting the prompt cannot fix that,
139
+ # so it must not be reported as "prompt is too long".
140
+ _COMPLETION_BUDGET_RE = re.compile(
141
+ r"in the completion|max_tokens|max_completion_tokens|n_predict",
142
+ re.IGNORECASE,
143
+ )
144
+
145
+
146
+ def is_context_overflow(err: AdapterError) -> bool:
147
+ """True when the upstream said the *prompt* does not fit its context."""
148
+ if err.status_code is not None and err.status_code not in (400, 413, 422):
149
+ return False
150
+ haystack = " ".join(
151
+ part for part in (getattr(err, "upstream_body", None), Exception.__str__(err)) if part
152
+ )
153
+ if not _CONTEXT_OVERFLOW_RE.search(haystack):
154
+ return False
155
+ return not _COMPLETION_BUDGET_RE.search(haystack)
156
+
157
+
158
+ def map_no_providers_error(exc: NoProvidersAvailableError) -> AnthropicErrorResponse:
159
+ """Decide status / type / message for a chain where every provider failed."""
160
+ errors = list(exc.errors)
161
+
162
+ for err in errors:
163
+ if is_context_overflow(err):
164
+ msg = upstream_message(err)
165
+ if not msg.lower().startswith(PROMPT_TOO_LONG_PREFIX):
166
+ msg = f"{PROMPT_TOO_LONG_PREFIX}: {msg}"
167
+ return AnthropicErrorResponse(400, "invalid_request_error", msg)
168
+
169
+ terminal = errors[-1] if errors else None
170
+ if terminal is not None and terminal.status_code is not None:
171
+ status = terminal.status_code
172
+ if status in (400, 422) and not terminal.retryable:
173
+ return AnthropicErrorResponse(400, "invalid_request_error", upstream_message(terminal))
174
+ if status == 413:
175
+ return AnthropicErrorResponse(413, "request_too_large", upstream_message(terminal))
176
+ if status == 429:
177
+ headers: dict[str, str] = {}
178
+ retry_after = getattr(terminal, "retry_after", None)
179
+ if retry_after:
180
+ headers["retry-after"] = retry_after
181
+ return AnthropicErrorResponse(
182
+ 429, "rate_limit_error", upstream_message(terminal), headers
183
+ )
184
+
185
+ return AnthropicErrorResponse(502, "api_error", redact(str(exc)))
@@ -28,10 +28,21 @@ from collections.abc import AsyncIterator
28
28
  from typing import Any
29
29
 
30
30
  from fastapi import APIRouter, Header, HTTPException, Request
31
- from fastapi.responses import JSONResponse, StreamingResponse
31
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
32
32
 
33
33
  from coderouter.adapters.base import StreamTruncatedError
34
+ from coderouter.guards.context_budget import ContextBudgetRejectError
34
35
  from coderouter.guards.tool_loop import ToolCountExceededError, ToolLoopBreakError
36
+ from coderouter.ingress.anthropic_errors import (
37
+ AnthropicErrorResponse,
38
+ map_no_providers_error,
39
+ )
40
+ from coderouter.ingress.tool_search import (
41
+ REJECT_MESSAGE,
42
+ expand_tool_search,
43
+ resolve_action,
44
+ uses_tool_search,
45
+ )
35
46
  from coderouter.logging import get_logger
36
47
  from coderouter.routing import (
37
48
  FallbackEngine,
@@ -115,6 +126,7 @@ async def _guard_stream(
115
126
  *,
116
127
  timeout_s: float,
117
128
  label: str,
129
+ deadline: float | None = None,
118
130
  ) -> AsyncIterator[str]:
119
131
  """M14: enforce an overall timeout + clean cancellation on an SSE stream.
120
132
 
@@ -135,7 +147,10 @@ async def _guard_stream(
135
147
  wrapper only has to deal with timeout / cancellation / normal completion.
136
148
  """
137
149
  try:
138
- async with asyncio.timeout(timeout_s):
150
+ # v2.16.0: ``deadline`` (loop time) lets the caller share one overall
151
+ # ceiling with work it did before the stream started.
152
+ when = deadline if deadline is not None else asyncio.get_running_loop().time() + timeout_s
153
+ async with asyncio.timeout_at(when):
139
154
  async for frame in source:
140
155
  yield frame
141
156
  except TimeoutError:
@@ -177,7 +192,7 @@ async def messages(
177
192
  x_coderouter_mode: str | None = Header(default=None, alias=_MODE_HEADER),
178
193
  anthropic_version: str | None = Header(default=None, alias=_ANTHROPIC_VERSION_HEADER),
179
194
  anthropic_beta: str | None = Header(default=None, alias=_ANTHROPIC_BETA_HEADER),
180
- ) -> StreamingResponse | dict[str, Any]:
195
+ ) -> Response | dict[str, Any]:
181
196
  """Anthropic Messages API endpoint.
182
197
 
183
198
  Validates the body into :class:`AnthropicRequest`, resolves the
@@ -250,10 +265,33 @@ async def messages(
250
265
  detail=(f"unknown profile {anth_req.profile!r}. available: {available}"),
251
266
  ) from exc
252
267
 
268
+ # v2.16.0: Claude Code tool search (``defer_loading`` tools plus a
269
+ # server-side ``tool_search_tool_*``) only works on api.anthropic.com.
270
+ # See ``coderouter.ingress.tool_search``.
271
+ if uses_tool_search(anth_req):
272
+ ts_action = resolve_action(config, anth_req.profile)
273
+ logger.info(
274
+ "tool-search-request",
275
+ extra={"action": ts_action, "profile": anth_req.profile or config.default_profile},
276
+ )
277
+ if ts_action == "reject":
278
+ return _anthropic_error_response(
279
+ AnthropicErrorResponse(400, "invalid_request_error", REJECT_MESSAGE)
280
+ )
281
+ if ts_action == "expand":
282
+ anth_req = expand_tool_search(anth_req)
283
+
253
284
  # v2.0-F (L1): run context budget guard before dispatch so the
254
285
  # response header can be set for both streaming and non-streaming.
255
286
  # The engine's internal guard re-check is a cheap no-op.
256
- anth_req, ctx_budget_status = engine.apply_context_budget(anth_req)
287
+ try:
288
+ anth_req, ctx_budget_status = engine.apply_context_budget(anth_req)
289
+ except ContextBudgetRejectError as exc:
290
+ # v2.16.0: ``context_budget_action: reject`` — Anthropic's own
291
+ # overflow answer, so Claude Code compacts the conversation.
292
+ return _anthropic_error_response(
293
+ AnthropicErrorResponse(400, "invalid_request_error", str(exc))
294
+ )
257
295
 
258
296
  if anth_req.stream:
259
297
  stream_headers: dict[str, str] = {
@@ -286,9 +324,84 @@ async def messages(
286
324
  # disconnect cleanup so a wedged upstream cannot pin the client
287
325
  # (or the upstream socket) open forever.
288
326
  timeout_s = _resolve_stream_timeout_s(engine, anth_req.profile)
327
+
328
+ # v2.16.0: pull the engine's first event *before* committing the
329
+ # HTTP response. A chain that fails before producing anything (every
330
+ # provider refused, an upstream said the prompt is too long, a 429)
331
+ # then gets a real HTTP status and an Anthropic error body — the
332
+ # same thing api.anthropic.com does — instead of ``200`` plus an
333
+ # in-stream ``error`` event. Clients key retries and reactive
334
+ # compaction off that status. Once the first event exists, nothing
335
+ # changes: the stream is committed exactly as before.
336
+ source = engine.stream_anthropic(anth_req)
337
+ first_event: AnthropicStreamEvent | None = None
338
+ pending_exc: BaseException | None = None
339
+ # One deadline for the whole stream, prefetch included, so the M14
340
+ # ceiling stays an *overall* ceiling (review fix: two independent
341
+ # ``timeout_s`` windows allowed ~2x).
342
+ deadline = asyncio.get_running_loop().time() + timeout_s
343
+ # Starlette cancels a StreamingResponse body when the client leaves,
344
+ # but not an endpoint that is still awaiting. The prefetch runs in
345
+ # the endpoint, so watch for the disconnect ourselves and cancel the
346
+ # wait — otherwise a user pressing Esc during a long local prefill
347
+ # would leave the upstream (and its llama.cpp slot) busy until the
348
+ # first token arrived.
349
+ watcher = _DisconnectWatcher(request)
350
+ watcher.start()
351
+ try:
352
+ async with asyncio.timeout_at(deadline) as prefetch_scope:
353
+ first_event = await anext(source)
354
+ except StopAsyncIteration:
355
+ first_event = None
356
+ except NoProvidersAvailableError as exc:
357
+ await _aclose_quietly(source)
358
+ return _anthropic_error_response(map_no_providers_error(exc))
359
+ except ContextBudgetRejectError as exc:
360
+ # The engine re-runs the budget guard when the prepared dispatch
361
+ # is not reused (input-filter plugins, tool-loop inject, model
362
+ # swap) — same answer as the pre-dispatch check above.
363
+ await _aclose_quietly(source)
364
+ return _anthropic_error_response(
365
+ AnthropicErrorResponse(400, "invalid_request_error", str(exc))
366
+ )
367
+ except (ToolLoopBreakError, ToolCountExceededError, MidStreamError) as exc:
368
+ # Policy errors and mid-stream failures keep their existing
369
+ # in-stream framing; the iterator below re-raises them.
370
+ pending_exc = exc
371
+ except TimeoutError:
372
+ await _aclose_quietly(source)
373
+ if not prefetch_scope.expired():
374
+ # A TimeoutError raised *inside* the engine is not our
375
+ # ceiling — do not dress it up as one.
376
+ raise
377
+ return _anthropic_error_response(
378
+ AnthropicErrorResponse(
379
+ 504, "timeout_error", f"no upstream event within {timeout_s:.0f}s"
380
+ )
381
+ )
382
+ except asyncio.CancelledError:
383
+ await _aclose_quietly(source)
384
+ if watcher.fired:
385
+ logger.info("sse-client-disconnect", extra={"label": "anthropic-prefetch"})
386
+ watcher.uncancel()
387
+ return Response(status_code=499)
388
+ raise
389
+ except BaseException:
390
+ await _aclose_quietly(source)
391
+ raise
392
+ finally:
393
+ await watcher.stop()
394
+
289
395
  guarded = _guard_stream(
290
- _anthropic_sse_iterator(engine, anth_req),
396
+ _anthropic_sse_iterator(
397
+ engine,
398
+ anth_req,
399
+ source=source,
400
+ first_event=first_event,
401
+ pending_exc=pending_exc,
402
+ ),
291
403
  timeout_s=timeout_s,
404
+ deadline=deadline,
292
405
  label="anthropic",
293
406
  )
294
407
  return StreamingResponse(
@@ -299,8 +412,16 @@ async def messages(
299
412
 
300
413
  try:
301
414
  anth_resp = await engine.generate_anthropic(anth_req)
415
+ except ContextBudgetRejectError as exc:
416
+ # See the streaming branch: the engine can re-run the guard.
417
+ return _anthropic_error_response(
418
+ AnthropicErrorResponse(400, "invalid_request_error", str(exc))
419
+ )
302
420
  except NoProvidersAvailableError as exc:
303
- raise HTTPException(status_code=502, detail=str(exc)) from exc
421
+ # v2.16.0: Anthropic-shaped error with the upstream's own wording
422
+ # and a status that reflects it (400 / 413 / 429 / 502). See
423
+ # ``coderouter.ingress.anthropic_errors``.
424
+ return _anthropic_error_response(map_no_providers_error(exc))
304
425
  except ToolLoopBreakError as exc:
305
426
  # v1.9-E (L3): the ``break`` action short-circuits the request
306
427
  # before any provider is called. Surface as a structured 400 so
@@ -457,8 +578,81 @@ async def count_tokens_route(
457
578
  return {"input_tokens": input_tokens}
458
579
 
459
580
 
581
+ class _DisconnectWatcher:
582
+ """Cancel the current task when the HTTP client disconnects.
583
+
584
+ Used only around the streaming prefetch (see ``messages``). Polls
585
+ ``request.is_disconnected()`` — a non-blocking check — every
586
+ ``interval`` seconds from a side task and, on disconnect, cancels the
587
+ task that created the watcher. ``fired`` tells the caller that a
588
+ ``CancelledError`` came from here (and ``uncancel`` clears it), as
589
+ opposed to a real cancellation from the server.
590
+ """
591
+
592
+ def __init__(self, request: Request, interval: float = 0.25) -> None:
593
+ self._request = request
594
+ self._interval = interval
595
+ self._owner = asyncio.current_task()
596
+ self._task: asyncio.Task[None] | None = None
597
+ self.fired = False
598
+
599
+ def start(self) -> None:
600
+ if self._owner is not None:
601
+ self._task = asyncio.create_task(self._run())
602
+
603
+ async def _run(self) -> None:
604
+ while True:
605
+ await asyncio.sleep(self._interval)
606
+ try:
607
+ gone = await self._request.is_disconnected()
608
+ except Exception:
609
+ return
610
+ if gone:
611
+ self.fired = True
612
+ if self._owner is not None:
613
+ self._owner.cancel()
614
+ return
615
+
616
+ def uncancel(self) -> None:
617
+ if self._owner is not None:
618
+ self._owner.uncancel()
619
+
620
+ async def stop(self) -> None:
621
+ if self._task is not None and not self._task.done():
622
+ self._task.cancel()
623
+ with contextlib.suppress(asyncio.CancelledError, Exception):
624
+ await self._task
625
+
626
+
627
+ def _anthropic_error_response(mapped: AnthropicErrorResponse) -> JSONResponse:
628
+ """Render a mapped failure, carrying the v2.15.0 fallback headers."""
629
+ headers = dict(mapped.headers)
630
+ trace = current_fallback_trace()
631
+ if trace is not None:
632
+ headers.update(trace.header_values())
633
+ logger.info(
634
+ "anthropic-error-response",
635
+ extra={"status": mapped.status_code, "error_type": mapped.error_type},
636
+ )
637
+ return JSONResponse(
638
+ content=mapped.body(), status_code=mapped.status_code, headers=headers or None
639
+ )
640
+
641
+
642
+ async def _aclose_quietly(source: AsyncIterator[Any]) -> None:
643
+ aclose = getattr(source, "aclose", None)
644
+ if aclose is not None:
645
+ with contextlib.suppress(Exception):
646
+ await aclose()
647
+
648
+
460
649
  async def _anthropic_sse_iterator(
461
- engine: FallbackEngine, anth_req: AnthropicRequest
650
+ engine: FallbackEngine,
651
+ anth_req: AnthropicRequest,
652
+ *,
653
+ source: AsyncIterator[AnthropicStreamEvent] | None = None,
654
+ first_event: AnthropicStreamEvent | None = None,
655
+ pending_exc: BaseException | None = None,
462
656
  ) -> AsyncIterator[str]:
463
657
  """Serialize engine.stream_anthropic() onto the Anthropic SSE wire.
464
658
 
@@ -466,9 +660,20 @@ async def _anthropic_sse_iterator(
466
660
  Anthropic spec (distinct from OpenAI's `data:`-only format).
467
661
  Errors map to in-stream `event: error` events — we never switch an
468
662
  in-flight HTTP response to a 5xx once headers have shipped.
663
+
664
+ v2.16.0: the route may have already pulled the first event (to answer
665
+ pre-content failures with a real HTTP status). It then passes the
666
+ started ``source``, that ``first_event``, and any exception the pull
667
+ raised (``pending_exc``), which is re-raised here so its existing
668
+ in-stream framing below applies unchanged.
469
669
  """
670
+ events = source if source is not None else engine.stream_anthropic(anth_req)
470
671
  try:
471
- async for ev in engine.stream_anthropic(anth_req):
672
+ if pending_exc is not None:
673
+ raise pending_exc
674
+ if first_event is not None:
675
+ yield _format_anthropic_sse(first_event)
676
+ async for ev in events:
472
677
  yield _format_anthropic_sse(ev)
473
678
  except NoProvidersAvailableError as exc:
474
679
  # No provider produced even the first event — surface as overloaded.
@@ -0,0 +1,100 @@
1
+ """Claude Code tool search on non-Anthropic upstreams (v2.16.0).
2
+
3
+ Claude Code 2.1.304 turned tool search on by default behind a custom
4
+ ``ANTHROPIC_BASE_URL``. A tool-search request carries:
5
+
6
+ - a server-side search tool, ``{"type": "tool_search_tool_regex_...",
7
+ "name": "tool_search_tool_regex"}`` (or the ``bm25`` variant), which only
8
+ api.anthropic.com executes, and
9
+ - ordinary tools marked ``"defer_loading": true`` that the API keeps out
10
+ of the model's context until a search surfaces them.
11
+
12
+ A local model behind CodeRouter cannot run the search: before v2.16.0 the
13
+ search tool reached it as an ordinary function it could "call", and every
14
+ deferred tool was loaded anyway. Claude Code 2.1.305 retries a request
15
+ without tool search when the gateway answers 400 / 422, so rejecting is the
16
+ cheapest correct answer. Which action applies is the profile's
17
+ ``deferred_tools_action`` (see ``FallbackChain``).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import TYPE_CHECKING, Any, Literal
23
+ from urllib.parse import urlparse
24
+
25
+ if TYPE_CHECKING:
26
+ from coderouter.config.schemas import CodeRouterConfig
27
+ from coderouter.translation import AnthropicRequest
28
+
29
+ TOOL_SEARCH_TYPE_PREFIX = "tool_search_tool"
30
+
31
+ #: Message for the 400. Mentions both trigger words so a client (or a human
32
+ #: reading the log) can tell why tool search was refused.
33
+ REJECT_MESSAGE = (
34
+ "tool search is not supported by this gateway's upstreams: remove the "
35
+ "tool_search_tool and defer_loading (CodeRouter deferred_tools_action)"
36
+ )
37
+
38
+ Action = Literal["reject", "expand", "passthrough"]
39
+
40
+
41
+ def _tool_dict(tool: Any) -> dict[str, Any]:
42
+ if hasattr(tool, "model_dump"):
43
+ dumped: dict[str, Any] = tool.model_dump()
44
+ return dumped
45
+ return dict(tool)
46
+
47
+
48
+ def _is_search_tool(t: dict[str, Any]) -> bool:
49
+ kind = t.get("type")
50
+ return isinstance(kind, str) and kind.startswith(TOOL_SEARCH_TYPE_PREFIX)
51
+
52
+
53
+ def uses_tool_search(req: AnthropicRequest) -> bool:
54
+ """True when the request carries a search tool or a deferred tool."""
55
+ for tool in req.tools or []:
56
+ t = _tool_dict(tool)
57
+ if _is_search_tool(t) or t.get("defer_loading") is True:
58
+ return True
59
+ return False
60
+
61
+
62
+ def _is_anthropic_api(provider: Any) -> bool:
63
+ if getattr(provider, "kind", None) != "anthropic":
64
+ return False
65
+ host = urlparse(str(getattr(provider, "base_url", ""))).hostname or ""
66
+ return host == "anthropic.com" or host.endswith(".anthropic.com")
67
+
68
+
69
+ def resolve_action(config: CodeRouterConfig, profile_name: str | None) -> Action:
70
+ """Resolve the profile's ``deferred_tools_action`` (``auto`` → concrete)."""
71
+ try:
72
+ profile = config.profile_by_name(profile_name or config.default_profile)
73
+ except (KeyError, ValueError):
74
+ return "reject"
75
+ action = getattr(profile, "deferred_tools_action", "auto")
76
+ if action == "reject":
77
+ return "reject"
78
+ if action == "expand":
79
+ return "expand"
80
+ if action == "passthrough":
81
+ return "passthrough"
82
+ providers = {p.name: p for p in config.providers}
83
+ chain = [providers.get(name) for name in profile.providers]
84
+ if chain and all(p is not None and _is_anthropic_api(p) for p in chain):
85
+ return "passthrough"
86
+ return "reject"
87
+
88
+
89
+ def expand_tool_search(req: AnthropicRequest) -> AnthropicRequest:
90
+ """Drop the search tool(s) and every ``defer_loading`` flag."""
91
+ from coderouter.translation.anthropic import AnthropicTool
92
+
93
+ kept: list[AnthropicTool] = []
94
+ for tool in req.tools or []:
95
+ t = _tool_dict(tool)
96
+ if _is_search_tool(t):
97
+ continue
98
+ t.pop("defer_loading", None)
99
+ kept.append(AnthropicTool.model_validate(t))
100
+ return req.model_copy(update={"tools": kept or None})
@@ -436,6 +436,16 @@ def _apply_context_budget_guard(
436
436
  if profile.context_budget_action == "warn" or not estimate.over_trim_threshold:
437
437
  return request, "warning"
438
438
 
439
+ # v2.16.0: reject → let the client compact instead of trimming here.
440
+ if profile.context_budget_action == "reject":
441
+ from coderouter.guards.context_budget import ContextBudgetRejectError
442
+
443
+ raise ContextBudgetRejectError(
444
+ estimated_tokens=estimate.estimated_tokens,
445
+ max_context_tokens=estimate.max_context_tokens,
446
+ profile=profile.name,
447
+ )
448
+
439
449
  # Over trim threshold + action is trim → trim messages
440
450
  trimmed_request, trim_result = trim_to_budget(
441
451
  request,
coderouter/vscode_init.py CHANGED
@@ -112,6 +112,37 @@ _MANAGED_ENV_KEYS: tuple[str, ...] = (
112
112
  "CODEROUTER_MODE",
113
113
  )
114
114
 
115
+ # v2.16.0: Claude Code settings for a gateway that is not api.anthropic.com.
116
+ # Written by default (``--no-claude-code-env`` opts out):
117
+ #
118
+ # * ``ENABLE_TOOL_SEARCH=false`` — 2.1.304 turned tool search on behind a
119
+ # custom ANTHROPIC_BASE_URL. Only api.anthropic.com runs the search; the
120
+ # gateway would answer 400 on every session's first request and Claude
121
+ # Code would retry without it. Saying so up front skips that round trip.
122
+ # * ``CLAUDE_CODE_AUTO_MODE_SERVER=0`` — on gateways auto mode defaults to
123
+ # the server-side permission classifier, which a local backend does not
124
+ # provide; ``0`` keeps the classifier client-side (its requests then go
125
+ # through CodeRouter like any other request).
126
+ # * ``CLAUDE_CODE_MAX_CONTEXT_TOKENS`` — only when ``--max-context-tokens``
127
+ # is given. Since 2.1.285 Claude Code assumes a 1M window behind a
128
+ # gateway for Opus 4.7+ / Sonnet 5+ / Fable ids, so auto-compaction would
129
+ # wait far past what a local model holds.
130
+ CLAUDE_CODE_ENV_KEYS: tuple[str, ...] = (
131
+ "ENABLE_TOOL_SEARCH",
132
+ "CLAUDE_CODE_AUTO_MODE_SERVER",
133
+ "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
134
+ )
135
+
136
+
137
+ def claude_code_env(*, max_context_tokens: int | None = None) -> dict[str, str]:
138
+ """v2.16.0: the Claude Code env vars ``vscode-init`` writes by default."""
139
+ env = {"ENABLE_TOOL_SEARCH": "false", "CLAUDE_CODE_AUTO_MODE_SERVER": "0"}
140
+ if max_context_tokens is not None:
141
+ if max_context_tokens <= 0:
142
+ raise ValueError("max_context_tokens must be positive")
143
+ env["CLAUDE_CODE_MAX_CONTEXT_TOKENS"] = str(max_context_tokens)
144
+ return env
145
+
115
146
 
116
147
  @dataclass(frozen=True)
117
148
  class FileOutcome:
@@ -155,6 +186,7 @@ def run_vscode_init(
155
186
  with_envrc: bool = False,
156
187
  dry_run: bool = False,
157
188
  force: bool = False,
189
+ extra_env: dict[str, str] | None = None,
158
190
  ) -> VSCodeInitResult:
159
191
  """Scaffold VSCode workspace settings for CodeRouter.
160
192
 
@@ -182,6 +214,9 @@ def run_vscode_init(
182
214
  When true, existing conflicting values are overwritten. Without
183
215
  this flag, a conflict is reported with a diff and the file is
184
216
  left untouched.
217
+ extra_env:
218
+ v2.16.0: additional variables to write next to the base ones
219
+ (the CLI passes :func:`claude_code_env`). Same conflict rules.
185
220
 
186
221
  Raises
187
222
  ------
@@ -205,6 +240,7 @@ def run_vscode_init(
205
240
  profile=profile,
206
241
  force=force,
207
242
  dry_run=dry_run,
243
+ extra_env=extra_env,
208
244
  )
209
245
  )
210
246
 
@@ -217,6 +253,7 @@ def run_vscode_init(
217
253
  profile=profile,
218
254
  force=force,
219
255
  dry_run=dry_run,
256
+ extra_env=extra_env,
220
257
  )
221
258
  )
222
259
 
@@ -235,6 +272,7 @@ def _apply_settings_json(
235
272
  profile: str | None,
236
273
  force: bool,
237
274
  dry_run: bool,
275
+ extra_env: dict[str, str] | None = None,
238
276
  ) -> FileOutcome:
239
277
  desired_env: dict[str, str] = {
240
278
  "ANTHROPIC_BASE_URL": base_url,
@@ -242,6 +280,8 @@ def _apply_settings_json(
242
280
  }
243
281
  if profile:
244
282
  desired_env["CODEROUTER_MODE"] = profile
283
+ if extra_env:
284
+ desired_env.update(extra_env)
245
285
 
246
286
  old_text = ""
247
287
  if path.exists():
@@ -401,6 +441,7 @@ def _apply_envrc(
401
441
  profile: str | None,
402
442
  force: bool,
403
443
  dry_run: bool,
444
+ extra_env: dict[str, str] | None = None,
404
445
  ) -> FileOutcome:
405
446
  """Write (or refresh) the managed block in ``.envrc``.
406
447
 
@@ -413,7 +454,7 @@ def _apply_envrc(
413
454
  which now *appends* the block rather than replacing the file. The
414
455
  operator's lines survive either way.
415
456
  """
416
- block = _render_envrc_block(base_url=base_url, profile=profile)
457
+ block = _render_envrc_block(base_url=base_url, profile=profile, extra_env=extra_env)
417
458
 
418
459
  old_text = ""
419
460
  if path.exists():
@@ -467,7 +508,7 @@ def _apply_envrc(
467
508
  before.rstrip("\n") + ("\n\n" if before.strip() else "")
468
509
  ) + block + ("\n\n" + after.lstrip("\n") if after.strip() else "\n")
469
510
 
470
- conflicts = _unmanaged_conflicts(outside)
511
+ conflicts = _unmanaged_conflicts(outside, extra_keys=tuple(extra_env or ()))
471
512
  if conflicts:
472
513
  # direnv applies exports in order, so a duplicate outside the
473
514
  # fence would silently win or lose depending on placement.
@@ -568,7 +609,9 @@ def _split_managed_block(text: str) -> tuple[str, str] | None:
568
609
  return text[:start], text[end + len(_ENVRC_END) :].lstrip("\n")
569
610
 
570
611
 
571
- def _render_envrc_block(*, base_url: str, profile: str | None) -> str:
612
+ def _render_envrc_block(
613
+ *, base_url: str, profile: str | None, extra_env: dict[str, str] | None = None
614
+ ) -> str:
572
615
  """The fenced block CodeRouter owns, markers included."""
573
616
  lines = [
574
617
  _ENVRC_BEGIN,
@@ -579,18 +622,25 @@ def _render_envrc_block(*, base_url: str, profile: str | None) -> str:
579
622
  ]
580
623
  if profile:
581
624
  lines.append(f'export CODEROUTER_MODE="{profile}"')
625
+ for key, value in (extra_env or {}).items():
626
+ lines.append(f'export {key}="{value}"')
582
627
  lines.append(_ENVRC_END)
583
628
  return "\n".join(lines)
584
629
 
585
630
 
586
- def _unmanaged_conflicts(outside_text: str) -> list[str]:
587
- """Managed variables the operator exports outside our block."""
631
+ def _unmanaged_conflicts(outside_text: str, *, extra_keys: tuple[str, ...] = ()) -> list[str]:
632
+ """Managed variables the operator exports outside our block.
633
+
634
+ v2.16.0: ``extra_keys`` (the Claude Code variables this run writes) are
635
+ checked too — but only when this run writes them, so an operator who
636
+ passed ``--no-claude-code-env`` can keep their own exports.
637
+ """
588
638
  found: list[str] = []
589
639
  for line in outside_text.splitlines():
590
640
  stripped = line.strip()
591
641
  if stripped.startswith("#"):
592
642
  continue
593
- for var in _MANAGED_ENVRC_VARS:
643
+ for var in (*_MANAGED_ENVRC_VARS, *extra_keys):
594
644
  if stripped.startswith((f"export {var}=", f"{var}=")) and var not in found:
595
645
  found.append(var)
596
646
  return found
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: coderouter-cli
3
- Version: 2.15.1
3
+ Version: 2.16.0
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
@@ -1,11 +1,12 @@
1
1
  coderouter/__init__.py,sha256=ghdjPrLtnRzY8fyQ4CJZI1UJKADyNTLtA3G7se8H7Ns,696
2
2
  coderouter/__main__.py,sha256=-LCgxJnvgUV240HjQKv7ly-mn2NuKHpC4nCpvTHjeSU,130
3
- coderouter/cli.py,sha256=Nbx1ur_HnssFtuM48yPELwhY40veVNnTAfK7OVF2uVc,42400
3
+ coderouter/cli.py,sha256=-QPJ2wTJIpa4TvIFObNaV4hyv_OeDtoLebc692ZWYQA,44775
4
4
  coderouter/cli_stats.py,sha256=CCjzc1G4hTRHZ2gG1XhxhDpUkJnnl3NXbcbp1T18jpg,29894
5
5
  coderouter/cost.py,sha256=32h6uzb4nxh2eA5d2Hn3kD9yJbtis6CFDAbeIy5KRkM,7431
6
6
  coderouter/credentials.py,sha256=R_PdrcA941_jJX_U8hxrjPDTCVVzLd4dsw2c00KwAwE,12991
7
7
  coderouter/doctor.py,sha256=0EUqKaEw2GaqX6zGKjR-bVDdlmBznFIklSz3yIoQbE0,91969
8
8
  coderouter/doctor_apply.py,sha256=tZT3bEd1G6t67YRhf1TGY4NlSvZuJJbnEqN-3AKy8Hc,29972
9
+ coderouter/doctor_claude_code.py,sha256=15ZdyUg0M_wZGEBN9Q3eb_D75lncqxQi1dNV6irWh2U,6621
9
10
  coderouter/env_security.py,sha256=FEBZnXfJ0xE39kmMMn39zk0W_DRRnmcB_REmP9f4xWo,14796
10
11
  coderouter/errors.py,sha256=Xmq67lheyw8iv3Ox39jh2c4tvNI5RcUR4QkoxVDN6l4,1130
11
12
  coderouter/gguf_introspect.py,sha256=Scu0JjUfLHj7T7TL0HQIm1AZcGYrXsjkaMMSN9HkTro,16121
@@ -20,23 +21,23 @@ coderouter/rollback.py,sha256=Bqxx1MALTDLYc0BtGMEMqRyiZIWFoAZJH_RBQw-M5ow,7675
20
21
  coderouter/secret_redaction.py,sha256=Rg71gqKN6jnybZ6UvCvXIvnThI9uRBvmOqC2grSUSUk,23159
21
22
  coderouter/token_estimation.py,sha256=7klQYA4FIg1vtjQGWAhwiBhiLwyz8KTetrLVXFJLGIc,16957
22
23
  coderouter/token_estimation_accurate.py,sha256=GTfzrBVnvAGjeVzmzAeUdOYZvWZKLAxcxPpFiJGlzjk,4609
23
- coderouter/vscode_init.py,sha256=ZEROtETsHjyBkLzZKPnF-zsBpLJx4ph0vmgyty6kNYQ,33433
24
+ coderouter/vscode_init.py,sha256=rs3S0FOuPFZhhDvE547Zonhj98al66Jc8G1wvBnFq40,35915
24
25
  coderouter/adapters/__init__.py,sha256=7dIDSZ-FE_0iSqLSDc_lK1idRdLTKcM2hP9tCJipgPI,463
25
- coderouter/adapters/anthropic_native.py,sha256=JxOaU-ymU1K3Tnvhufi_li1qChW69XtUszKJ2DdFlDQ,29936
26
- coderouter/adapters/base.py,sha256=qW0QMbO08Dw2ZDruL9rCvCxA0FXhKnSodlK9g-8Y350,13535
27
- coderouter/adapters/openai_compat.py,sha256=Kl5AACqOHxTPUn8c-4IFu4ug9_E3loLKTm2yYmvalho,23872
26
+ coderouter/adapters/anthropic_native.py,sha256=fYM4uXdYYvXfYBgz2dPjdPwv-yKY7XfnXp18YZNCsjg,30185
27
+ coderouter/adapters/base.py,sha256=8TxlhYwrLk6hFg3_j84yoJq-55QxDmyfo0BLeTcRMro,14573
28
+ coderouter/adapters/openai_compat.py,sha256=-6jD8RLjlocmnGCkGWm6C0j5fLumadJb9J-wZW4a-GQ,24121
28
29
  coderouter/adapters/registry.py,sha256=XNc1wmFv_oZlqKS0rA693TRDdbq1sZ-OIcofXOVOtq0,3680
29
30
  coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g,274
30
31
  coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
31
32
  coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
32
33
  coderouter/config/loader.py,sha256=7FoJXWaO_vN9FAjUStJDwI4aAYcUQQgMJtq5BNy8Pnc,12419
33
- coderouter/config/schemas.py,sha256=0NM5KtVGsjH8BPBeBfgM-ItfOYQKhyI-P_3EbJjIaps,146436
34
+ coderouter/config/schemas.py,sha256=pabFnhY070rWhzl9JF9jqyfRGhrD4VSwIC-xWsHGskc,148608
34
35
  coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
35
36
  coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
36
37
  coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
37
38
  coderouter/guards/_fingerprint.py,sha256=qsgNzIq9jv3FHrKL39nGJARp0cMenpN_QmWoJu87vU4,4835
38
39
  coderouter/guards/backend_health.py,sha256=GLUVQ5BhU6i8Cxz_FrgIMs5AZ-YSKAW8veFWgvn9GFI,12565
39
- coderouter/guards/context_budget.py,sha256=gw3MkiHDjpLmQTlTdJT6gRAxYEnoU0xmwSvPZk_miOQ,23059
40
+ coderouter/guards/context_budget.py,sha256=zbplo_1G54kHITdr4kkgUxjQX20Y8MN2Z4XdxETAGwI,23834
40
41
  coderouter/guards/continuous_probe.py,sha256=WIfS-apVMWXGv7bPBxxkJssePa95I4fT0mi4PNqK5iE,12181
41
42
  coderouter/guards/drift_actions.py,sha256=A6pY5CR480Ct5rCVyjlBvjPFVc93eu_r5qcUpK9mWKc,3602
42
43
  coderouter/guards/drift_detection.py,sha256=lW45EZDGRleDUTstw_CHlfUiwLUrlDHX8g7ncuOYNEw,13962
@@ -45,12 +46,14 @@ coderouter/guards/memory_pressure.py,sha256=9Xm-b7rv9Td5kPNmAFoL3sNmaOR5zT8NLdn6
45
46
  coderouter/guards/self_healing.py,sha256=XeUlgFWGqMQ5E9YvvzR2CsHRJOAJEyDChqRN0xmZ0vo,16841
46
47
  coderouter/guards/tool_loop.py,sha256=EzeMcmU7BLeTW2jsRVevU81l5rhWcn1oUr7EpzgXjVM,15209
47
48
  coderouter/ingress/__init__.py,sha256=WQsCH2CGJCAhy0mS6GSEdeYZRkkQu2OHDsP4CJWTLug,155
48
- coderouter/ingress/anthropic_routes.py,sha256=wlOb7ChWHMvuERR0lj8TF4MfPis2ZKegceoak4eREes,31131
49
+ coderouter/ingress/anthropic_errors.py,sha256=SYmcqHeQWuiAsgBjquOemmv_KIr23WTyWtjohuzLOLg,7352
50
+ coderouter/ingress/anthropic_routes.py,sha256=O2ejNl8SdXaMt-EArgsPa47NHxzvIRtVOeHMr5kJApM,39963
49
51
  coderouter/ingress/app.py,sha256=ZOx7dSvMxaWS4CzXOOEwgzFuycAVgAeWKCN6lFNF-F0,22433
50
52
  coderouter/ingress/dashboard_routes.py,sha256=nPW5_pJe27-hzqCs4sqVVn0BrgXB_7Wcd4qZ6cirvWo,26736
51
53
  coderouter/ingress/launcher_routes.py,sha256=uP_Otp9JE0fydUt1YP-wYcnS8Kj5RPLNIFf88YpfkPM,141535
52
54
  coderouter/ingress/metrics_routes.py,sha256=4s_f9CY8nVIdQBfXxyUMlN7r5_cXHOaAu7PvNbUuoxg,4609
53
55
  coderouter/ingress/openai_routes.py,sha256=9DHVtGe1mpcUBH3ziDL4RGpEA-LzjyANpBIN3Kfbpxc,14193
56
+ coderouter/ingress/tool_search.py,sha256=JYnJ8SUqPmBfZmsl41TdBQNnux0mwbidKdsr5qn15Hk,3753
54
57
  coderouter/ingress/ui_auth.py,sha256=uCWB5IaMQRNFYdY39FQ5DDVhsoRQJoqimZNSAu_QK_Q,3742
55
58
  coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
56
59
  coderouter/metrics/collector.py,sha256=-744UrUS6_aEY7hbwuvoX0Nn7wuOsQ57IIVr52nPe2M,63268
@@ -64,7 +67,7 @@ coderouter/routing/adaptive.py,sha256=G2o377twGSjbUh65wiIFx6klnpFGjsD_nI3oDvcBwh
64
67
  coderouter/routing/auto_router.py,sha256=y4v0c8u5F9f98Vmhx1vRcKPiOgAvpzbFqr6TIh058h0,13341
65
68
  coderouter/routing/budget.py,sha256=PblmVKJGs_BwNa9uDHAA8hmZ4XIVKv38mHAeU0V3OMs,8451
66
69
  coderouter/routing/capability.py,sha256=rRQhzTgQYFTFDNYcSLKvq3xjdw6B1w7T-3jS7PTMI14,27864
67
- coderouter/routing/fallback.py,sha256=Hzy-_2_zK64GUqGGcOZP-RgOq8wZ4PdBpVu4OBTx8dM,172282
70
+ coderouter/routing/fallback.py,sha256=0tN6LPFijZ5cZ-Fc2VpipIVvFHpgCgNCx1COXfenxgs,172687
68
71
  coderouter/routing/fallback_trace.py,sha256=0BysXY1CZqSvVTuePbgmHbu1AKkHLMX7hsuG6rKlDdE,19787
69
72
  coderouter/state/__init__.py,sha256=XoGcPmmBQSiZWML2S0juSveQ78xfhtdeCliNnVyzu7E,1088
70
73
  coderouter/state/audit_log.py,sha256=CEFNJht2WZ8mqesI8CzeuL63NfzFzAZ7wF88auyl6l0,11444
@@ -77,8 +80,8 @@ coderouter/translation/anthropic.py,sha256=ZQQGS9itN_3o7IKD68lKr5gR_Kem32Jsd7OcG
77
80
  coderouter/translation/convert.py,sha256=VrstDAMsUpcQ1VgMTn_-ETp1OjGX8v0ZGhCkcd4icyU,53037
78
81
  coderouter/translation/tool_repair.py,sha256=1p8rmdyyscGX2g0DDOYitMnLv-nKfR1SLuLaRB4z7GM,64244
79
82
  coderouter/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
80
- coderouter_cli-2.15.1.dist-info/METADATA,sha256=oJXnZHAWbbgbFy7wWTJW4LxBTHkA0NSDMWubhzUqo_0,18221
81
- coderouter_cli-2.15.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
82
- coderouter_cli-2.15.1.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
83
- coderouter_cli-2.15.1.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
84
- coderouter_cli-2.15.1.dist-info/RECORD,,
83
+ coderouter_cli-2.16.0.dist-info/METADATA,sha256=qGzoESa_lbm11v64IwRodTt2j1HC7uOd2-hvsXN1ETg,18221
84
+ coderouter_cli-2.16.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
85
+ coderouter_cli-2.16.0.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
86
+ coderouter_cli-2.16.0.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
87
+ coderouter_cli-2.16.0.dist-info/RECORD,,