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.
- openvidia/__init__.py +30 -0
- openvidia/__main__.py +827 -0
- openvidia/_upstream_utils.py +57 -0
- openvidia/anthropic_shim.py +714 -0
- openvidia/compaction.py +851 -0
- openvidia/config.py +513 -0
- openvidia/embedding_cache.py +69 -0
- openvidia/graph_engine.py +466 -0
- openvidia/provider_fallback.py +98 -0
- openvidia/proxy_app.py +863 -0
- openvidia/proxy_state.py +1010 -0
- openvidia/redis_sync.py +93 -0
- openvidia/responses_shim.py +1374 -0
- openvidia/safe_file.py +100 -0
- openvidia/server_manager.py +106 -0
- openvidia/upstream_router.py +50 -0
- openvidia/web/assets/32x32.ico +0 -0
- openvidia/web/assets/64x64.ico +0 -0
- openvidia/web/assets/dashboard.gif +0 -0
- openvidia/web/assets/favicon.ico +0 -0
- openvidia/web/assets/logo.png +0 -0
- openvidia/web/favicon.ico +0 -0
- openvidia/web/index.html +166 -0
- openvidia/web/logo.png +0 -0
- openvidia/web/main.js +732 -0
- openvidia/web/styles.css +425 -0
- openvidia/webui.py +532 -0
- openvidia-2.2.0.dist-info/METADATA +814 -0
- openvidia-2.2.0.dist-info/RECORD +33 -0
- openvidia-2.2.0.dist-info/WHEEL +5 -0
- openvidia-2.2.0.dist-info/entry_points.txt +2 -0
- openvidia-2.2.0.dist-info/licenses/LICENSE +21 -0
- openvidia-2.2.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|