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.
- coderouter/adapters/anthropic_native.py +4 -0
- coderouter/adapters/base.py +20 -0
- coderouter/adapters/openai_compat.py +4 -0
- coderouter/cli.py +67 -2
- coderouter/config/schemas.py +36 -2
- coderouter/doctor_claude_code.py +181 -0
- coderouter/guards/context_budget.py +19 -0
- coderouter/ingress/anthropic_errors.py +185 -0
- coderouter/ingress/anthropic_routes.py +213 -8
- coderouter/ingress/tool_search.py +100 -0
- coderouter/routing/fallback.py +10 -0
- coderouter/vscode_init.py +56 -6
- {coderouter_cli-2.15.1.dist-info → coderouter_cli-2.16.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.15.1.dist-info → coderouter_cli-2.16.0.dist-info}/RECORD +17 -14
- {coderouter_cli-2.15.1.dist-info → coderouter_cli-2.16.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.15.1.dist-info → coderouter_cli-2.16.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.15.1.dist-info → coderouter_cli-2.16.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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:
|
coderouter/adapters/base.py
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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
|
coderouter/config/schemas.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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})
|
coderouter/routing/fallback.py
CHANGED
|
@@ -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(
|
|
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.
|
|
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
|
|
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=
|
|
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=
|
|
26
|
-
coderouter/adapters/base.py,sha256=
|
|
27
|
-
coderouter/adapters/openai_compat.py,sha256
|
|
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=
|
|
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=
|
|
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/
|
|
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=
|
|
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.
|
|
81
|
-
coderouter_cli-2.
|
|
82
|
-
coderouter_cli-2.
|
|
83
|
-
coderouter_cli-2.
|
|
84
|
-
coderouter_cli-2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|