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.
Files changed (43) hide show
  1. coderouter/__init__.py +17 -0
  2. coderouter/__main__.py +6 -0
  3. coderouter/adapters/__init__.py +23 -0
  4. coderouter/adapters/anthropic_native.py +502 -0
  5. coderouter/adapters/base.py +220 -0
  6. coderouter/adapters/openai_compat.py +395 -0
  7. coderouter/adapters/registry.py +17 -0
  8. coderouter/cli.py +345 -0
  9. coderouter/cli_stats.py +751 -0
  10. coderouter/config/__init__.py +10 -0
  11. coderouter/config/capability_registry.py +339 -0
  12. coderouter/config/env_file.py +295 -0
  13. coderouter/config/loader.py +73 -0
  14. coderouter/config/schemas.py +515 -0
  15. coderouter/data/__init__.py +7 -0
  16. coderouter/data/model-capabilities.yaml +86 -0
  17. coderouter/doctor.py +1596 -0
  18. coderouter/env_security.py +434 -0
  19. coderouter/errors.py +29 -0
  20. coderouter/ingress/__init__.py +5 -0
  21. coderouter/ingress/anthropic_routes.py +205 -0
  22. coderouter/ingress/app.py +144 -0
  23. coderouter/ingress/dashboard_routes.py +493 -0
  24. coderouter/ingress/metrics_routes.py +92 -0
  25. coderouter/ingress/openai_routes.py +153 -0
  26. coderouter/logging.py +315 -0
  27. coderouter/metrics/__init__.py +39 -0
  28. coderouter/metrics/collector.py +471 -0
  29. coderouter/metrics/prometheus.py +221 -0
  30. coderouter/output_filters.py +407 -0
  31. coderouter/routing/__init__.py +13 -0
  32. coderouter/routing/auto_router.py +244 -0
  33. coderouter/routing/capability.py +285 -0
  34. coderouter/routing/fallback.py +611 -0
  35. coderouter/translation/__init__.py +57 -0
  36. coderouter/translation/anthropic.py +204 -0
  37. coderouter/translation/convert.py +1291 -0
  38. coderouter/translation/tool_repair.py +236 -0
  39. coderouter_cli-1.7.0.dist-info/METADATA +509 -0
  40. coderouter_cli-1.7.0.dist-info/RECORD +43 -0
  41. coderouter_cli-1.7.0.dist-info/WHEEL +4 -0
  42. coderouter_cli-1.7.0.dist-info/entry_points.txt +2 -0
  43. 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,6 @@
1
+ """Allow `python -m coderouter ...`."""
2
+
3
+ from coderouter.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -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