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.
@@ -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
- async with asyncio.timeout(timeout_s):
150
+ # v2.16.0: ``deadline`` (loop time) lets the caller share one overall
151
+ # ceiling with work it did before the stream started.
152
+ when = deadline if deadline is not None else asyncio.get_running_loop().time() + timeout_s
153
+ async with asyncio.timeout_at(when):
139
154
  async for frame in source:
140
155
  yield frame
141
156
  except TimeoutError:
@@ -177,7 +192,7 @@ async def messages(
177
192
  x_coderouter_mode: str | None = Header(default=None, alias=_MODE_HEADER),
178
193
  anthropic_version: str | None = Header(default=None, alias=_ANTHROPIC_VERSION_HEADER),
179
194
  anthropic_beta: str | None = Header(default=None, alias=_ANTHROPIC_BETA_HEADER),
180
- ) -> StreamingResponse | dict[str, Any]:
195
+ ) -> Response | dict[str, Any]:
181
196
  """Anthropic Messages API endpoint.
182
197
 
183
198
  Validates the body into :class:`AnthropicRequest`, resolves the
@@ -250,10 +265,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
- anth_req, ctx_budget_status = engine.apply_context_budget(anth_req)
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(engine, anth_req),
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
- raise HTTPException(status_code=502, detail=str(exc)) from exc
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, anth_req: AnthropicRequest
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
- async for ev in engine.stream_anthropic(anth_req):
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})