openvidia 2.2.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,714 @@
1
+ """
2
+ Anthropic Messages API → chat/completions shim.
3
+
4
+ Translates /v1/messages (the API used by Claude Code CLI) into
5
+ /v1/chat/completions, which the proxy already knows how to forward to NVIDIA
6
+ NIM. The translation is bidirectional:
7
+
8
+ - request: messages + system → chat messages (system as leading message)
9
+ - response: chat completion → Anthropic content blocks (text, tool_use)
10
+ - streaming: SSE chat chunks → SSE Anthropic events
11
+ - tools: Anthropic tool schema (input_schema) → OpenAI function (parameters)
12
+
13
+ This is a separate endpoint — it does not interfere with Claude Code's normal
14
+ operation pointing at api.anthropic.com. It activates only when the user
15
+ chooses to point ANTHROPIC_BASE_URL at localhost:1919.
16
+
17
+ No abstractions — just payload translation. All names, signatures and control
18
+ flow are frozen; only prose (docstrings/comments) is reformatted.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import uuid
25
+ from collections.abc import AsyncGenerator
26
+ from typing import Any
27
+
28
+ import httpx
29
+ from fastapi import Request
30
+ from fastapi.responses import JSONResponse, StreamingResponse
31
+
32
+ from .config import UPSTREAM_CHAT as UPSTREAM
33
+ from .proxy_state import ProxyState
34
+ from .responses_shim import (
35
+ _MAX_ROTATE_ATTEMPTS,
36
+ _MIN_LIVE_FRACTION,
37
+ _ROTATE_SEND_TIMEOUT,
38
+ _live_pool_snapshot,
39
+ _rotation_phase,
40
+ _sanitize_chat_messages,
41
+ _sse_event,
42
+ _upstream_error_message,
43
+ json_headers,
44
+ )
45
+
46
+ # ── Request: Anthropic Messages → chat/completions ──────────────────
47
+
48
+
49
+ def _anthropic_to_chat_messages(body: dict) -> list[dict]:
50
+ """
51
+ Convert Anthropic messages into chat/completions messages.
52
+
53
+ Anthropic message content may be a string or an array of content blocks:
54
+ - {type:"text", text:"..."}
55
+ - {type:"image", source:{...}} (dropped — NVIDIA has no vision)
56
+ - {type:"tool_use", id, name, input} → assistant tool_calls
57
+ - {type:"tool_result", tool_use_id, content} → role:tool
58
+ """
59
+ messages: list[dict] = []
60
+
61
+ # Anthropic carries the system prompt separately from the messages array.
62
+ system = body.get("system")
63
+ if system:
64
+ if isinstance(system, list):
65
+ # Anthropic system may be an array of {type:"text", text:"..."}.
66
+ system = "\n".join(b.get("text", "") for b in system if b.get("type") == "text")
67
+ if system:
68
+ messages.append({"role": "system", "content": system})
69
+
70
+ for msg in body.get("messages", []):
71
+ role = msg.get("role", "user")
72
+ content = msg.get("content", "")
73
+
74
+ # Simple string content — pass through.
75
+ if isinstance(content, str):
76
+ messages.append({"role": role, "content": content})
77
+ continue
78
+
79
+ # Non-list, non-string — stringify defensively.
80
+ if not isinstance(content, list):
81
+ messages.append({"role": role, "content": str(content)})
82
+ continue
83
+
84
+ # Parse content blocks.
85
+ text_parts: list[str] = []
86
+ tool_calls: list[dict] = []
87
+ tool_results: list[dict] = []
88
+
89
+ for block in content:
90
+ btype = block.get("type", "text")
91
+
92
+ if btype == "text":
93
+ text_parts.append(block.get("text", ""))
94
+
95
+ elif btype == "tool_use":
96
+ # Assistant invoked a tool → assistant tool_calls entry.
97
+ tool_calls.append(
98
+ {
99
+ "id": block.get("id", f"call_{uuid.uuid4().hex[:24]}"),
100
+ "type": "function",
101
+ "function": {
102
+ "name": block.get("name", ""),
103
+ "arguments": json.dumps(block.get("input", {})),
104
+ },
105
+ }
106
+ )
107
+
108
+ elif btype == "tool_result":
109
+ # Tool result → role:tool message.
110
+ tr_content = block.get("content", "")
111
+ if isinstance(tr_content, list):
112
+ # Array of content blocks (text-only).
113
+ tr_content = "\n".join(
114
+ b.get("text", "") for b in tr_content if b.get("type") == "text"
115
+ )
116
+ elif isinstance(tr_content, dict):
117
+ tr_content = json.dumps(tr_content)
118
+ tool_results.append(
119
+ {
120
+ "role": "tool",
121
+ "tool_call_id": block.get("tool_use_id", ""),
122
+ "content": str(tr_content),
123
+ }
124
+ )
125
+
126
+ elif btype == "image":
127
+ # NVIDIA NIM has no vision support — inject a textual
128
+ # placeholder so the model knows an image was present,
129
+ # rather than silently dropping it.
130
+ text_parts.append("[image omitted: the model does not support vision]")
131
+
132
+ # Assemble the final message(s).
133
+ if role == "assistant":
134
+ msg_dict: dict[str, Any] = {"role": "assistant"}
135
+ if text_parts:
136
+ msg_dict["content"] = "\n".join(text_parts)
137
+ if tool_calls:
138
+ msg_dict["tool_calls"] = tool_calls
139
+ if not text_parts:
140
+ msg_dict["content"] = None
141
+ messages.append(msg_dict)
142
+ elif role == "user":
143
+ # User with tool_results → emit tool messages, then any text.
144
+ if tool_results:
145
+ messages.extend(tool_results)
146
+ if text_parts:
147
+ messages.append({"role": "user", "content": "\n".join(text_parts)})
148
+ else:
149
+ if text_parts:
150
+ messages.append({"role": role, "content": "\n".join(text_parts)})
151
+
152
+ return messages
153
+
154
+
155
+ def _anthropic_tools_to_chat_tools(tools: list[dict]) -> list[dict]:
156
+ """
157
+ Convert Anthropic tools into chat/completions tools.
158
+
159
+ Anthropic shape: {name, description, input_schema: {...}}
160
+ OpenAI shape: {type:"function", function:{name, description, parameters:{...}}}
161
+
162
+ Non-function tools (computer_use, bash, text_editor, typed tools like
163
+ "computer_20241022") are skipped — NVIDIA only understands function tools.
164
+ """
165
+ chat_tools = []
166
+ for tool in tools:
167
+ # Anthropic tags some tools with a type; "custom" is the generic
168
+ # function tool. Anything else (computer, bash, etc.) is unsupported.
169
+ if tool.get("type") and tool.get("type") != "custom":
170
+ continue
171
+
172
+ name = tool.get("name", "")
173
+ if not name:
174
+ continue
175
+
176
+ chat_tools.append(
177
+ {
178
+ "type": "function",
179
+ "function": {
180
+ "name": name,
181
+ "description": tool.get("description", ""),
182
+ "parameters": tool.get("input_schema", {"type": "object", "properties": {}}),
183
+ },
184
+ }
185
+ )
186
+ return chat_tools
187
+
188
+
189
+ def _build_chat_payload(body: dict, model_override: str | None) -> dict:
190
+ """Build the chat/completions payload from an Anthropic Messages request body."""
191
+ messages = _anthropic_to_chat_messages(body)
192
+
193
+ # model_override (from state.active_model) takes precedence.
194
+ # No hardcoded model: resolved live from the user's selection.
195
+ from .proxy_app import default_model
196
+
197
+ effective_model = model_override or default_model()
198
+
199
+ payload: dict[str, Any] = {
200
+ "model": effective_model,
201
+ "messages": messages,
202
+ }
203
+
204
+ # Tools translation + tool_choice mapping.
205
+ tools = body.get("tools", [])
206
+ if tools:
207
+ payload["tools"] = _anthropic_tools_to_chat_tools(tools)
208
+ # Anthropic tool_choice → OpenAI tool_choice
209
+ tc = body.get("tool_choice")
210
+ if tc:
211
+ if isinstance(tc, dict) and tc.get("type") == "auto":
212
+ payload["tool_choice"] = "auto"
213
+ elif isinstance(tc, dict) and tc.get("type") == "any":
214
+ payload["tool_choice"] = "required"
215
+ elif isinstance(tc, dict) and tc.get("type") == "tool":
216
+ payload["tool_choice"] = {
217
+ "type": "function",
218
+ "function": {"name": tc.get("name", "")},
219
+ }
220
+
221
+ # Parameter pass-through. Anthropic uses max_tokens (required), temperature,
222
+ # top_p; OpenAI uses max_tokens or max_completion_tokens.
223
+ max_tokens = body.get("max_tokens")
224
+ if max_tokens:
225
+ payload["max_tokens"] = max_tokens
226
+
227
+ for key in ("temperature", "top_p", "stream"):
228
+ val = body.get(key)
229
+ if val is not None:
230
+ payload[key] = val
231
+
232
+ # stop_sequences → stop
233
+ stop = body.get("stop_sequences")
234
+ if stop:
235
+ payload["stop"] = stop
236
+
237
+ payload["messages"] = _sanitize_chat_messages(payload["messages"])
238
+ # Dashboard thinking toggle — never overrides what the client asked for.
239
+ from . import config as _cfg
240
+
241
+ _cfg.apply_model_options(payload)
242
+ return payload
243
+
244
+
245
+ # ── Response: chat/completions → Anthropic Messages ─────────────────
246
+
247
+
248
+ def _chat_to_anthropic_response(chat_data: dict, model: str) -> dict:
249
+ """Translate a chat/completions response into Anthropic Messages format."""
250
+ choice = (chat_data.get("choices") or [{}])[0]
251
+ msg = choice.get("message", {})
252
+
253
+ content_blocks: list[dict] = []
254
+
255
+ # Tool calls → tool_use blocks. Arguments are parsed back into objects
256
+ # to match Anthropic's input field (not a JSON string).
257
+ tool_calls = msg.get("tool_calls", [])
258
+ for tc in tool_calls:
259
+ fn = tc.get("function", {})
260
+ try:
261
+ tool_input = json.loads(fn.get("arguments", "{}"))
262
+ except json.JSONDecodeError:
263
+ tool_input = {}
264
+ content_blocks.append(
265
+ {
266
+ "type": "tool_use",
267
+ "id": tc.get("id", f"toolu_{uuid.uuid4().hex[:24]}"),
268
+ "name": fn.get("name", ""),
269
+ "input": tool_input,
270
+ }
271
+ )
272
+
273
+ # Text content → text block.
274
+ text = msg.get("content")
275
+ if text:
276
+ content_blocks.append({"type": "text", "text": text})
277
+
278
+ # Anthropic requires a stop_reason — map from OpenAI's finish_reason.
279
+ finish = choice.get("finish_reason", "stop")
280
+ stop_reason_map = {
281
+ "stop": "end_turn",
282
+ "length": "max_tokens",
283
+ "tool_calls": "tool_use",
284
+ "content_filter": "end_turn",
285
+ }
286
+ stop_reason = stop_reason_map.get(finish, "end_turn")
287
+
288
+ # Usage mapping (prompt/completion → input/output).
289
+ usage = chat_data.get("usage", {})
290
+ anthropic_usage = {
291
+ "input_tokens": usage.get("prompt_tokens", 0),
292
+ "output_tokens": usage.get("completion_tokens", 0),
293
+ }
294
+
295
+ return {
296
+ "id": f"msg_{uuid.uuid4().hex[:24]}",
297
+ "type": "message",
298
+ "role": "assistant",
299
+ "model": model,
300
+ "content": content_blocks,
301
+ "stop_reason": stop_reason,
302
+ "stop_sequence": None,
303
+ "usage": anthropic_usage,
304
+ }
305
+
306
+
307
+ # ── Streaming: SSE chat chunks → SSE Anthropic events ───────────────
308
+
309
+
310
+ async def _stream_anthropic(
311
+ state: ProxyState,
312
+ chat_payload: dict,
313
+ model: str,
314
+ client,
315
+ request: Request,
316
+ ) -> AsyncGenerator[bytes, None]:
317
+ """Forward chat/completions with stream:true and re-translate SSE chunks into Anthropic events."""
318
+ msg_id = f"msg_{uuid.uuid4().hex[:24]}"
319
+
320
+ # message_start — the opening event of the Anthropic stream.
321
+ yield _sse_event(
322
+ "message_start",
323
+ {
324
+ "type": "message_start",
325
+ "message": {
326
+ "id": msg_id,
327
+ "type": "message",
328
+ "role": "assistant",
329
+ "model": model,
330
+ "content": [],
331
+ "stop_reason": None,
332
+ "stop_sequence": None,
333
+ "usage": {"input_tokens": 0, "output_tokens": 0},
334
+ },
335
+ },
336
+ )
337
+
338
+ # Candidate keys resolved inside the generator — avoids concurrent
339
+ # requests sharing a precomputed list.
340
+ async with state.lock:
341
+ candidates = state.get_candidate_keys()
342
+
343
+ # Saturation gate + bounded rotation (shared with responses shim).
344
+ _live, _valid = _live_pool_snapshot(state, candidates)
345
+ resp = None
346
+ used_key = None
347
+ used_idx = None
348
+ _outcome: dict = {}
349
+ if not (_valid and _live < max(1, int(_valid * _MIN_LIVE_FRACTION))):
350
+ resp, used_key, used_idx = await _rotation_phase(
351
+ client,
352
+ UPSTREAM,
353
+ chat_payload,
354
+ json_headers,
355
+ state,
356
+ candidates,
357
+ max_attempts=_MAX_ROTATE_ATTEMPTS,
358
+ timeout=_ROTATE_SEND_TIMEOUT,
359
+ stream=True,
360
+ log_tag="anthropic shim",
361
+ outcome_box=_outcome,
362
+ )
363
+
364
+ if resp is None or used_key is None:
365
+ # _live is the count of usable keys; the old check used _valid (the
366
+ # whole pool), so it reported "pool saturated" whenever any key
367
+ # existed — which is always.
368
+ base = "all keys failed (pool saturated)" if _live == 0 else "all keys failed"
369
+ yield _sse_event(
370
+ "error",
371
+ {
372
+ "type": "error",
373
+ "error": {
374
+ "type": "invalid_request_error"
375
+ if _outcome.get("deterministic")
376
+ else "api_error",
377
+ "message": _upstream_error_message(_outcome, base),
378
+ },
379
+ },
380
+ )
381
+ return
382
+
383
+ state.stats.success += 1
384
+ state.stats.record_key_usage(used_key, ok=True)
385
+ state.record_key_model_result(used_key, chat_payload.get("model", ""), ok=True)
386
+ if used_idx is not None:
387
+ state.log_cb(f"✔ key[{used_idx}] OK")
388
+
389
+ # Accumulators for streamed content blocks.
390
+ text_block_started = False
391
+ text_block_index = 0
392
+ text_full = ""
393
+
394
+ # Tool calls: index → state.
395
+ tool_map: dict[int, dict] = {}
396
+ # Block indices are handed out in arrival order. This used to start at 1 on
397
+ # the assumption that index 0 was always the text block — but a reply that
398
+ # is nothing but tool calls (finish_reason == "tool_calls" with no preamble,
399
+ # which is the common case in an agent loop) never opens a text block. The
400
+ # client then received its first content block at index 1 and was left with
401
+ # a hole at content[0].
402
+ next_block_index = 0
403
+ # Whether the content_block_stop events have already been emitted, so the
404
+ # tail below closes the blocks exactly once.
405
+ blocks_closed = False
406
+ stop_reason = "end_turn"
407
+ usage_out = 0
408
+
409
+ def _close_blocks():
410
+ """The content_block_stop events for every block opened so far."""
411
+ events = []
412
+ if text_block_started:
413
+ events.append(
414
+ _sse_event(
415
+ "content_block_stop",
416
+ {"type": "content_block_stop", "index": text_block_index},
417
+ )
418
+ )
419
+ for tcm in tool_map.values():
420
+ events.append(
421
+ _sse_event(
422
+ "content_block_stop",
423
+ {"type": "content_block_stop", "index": tcm["block_idx"]},
424
+ )
425
+ )
426
+ return events
427
+
428
+ try:
429
+ async for line in resp.aiter_lines():
430
+ if await request.is_disconnected():
431
+ break
432
+ if not line or not line.startswith("data: "):
433
+ continue
434
+ data_str = line[6:]
435
+ if data_str.strip() == "[DONE]":
436
+ break
437
+
438
+ try:
439
+ chunk = json.loads(data_str)
440
+ except json.JSONDecodeError:
441
+ continue
442
+
443
+ # A usage-only chunk (choices == []) arrives after the finish chunk
444
+ # when stream_options.include_usage is set. Read it rather than
445
+ # reporting a character-count estimate.
446
+ usage = chunk.get("usage")
447
+ if isinstance(usage, dict) and usage.get("completion_tokens"):
448
+ usage_out = usage["completion_tokens"]
449
+
450
+ choice = (chunk.get("choices") or [{}])[0]
451
+ delta = choice.get("delta", {})
452
+
453
+ # Text delta → content_block_delta (text_delta).
454
+ text_content = delta.get("content")
455
+ if text_content:
456
+ if not text_block_started:
457
+ text_block_started = True
458
+ text_block_index = next_block_index
459
+ next_block_index += 1
460
+ yield _sse_event(
461
+ "content_block_start",
462
+ {
463
+ "type": "content_block_start",
464
+ "index": text_block_index,
465
+ "content_block": {"type": "text", "text": ""},
466
+ },
467
+ )
468
+ yield _sse_event(
469
+ "content_block_delta",
470
+ {
471
+ "type": "content_block_delta",
472
+ "index": text_block_index,
473
+ "delta": {"type": "text_delta", "text": text_content},
474
+ },
475
+ )
476
+ text_full += text_content
477
+
478
+ # Tool call deltas → content_block_start (tool_use) + input_json_delta.
479
+ tc_deltas = delta.get("tool_calls", [])
480
+ for tc in tc_deltas:
481
+ idx = tc.get("index", 0)
482
+ if idx not in tool_map:
483
+ toolu_id = f"toolu_{uuid.uuid4().hex[:24]}"
484
+ block_idx = next_block_index
485
+ next_block_index += 1
486
+ tool_map[idx] = {
487
+ "toolu_id": toolu_id,
488
+ "name": tc.get("function", {}).get("name", ""),
489
+ "arguments": "",
490
+ "block_idx": block_idx,
491
+ }
492
+ yield _sse_event(
493
+ "content_block_start",
494
+ {
495
+ "type": "content_block_start",
496
+ "index": block_idx,
497
+ "content_block": {
498
+ "type": "tool_use",
499
+ "id": toolu_id,
500
+ "name": tc.get("function", {}).get("name", ""),
501
+ "input": {},
502
+ },
503
+ },
504
+ )
505
+ tcm = tool_map[idx]
506
+ args_delta = tc.get("function", {}).get("arguments", "")
507
+ if args_delta:
508
+ tcm["arguments"] += args_delta
509
+ yield _sse_event(
510
+ "content_block_delta",
511
+ {
512
+ "type": "content_block_delta",
513
+ "index": tcm["block_idx"],
514
+ "delta": {
515
+ "type": "input_json_delta",
516
+ "partial_json": args_delta,
517
+ },
518
+ },
519
+ )
520
+
521
+ # Finish: close the content blocks now, but hold the terminator.
522
+ # The usage chunk arrives AFTER this one, and message_delta is
523
+ # where the token counts belong.
524
+ finish = choice.get("finish_reason")
525
+ if finish and not blocks_closed:
526
+ blocks_closed = True
527
+ for event in _close_blocks():
528
+ yield event
529
+ stop_map = {
530
+ "stop": "end_turn",
531
+ "length": "max_tokens",
532
+ "tool_calls": "tool_use",
533
+ }
534
+ stop_reason = stop_map.get(finish, "end_turn")
535
+
536
+ # One terminator, always, whatever the upstream did. Reaching here
537
+ # without a finish_reason (disconnect, bare [DONE], absorbed protocol
538
+ # error) used to emit nothing at all when there were no tool calls,
539
+ # leaving the client waiting on its own timeout — and to emit one copy
540
+ # per tool call when there were, the second of which overwrote
541
+ # stop_reason "tool_use" with "end_turn" and killed the agent loop.
542
+ if not blocks_closed:
543
+ for event in _close_blocks():
544
+ yield event
545
+ yield _sse_event(
546
+ "message_delta",
547
+ {
548
+ "type": "message_delta",
549
+ "delta": {"stop_reason": stop_reason, "stop_sequence": None},
550
+ "usage": {"output_tokens": usage_out or len(text_full) // 4},
551
+ },
552
+ )
553
+ yield _sse_event("message_stop", {"type": "message_stop"})
554
+ finally:
555
+ # Must run even when the client disconnects mid-stream (GeneratorExit)
556
+ # or httpx raises while reading the body. Without it the key keeps its
557
+ # in-flight claim forever: the scheduler weighs in_flight heavily, so
558
+ # each leak pushes that key further down the ranking until it is
559
+ # effectively out of the pool until restart.
560
+ await resp.aclose()
561
+ state.end_in_flight(used_key)
562
+
563
+
564
+ # ── Entry point ─────────────────────────────────────────────────────────
565
+
566
+
567
+ async def handle_anthropic_messages(
568
+ request: Request,
569
+ state: ProxyState,
570
+ client,
571
+ ) -> JSONResponse | StreamingResponse:
572
+ """
573
+ Handle POST /v1/messages.
574
+
575
+ Translates the Anthropic Messages API into chat/completions and forwards
576
+ to NVIDIA NIM. This is a separate endpoint — it does not interfere with
577
+ Claude Code's default routing to api.anthropic.com.
578
+ """
579
+ raw = await request.body()
580
+ try:
581
+ body = json.loads(raw)
582
+ except json.JSONDecodeError:
583
+ return JSONResponse(
584
+ {
585
+ "type": "error",
586
+ "error": {"type": "invalid_request_error", "message": "invalid JSON"},
587
+ },
588
+ status_code=400,
589
+ )
590
+
591
+ state.log_cb(
592
+ f" anthropic: model={body.get('model', '?')} stream={body.get('stream', False)} "
593
+ f"tools={len(body.get('tools', []))}"
594
+ )
595
+
596
+ # Count images so the user gets a visible warning instead of silent drops.
597
+ n_img = sum(
598
+ 1
599
+ for m in body.get("messages", [])
600
+ if isinstance(m.get("content"), list)
601
+ for b in m["content"]
602
+ if isinstance(b, dict) and b.get("type") == "image"
603
+ )
604
+ if n_img:
605
+ state.log_cb(f" ⚠ anthropic: {n_img} immagine/i omessa/e (NVIDIA NIM no-vision)")
606
+
607
+ model_override = state.active_model
608
+ chat_payload = _build_chat_payload(body, model_override)
609
+
610
+ # Auto-compaction: summarize oversized history in place (trim fallback).
611
+ from .compaction import maybe_compact
612
+
613
+ chat_payload["messages"] = await maybe_compact(
614
+ chat_payload["messages"],
615
+ state=state,
616
+ client=client,
617
+ log=state.log_cb,
618
+ model=chat_payload.get("model", ""),
619
+ )
620
+
621
+ want_stream = body.get("stream", False)
622
+
623
+ if want_stream:
624
+ chat_payload["stream"] = True
625
+ # Ask for real token counts. Without this the shim reported
626
+ # len(text)//4, so Claude Code showed an invented number.
627
+ chat_payload.setdefault("stream_options", {"include_usage": True})
628
+ return StreamingResponse(
629
+ _stream_anthropic(
630
+ state,
631
+ chat_payload,
632
+ model_override or chat_payload["model"],
633
+ client,
634
+ request,
635
+ ),
636
+ media_type="text/event-stream",
637
+ headers={
638
+ "Cache-Control": "no-cache",
639
+ "Connection": "keep-alive",
640
+ "X-Accel-Buffering": "no",
641
+ },
642
+ )
643
+
644
+ # Non-streaming: resolve candidates right before the HTTP loop.
645
+ async with state.lock:
646
+ candidates = state.get_candidate_keys()
647
+ if not candidates:
648
+ return JSONResponse(
649
+ {
650
+ "type": "error",
651
+ "error": {"type": "api_error", "message": "no keys available"},
652
+ },
653
+ status_code=503,
654
+ )
655
+
656
+ # Saturation gate + bounded rotation (shared with responses shim).
657
+ _live, _valid = _live_pool_snapshot(state, candidates)
658
+ resp = None
659
+ used_key = None
660
+ used_idx = None
661
+ _outcome: dict = {}
662
+ if not (_valid and _live < max(1, int(_valid * _MIN_LIVE_FRACTION))):
663
+ resp, used_key, used_idx = await _rotation_phase(
664
+ client,
665
+ UPSTREAM,
666
+ chat_payload,
667
+ json_headers,
668
+ state,
669
+ candidates,
670
+ max_attempts=_MAX_ROTATE_ATTEMPTS,
671
+ timeout=_ROTATE_SEND_TIMEOUT,
672
+ stream=False,
673
+ log_tag="anthropic shim",
674
+ outcome_box=_outcome,
675
+ )
676
+ if used_idx is not None and isinstance(resp, httpx.Response):
677
+ await resp.aread()
678
+
679
+ if resp is None or used_key is None:
680
+ if _outcome.get("deterministic"):
681
+ return JSONResponse(
682
+ {
683
+ "type": "error",
684
+ "error": {
685
+ "type": "invalid_request_error",
686
+ "message": _upstream_error_message(_outcome),
687
+ },
688
+ },
689
+ status_code=_outcome.get("status") or 400,
690
+ )
691
+ base = "all keys failed (pool saturated)" if _live == 0 else "all keys failed"
692
+ return JSONResponse(
693
+ {
694
+ "type": "error",
695
+ "error": {
696
+ "type": "api_error",
697
+ "message": _upstream_error_message(_outcome, base),
698
+ },
699
+ },
700
+ status_code=503,
701
+ )
702
+
703
+ state.stats.success += 1
704
+ state.stats.record_key_usage(used_key, ok=True)
705
+ state.record_key_model_result(used_key, chat_payload.get("model", ""), ok=True)
706
+ if used_idx is not None:
707
+ state.log_cb(f"✔ key[{used_idx}] OK")
708
+
709
+ chat_data = resp.json()
710
+ await resp.aclose()
711
+ state.end_in_flight(used_key) # release the load-balancer claim
712
+
713
+ anthropic_data = _chat_to_anthropic_response(chat_data, model_override or chat_payload["model"])
714
+ return JSONResponse(anthropic_data)