coderouter-cli 1.7.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/__init__.py +17 -0
- coderouter/__main__.py +6 -0
- coderouter/adapters/__init__.py +23 -0
- coderouter/adapters/anthropic_native.py +502 -0
- coderouter/adapters/base.py +220 -0
- coderouter/adapters/openai_compat.py +395 -0
- coderouter/adapters/registry.py +17 -0
- coderouter/cli.py +345 -0
- coderouter/cli_stats.py +751 -0
- coderouter/config/__init__.py +10 -0
- coderouter/config/capability_registry.py +339 -0
- coderouter/config/env_file.py +295 -0
- coderouter/config/loader.py +73 -0
- coderouter/config/schemas.py +515 -0
- coderouter/data/__init__.py +7 -0
- coderouter/data/model-capabilities.yaml +86 -0
- coderouter/doctor.py +1596 -0
- coderouter/env_security.py +434 -0
- coderouter/errors.py +29 -0
- coderouter/ingress/__init__.py +5 -0
- coderouter/ingress/anthropic_routes.py +205 -0
- coderouter/ingress/app.py +144 -0
- coderouter/ingress/dashboard_routes.py +493 -0
- coderouter/ingress/metrics_routes.py +92 -0
- coderouter/ingress/openai_routes.py +153 -0
- coderouter/logging.py +315 -0
- coderouter/metrics/__init__.py +39 -0
- coderouter/metrics/collector.py +471 -0
- coderouter/metrics/prometheus.py +221 -0
- coderouter/output_filters.py +407 -0
- coderouter/routing/__init__.py +13 -0
- coderouter/routing/auto_router.py +244 -0
- coderouter/routing/capability.py +285 -0
- coderouter/routing/fallback.py +611 -0
- coderouter/translation/__init__.py +57 -0
- coderouter/translation/anthropic.py +204 -0
- coderouter/translation/convert.py +1291 -0
- coderouter/translation/tool_repair.py +236 -0
- coderouter_cli-1.7.0.dist-info/METADATA +509 -0
- coderouter_cli-1.7.0.dist-info/RECORD +43 -0
- coderouter_cli-1.7.0.dist-info/WHEEL +4 -0
- coderouter_cli-1.7.0.dist-info/entry_points.txt +2 -0
- coderouter_cli-1.7.0.dist-info/licenses/LICENSE +21 -0
coderouter/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""CodeRouter — local-first, free-first, fallback-built-in LLM router."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _pkg_version
|
|
5
|
+
|
|
6
|
+
from coderouter.errors import CodeRouterError
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
# v1.7-A: PyPI distribution name is `coderouter-cli` (the bare
|
|
10
|
+
# `coderouter` slot was taken). The Python import name stays
|
|
11
|
+
# `coderouter` regardless. See pyproject.toml top-of-file comment
|
|
12
|
+
# for the full story.
|
|
13
|
+
__version__ = _pkg_version("coderouter-cli")
|
|
14
|
+
except PackageNotFoundError: # pragma: no cover — package not installed (e.g. raw source checkout)
|
|
15
|
+
__version__ = "0.0.0+unknown"
|
|
16
|
+
|
|
17
|
+
__all__ = ["CodeRouterError", "__version__"]
|
coderouter/__main__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Provider adapters."""
|
|
2
|
+
|
|
3
|
+
from coderouter.adapters.base import (
|
|
4
|
+
AdapterError,
|
|
5
|
+
BaseAdapter,
|
|
6
|
+
ChatRequest,
|
|
7
|
+
ChatResponse,
|
|
8
|
+
Message,
|
|
9
|
+
StreamChunk,
|
|
10
|
+
)
|
|
11
|
+
from coderouter.adapters.openai_compat import OpenAICompatAdapter
|
|
12
|
+
from coderouter.adapters.registry import build_adapter
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AdapterError",
|
|
16
|
+
"BaseAdapter",
|
|
17
|
+
"ChatRequest",
|
|
18
|
+
"ChatResponse",
|
|
19
|
+
"Message",
|
|
20
|
+
"OpenAICompatAdapter",
|
|
21
|
+
"StreamChunk",
|
|
22
|
+
"build_adapter",
|
|
23
|
+
]
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Native Anthropic Messages API adapter (v0.3.x-1, extended in v0.4-A).
|
|
2
|
+
|
|
3
|
+
A passthrough adapter that speaks the Anthropic wire format directly, used
|
|
4
|
+
when the Anthropic ingress routes to a `kind: "anthropic"` provider (most
|
|
5
|
+
commonly api.anthropic.com itself, but also any server that speaks the
|
|
6
|
+
Anthropic Messages protocol — e.g. AWS Bedrock's Anthropic shim).
|
|
7
|
+
|
|
8
|
+
Design decisions:
|
|
9
|
+
- No SDK dependency (plan.md §5.4): all calls are plain httpx.
|
|
10
|
+
- v0.3.x-1 introduced `generate_anthropic` / `stream_anthropic` as the
|
|
11
|
+
native passthrough entry points for the Anthropic ingress.
|
|
12
|
+
- v0.4-A fills in the OpenAI-shaped `generate` / `stream` methods via
|
|
13
|
+
reverse translation (ChatRequest ↔ AnthropicRequest). That means a
|
|
14
|
+
`kind: anthropic` provider is now reachable from both /v1/messages
|
|
15
|
+
(native passthrough) AND /v1/chat/completions (reverse-translated).
|
|
16
|
+
- Streaming is parse-based (event/data → AnthropicStreamEvent) rather
|
|
17
|
+
than pure byte passthrough. This preserves the v0.3-B mid-stream
|
|
18
|
+
guard: upstream errors that surface after the first chunk still
|
|
19
|
+
raise AdapterError, which the engine converts to MidStreamError.
|
|
20
|
+
|
|
21
|
+
Auth:
|
|
22
|
+
Anthropic uses `x-api-key`, NOT `Authorization: Bearer`. `api_key_env`
|
|
23
|
+
in ProviderConfig names the env var holding the key (typically
|
|
24
|
+
ANTHROPIC_API_KEY).
|
|
25
|
+
|
|
26
|
+
`anthropic-version` header defaults to "2023-06-01" and can be
|
|
27
|
+
overridden via `provider.extra_body["anthropic_version"]` in
|
|
28
|
+
providers.yaml for users on a pinned minor version.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
from collections.abc import AsyncIterator
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
import httpx
|
|
38
|
+
|
|
39
|
+
from coderouter.adapters.base import (
|
|
40
|
+
AdapterError,
|
|
41
|
+
BaseAdapter,
|
|
42
|
+
ChatRequest,
|
|
43
|
+
ChatResponse,
|
|
44
|
+
ProviderCallOverrides,
|
|
45
|
+
StreamChunk,
|
|
46
|
+
)
|
|
47
|
+
from coderouter.config.loader import resolve_api_key
|
|
48
|
+
from coderouter.logging import get_logger, log_output_filter_applied
|
|
49
|
+
from coderouter.output_filters import OutputFilterChain
|
|
50
|
+
from coderouter.translation.anthropic import (
|
|
51
|
+
AnthropicRequest,
|
|
52
|
+
AnthropicResponse,
|
|
53
|
+
AnthropicStreamEvent,
|
|
54
|
+
)
|
|
55
|
+
from coderouter.translation.convert import (
|
|
56
|
+
stream_anthropic_to_chat_chunks,
|
|
57
|
+
to_anthropic_request,
|
|
58
|
+
to_chat_response,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
logger = get_logger(__name__)
|
|
62
|
+
|
|
63
|
+
# Mirror openai_compat._RETRYABLE_STATUSES — same reasoning applies.
|
|
64
|
+
# 404: upstream may not have the requested model → next provider.
|
|
65
|
+
# 408 / 504: timeouts. 425: too early. 429: rate limit. 5xx: upstream errors.
|
|
66
|
+
_RETRYABLE_STATUSES = {404, 408, 425, 429, 500, 502, 503, 504}
|
|
67
|
+
|
|
68
|
+
_DEFAULT_ANTHROPIC_VERSION = "2023-06-01"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AnthropicAdapter(BaseAdapter):
|
|
72
|
+
"""Native Anthropic Messages API adapter (passthrough).
|
|
73
|
+
|
|
74
|
+
The new methods ``generate_anthropic`` / ``stream_anthropic`` speak the
|
|
75
|
+
Anthropic wire format end-to-end. The OpenAI-shaped ``generate`` /
|
|
76
|
+
``stream`` inherited contract raises a non-retryable error — if you
|
|
77
|
+
want to reach Anthropic via /v1/chat/completions, configure an
|
|
78
|
+
OpenRouter (or similar) `openai_compat` provider instead.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# HTTP plumbing
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _url(self) -> str:
|
|
86
|
+
"""Resolve the ``/v1/messages`` endpoint URL from ``base_url``.
|
|
87
|
+
|
|
88
|
+
``base_url`` may be given with or without a trailing ``/v1`` —
|
|
89
|
+
we normalize by stripping it so appending ``/v1/messages``
|
|
90
|
+
always yields a valid URL.
|
|
91
|
+
"""
|
|
92
|
+
base = str(self.config.base_url).rstrip("/")
|
|
93
|
+
# Users may point base_url at either `https://api.anthropic.com`
|
|
94
|
+
# or `https://api.anthropic.com/v1`. We normalize to the former so
|
|
95
|
+
# we can always append /v1/messages.
|
|
96
|
+
if base.endswith("/v1"):
|
|
97
|
+
base = base[: -len("/v1")]
|
|
98
|
+
return f"{base}/v1/messages"
|
|
99
|
+
|
|
100
|
+
def _headers(self, request: AnthropicRequest | None = None) -> dict[str, str]:
|
|
101
|
+
"""Build Anthropic-native HTTP headers, including beta-header forwarding.
|
|
102
|
+
|
|
103
|
+
Always sets ``anthropic-version`` (configurable via ``extra_body``)
|
|
104
|
+
and, when an API key is configured, ``x-api-key``. When the
|
|
105
|
+
caller passes an ``AnthropicRequest`` that carries an
|
|
106
|
+
``anthropic-beta`` header value, it is forwarded verbatim so
|
|
107
|
+
beta-gated body fields (``context_management`` etc.) survive.
|
|
108
|
+
"""
|
|
109
|
+
headers: dict[str, str] = {
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
"User-Agent": "CodeRouter/0.1",
|
|
112
|
+
"anthropic-version": str(
|
|
113
|
+
self.config.extra_body.get("anthropic_version", _DEFAULT_ANTHROPIC_VERSION)
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
api_key = resolve_api_key(self.config.api_key_env)
|
|
117
|
+
if api_key:
|
|
118
|
+
headers["x-api-key"] = api_key
|
|
119
|
+
# v0.4-D: forward the client's anthropic-beta header verbatim.
|
|
120
|
+
# This is what unlocks beta-gated body fields Claude Code relies on
|
|
121
|
+
# (context_management, newer cache_control / thinking variants).
|
|
122
|
+
# When the entry point is `/v1/chat/completions` (reverse
|
|
123
|
+
# translation), the request won't carry one and we skip the header
|
|
124
|
+
# — OpenAI clients wouldn't know what to put there anyway.
|
|
125
|
+
if request is not None and request.anthropic_beta:
|
|
126
|
+
headers["anthropic-beta"] = request.anthropic_beta
|
|
127
|
+
return headers
|
|
128
|
+
|
|
129
|
+
def _payload(self, req: AnthropicRequest, *, stream: bool) -> dict[str, Any]:
|
|
130
|
+
"""Serialize the AnthropicRequest to an outbound JSON body.
|
|
131
|
+
|
|
132
|
+
The provider's configured `model` ALWAYS wins — the client-sent
|
|
133
|
+
`model` field is treated as a routing placeholder (same policy as
|
|
134
|
+
the OpenAI-compat adapter; see plan.md routing rules).
|
|
135
|
+
"""
|
|
136
|
+
# Start from extra_body so client fields can override vendor defaults.
|
|
137
|
+
# `anthropic_version` is a header, not body — strip it here.
|
|
138
|
+
body: dict[str, Any] = {
|
|
139
|
+
k: v for k, v in self.config.extra_body.items() if k != "anthropic_version"
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
dumped = req.model_dump(exclude_none=True)
|
|
143
|
+
# `profile` is CodeRouter-only; `model` comes from provider config.
|
|
144
|
+
dumped.pop("profile", None)
|
|
145
|
+
dumped.pop("model", None)
|
|
146
|
+
|
|
147
|
+
body.update(dumped)
|
|
148
|
+
body["model"] = self.config.model
|
|
149
|
+
body["stream"] = stream
|
|
150
|
+
return body
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# BaseAdapter contract
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
async def healthcheck(self) -> bool:
|
|
157
|
+
"""Cheapest meaningful probe: POST /v1/messages with 1 token cap.
|
|
158
|
+
|
|
159
|
+
Anthropic doesn't expose a public /models list, and a GET to / or
|
|
160
|
+
/v1/messages returns 405. A minimal POST is the least-bad signal
|
|
161
|
+
that auth works and the endpoint is reachable.
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
165
|
+
resp = await client.post(
|
|
166
|
+
self._url(),
|
|
167
|
+
headers=self._headers(),
|
|
168
|
+
json={
|
|
169
|
+
"model": self.config.model,
|
|
170
|
+
"max_tokens": 1,
|
|
171
|
+
"messages": [{"role": "user", "content": "ping"}],
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
# 200 is clearly healthy. 4xx auth errors indicate the
|
|
175
|
+
# endpoint is reachable even if the key is bad — still a
|
|
176
|
+
# "the server answered" signal for healthcheck purposes.
|
|
177
|
+
# 5xx is upstream trouble.
|
|
178
|
+
return resp.status_code < 500
|
|
179
|
+
except httpx.HTTPError:
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
async def generate(
|
|
183
|
+
self,
|
|
184
|
+
request: ChatRequest,
|
|
185
|
+
*,
|
|
186
|
+
overrides: ProviderCallOverrides | None = None,
|
|
187
|
+
) -> ChatResponse:
|
|
188
|
+
"""OpenAI-shaped generate (v0.4-A): reverse-translate → native call → back.
|
|
189
|
+
|
|
190
|
+
ChatRequest is converted to AnthropicRequest via
|
|
191
|
+
``to_anthropic_request``, the native ``generate_anthropic`` path
|
|
192
|
+
handles the HTTP call (including retryable status mapping), and
|
|
193
|
+
the AnthropicResponse is converted back to ChatResponse via
|
|
194
|
+
``to_chat_response``. The ``coderouter_provider`` tag is preserved
|
|
195
|
+
on both sides.
|
|
196
|
+
"""
|
|
197
|
+
anth_req = to_anthropic_request(request)
|
|
198
|
+
anth_resp = await self.generate_anthropic(anth_req, overrides=overrides)
|
|
199
|
+
chat_resp = to_chat_response(anth_resp)
|
|
200
|
+
# generate_anthropic stamps coderouter_provider on the Anthropic
|
|
201
|
+
# response; to_chat_response forwards it. Re-assert for safety
|
|
202
|
+
# in case a caller constructed a response without the tag.
|
|
203
|
+
if not chat_resp.coderouter_provider:
|
|
204
|
+
chat_resp.coderouter_provider = self.name
|
|
205
|
+
return chat_resp
|
|
206
|
+
|
|
207
|
+
async def stream(
|
|
208
|
+
self,
|
|
209
|
+
request: ChatRequest,
|
|
210
|
+
*,
|
|
211
|
+
overrides: ProviderCallOverrides | None = None,
|
|
212
|
+
) -> AsyncIterator[StreamChunk]:
|
|
213
|
+
"""OpenAI-shaped stream (v0.4-A): reverse-translate → native SSE → chunks.
|
|
214
|
+
|
|
215
|
+
Mirrors ``generate()``: ChatRequest → AnthropicRequest via
|
|
216
|
+
``to_anthropic_request``, the native ``stream_anthropic`` yields
|
|
217
|
+
AnthropicStreamEvents, and ``stream_anthropic_to_chat_chunks``
|
|
218
|
+
re-segments those into OpenAI StreamChunks on the fly.
|
|
219
|
+
|
|
220
|
+
Error semantics preserve the v0.3-B mid-stream guard:
|
|
221
|
+
- Initial upstream failure → AdapterError propagates to the
|
|
222
|
+
engine's fallback path.
|
|
223
|
+
- Mid-stream failure (after first chunk) → AdapterError is
|
|
224
|
+
re-raised by the engine as MidStreamError.
|
|
225
|
+
- Anthropic ``event: error`` → translator raises
|
|
226
|
+
AdapterError(retryable=False), same treatment as above.
|
|
227
|
+
"""
|
|
228
|
+
anth_req = to_anthropic_request(request)
|
|
229
|
+
events = self.stream_anthropic(anth_req, overrides=overrides)
|
|
230
|
+
async for chunk in stream_anthropic_to_chat_chunks(events, provider_name=self.name):
|
|
231
|
+
yield chunk
|
|
232
|
+
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
# v1.0-A: output_filters helpers (native Anthropic streaming)
|
|
235
|
+
# ------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def _process_stream_event_for_filters(
|
|
238
|
+
self,
|
|
239
|
+
event: AnthropicStreamEvent,
|
|
240
|
+
*,
|
|
241
|
+
chains: dict[int, OutputFilterChain],
|
|
242
|
+
logged_flag: list[bool],
|
|
243
|
+
) -> list[AnthropicStreamEvent]:
|
|
244
|
+
"""Apply the configured output_filters chain to a parsed SSE event.
|
|
245
|
+
|
|
246
|
+
Returns the list of events to yield downstream:
|
|
247
|
+
- ``content_block_start`` (type=text) → create a fresh chain
|
|
248
|
+
keyed by block index, return [event].
|
|
249
|
+
- ``content_block_delta`` (type=text_delta) → filter the
|
|
250
|
+
delta text in place (may produce an empty string that the
|
|
251
|
+
downstream pass-through still emits; clients tolerate it).
|
|
252
|
+
- ``content_block_stop`` → flush the chain for this index.
|
|
253
|
+
If the flush produced tail text (the safe-to-emit suffix
|
|
254
|
+
the filter had been holding), a synthetic
|
|
255
|
+
``content_block_delta`` event is prepended so the client
|
|
256
|
+
sees every byte that was not part of a matched tag, then
|
|
257
|
+
the original ``content_block_stop`` follows.
|
|
258
|
+
- All other event types pass through unchanged.
|
|
259
|
+
|
|
260
|
+
Logs ``output-filter-applied`` exactly once per stream (via the
|
|
261
|
+
``logged_flag`` mutable cell; same dedupe shape as v0.5-C's
|
|
262
|
+
reasoning-strip log).
|
|
263
|
+
"""
|
|
264
|
+
if not self.config.output_filters:
|
|
265
|
+
return [event]
|
|
266
|
+
|
|
267
|
+
data = event.data
|
|
268
|
+
|
|
269
|
+
if event.type == "content_block_start":
|
|
270
|
+
block = data.get("content_block") or {}
|
|
271
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
272
|
+
idx = data.get("index", 0)
|
|
273
|
+
chains[idx] = OutputFilterChain(self.config.output_filters)
|
|
274
|
+
return [event]
|
|
275
|
+
|
|
276
|
+
if event.type == "content_block_delta":
|
|
277
|
+
delta = data.get("delta") or {}
|
|
278
|
+
if isinstance(delta, dict) and delta.get("type") == "text_delta":
|
|
279
|
+
idx = data.get("index", 0)
|
|
280
|
+
chain = chains.get(idx)
|
|
281
|
+
if chain is not None:
|
|
282
|
+
text = delta.get("text", "")
|
|
283
|
+
if isinstance(text, str) and text:
|
|
284
|
+
delta["text"] = chain.feed(text)
|
|
285
|
+
return [event]
|
|
286
|
+
|
|
287
|
+
if event.type == "content_block_stop":
|
|
288
|
+
idx = data.get("index", 0)
|
|
289
|
+
chain = chains.pop(idx, None)
|
|
290
|
+
if chain is None:
|
|
291
|
+
return [event]
|
|
292
|
+
tail = chain.feed("", eof=True)
|
|
293
|
+
events: list[AnthropicStreamEvent] = []
|
|
294
|
+
if tail:
|
|
295
|
+
events.append(
|
|
296
|
+
AnthropicStreamEvent(
|
|
297
|
+
type="content_block_delta",
|
|
298
|
+
data={
|
|
299
|
+
"type": "content_block_delta",
|
|
300
|
+
"index": idx,
|
|
301
|
+
"delta": {"type": "text_delta", "text": tail},
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
events.append(event)
|
|
306
|
+
if chain.any_applied and not logged_flag[0]:
|
|
307
|
+
log_output_filter_applied(
|
|
308
|
+
logger,
|
|
309
|
+
provider=self.name,
|
|
310
|
+
filters=chain.applied_filters(),
|
|
311
|
+
streaming=True,
|
|
312
|
+
)
|
|
313
|
+
logged_flag[0] = True
|
|
314
|
+
return events
|
|
315
|
+
|
|
316
|
+
return [event]
|
|
317
|
+
|
|
318
|
+
# ------------------------------------------------------------------
|
|
319
|
+
# Native Anthropic entry points (v0.3.x-1)
|
|
320
|
+
# ------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
async def generate_anthropic(
|
|
323
|
+
self,
|
|
324
|
+
request: AnthropicRequest,
|
|
325
|
+
*,
|
|
326
|
+
overrides: ProviderCallOverrides | None = None,
|
|
327
|
+
) -> AnthropicResponse:
|
|
328
|
+
"""Non-streaming passthrough: AnthropicRequest → AnthropicResponse."""
|
|
329
|
+
url = self._url()
|
|
330
|
+
payload = self._payload(request, stream=False)
|
|
331
|
+
timeout = self.effective_timeout(overrides)
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
335
|
+
resp = await client.post(url, json=payload, headers=self._headers(request))
|
|
336
|
+
except httpx.TimeoutException as exc:
|
|
337
|
+
raise AdapterError(
|
|
338
|
+
f"timeout contacting {url}", provider=self.name, retryable=True
|
|
339
|
+
) from exc
|
|
340
|
+
except httpx.HTTPError as exc:
|
|
341
|
+
raise AdapterError(
|
|
342
|
+
f"transport error: {exc}", provider=self.name, retryable=True
|
|
343
|
+
) from exc
|
|
344
|
+
|
|
345
|
+
if resp.status_code >= 400:
|
|
346
|
+
raise AdapterError(
|
|
347
|
+
f"{resp.status_code} from upstream: {resp.text[:200]}",
|
|
348
|
+
provider=self.name,
|
|
349
|
+
status_code=resp.status_code,
|
|
350
|
+
retryable=resp.status_code in _RETRYABLE_STATUSES,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
data = resp.json()
|
|
355
|
+
except json.JSONDecodeError as exc:
|
|
356
|
+
raise AdapterError(
|
|
357
|
+
f"invalid JSON from upstream: {exc}",
|
|
358
|
+
provider=self.name,
|
|
359
|
+
retryable=False,
|
|
360
|
+
) from exc
|
|
361
|
+
|
|
362
|
+
# v1.0-A: apply output_filters to every text content block. One
|
|
363
|
+
# fresh chain per block so `<think>...</think>` state from block N
|
|
364
|
+
# never bleeds into block N+1 (matters when Anthropic thinking +
|
|
365
|
+
# text blocks coexist, or with future multi-block responses).
|
|
366
|
+
if self.config.output_filters:
|
|
367
|
+
any_block_modified = False
|
|
368
|
+
applied_names: list[str] = []
|
|
369
|
+
blocks = data.get("content")
|
|
370
|
+
if isinstance(blocks, list):
|
|
371
|
+
for block in blocks:
|
|
372
|
+
if (
|
|
373
|
+
isinstance(block, dict)
|
|
374
|
+
and block.get("type") == "text"
|
|
375
|
+
and isinstance(block.get("text"), str)
|
|
376
|
+
and block["text"]
|
|
377
|
+
):
|
|
378
|
+
chain = OutputFilterChain(self.config.output_filters)
|
|
379
|
+
block["text"] = chain.feed(block["text"], eof=True)
|
|
380
|
+
if chain.any_applied:
|
|
381
|
+
any_block_modified = True
|
|
382
|
+
for n in chain.applied_filters():
|
|
383
|
+
if n not in applied_names:
|
|
384
|
+
applied_names.append(n)
|
|
385
|
+
if any_block_modified:
|
|
386
|
+
log_output_filter_applied(
|
|
387
|
+
logger,
|
|
388
|
+
provider=self.name,
|
|
389
|
+
filters=applied_names,
|
|
390
|
+
streaming=False,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# Tag with provider metadata and return. Unknown Anthropic fields
|
|
394
|
+
# (future additions like thinking blocks) pass through via
|
|
395
|
+
# extra="allow" on AnthropicResponse.
|
|
396
|
+
data["coderouter_provider"] = self.name
|
|
397
|
+
return AnthropicResponse.model_validate(data)
|
|
398
|
+
|
|
399
|
+
async def stream_anthropic(
|
|
400
|
+
self,
|
|
401
|
+
request: AnthropicRequest,
|
|
402
|
+
*,
|
|
403
|
+
overrides: ProviderCallOverrides | None = None,
|
|
404
|
+
) -> AsyncIterator[AnthropicStreamEvent]:
|
|
405
|
+
"""Streaming passthrough: Anthropic SSE → AnthropicStreamEvent iterator.
|
|
406
|
+
|
|
407
|
+
Parses the upstream SSE stream (event/data pairs) and yields each
|
|
408
|
+
event as a typed AnthropicStreamEvent. The ingress re-serializes
|
|
409
|
+
these back to the wire, so events are round-tripped through the
|
|
410
|
+
same structure the non-native path produces.
|
|
411
|
+
|
|
412
|
+
Upstream errors after the first event raise AdapterError; the
|
|
413
|
+
FallbackEngine converts those to MidStreamError (v0.3-B).
|
|
414
|
+
"""
|
|
415
|
+
url = self._url()
|
|
416
|
+
payload = self._payload(request, stream=True)
|
|
417
|
+
timeout = self.effective_timeout(overrides)
|
|
418
|
+
|
|
419
|
+
# v1.0-A: per-block filter chains (keyed by content block index)
|
|
420
|
+
# + a mutable one-cell flag that lets the filter helper log
|
|
421
|
+
# exactly once per stream without ping-ponging state through
|
|
422
|
+
# every yield site.
|
|
423
|
+
filter_chains: dict[int, OutputFilterChain] = {}
|
|
424
|
+
logged_flag: list[bool] = [False]
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
async with (
|
|
428
|
+
httpx.AsyncClient(timeout=timeout) as client,
|
|
429
|
+
client.stream("POST", url, json=payload, headers=self._headers(request)) as resp,
|
|
430
|
+
):
|
|
431
|
+
if resp.status_code >= 400:
|
|
432
|
+
body = await resp.aread()
|
|
433
|
+
raise AdapterError(
|
|
434
|
+
f"{resp.status_code} from upstream: {body[:200]!r}",
|
|
435
|
+
provider=self.name,
|
|
436
|
+
status_code=resp.status_code,
|
|
437
|
+
retryable=resp.status_code in _RETRYABLE_STATUSES,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# Anthropic SSE block shape:
|
|
441
|
+
# event: <event-type>
|
|
442
|
+
# data: <json>
|
|
443
|
+
# <blank line>
|
|
444
|
+
# We buffer per-line and emit on blank-line boundary.
|
|
445
|
+
current_event: str | None = None
|
|
446
|
+
data_lines: list[str] = []
|
|
447
|
+
|
|
448
|
+
async for line in resp.aiter_lines():
|
|
449
|
+
if line == "":
|
|
450
|
+
# End of block — flush if well-formed.
|
|
451
|
+
if current_event is not None and data_lines:
|
|
452
|
+
data_str = "\n".join(data_lines)
|
|
453
|
+
try:
|
|
454
|
+
data_obj = json.loads(data_str)
|
|
455
|
+
except json.JSONDecodeError:
|
|
456
|
+
# Skip malformed blocks rather than
|
|
457
|
+
# abort the whole stream.
|
|
458
|
+
current_event = None
|
|
459
|
+
data_lines = []
|
|
460
|
+
continue
|
|
461
|
+
for out_event in self._process_stream_event_for_filters(
|
|
462
|
+
AnthropicStreamEvent(type=current_event, data=data_obj),
|
|
463
|
+
chains=filter_chains,
|
|
464
|
+
logged_flag=logged_flag,
|
|
465
|
+
):
|
|
466
|
+
yield out_event
|
|
467
|
+
current_event = None
|
|
468
|
+
data_lines = []
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
if line.startswith(":"):
|
|
472
|
+
# SSE comment / heartbeat
|
|
473
|
+
continue
|
|
474
|
+
if line.startswith("event:"):
|
|
475
|
+
current_event = line[len("event:") :].strip()
|
|
476
|
+
elif line.startswith("data:"):
|
|
477
|
+
data_lines.append(line[len("data:") :].lstrip())
|
|
478
|
+
# Silently ignore other field names (id:, retry:) —
|
|
479
|
+
# Anthropic doesn't use them today.
|
|
480
|
+
|
|
481
|
+
# Trailing block without terminating blank line.
|
|
482
|
+
if current_event is not None and data_lines:
|
|
483
|
+
data_str = "\n".join(data_lines)
|
|
484
|
+
try:
|
|
485
|
+
data_obj = json.loads(data_str)
|
|
486
|
+
except json.JSONDecodeError:
|
|
487
|
+
data_obj = None
|
|
488
|
+
if data_obj is not None:
|
|
489
|
+
for out_event in self._process_stream_event_for_filters(
|
|
490
|
+
AnthropicStreamEvent(type=current_event, data=data_obj),
|
|
491
|
+
chains=filter_chains,
|
|
492
|
+
logged_flag=logged_flag,
|
|
493
|
+
):
|
|
494
|
+
yield out_event
|
|
495
|
+
except httpx.TimeoutException as exc:
|
|
496
|
+
raise AdapterError(
|
|
497
|
+
f"timeout streaming from {url}", provider=self.name, retryable=True
|
|
498
|
+
) from exc
|
|
499
|
+
except httpx.HTTPError as exc:
|
|
500
|
+
raise AdapterError(
|
|
501
|
+
f"transport error: {exc}", provider=self.name, retryable=True
|
|
502
|
+
) from exc
|