mirror of
https://github.com/Geek66666/nim4cc.git
synced 2026-10-06 14:43:49 +08:00
fix
This commit is contained in:
Binary file not shown.
+740
-35
@@ -1,17 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
@@ -33,12 +37,16 @@ MAX_UPSTREAM_CONNECTIONS = int(os.getenv("MAX_UPSTREAM_CONNECTIONS", "512"))
|
||||
MAX_KEEPALIVE_CONNECTIONS = int(os.getenv("MAX_KEEPALIVE_CONNECTIONS", "128"))
|
||||
MODEL_SYNC_INTERVAL_MINUTES = int(os.getenv("MODEL_SYNC_INTERVAL_MINUTES", "30"))
|
||||
PUBLIC_HISTORY_BUCKETS = int(os.getenv("PUBLIC_HISTORY_BUCKETS", "22"))
|
||||
HEALTH_SUMMARY_WINDOW_MINUTES = 120
|
||||
UPSTREAM_TIMEOUT_RETRIES = 1
|
||||
BUCKET_MINUTES = 10
|
||||
DEFAULT_MONITORED_MODELS = "z-ai/glm5,z-ai/glm4.7,minimaxai/minimax-m2.5,minimaxai/minimax-m2.7,moonshotai/kimi-k2.5,deepseek-ai/deepseek-v3.2,google/gemma-4-31b-it,qwen/qwen3.5-397b-a17b"
|
||||
MODEL_LIST = [item.strip() for item in os.getenv("MODEL_LIST", DEFAULT_MONITORED_MODELS).split(",") if item.strip()]
|
||||
APP_TIMEZONE = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
|
||||
ANTHROPIC_API_VERSION = "2023-06-01"
|
||||
ANTHROPIC_INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14"
|
||||
ANTHROPIC_MIN_THINKING_BUDGET_TOKENS = 1024
|
||||
ANTHROPIC_SERVER_TOOL_MAX_ITERATIONS = 8
|
||||
ANTHROPIC_SERVER_TOOL_PREFIXES = (
|
||||
"web_search_",
|
||||
"web_fetch_",
|
||||
@@ -47,12 +55,16 @@ ANTHROPIC_SERVER_TOOL_PREFIXES = (
|
||||
"tool_search_tool_",
|
||||
"mcp_toolset",
|
||||
)
|
||||
WEB_SEARCH_RSS_URL = os.getenv("WEB_SEARCH_RSS_URL", "https://www.bing.com/search")
|
||||
WEB_SEARCH_DEFAULT_MAX_RESULTS = int(os.getenv("WEB_SEARCH_DEFAULT_MAX_RESULTS", "5"))
|
||||
WEB_SEARCH_MAX_QUERY_LENGTH = int(os.getenv("WEB_SEARCH_MAX_QUERY_LENGTH", "512"))
|
||||
|
||||
http_client: httpx.AsyncClient | None = None
|
||||
model_cache: list[dict[str, Any]] = []
|
||||
model_cache_synced_at: str | None = None
|
||||
model_cache_lock: asyncio.Lock | None = None
|
||||
model_sync_task: asyncio.Task[None] | None = None
|
||||
THINK_TAG_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
@@ -535,6 +547,10 @@ def extract_anthropic_text(value: Any) -> str:
|
||||
value_type = value.get("type")
|
||||
if value_type in {"text", "input_text", "output_text"}:
|
||||
return str(value.get("text", ""))
|
||||
if value_type == "thinking":
|
||||
return str(value.get("thinking", ""))
|
||||
if value_type == "redacted_thinking":
|
||||
return ""
|
||||
if value_type in {"image", "document"}:
|
||||
return f"[{value_type} content omitted]"
|
||||
if "content" in value:
|
||||
@@ -571,6 +587,175 @@ def is_anthropic_tool_result_block(block: dict[str, Any]) -> bool:
|
||||
return isinstance(block_type, str) and (block_type == "tool_result" or block_type.endswith("_tool_result"))
|
||||
|
||||
|
||||
def parse_anthropic_beta_header(value: str | None) -> set[str]:
|
||||
if not value:
|
||||
return set()
|
||||
return {item.strip() for item in value.split(",") if item.strip()}
|
||||
|
||||
|
||||
def has_anthropic_message_prefill(messages: Any) -> bool:
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return False
|
||||
last_message = messages[-1]
|
||||
if not isinstance(last_message, dict):
|
||||
return False
|
||||
if last_message.get("role") != "assistant":
|
||||
return False
|
||||
blocks = anthropic_content_to_blocks(last_message.get("content"))
|
||||
return any(block.get("type") != "redacted_thinking" for block in blocks)
|
||||
|
||||
|
||||
def is_forced_anthropic_tool_choice(tool_choice: Any) -> bool:
|
||||
if isinstance(tool_choice, str):
|
||||
return tool_choice in {"any", "required"}
|
||||
if not isinstance(tool_choice, dict):
|
||||
return False
|
||||
return tool_choice.get("type") in {"any", "tool"}
|
||||
|
||||
|
||||
def build_anthropic_thinking_config(body: dict[str, Any], anthropic_beta: str | None) -> dict[str, Any]:
|
||||
thinking = body.get("thinking")
|
||||
enabled = False
|
||||
budget_tokens = None
|
||||
|
||||
if thinking is None:
|
||||
return {"enabled": False, "budget_tokens": None, "synthetic_signature": True}
|
||||
if isinstance(thinking, bool):
|
||||
thinking = {"type": "enabled" if thinking else "disabled"}
|
||||
if not isinstance(thinking, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="thinking 字段必须是对象或布尔值。")
|
||||
|
||||
raw_thinking_type = thinking.get("type")
|
||||
if isinstance(raw_thinking_type, bool):
|
||||
thinking_type = "enabled" if raw_thinking_type else "disabled"
|
||||
elif isinstance(raw_thinking_type, str):
|
||||
lowered_type = raw_thinking_type.strip().lower()
|
||||
if lowered_type in {"enabled", "enable", "on", "true"}:
|
||||
thinking_type = "enabled"
|
||||
elif lowered_type in {"disabled", "disable", "off", "false"}:
|
||||
thinking_type = "disabled"
|
||||
else:
|
||||
thinking_type = lowered_type
|
||||
elif "enabled" in thinking:
|
||||
thinking_type = "enabled" if bool(thinking.get("enabled")) else "disabled"
|
||||
elif any(key in thinking for key in ("budget_tokens", "budgetTokens")):
|
||||
thinking_type = "enabled"
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="thinking.type 仅支持 enabled 或 disabled。")
|
||||
|
||||
if thinking_type == "disabled":
|
||||
return {"enabled": False, "budget_tokens": None, "synthetic_signature": True}
|
||||
if thinking_type != "enabled":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="thinking.type 仅支持 enabled 或 disabled。")
|
||||
|
||||
budget_tokens = thinking.get("budget_tokens", thinking.get("budgetTokens"))
|
||||
if isinstance(budget_tokens, str):
|
||||
try:
|
||||
budget_tokens = int(budget_tokens.strip())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="thinking.enabled 时必须提供整数类型的 budget_tokens。",
|
||||
) from exc
|
||||
if not isinstance(budget_tokens, int):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="thinking.enabled 时必须提供整数类型的 budget_tokens。")
|
||||
if budget_tokens < ANTHROPIC_MIN_THINKING_BUDGET_TOKENS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"thinking.budget_tokens 不能小于 {ANTHROPIC_MIN_THINKING_BUDGET_TOKENS}。",
|
||||
)
|
||||
|
||||
max_tokens = body.get("max_tokens")
|
||||
beta_flags = parse_anthropic_beta_header(anthropic_beta)
|
||||
interleaved_thinking = (
|
||||
ANTHROPIC_INTERLEAVED_THINKING_BETA in beta_flags
|
||||
and bool(body.get("tools"))
|
||||
)
|
||||
if isinstance(max_tokens, int) and budget_tokens >= max_tokens and not interleaved_thinking:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="thinking.budget_tokens 必须小于 max_tokens;只有启用 interleaved thinking beta 且使用工具时可以例外。",
|
||||
)
|
||||
|
||||
if body.get("temperature") is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Anthropic thinking 模式不支持自定义 temperature。",
|
||||
)
|
||||
|
||||
top_p = body.get("top_p")
|
||||
if top_p is not None:
|
||||
try:
|
||||
top_p_value = float(top_p)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="top_p 必须是数值。",
|
||||
) from exc
|
||||
if not (0.95 <= top_p_value <= 1.0):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Anthropic thinking 模式下 top_p 只能在 0.95 到 1.0 之间。",
|
||||
)
|
||||
|
||||
if is_forced_anthropic_tool_choice(body.get("tool_choice")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Anthropic thinking 模式不支持 forced tool choice。",
|
||||
)
|
||||
|
||||
if has_anthropic_message_prefill(body.get("messages")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Anthropic thinking 模式不支持 assistant prefill。",
|
||||
)
|
||||
|
||||
enabled = True
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"budget_tokens": budget_tokens,
|
||||
"interleaved": interleaved_thinking,
|
||||
"synthetic_signature": True,
|
||||
}
|
||||
|
||||
|
||||
def build_synthetic_thinking_signature(model_id: str | None, thinking_text: str) -> str:
|
||||
digest = hashlib.sha256(f"{model_id or 'unknown'}\n{thinking_text}".encode("utf-8")).digest()
|
||||
encoded = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
return f"nimthinking_{encoded}"
|
||||
|
||||
|
||||
def split_anthropic_thinking_blocks(text: str, model_id: str | None) -> list[dict[str, Any]]:
|
||||
if not text:
|
||||
return []
|
||||
|
||||
blocks: list[dict[str, Any]] = []
|
||||
cursor = 0
|
||||
for match in THINK_TAG_PATTERN.finditer(text):
|
||||
before = text[cursor:match.start()]
|
||||
if before.strip():
|
||||
blocks.append({"type": "text", "text": before.strip()})
|
||||
|
||||
thinking_text = match.group(1).strip()
|
||||
if thinking_text:
|
||||
blocks.append(
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": thinking_text,
|
||||
"signature": build_synthetic_thinking_signature(model_id, thinking_text),
|
||||
}
|
||||
)
|
||||
cursor = match.end()
|
||||
|
||||
if cursor == 0:
|
||||
return [{"type": "text", "text": text.strip()}] if text.strip() else []
|
||||
|
||||
after = text[cursor:]
|
||||
if after.strip():
|
||||
blocks.append({"type": "text", "text": after.strip()})
|
||||
return blocks
|
||||
|
||||
|
||||
def build_bash_tool_schema() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
@@ -717,6 +902,110 @@ def build_computer_tool_schema(tool_type: str | None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def build_web_search_tool_schema() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The web search query to execute.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
|
||||
def normalize_domain_name(value: str) -> str:
|
||||
parsed = urlparse(value if "://" in value else f"https://{value}")
|
||||
host = (parsed.netloc or parsed.path or "").strip().lower()
|
||||
if host.startswith("www."):
|
||||
host = host[4:]
|
||||
return host.split("/", 1)[0]
|
||||
|
||||
|
||||
def normalize_domain_list(values: Any) -> list[str]:
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
normalized = [normalize_domain_name(str(item)) for item in values if str(item).strip()]
|
||||
return [item for item in normalized if item]
|
||||
|
||||
|
||||
def domain_matches(host: str, domain: str) -> bool:
|
||||
return host == domain or host.endswith(f".{domain}")
|
||||
|
||||
|
||||
def filter_web_search_url(url: str, allowed_domains: list[str], blocked_domains: list[str]) -> bool:
|
||||
try:
|
||||
host = normalize_domain_name(url)
|
||||
except Exception:
|
||||
return False
|
||||
if not host:
|
||||
return False
|
||||
if allowed_domains and not any(domain_matches(host, domain) for domain in allowed_domains):
|
||||
return False
|
||||
if blocked_domains and any(domain_matches(host, domain) for domain in blocked_domains):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def build_web_search_tool_description(raw_tool: dict[str, Any]) -> str:
|
||||
description = raw_tool.get("description") or "Search the public web and return concise result metadata."
|
||||
allowed_domains = normalize_domain_list(raw_tool.get("allowed_domains"))
|
||||
blocked_domains = normalize_domain_list(raw_tool.get("blocked_domains"))
|
||||
if allowed_domains:
|
||||
description = f"{description}\n\nOnly search and return results from these domains: {', '.join(allowed_domains)}."
|
||||
if blocked_domains:
|
||||
description = f"{description}\n\nDo not return results from these domains: {', '.join(blocked_domains)}."
|
||||
return description
|
||||
|
||||
|
||||
def build_server_tool_result_error_block(
|
||||
result_type: str,
|
||||
tool_use_id: str,
|
||||
error_code: str,
|
||||
message: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
error_item: dict[str, Any] = {"type": f"{result_type}_error", "error_code": error_code}
|
||||
if message:
|
||||
error_item["message"] = message
|
||||
return {
|
||||
"type": result_type,
|
||||
"tool_use_id": tool_use_id,
|
||||
"content": error_item,
|
||||
"is_error": True,
|
||||
}
|
||||
|
||||
|
||||
def build_web_search_encrypted_content(payload: dict[str, Any]) -> str:
|
||||
raw = json_dumps(payload).encode("utf-8")
|
||||
encoded = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
return f"nimsearch_{encoded}"
|
||||
|
||||
|
||||
def parse_rss_results(xml_text: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except ET.ParseError:
|
||||
return []
|
||||
results: list[dict[str, Any]] = []
|
||||
for item in root.findall(".//item"):
|
||||
title = (item.findtext("title") or "").strip()
|
||||
link = (item.findtext("link") or "").strip()
|
||||
description = (item.findtext("description") or "").strip()
|
||||
pub_date = (item.findtext("pubDate") or "").strip()
|
||||
if not title or not link:
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"title": title,
|
||||
"url": link,
|
||||
"snippet": description,
|
||||
"page_age": pub_date or None,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def append_anthropic_tool_examples(description: str | None, examples: Any) -> str | None:
|
||||
if not isinstance(examples, list) or not examples:
|
||||
return description
|
||||
@@ -763,12 +1052,49 @@ def anthropic_tools_to_chat_tools(tools: Any) -> tuple[list[dict[str, Any]], dic
|
||||
detail=f"当前网关暂不支持仅允许 programmatic caller 的工具:{tool_name or tool_type or 'unknown'}。",
|
||||
)
|
||||
|
||||
if isinstance(tool_type, str) and (tool_type == "mcp_toolset" or tool_type.startswith(ANTHROPIC_SERVER_TOOL_PREFIXES)):
|
||||
if tool_type == "mcp_toolset":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"当前网关暂不支持 Anthropic 服务端工具 '{tool_type}';请改用客户端工具或自定义 tools。",
|
||||
)
|
||||
|
||||
if isinstance(tool_type, str) and tool_type.startswith("web_search_"):
|
||||
tool_name = tool_name or "web_search"
|
||||
if normalize_domain_list(raw_tool.get("allowed_domains")) and normalize_domain_list(raw_tool.get("blocked_domains")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="web_search 工具不能同时设置 allowed_domains 和 blocked_domains。",
|
||||
)
|
||||
description = build_web_search_tool_description(raw_tool)
|
||||
parameters = build_web_search_tool_schema()
|
||||
normalized.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"description": description,
|
||||
"parameters": parameters,
|
||||
},
|
||||
}
|
||||
)
|
||||
metadata_by_name[tool_name] = {
|
||||
"anthropic_type": tool_type,
|
||||
"allowed_callers": sorted(allowed_callers_set) or ["direct"],
|
||||
"server_execution": "web_search",
|
||||
"allowed_domains": normalize_domain_list(raw_tool.get("allowed_domains")),
|
||||
"blocked_domains": normalize_domain_list(raw_tool.get("blocked_domains")),
|
||||
"user_location": raw_tool.get("user_location") if isinstance(raw_tool.get("user_location"), dict) else None,
|
||||
"max_uses": raw_tool.get("max_uses") if isinstance(raw_tool.get("max_uses"), int) and raw_tool.get("max_uses") > 0 else None,
|
||||
"uses": 0,
|
||||
}
|
||||
continue
|
||||
|
||||
if isinstance(tool_type, str) and tool_type.startswith(ANTHROPIC_SERVER_TOOL_PREFIXES):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"当前网关暂不支持 Anthropic 服务端工具 '{tool_type}';目前已兼容 web_search 工具,其他服务端工具仍需单独适配。",
|
||||
)
|
||||
|
||||
if isinstance(tool_type, str) and tool_type.startswith("bash_"):
|
||||
description = raw_tool.get("description") or "Execute shell commands in a persistent bash session."
|
||||
parameters = build_bash_tool_schema()
|
||||
@@ -862,6 +1188,13 @@ def anthropic_messages_to_chat_messages(body: dict[str, Any]) -> list[dict[str,
|
||||
if block_type == "text":
|
||||
text_chunks.append(str(block.get("text", "")))
|
||||
continue
|
||||
if block_type == "thinking":
|
||||
thinking_text = str(block.get("thinking", "")).strip()
|
||||
if thinking_text:
|
||||
text_chunks.append(f"<think>\n{thinking_text}\n</think>")
|
||||
continue
|
||||
if block_type == "redacted_thinking":
|
||||
continue
|
||||
if block_type in {"tool_use", "server_tool_use"}:
|
||||
arguments = block.get("input")
|
||||
if not isinstance(arguments, str):
|
||||
@@ -927,13 +1260,11 @@ def anthropic_messages_to_chat_messages(body: dict[str, Any]) -> list[dict[str,
|
||||
return [message for message in chat_messages if message.get("content") is not None or message.get("tool_calls")]
|
||||
|
||||
|
||||
def build_anthropic_chat_payload(body: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, dict[str, Any]]]:
|
||||
thinking = body.get("thinking")
|
||||
if thinking and (not isinstance(thinking, dict) or thinking.get("type") != "disabled"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="当前网关暂不支持 Anthropic thinking 模式。",
|
||||
)
|
||||
def build_anthropic_chat_payload(
|
||||
body: dict[str, Any],
|
||||
anthropic_beta: str | None = None,
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, dict[str, Any]], dict[str, Any]]:
|
||||
thinking_config = build_anthropic_thinking_config(body, anthropic_beta)
|
||||
if body.get("mcp_servers"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -948,6 +1279,11 @@ def build_anthropic_chat_payload(body: dict[str, Any]) -> tuple[dict[str, Any],
|
||||
"messages": messages,
|
||||
"max_tokens": body.get("max_tokens"),
|
||||
}
|
||||
if thinking_config["enabled"]:
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": True}
|
||||
payload["nvext"] = {"max_thinking_tokens": thinking_config["budget_tokens"]}
|
||||
elif body.get("thinking") is not None:
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
if tool_choice is not None:
|
||||
@@ -960,7 +1296,7 @@ def build_anthropic_chat_payload(body: dict[str, Any]) -> tuple[dict[str, Any],
|
||||
payload["top_p"] = body.get("top_p")
|
||||
if body.get("stop_sequences"):
|
||||
payload["stop"] = body.get("stop_sequences")
|
||||
return payload, messages, tool_metadata
|
||||
return payload, messages, tool_metadata, thinking_config
|
||||
|
||||
|
||||
def parse_anthropic_tool_input(arguments: Any) -> dict[str, Any]:
|
||||
@@ -991,6 +1327,235 @@ def normalize_anthropic_tool_use_id(tool_use_id: Any) -> str:
|
||||
return f"toolu_{uuid.uuid4().hex[:24]}"
|
||||
|
||||
|
||||
def anthropic_block_to_chat_tool_call(block: dict[str, Any]) -> dict[str, Any]:
|
||||
arguments = block.get("input") or {}
|
||||
if not isinstance(arguments, str):
|
||||
arguments = json_dumps(arguments)
|
||||
return {
|
||||
"id": block.get("id") or normalize_anthropic_tool_use_id(None),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": block.get("name"),
|
||||
"arguments": arguments,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def anthropic_blocks_to_chat_assistant_message(blocks: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
text_chunks: list[str] = []
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
|
||||
for block in blocks:
|
||||
block_type = block.get("type")
|
||||
if block_type == "text":
|
||||
text_value = str(block.get("text", "")).strip()
|
||||
if text_value:
|
||||
text_chunks.append(text_value)
|
||||
continue
|
||||
if block_type == "thinking":
|
||||
thinking_text = str(block.get("thinking", "")).strip()
|
||||
if thinking_text:
|
||||
text_chunks.append(f"<think>\n{thinking_text}\n</think>")
|
||||
continue
|
||||
if block_type in {"tool_use", "server_tool_use"}:
|
||||
tool_calls.append(anthropic_block_to_chat_tool_call(block))
|
||||
|
||||
message: dict[str, Any] = {"role": "assistant", "content": "\n".join(filter(None, text_chunks))}
|
||||
if tool_calls:
|
||||
message["tool_calls"] = tool_calls
|
||||
return message
|
||||
|
||||
|
||||
def is_server_tool_block(block: dict[str, Any], tool_metadata: dict[str, dict[str, Any]]) -> bool:
|
||||
if block.get("type") != "server_tool_use":
|
||||
return False
|
||||
tool_name = block.get("name")
|
||||
return bool(tool_name and tool_metadata.get(tool_name, {}).get("server_execution"))
|
||||
|
||||
|
||||
def count_server_tool_blocks(content_blocks: list[dict[str, Any]]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for block in content_blocks:
|
||||
if block.get("type") != "server_tool_use":
|
||||
continue
|
||||
name = str(block.get("name") or "unknown")
|
||||
counts[name] = counts.get(name, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def merge_anthropic_usage(base_usage: dict[str, Any], extra_usage: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = {
|
||||
"input_tokens": (base_usage or {}).get("input_tokens") or 0,
|
||||
"output_tokens": (base_usage or {}).get("output_tokens") or 0,
|
||||
}
|
||||
merged["input_tokens"] += (extra_usage or {}).get("input_tokens") or 0
|
||||
merged["output_tokens"] += (extra_usage or {}).get("output_tokens") or 0
|
||||
|
||||
base_server = dict((base_usage or {}).get("server_tool_use") or {})
|
||||
extra_server = (extra_usage or {}).get("server_tool_use") or {}
|
||||
for key, value in extra_server.items():
|
||||
base_server[key] = (base_server.get(key) or 0) + (value or 0)
|
||||
if base_server:
|
||||
merged["server_tool_use"] = base_server
|
||||
return merged
|
||||
|
||||
|
||||
async def execute_web_search_tool(block: dict[str, Any], metadata: dict[str, Any]) -> tuple[dict[str, Any], str, dict[str, int]]:
|
||||
tool_use_id = block.get("id") or normalize_anthropic_tool_use_id(None)
|
||||
tool_input = block.get("input") if isinstance(block.get("input"), dict) else {}
|
||||
query = str(
|
||||
tool_input.get("query")
|
||||
or tool_input.get("q")
|
||||
or tool_input.get("search_query")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
if not query:
|
||||
result_block = build_server_tool_result_error_block(
|
||||
"web_search_tool_result",
|
||||
tool_use_id,
|
||||
"invalid_input",
|
||||
"web_search 需要 query 字段。",
|
||||
)
|
||||
return result_block, json_dumps({"error": "missing_query"}), {"web_search_requests": 1}
|
||||
|
||||
if len(query) > WEB_SEARCH_MAX_QUERY_LENGTH:
|
||||
result_block = build_server_tool_result_error_block(
|
||||
"web_search_tool_result",
|
||||
tool_use_id,
|
||||
"query_too_long",
|
||||
f"query 长度不能超过 {WEB_SEARCH_MAX_QUERY_LENGTH} 个字符。",
|
||||
)
|
||||
return result_block, json_dumps({"error": "query_too_long", "query": query}), {"web_search_requests": 1}
|
||||
|
||||
max_uses = metadata.get("max_uses")
|
||||
if isinstance(max_uses, int) and metadata.get("uses", 0) >= max_uses:
|
||||
result_block = build_server_tool_result_error_block(
|
||||
"web_search_tool_result",
|
||||
tool_use_id,
|
||||
"max_uses_exceeded",
|
||||
"web_search 已达到当前请求允许的最大调用次数。",
|
||||
)
|
||||
return result_block, json_dumps({"error": "max_uses_exceeded", "query": query}), {"web_search_requests": 0}
|
||||
|
||||
metadata["uses"] = metadata.get("uses", 0) + 1
|
||||
|
||||
search_query = query
|
||||
user_location = metadata.get("user_location") if isinstance(metadata.get("user_location"), dict) else None
|
||||
if user_location:
|
||||
location_parts = [
|
||||
str(user_location.get(key)).strip()
|
||||
for key in ("city", "region", "country")
|
||||
if user_location.get(key)
|
||||
]
|
||||
if location_parts:
|
||||
search_query = f"{query} {' '.join(location_parts)}"
|
||||
|
||||
client = await get_http_client()
|
||||
try:
|
||||
response = await client.get(
|
||||
WEB_SEARCH_RSS_URL,
|
||||
params={"format": "rss", "q": search_query},
|
||||
headers={
|
||||
"Accept": "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8",
|
||||
"User-Agent": "nim4cc/1.0 (+https://github.com/Geek66666/nim4cc)",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
result_block = build_server_tool_result_error_block(
|
||||
"web_search_tool_result",
|
||||
tool_use_id,
|
||||
"search_unavailable",
|
||||
f"web_search 上游请求失败:HTTP {exc.response.status_code}",
|
||||
)
|
||||
return result_block, json_dumps({"error": "search_unavailable", "query": query}), {"web_search_requests": 1}
|
||||
except httpx.RequestError as exc:
|
||||
result_block = build_server_tool_result_error_block(
|
||||
"web_search_tool_result",
|
||||
tool_use_id,
|
||||
"search_unavailable",
|
||||
f"web_search 请求异常:{exc}",
|
||||
)
|
||||
return result_block, json_dumps({"error": "search_unavailable", "query": query}), {"web_search_requests": 1}
|
||||
|
||||
allowed_domains = metadata.get("allowed_domains") or []
|
||||
blocked_domains = metadata.get("blocked_domains") or []
|
||||
parsed_results = [
|
||||
item
|
||||
for item in parse_rss_results(response.text)
|
||||
if filter_web_search_url(item.get("url", ""), allowed_domains, blocked_domains)
|
||||
][:max(1, WEB_SEARCH_DEFAULT_MAX_RESULTS)]
|
||||
|
||||
outward_results: list[dict[str, Any]] = []
|
||||
model_results: list[dict[str, Any]] = []
|
||||
for result in parsed_results:
|
||||
encrypted_content = build_web_search_encrypted_content(
|
||||
{
|
||||
"query": query,
|
||||
"url": result.get("url"),
|
||||
"title": result.get("title"),
|
||||
"snippet": result.get("snippet"),
|
||||
"page_age": result.get("page_age"),
|
||||
"retrieved_at": utcnow_iso(),
|
||||
}
|
||||
)
|
||||
outward_item = {
|
||||
"type": "web_search_result",
|
||||
"url": result.get("url"),
|
||||
"title": result.get("title"),
|
||||
"encrypted_content": encrypted_content,
|
||||
}
|
||||
if result.get("page_age"):
|
||||
outward_item["page_age"] = result.get("page_age")
|
||||
outward_results.append(outward_item)
|
||||
model_results.append(
|
||||
{
|
||||
"url": result.get("url"),
|
||||
"title": result.get("title"),
|
||||
"snippet": result.get("snippet"),
|
||||
"page_age": result.get("page_age"),
|
||||
"encrypted_content": encrypted_content,
|
||||
}
|
||||
)
|
||||
|
||||
result_block = {
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": tool_use_id,
|
||||
"content": outward_results,
|
||||
}
|
||||
model_payload = json_dumps({"query": query, "results": model_results})
|
||||
return result_block, model_payload, {"web_search_requests": 1}
|
||||
|
||||
|
||||
async def execute_anthropic_server_tool_block(
|
||||
block: dict[str, Any],
|
||||
tool_metadata: dict[str, dict[str, Any]],
|
||||
) -> tuple[dict[str, Any], str, dict[str, int]]:
|
||||
tool_name = block.get("name")
|
||||
metadata = tool_metadata.get(tool_name or "")
|
||||
if not metadata:
|
||||
result_block = build_server_tool_result_error_block(
|
||||
"tool_result",
|
||||
block.get("id") or normalize_anthropic_tool_use_id(None),
|
||||
"unknown_tool",
|
||||
f"未找到服务端工具 {tool_name} 的元数据。",
|
||||
)
|
||||
return result_block, json_dumps({"error": "unknown_tool"}), {}
|
||||
|
||||
server_execution = metadata.get("server_execution")
|
||||
if server_execution == "web_search":
|
||||
return await execute_web_search_tool(block, metadata)
|
||||
|
||||
result_block = build_server_tool_result_error_block(
|
||||
"tool_result",
|
||||
block.get("id") or normalize_anthropic_tool_use_id(None),
|
||||
"unsupported_tool",
|
||||
f"当前网关尚未实现服务端工具 {tool_name}。",
|
||||
)
|
||||
return result_block, json_dumps({"error": "unsupported_tool"}), {}
|
||||
|
||||
|
||||
def anthropic_stop_reason(finish_reason: str | None, content_blocks: list[dict[str, Any]]) -> str:
|
||||
if any(block.get("type") == "tool_use" for block in content_blocks):
|
||||
return "tool_use"
|
||||
@@ -1005,21 +1570,31 @@ def chat_completion_to_anthropic_message(
|
||||
body: dict[str, Any],
|
||||
upstream_json: dict[str, Any],
|
||||
tool_metadata: dict[str, dict[str, Any]],
|
||||
thinking_config: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
del tool_metadata
|
||||
upstream_message, finish_reason = extract_upstream_message(upstream_json)
|
||||
assistant_text, tool_calls = extract_text_and_tool_calls(upstream_message)
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
if assistant_text:
|
||||
if thinking_config.get("enabled"):
|
||||
content_blocks.extend(split_anthropic_thinking_blocks(assistant_text, body.get("model")))
|
||||
else:
|
||||
content_blocks.append({"type": "text", "text": assistant_text})
|
||||
for tool_call in tool_calls:
|
||||
tool_name = tool_call.get("name")
|
||||
tool_info = tool_metadata.get(tool_name or "", {})
|
||||
is_server_tool = bool(tool_info.get("server_execution"))
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": normalize_anthropic_tool_use_id(tool_call.get("id")),
|
||||
"name": tool_call.get("name"),
|
||||
"type": "server_tool_use" if is_server_tool else "tool_use",
|
||||
"id": (
|
||||
tool_call.get("id")
|
||||
if isinstance(tool_call.get("id"), str) and tool_call.get("id").startswith("srvtoolu_")
|
||||
else (f"srvtoolu_{uuid.uuid4().hex[:24]}" if is_server_tool else normalize_anthropic_tool_use_id(tool_call.get("id")))
|
||||
),
|
||||
"name": tool_name,
|
||||
"input": parse_anthropic_tool_input(tool_call.get("arguments")),
|
||||
"caller": {"type": "direct"},
|
||||
**({} if is_server_tool else {"caller": {"type": "direct"}}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1051,6 +1626,78 @@ def build_anthropic_storage_items(body: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return items
|
||||
|
||||
|
||||
async def create_anthropic_message_with_server_tools(
|
||||
api_key: str,
|
||||
body: dict[str, Any],
|
||||
chat_payload: dict[str, Any],
|
||||
tool_metadata: dict[str, dict[str, Any]],
|
||||
thinking_config: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], float]:
|
||||
request_messages = list(chat_payload.get("messages") or [])
|
||||
base_payload = {key: value for key, value in chat_payload.items() if key != "messages"}
|
||||
accumulated_content: list[dict[str, Any]] = []
|
||||
accumulated_usage: dict[str, Any] = {}
|
||||
last_message_payload: dict[str, Any] | None = None
|
||||
started = time.perf_counter()
|
||||
|
||||
for _ in range(ANTHROPIC_SERVER_TOOL_MAX_ITERATIONS):
|
||||
loop_payload = {**base_payload, "messages": request_messages}
|
||||
upstream_json, _latency_ms = await post_nvidia_chat_completion(api_key, loop_payload)
|
||||
loop_message = chat_completion_to_anthropic_message(body, upstream_json, tool_metadata, thinking_config)
|
||||
last_message_payload = loop_message
|
||||
accumulated_usage = merge_anthropic_usage(accumulated_usage, loop_message.get("usage") or {})
|
||||
|
||||
loop_content = list(loop_message.get("content") or [])
|
||||
accumulated_content.extend(loop_content)
|
||||
|
||||
client_tool_blocks = [block for block in loop_content if block.get("type") == "tool_use"]
|
||||
server_tool_blocks = [block for block in loop_content if is_server_tool_block(block, tool_metadata)]
|
||||
|
||||
if client_tool_blocks:
|
||||
break
|
||||
if not server_tool_blocks:
|
||||
break
|
||||
|
||||
request_messages.append(anthropic_blocks_to_chat_assistant_message(loop_content))
|
||||
executed_usage: dict[str, Any] = {}
|
||||
for block in server_tool_blocks:
|
||||
result_block, model_tool_content, usage_increment = await execute_anthropic_server_tool_block(block, tool_metadata)
|
||||
accumulated_content.append(result_block)
|
||||
executed_usage = merge_anthropic_usage(executed_usage, {"server_tool_use": usage_increment})
|
||||
request_messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": block.get("id"),
|
||||
"content": model_tool_content,
|
||||
}
|
||||
)
|
||||
accumulated_usage = merge_anthropic_usage(accumulated_usage, executed_usage)
|
||||
else:
|
||||
accumulated_content.append(
|
||||
build_server_tool_result_error_block(
|
||||
"tool_result",
|
||||
f"srvtoolu_{uuid.uuid4().hex[:24]}",
|
||||
"max_iterations_exceeded",
|
||||
"服务端工具循环次数过多,已停止继续执行。",
|
||||
)
|
||||
)
|
||||
|
||||
if last_message_payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="上游未返回有效的 Anthropic 兼容消息。",
|
||||
)
|
||||
|
||||
message_payload = {
|
||||
**last_message_payload,
|
||||
"content": accumulated_content,
|
||||
"stop_reason": anthropic_stop_reason(None, accumulated_content),
|
||||
"usage": accumulated_usage,
|
||||
}
|
||||
latency_ms = round((time.perf_counter() - started) * 1000, 2)
|
||||
return message_payload, latency_ms
|
||||
|
||||
|
||||
def build_anthropic_streaming_response(message_payload: dict[str, Any], anthropic_version: str | None) -> StreamingResponse:
|
||||
async def event_stream() -> Any:
|
||||
opening_message = {
|
||||
@@ -1071,6 +1718,18 @@ def build_anthropic_streaming_response(message_payload: dict[str, Any], anthropi
|
||||
yield f"event: content_block_stop\ndata: {json_dumps({'type': 'content_block_stop', 'index': index})}\n\n"
|
||||
continue
|
||||
|
||||
if block_type == "thinking":
|
||||
content_block = {"type": "thinking", "thinking": ""}
|
||||
yield f"event: content_block_start\ndata: {json_dumps({'type': 'content_block_start', 'index': index, 'content_block': content_block})}\n\n"
|
||||
thinking_text = str(block.get("thinking", ""))
|
||||
if thinking_text:
|
||||
yield f"event: content_block_delta\ndata: {json_dumps({'type': 'content_block_delta', 'index': index, 'delta': {'type': 'thinking_delta', 'thinking': thinking_text}})}\n\n"
|
||||
signature = block.get("signature")
|
||||
if signature:
|
||||
yield f"event: content_block_delta\ndata: {json_dumps({'type': 'content_block_delta', 'index': index, 'delta': {'type': 'signature_delta', 'signature': signature}})}\n\n"
|
||||
yield f"event: content_block_stop\ndata: {json_dumps({'type': 'content_block_stop', 'index': index})}\n\n"
|
||||
continue
|
||||
|
||||
if block_type == "tool_use":
|
||||
content_block = {**block, "input": {}}
|
||||
yield f"event: content_block_start\ndata: {json_dumps({'type': 'content_block_start', 'index': index, 'content_block': content_block})}\n\n"
|
||||
@@ -1078,6 +1737,21 @@ def build_anthropic_streaming_response(message_payload: dict[str, Any], anthropi
|
||||
if input_json:
|
||||
yield f"event: content_block_delta\ndata: {json_dumps({'type': 'content_block_delta', 'index': index, 'delta': {'type': 'input_json_delta', 'partial_json': input_json}})}\n\n"
|
||||
yield f"event: content_block_stop\ndata: {json_dumps({'type': 'content_block_stop', 'index': index})}\n\n"
|
||||
continue
|
||||
|
||||
if block_type == "server_tool_use":
|
||||
content_block = {**block, "input": {}}
|
||||
yield f"event: content_block_start\ndata: {json_dumps({'type': 'content_block_start', 'index': index, 'content_block': content_block})}\n\n"
|
||||
input_json = json_dumps(block.get("input") or {})
|
||||
if input_json:
|
||||
yield f"event: content_block_delta\ndata: {json_dumps({'type': 'content_block_delta', 'index': index, 'delta': {'type': 'input_json_delta', 'partial_json': input_json}})}\n\n"
|
||||
yield f"event: content_block_stop\ndata: {json_dumps({'type': 'content_block_stop', 'index': index})}\n\n"
|
||||
continue
|
||||
|
||||
if isinstance(block_type, str) and block_type.endswith("_tool_result"):
|
||||
yield f"event: content_block_start\ndata: {json_dumps({'type': 'content_block_start', 'index': index, 'content_block': block})}\n\n"
|
||||
yield f"event: content_block_stop\ndata: {json_dumps({'type': 'content_block_stop', 'index': index})}\n\n"
|
||||
continue
|
||||
|
||||
yield f"event: message_delta\ndata: {json_dumps({'type': 'message_delta', 'delta': {'stop_reason': message_payload.get('stop_reason'), 'stop_sequence': message_payload.get('stop_sequence')}, 'usage': {'output_tokens': (message_payload.get('usage') or {}).get('output_tokens')}})}\n\n"
|
||||
yield "event: message_stop\ndata: {\"type\": \"message_stop\"}\n\n"
|
||||
@@ -1349,6 +2023,11 @@ def load_dashboard_data() -> dict[str, Any]:
|
||||
total_requests = totals_row["total_requests"] if totals_row else 0
|
||||
now_bucket = bucket_start()
|
||||
bucket_points = [(now_bucket - timedelta(minutes=BUCKET_MINUTES * offset)).isoformat() for offset in reversed(range(PUBLIC_HISTORY_BUCKETS))]
|
||||
health_window_buckets = max(1, HEALTH_SUMMARY_WINDOW_MINUTES // BUCKET_MINUTES)
|
||||
health_window_points = [
|
||||
(now_bucket - timedelta(minutes=BUCKET_MINUTES * offset)).isoformat()
|
||||
for offset in reversed(range(health_window_buckets))
|
||||
]
|
||||
placeholders = ",".join("?" for _ in MODEL_LIST) if MODEL_LIST else "''"
|
||||
totals_by_model = {
|
||||
row["model_id"]: row["total_count"]
|
||||
@@ -1357,7 +2036,8 @@ def load_dashboard_data() -> dict[str, Any]:
|
||||
MODEL_LIST,
|
||||
).fetchall()
|
||||
} if MODEL_LIST else {}
|
||||
since = bucket_points[0] if bucket_points else utcnow_iso()
|
||||
since_candidates = [value for value in [bucket_points[0] if bucket_points else None, health_window_points[0] if health_window_points else None] if value]
|
||||
since = min(since_candidates) if since_candidates else utcnow_iso()
|
||||
recent_rows = conn.execute(
|
||||
f"SELECT bucket_start, model_id, total_count, success_count FROM metric_buckets WHERE model_id IN ({placeholders}) AND bucket_start >= ? ORDER BY bucket_start ASC",
|
||||
[*MODEL_LIST, since],
|
||||
@@ -1366,10 +2046,10 @@ def load_dashboard_data() -> dict[str, Any]:
|
||||
for row in recent_rows:
|
||||
row_map.setdefault(row["model_id"], {})[row["bucket_start"]] = row
|
||||
models: list[dict[str, Any]] = []
|
||||
latest_rates: list[float] = []
|
||||
health_window_rates: list[float] = []
|
||||
for model_id in MODEL_LIST:
|
||||
points: list[dict[str, Any]] = []
|
||||
latest_rate: float | None = None
|
||||
latest_bucket_rate: float | None = None
|
||||
for bucket_value in bucket_points:
|
||||
row = row_map.get(model_id, {}).get(bucket_value)
|
||||
total_count = row["total_count"] if row else 0
|
||||
@@ -1384,28 +2064,41 @@ def load_dashboard_data() -> dict[str, Any]:
|
||||
"success_rate": success_rate,
|
||||
}
|
||||
)
|
||||
if total_count:
|
||||
latest_rate = success_rate
|
||||
if latest_rate is not None:
|
||||
latest_rates.append(latest_rate)
|
||||
average_rate = None
|
||||
non_empty = [point["success_rate"] for point in points if point["success_rate"] is not None]
|
||||
if non_empty:
|
||||
average_rate = round(sum(non_empty) / len(non_empty), 1)
|
||||
if bucket_value == bucket_points[-1] and total_count:
|
||||
latest_bucket_rate = success_rate
|
||||
|
||||
health_window_total = 0
|
||||
health_window_success = 0
|
||||
for bucket_value in health_window_points:
|
||||
row = row_map.get(model_id, {}).get(bucket_value)
|
||||
if not row:
|
||||
continue
|
||||
health_window_total += row["total_count"]
|
||||
health_window_success += row["success_count"]
|
||||
|
||||
health_window_rate = round((health_window_success / health_window_total) * 100, 1) if health_window_total else None
|
||||
if health_window_rate is not None:
|
||||
health_window_rates.append(health_window_rate)
|
||||
models.append(
|
||||
{
|
||||
"model_id": model_id,
|
||||
"provider": normalize_provider(model_id),
|
||||
"total_calls": totals_by_model.get(model_id, 0),
|
||||
"latest_success_rate": latest_rate,
|
||||
"average_success_rate": average_rate,
|
||||
"latest_success_rate": health_window_rate,
|
||||
"average_success_rate": health_window_rate,
|
||||
"health_window_success_rate": health_window_rate,
|
||||
"health_window_total_count": health_window_total,
|
||||
"health_window_success_count": health_window_success,
|
||||
"health_window_minutes": HEALTH_SUMMARY_WINDOW_MINUTES,
|
||||
"latest_bucket_success_rate": latest_bucket_rate,
|
||||
"points": points,
|
||||
}
|
||||
)
|
||||
average_health = round(sum(latest_rates) / len(latest_rates), 1) if latest_rates else None
|
||||
average_health = round(sum(health_window_rates) / len(health_window_rates), 1) if health_window_rates else None
|
||||
return {
|
||||
"generated_at": utcnow_iso(),
|
||||
"bucket_minutes": BUCKET_MINUTES,
|
||||
"health_window_minutes": HEALTH_SUMMARY_WINDOW_MINUTES,
|
||||
"total_requests": total_requests,
|
||||
"average_health": average_health,
|
||||
"models": models,
|
||||
@@ -1563,8 +2256,7 @@ async def create_anthropic_message(
|
||||
anthropic_version: str | None = Header(default=None),
|
||||
anthropic_beta: str | None = Header(default=None),
|
||||
):
|
||||
del anthropic_beta
|
||||
return await create_anthropic_message_impl(request, api_key, anthropic_version)
|
||||
return await create_anthropic_message_impl(request, api_key, anthropic_version, anthropic_beta)
|
||||
|
||||
|
||||
@app.get("/v1/responses/{response_id}")
|
||||
@@ -1587,7 +2279,12 @@ async def create_response(request: Request, api_key: str = Depends(extract_user_
|
||||
return await create_response_impl(request, api_key)
|
||||
|
||||
|
||||
async def create_anthropic_message_impl(request: Request, api_key: str, anthropic_version: str | None):
|
||||
async def create_anthropic_message_impl(
|
||||
request: Request,
|
||||
api_key: str,
|
||||
anthropic_version: str | None,
|
||||
anthropic_beta: str | None = None,
|
||||
):
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请求体必须是 JSON 对象。")
|
||||
@@ -1602,11 +2299,21 @@ async def create_anthropic_message_impl(request: Request, api_key: str, anthropi
|
||||
|
||||
api_key_hash = hash_api_key(api_key)
|
||||
storage_items = build_anthropic_storage_items(body)
|
||||
chat_payload, _chat_messages, tool_metadata = build_anthropic_chat_payload(body)
|
||||
chat_payload, _chat_messages, tool_metadata, thinking_config = build_anthropic_chat_payload(body, anthropic_beta)
|
||||
has_server_tools = any(meta.get("server_execution") for meta in tool_metadata.values())
|
||||
|
||||
try:
|
||||
if has_server_tools:
|
||||
message_payload, latency_ms = await create_anthropic_message_with_server_tools(
|
||||
api_key,
|
||||
body,
|
||||
chat_payload,
|
||||
tool_metadata,
|
||||
thinking_config,
|
||||
)
|
||||
else:
|
||||
upstream_json, latency_ms = await post_nvidia_chat_completion(api_key, chat_payload)
|
||||
message_payload = chat_completion_to_anthropic_message(body, upstream_json, tool_metadata)
|
||||
message_payload = chat_completion_to_anthropic_message(body, upstream_json, tool_metadata, thinking_config)
|
||||
await run_db(store_success_record, api_key_hash, body.get("model"), body, storage_items, message_payload, latency_ms)
|
||||
except HTTPException as exc:
|
||||
with contextlib.suppress(Exception):
|
||||
@@ -1676,5 +2383,3 @@ async def create_response_impl(request: Request, api_key: str):
|
||||
|
||||
return response_payload
|
||||
|
||||
|
||||
|
||||
|
||||
+5
-3
@@ -51,10 +51,11 @@ function renderOverview(data) {
|
||||
const totalCalls = data.total_requests ?? 0;
|
||||
const healthyModels = (data.models || []).filter((model) => (model.latest_success_rate ?? 0) >= 95).length;
|
||||
const displayedBuckets = data.models?.[0]?.points?.length || 0;
|
||||
const healthWindowMinutes = data.health_window_minutes || 120;
|
||||
|
||||
overviewCards.appendChild(createSummaryCard("总调用次数", totalCalls, "统计来自网关累计成功与失败请求"));
|
||||
overviewCards.appendChild(createSummaryCard("平均健康度", averageHealth, "按监控模型最近 10 分钟成功率平均值计算"));
|
||||
overviewCards.appendChild(createSummaryCard("高健康模型数", healthyModels, "最近一档成功率达到 95% 以上的模型数量"));
|
||||
overviewCards.appendChild(createSummaryCard("平均健康度", averageHealth, `按监控模型最近 ${healthWindowMinutes} 分钟滚动成功率平均值计算`));
|
||||
overviewCards.appendChild(createSummaryCard("高健康模型数", healthyModels, `最近 ${healthWindowMinutes} 分钟滚动成功率达到 95% 以上的模型数量`));
|
||||
overviewCards.appendChild(createSummaryCard("统计窗口", `${displayedBuckets * (data.bucket_minutes || 10)} 分钟`, `当前按 ${data.bucket_minutes || 10} 分钟粒度滚动统计`));
|
||||
dashboardUpdated.textContent = formatDateTime(data.generated_at);
|
||||
}
|
||||
@@ -88,6 +89,7 @@ function renderHealthRows(models) {
|
||||
models.forEach((model) => {
|
||||
const latestRate = model.latest_success_rate === null || model.latest_success_rate === undefined ? "--" : `${model.latest_success_rate.toFixed(2)}%`;
|
||||
const latestMeta = rateMeta(model.latest_success_rate);
|
||||
const healthWindowMinutes = model.health_window_minutes || 120;
|
||||
|
||||
const row = document.createElement("article");
|
||||
row.className = "health-row-card";
|
||||
@@ -100,7 +102,7 @@ function renderHealthRows(models) {
|
||||
<div class="health-row-copy">
|
||||
<h3 title="${model.model_id}">${model.model_id}</h3>
|
||||
<div class="health-meta-inline">
|
||||
<span class="health-rate-pill ${latestMeta.className}">${latestRate}</span>
|
||||
<span class="health-rate-pill ${latestMeta.className}" title="最近 ${healthWindowMinutes} 分钟滚动成功率">${latestRate}</span>
|
||||
<span class="health-rate-pill health-call-pill">调用 ${model.total_calls ?? 0} 次</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user