coderouter-cli 2.15.1__py3-none-any.whl → 2.17.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 +37 -0
- coderouter/adapters/openai_compat.py +4 -0
- coderouter/cli.py +67 -2
- coderouter/config/schemas.py +58 -2
- coderouter/doctor_claude_code.py +181 -0
- coderouter/guards/context_budget.py +19 -0
- coderouter/ingress/anthropic_errors.py +204 -0
- coderouter/ingress/anthropic_routes.py +221 -8
- coderouter/ingress/tool_search.py +100 -0
- coderouter/routing/fallback.py +104 -6
- coderouter/routing/fallback_trace.py +8 -1
- coderouter/vscode_init.py +56 -6
- {coderouter_cli-2.15.1.dist-info → coderouter_cli-2.17.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.15.1.dist-info → coderouter_cli-2.17.0.dist-info}/RECORD +18 -15
- {coderouter_cli-2.15.1.dist-info → coderouter_cli-2.17.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.15.1.dist-info → coderouter_cli-2.17.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.15.1.dist-info → coderouter_cli-2.17.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,204 @@
|
|
|
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
|
+
v2.17.0: every mapped response also carries ``x-should-retry`` — the header
|
|
37
|
+
Claude Code reads (with ``retry-after``) to decide whether to spend its retry
|
|
38
|
+
budget. ``false`` for the 4xx rows (resending the same request cannot
|
|
39
|
+
help), ``true`` for 429, and for 502 ``true`` only when at least one
|
|
40
|
+
upstream failure was retryable — a chain that failed purely on, say, 401s
|
|
41
|
+
will fail the same way on every retry.
|
|
42
|
+
|
|
43
|
+
Upstream 401 / 403 deliberately fall into the last row: they describe
|
|
44
|
+
CodeRouter's *own* upstream credentials, not the client's, and handing a
|
|
45
|
+
401 to Claude Code would send the user to ``/login`` for nothing.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import json
|
|
51
|
+
import re
|
|
52
|
+
from dataclasses import dataclass, field
|
|
53
|
+
from typing import TYPE_CHECKING, Any
|
|
54
|
+
|
|
55
|
+
from coderouter.secret_redaction import redact
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from coderouter.adapters.base import AdapterError
|
|
59
|
+
from coderouter.routing.fallback import NoProvidersAvailableError
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
#: Prefix Claude Code matches to start reactive compaction. Anthropic's own
|
|
63
|
+
#: wording is ``prompt is too long: 208000 tokens > 200000 maximum``.
|
|
64
|
+
PROMPT_TOO_LONG_PREFIX = "prompt is too long"
|
|
65
|
+
|
|
66
|
+
# Upstream wordings for "the prompt does not fit the context window".
|
|
67
|
+
# Sources (2026-10): llama.cpp server ``exceed_context_size_error`` /
|
|
68
|
+
# "the request exceeds the available context size"; vLLM / OpenAI
|
|
69
|
+
# "This model's maximum context length is N tokens"; Ollama "prompt too
|
|
70
|
+
# long"; OpenAI ``context_length_exceeded``; Anthropic "prompt is too long".
|
|
71
|
+
_CONTEXT_OVERFLOW_RE = re.compile(
|
|
72
|
+
r"exceed_context_size"
|
|
73
|
+
r"|exceeds? the available context"
|
|
74
|
+
r"|context_length_exceeded"
|
|
75
|
+
r"|maximum context length"
|
|
76
|
+
r"|prompt is too long"
|
|
77
|
+
r"|prompt too long"
|
|
78
|
+
r"|context window (?:is )?(?:exceeded|full)"
|
|
79
|
+
r"|exceeds? (?:the )?(?:model'?s )?context (?:length|window|size)",
|
|
80
|
+
re.IGNORECASE,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
_MAX_MESSAGE_CHARS = 2000
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class AnthropicErrorResponse:
|
|
88
|
+
"""What the ingress should send for a failed chain."""
|
|
89
|
+
|
|
90
|
+
status_code: int
|
|
91
|
+
error_type: str
|
|
92
|
+
message: str
|
|
93
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
94
|
+
|
|
95
|
+
def body(self) -> dict[str, Any]:
|
|
96
|
+
"""Anthropic error envelope."""
|
|
97
|
+
return {
|
|
98
|
+
"type": "error",
|
|
99
|
+
"error": {"type": self.error_type, "message": self.message},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def upstream_message(err: AdapterError) -> str:
|
|
104
|
+
"""Best human-readable message for one upstream failure.
|
|
105
|
+
|
|
106
|
+
Pulls ``error.message`` out of Anthropic / OpenAI / llama.cpp JSON
|
|
107
|
+
bodies, falls back to the raw body text, then to the adapter's own
|
|
108
|
+
message. Always secret-redacted and length-capped.
|
|
109
|
+
"""
|
|
110
|
+
body = getattr(err, "upstream_body", None)
|
|
111
|
+
text: str | None = None
|
|
112
|
+
if body:
|
|
113
|
+
text = _message_from_body(body)
|
|
114
|
+
if not text:
|
|
115
|
+
text = Exception.__str__(err)
|
|
116
|
+
text = redact(text.strip())
|
|
117
|
+
if len(text) > _MAX_MESSAGE_CHARS:
|
|
118
|
+
text = text[:_MAX_MESSAGE_CHARS] + "…"
|
|
119
|
+
return text
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _message_from_body(body: str) -> str | None:
|
|
123
|
+
try:
|
|
124
|
+
parsed = json.loads(body)
|
|
125
|
+
except (json.JSONDecodeError, ValueError):
|
|
126
|
+
stripped = body.strip()
|
|
127
|
+
return stripped or None
|
|
128
|
+
if isinstance(parsed, dict):
|
|
129
|
+
err = parsed.get("error")
|
|
130
|
+
if isinstance(err, dict):
|
|
131
|
+
msg = err.get("message")
|
|
132
|
+
if isinstance(msg, str) and msg:
|
|
133
|
+
return msg
|
|
134
|
+
if isinstance(err, str) and err:
|
|
135
|
+
return err
|
|
136
|
+
for key in ("message", "detail"):
|
|
137
|
+
val = parsed.get(key)
|
|
138
|
+
if isinstance(val, str) and val:
|
|
139
|
+
return val
|
|
140
|
+
return body.strip() or None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# "prompt + max_tokens > window": the *completion* budget is what is too big.
|
|
144
|
+
# vLLM / OpenAI: "... However, you requested 33000 tokens (1000 in the
|
|
145
|
+
# messages, 32000 in the completion)". Compacting the prompt cannot fix that,
|
|
146
|
+
# so it must not be reported as "prompt is too long".
|
|
147
|
+
_COMPLETION_BUDGET_RE = re.compile(
|
|
148
|
+
r"in the completion|max_tokens|max_completion_tokens|n_predict",
|
|
149
|
+
re.IGNORECASE,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_context_overflow(err: AdapterError) -> bool:
|
|
154
|
+
"""True when the upstream said the *prompt* does not fit its context."""
|
|
155
|
+
if err.status_code is not None and err.status_code not in (400, 413, 422):
|
|
156
|
+
return False
|
|
157
|
+
haystack = " ".join(
|
|
158
|
+
part for part in (getattr(err, "upstream_body", None), Exception.__str__(err)) if part
|
|
159
|
+
)
|
|
160
|
+
if not _CONTEXT_OVERFLOW_RE.search(haystack):
|
|
161
|
+
return False
|
|
162
|
+
return not _COMPLETION_BUDGET_RE.search(haystack)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def map_no_providers_error(exc: NoProvidersAvailableError) -> AnthropicErrorResponse:
|
|
166
|
+
"""Decide status / type / message for a chain where every provider failed."""
|
|
167
|
+
errors = list(exc.errors)
|
|
168
|
+
|
|
169
|
+
for err in errors:
|
|
170
|
+
if is_context_overflow(err):
|
|
171
|
+
msg = upstream_message(err)
|
|
172
|
+
if not msg.lower().startswith(PROMPT_TOO_LONG_PREFIX):
|
|
173
|
+
msg = f"{PROMPT_TOO_LONG_PREFIX}: {msg}"
|
|
174
|
+
return AnthropicErrorResponse(
|
|
175
|
+
400, "invalid_request_error", msg, {"x-should-retry": "false"}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
terminal = errors[-1] if errors else None
|
|
179
|
+
if terminal is not None and terminal.status_code is not None:
|
|
180
|
+
status = terminal.status_code
|
|
181
|
+
if status in (400, 422) and not terminal.retryable:
|
|
182
|
+
return AnthropicErrorResponse(
|
|
183
|
+
400,
|
|
184
|
+
"invalid_request_error",
|
|
185
|
+
upstream_message(terminal),
|
|
186
|
+
{"x-should-retry": "false"},
|
|
187
|
+
)
|
|
188
|
+
if status == 413:
|
|
189
|
+
return AnthropicErrorResponse(
|
|
190
|
+
413, "request_too_large", upstream_message(terminal), {"x-should-retry": "false"}
|
|
191
|
+
)
|
|
192
|
+
if status == 429:
|
|
193
|
+
headers: dict[str, str] = {"x-should-retry": "true"}
|
|
194
|
+
retry_after = getattr(terminal, "retry_after", None)
|
|
195
|
+
if retry_after:
|
|
196
|
+
headers["retry-after"] = retry_after
|
|
197
|
+
return AnthropicErrorResponse(
|
|
198
|
+
429, "rate_limit_error", upstream_message(terminal), headers
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
retry = any(e.retryable for e in errors)
|
|
202
|
+
return AnthropicErrorResponse(
|
|
203
|
+
502, "api_error", redact(str(exc)), {"x-should-retry": "true" if retry else "false"}
|
|
204
|
+
)
|
|
@@ -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,37 @@ 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(
|
|
280
|
+
400, "invalid_request_error", REJECT_MESSAGE, {"x-should-retry": "false"}
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
if ts_action == "expand":
|
|
284
|
+
anth_req = expand_tool_search(anth_req)
|
|
285
|
+
|
|
253
286
|
# v2.0-F (L1): run context budget guard before dispatch so the
|
|
254
287
|
# response header can be set for both streaming and non-streaming.
|
|
255
288
|
# The engine's internal guard re-check is a cheap no-op.
|
|
256
|
-
|
|
289
|
+
try:
|
|
290
|
+
anth_req, ctx_budget_status = engine.apply_context_budget(anth_req)
|
|
291
|
+
except ContextBudgetRejectError as exc:
|
|
292
|
+
# v2.16.0: ``context_budget_action: reject`` — Anthropic's own
|
|
293
|
+
# overflow answer, so Claude Code compacts the conversation.
|
|
294
|
+
return _anthropic_error_response(
|
|
295
|
+
AnthropicErrorResponse(
|
|
296
|
+
400, "invalid_request_error", str(exc), {"x-should-retry": "false"}
|
|
297
|
+
)
|
|
298
|
+
)
|
|
257
299
|
|
|
258
300
|
if anth_req.stream:
|
|
259
301
|
stream_headers: dict[str, str] = {
|
|
@@ -286,9 +328,86 @@ async def messages(
|
|
|
286
328
|
# disconnect cleanup so a wedged upstream cannot pin the client
|
|
287
329
|
# (or the upstream socket) open forever.
|
|
288
330
|
timeout_s = _resolve_stream_timeout_s(engine, anth_req.profile)
|
|
331
|
+
|
|
332
|
+
# v2.16.0: pull the engine's first event *before* committing the
|
|
333
|
+
# HTTP response. A chain that fails before producing anything (every
|
|
334
|
+
# provider refused, an upstream said the prompt is too long, a 429)
|
|
335
|
+
# then gets a real HTTP status and an Anthropic error body — the
|
|
336
|
+
# same thing api.anthropic.com does — instead of ``200`` plus an
|
|
337
|
+
# in-stream ``error`` event. Clients key retries and reactive
|
|
338
|
+
# compaction off that status. Once the first event exists, nothing
|
|
339
|
+
# changes: the stream is committed exactly as before.
|
|
340
|
+
source = engine.stream_anthropic(anth_req)
|
|
341
|
+
first_event: AnthropicStreamEvent | None = None
|
|
342
|
+
pending_exc: BaseException | None = None
|
|
343
|
+
# One deadline for the whole stream, prefetch included, so the M14
|
|
344
|
+
# ceiling stays an *overall* ceiling (review fix: two independent
|
|
345
|
+
# ``timeout_s`` windows allowed ~2x).
|
|
346
|
+
deadline = asyncio.get_running_loop().time() + timeout_s
|
|
347
|
+
# Starlette cancels a StreamingResponse body when the client leaves,
|
|
348
|
+
# but not an endpoint that is still awaiting. The prefetch runs in
|
|
349
|
+
# the endpoint, so watch for the disconnect ourselves and cancel the
|
|
350
|
+
# wait — otherwise a user pressing Esc during a long local prefill
|
|
351
|
+
# would leave the upstream (and its llama.cpp slot) busy until the
|
|
352
|
+
# first token arrived.
|
|
353
|
+
watcher = _DisconnectWatcher(request)
|
|
354
|
+
watcher.start()
|
|
355
|
+
try:
|
|
356
|
+
async with asyncio.timeout_at(deadline) as prefetch_scope:
|
|
357
|
+
first_event = await anext(source)
|
|
358
|
+
except StopAsyncIteration:
|
|
359
|
+
first_event = None
|
|
360
|
+
except NoProvidersAvailableError as exc:
|
|
361
|
+
await _aclose_quietly(source)
|
|
362
|
+
return _anthropic_error_response(map_no_providers_error(exc))
|
|
363
|
+
except ContextBudgetRejectError as exc:
|
|
364
|
+
# The engine re-runs the budget guard when the prepared dispatch
|
|
365
|
+
# is not reused (input-filter plugins, tool-loop inject, model
|
|
366
|
+
# swap) — same answer as the pre-dispatch check above.
|
|
367
|
+
await _aclose_quietly(source)
|
|
368
|
+
return _anthropic_error_response(
|
|
369
|
+
AnthropicErrorResponse(
|
|
370
|
+
400, "invalid_request_error", str(exc), {"x-should-retry": "false"}
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
except (ToolLoopBreakError, ToolCountExceededError, MidStreamError) as exc:
|
|
374
|
+
# Policy errors and mid-stream failures keep their existing
|
|
375
|
+
# in-stream framing; the iterator below re-raises them.
|
|
376
|
+
pending_exc = exc
|
|
377
|
+
except TimeoutError:
|
|
378
|
+
await _aclose_quietly(source)
|
|
379
|
+
if not prefetch_scope.expired():
|
|
380
|
+
# A TimeoutError raised *inside* the engine is not our
|
|
381
|
+
# ceiling — do not dress it up as one.
|
|
382
|
+
raise
|
|
383
|
+
return _anthropic_error_response(
|
|
384
|
+
AnthropicErrorResponse(
|
|
385
|
+
504, "timeout_error", f"no upstream event within {timeout_s:.0f}s"
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
except asyncio.CancelledError:
|
|
389
|
+
await _aclose_quietly(source)
|
|
390
|
+
if watcher.fired:
|
|
391
|
+
logger.info("sse-client-disconnect", extra={"label": "anthropic-prefetch"})
|
|
392
|
+
watcher.uncancel()
|
|
393
|
+
return Response(status_code=499)
|
|
394
|
+
raise
|
|
395
|
+
except BaseException:
|
|
396
|
+
await _aclose_quietly(source)
|
|
397
|
+
raise
|
|
398
|
+
finally:
|
|
399
|
+
await watcher.stop()
|
|
400
|
+
|
|
289
401
|
guarded = _guard_stream(
|
|
290
|
-
_anthropic_sse_iterator(
|
|
402
|
+
_anthropic_sse_iterator(
|
|
403
|
+
engine,
|
|
404
|
+
anth_req,
|
|
405
|
+
source=source,
|
|
406
|
+
first_event=first_event,
|
|
407
|
+
pending_exc=pending_exc,
|
|
408
|
+
),
|
|
291
409
|
timeout_s=timeout_s,
|
|
410
|
+
deadline=deadline,
|
|
292
411
|
label="anthropic",
|
|
293
412
|
)
|
|
294
413
|
return StreamingResponse(
|
|
@@ -299,8 +418,18 @@ async def messages(
|
|
|
299
418
|
|
|
300
419
|
try:
|
|
301
420
|
anth_resp = await engine.generate_anthropic(anth_req)
|
|
421
|
+
except ContextBudgetRejectError as exc:
|
|
422
|
+
# See the streaming branch: the engine can re-run the guard.
|
|
423
|
+
return _anthropic_error_response(
|
|
424
|
+
AnthropicErrorResponse(
|
|
425
|
+
400, "invalid_request_error", str(exc), {"x-should-retry": "false"}
|
|
426
|
+
)
|
|
427
|
+
)
|
|
302
428
|
except NoProvidersAvailableError as exc:
|
|
303
|
-
|
|
429
|
+
# v2.16.0: Anthropic-shaped error with the upstream's own wording
|
|
430
|
+
# and a status that reflects it (400 / 413 / 429 / 502). See
|
|
431
|
+
# ``coderouter.ingress.anthropic_errors``.
|
|
432
|
+
return _anthropic_error_response(map_no_providers_error(exc))
|
|
304
433
|
except ToolLoopBreakError as exc:
|
|
305
434
|
# v1.9-E (L3): the ``break`` action short-circuits the request
|
|
306
435
|
# before any provider is called. Surface as a structured 400 so
|
|
@@ -457,8 +586,81 @@ async def count_tokens_route(
|
|
|
457
586
|
return {"input_tokens": input_tokens}
|
|
458
587
|
|
|
459
588
|
|
|
589
|
+
class _DisconnectWatcher:
|
|
590
|
+
"""Cancel the current task when the HTTP client disconnects.
|
|
591
|
+
|
|
592
|
+
Used only around the streaming prefetch (see ``messages``). Polls
|
|
593
|
+
``request.is_disconnected()`` — a non-blocking check — every
|
|
594
|
+
``interval`` seconds from a side task and, on disconnect, cancels the
|
|
595
|
+
task that created the watcher. ``fired`` tells the caller that a
|
|
596
|
+
``CancelledError`` came from here (and ``uncancel`` clears it), as
|
|
597
|
+
opposed to a real cancellation from the server.
|
|
598
|
+
"""
|
|
599
|
+
|
|
600
|
+
def __init__(self, request: Request, interval: float = 0.25) -> None:
|
|
601
|
+
self._request = request
|
|
602
|
+
self._interval = interval
|
|
603
|
+
self._owner = asyncio.current_task()
|
|
604
|
+
self._task: asyncio.Task[None] | None = None
|
|
605
|
+
self.fired = False
|
|
606
|
+
|
|
607
|
+
def start(self) -> None:
|
|
608
|
+
if self._owner is not None:
|
|
609
|
+
self._task = asyncio.create_task(self._run())
|
|
610
|
+
|
|
611
|
+
async def _run(self) -> None:
|
|
612
|
+
while True:
|
|
613
|
+
await asyncio.sleep(self._interval)
|
|
614
|
+
try:
|
|
615
|
+
gone = await self._request.is_disconnected()
|
|
616
|
+
except Exception:
|
|
617
|
+
return
|
|
618
|
+
if gone:
|
|
619
|
+
self.fired = True
|
|
620
|
+
if self._owner is not None:
|
|
621
|
+
self._owner.cancel()
|
|
622
|
+
return
|
|
623
|
+
|
|
624
|
+
def uncancel(self) -> None:
|
|
625
|
+
if self._owner is not None:
|
|
626
|
+
self._owner.uncancel()
|
|
627
|
+
|
|
628
|
+
async def stop(self) -> None:
|
|
629
|
+
if self._task is not None and not self._task.done():
|
|
630
|
+
self._task.cancel()
|
|
631
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
632
|
+
await self._task
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _anthropic_error_response(mapped: AnthropicErrorResponse) -> JSONResponse:
|
|
636
|
+
"""Render a mapped failure, carrying the v2.15.0 fallback headers."""
|
|
637
|
+
headers = dict(mapped.headers)
|
|
638
|
+
trace = current_fallback_trace()
|
|
639
|
+
if trace is not None:
|
|
640
|
+
headers.update(trace.header_values())
|
|
641
|
+
logger.info(
|
|
642
|
+
"anthropic-error-response",
|
|
643
|
+
extra={"status": mapped.status_code, "error_type": mapped.error_type},
|
|
644
|
+
)
|
|
645
|
+
return JSONResponse(
|
|
646
|
+
content=mapped.body(), status_code=mapped.status_code, headers=headers or None
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
async def _aclose_quietly(source: AsyncIterator[Any]) -> None:
|
|
651
|
+
aclose = getattr(source, "aclose", None)
|
|
652
|
+
if aclose is not None:
|
|
653
|
+
with contextlib.suppress(Exception):
|
|
654
|
+
await aclose()
|
|
655
|
+
|
|
656
|
+
|
|
460
657
|
async def _anthropic_sse_iterator(
|
|
461
|
-
engine: FallbackEngine,
|
|
658
|
+
engine: FallbackEngine,
|
|
659
|
+
anth_req: AnthropicRequest,
|
|
660
|
+
*,
|
|
661
|
+
source: AsyncIterator[AnthropicStreamEvent] | None = None,
|
|
662
|
+
first_event: AnthropicStreamEvent | None = None,
|
|
663
|
+
pending_exc: BaseException | None = None,
|
|
462
664
|
) -> AsyncIterator[str]:
|
|
463
665
|
"""Serialize engine.stream_anthropic() onto the Anthropic SSE wire.
|
|
464
666
|
|
|
@@ -466,9 +668,20 @@ async def _anthropic_sse_iterator(
|
|
|
466
668
|
Anthropic spec (distinct from OpenAI's `data:`-only format).
|
|
467
669
|
Errors map to in-stream `event: error` events — we never switch an
|
|
468
670
|
in-flight HTTP response to a 5xx once headers have shipped.
|
|
671
|
+
|
|
672
|
+
v2.16.0: the route may have already pulled the first event (to answer
|
|
673
|
+
pre-content failures with a real HTTP status). It then passes the
|
|
674
|
+
started ``source``, that ``first_event``, and any exception the pull
|
|
675
|
+
raised (``pending_exc``), which is re-raised here so its existing
|
|
676
|
+
in-stream framing below applies unchanged.
|
|
469
677
|
"""
|
|
678
|
+
events = source if source is not None else engine.stream_anthropic(anth_req)
|
|
470
679
|
try:
|
|
471
|
-
|
|
680
|
+
if pending_exc is not None:
|
|
681
|
+
raise pending_exc
|
|
682
|
+
if first_event is not None:
|
|
683
|
+
yield _format_anthropic_sse(first_event)
|
|
684
|
+
async for ev in events:
|
|
472
685
|
yield _format_anthropic_sse(ev)
|
|
473
686
|
except NoProvidersAvailableError as exc:
|
|
474
687
|
# 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})
|