forked from hesabix/arc
997 lines
39 KiB
Python
Executable file
997 lines
39 KiB
Python
Executable file
from __future__ import annotations
|
||
|
||
from typing import Dict, Any, List, Optional, AsyncGenerator
|
||
from abc import ABC, abstractmethod
|
||
import json
|
||
import logging
|
||
|
||
from app.services.ai.ai_prompt_cache import (
|
||
build_anthropic_system_blocks,
|
||
anthropic_request_cache_control,
|
||
extract_prompt_cache_policy,
|
||
normalize_anthropic_usage,
|
||
normalize_openai_usage,
|
||
openai_supports_prompt_cache,
|
||
split_system_messages_for_provider,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# خروجی بیشتر از این در بسیاری از gatewayها (vLLM و مشابه) رد میشود؛ حتی اگر
|
||
# مدیر در دیتابیس مقدار بسیار بزرگ بگذارد.
|
||
_MAX_SAFE_CHAT_OUTPUT_TOKENS = 32000
|
||
|
||
|
||
def _is_openai_model_unavailable_error(error_message: str) -> bool:
|
||
"""تشخیص «مدل ناموجود/غیرقابل استفاده» بدون false positive روی max_model_len و ..."""
|
||
s = error_message.lower()
|
||
if "model_not_found" in s or "model not found" in s:
|
||
return True
|
||
if "invalid model" in s or "model does not exist" in s or "does not exist" in s and "model" in s:
|
||
return True
|
||
if "unknown model" in s or "no such model" in s:
|
||
return True
|
||
return False
|
||
|
||
|
||
class AIProviderBase(ABC):
|
||
"""کلاس پایه برای AI Providers"""
|
||
|
||
def __init__(self, api_key: str, api_base_url: Optional[str] = None):
|
||
self.api_key = api_key
|
||
self.api_base_url = api_base_url
|
||
|
||
@abstractmethod
|
||
def chat_completion(
|
||
self,
|
||
messages: List[Dict[str, Any]],
|
||
model: str,
|
||
max_tokens: int,
|
||
temperature: float,
|
||
tools: Optional[List[Dict[str, Any]]] = None,
|
||
reasoning_effort: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""ارسال درخواست chat completion"""
|
||
pass
|
||
|
||
@abstractmethod
|
||
def estimate_tokens(self, text: str) -> int:
|
||
"""تخمین تعداد توکن"""
|
||
pass
|
||
|
||
@abstractmethod
|
||
async def chat_completion_stream(
|
||
self,
|
||
messages: List[Dict[str, Any]],
|
||
model: str,
|
||
max_tokens: int,
|
||
temperature: float,
|
||
tools: Optional[List[Dict[str, Any]]] = None,
|
||
reasoning_effort: Optional[str] = None,
|
||
tool_choice: Optional[Any] = None,
|
||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||
"""
|
||
ارسال درخواست chat completion به صورت streaming
|
||
هر chunk شامل:
|
||
- delta: محتوای جدید (content chunk)
|
||
- usage: در chunk آخر
|
||
- done: آیا streaming تمام شده است
|
||
"""
|
||
pass
|
||
|
||
|
||
class OpenAIProvider(AIProviderBase):
|
||
"""Provider برای OpenAI"""
|
||
|
||
def __init__(self, api_key: str, api_base_url: Optional[str] = None):
|
||
super().__init__(api_key, api_base_url or "https://api.openai.com/v1")
|
||
try:
|
||
import openai
|
||
|
||
from app.services.ai.ai_constants import (
|
||
AI_PROVIDER_STREAM_TIMEOUT_SEC,
|
||
AI_PROVIDER_TIMEOUT_SEC,
|
||
)
|
||
|
||
# استفاده از sync client برای non-streaming
|
||
self.client = openai.OpenAI(
|
||
api_key=api_key,
|
||
base_url=api_base_url or None,
|
||
timeout=AI_PROVIDER_TIMEOUT_SEC,
|
||
)
|
||
# استفاده از async client برای streaming
|
||
self.async_client = openai.AsyncOpenAI(
|
||
api_key=api_key,
|
||
base_url=api_base_url or None,
|
||
timeout=AI_PROVIDER_STREAM_TIMEOUT_SEC,
|
||
)
|
||
except ImportError:
|
||
raise ImportError("openai package is required. Install it with: pip install openai")
|
||
|
||
def _raise_mapped_api_error(self, e: Exception, *, model: str) -> None:
|
||
from app.core.responses import ApiError
|
||
|
||
error_message = str(e)
|
||
el = error_message.lower()
|
||
if (
|
||
"max_model_len" in el
|
||
or "max_total_tokens" in el
|
||
or (
|
||
"max_tokens" in el
|
||
and ("cannot" in el or "greater than" in el or "exceed" in el or "invalid" in el)
|
||
)
|
||
or ("context length" in el and ("exceed" in el or "exceeds" in el))
|
||
):
|
||
raise ApiError(
|
||
"AI_INVALID_MAX_TOKENS",
|
||
"مقدار «حداکثر توکن» در تنظیمات AI برای این سرویس بیشازحد مجاز است. "
|
||
f"لطفاً مقدار را به عددی معقول (مثلاً ۴۰۰۰ تا {_MAX_SAFE_CHAT_OUTPUT_TOKENS}) کاهش دهید.",
|
||
http_status=400,
|
||
) from e
|
||
if _is_openai_model_unavailable_error(error_message):
|
||
raise ApiError(
|
||
"MODEL_NOT_AVAILABLE",
|
||
f"مدل '{model}' در دسترس نیست. لطفاً مدل دیگری را در تنظیمات AI انتخاب کنید.",
|
||
http_status=400,
|
||
) from e
|
||
if "api_key" in el or "authentication" in el:
|
||
raise ApiError(
|
||
"INVALID_API_KEY",
|
||
"API Key نامعتبر است. لطفاً API Key را در تنظیمات AI بررسی کنید.",
|
||
http_status=400,
|
||
) from e
|
||
if "rate_limit" in el or "quota" in el:
|
||
raise ApiError(
|
||
"RATE_LIMIT_EXCEEDED",
|
||
"محدودیت استفاده از API رسیده است. لطفاً بعداً تلاش کنید.",
|
||
http_status=429,
|
||
) from e
|
||
if "debtor" in el or "insufficient" in el and "balance" in el:
|
||
raise ApiError(
|
||
"AI_PROVIDER_BILLING",
|
||
"اعتبار حساب AI Provider (آروان کلاد) تمام شده است. "
|
||
"لطفاً حساب را شارژ کنید یا مدل/ارائهدهنده دیگری تنظیم کنید.",
|
||
http_status=402,
|
||
) from e
|
||
raise ApiError(
|
||
"AI_PROVIDER_ERROR",
|
||
f"خطا در ارتباط با AI Provider: {error_message}",
|
||
http_status=500,
|
||
) from e
|
||
|
||
def _build_request_kwargs(
|
||
self,
|
||
messages: List[Dict[str, Any]],
|
||
model: str,
|
||
max_tokens: int,
|
||
temperature: float,
|
||
tools: Optional[List[Dict[str, Any]]],
|
||
reasoning_effort: Optional[str],
|
||
provider_extra: Optional[Dict[str, Any]] = None,
|
||
*,
|
||
stream: bool = False,
|
||
tool_choice: Optional[Any] = None,
|
||
) -> Dict[str, Any]:
|
||
"""ساخت پارامترهای درخواست با پشتیبانی از مدلهای reasoning.
|
||
|
||
مدلهای reasoning (o-series/gpt-5) بهجای `max_tokens` از
|
||
`max_completion_tokens` استفاده میکنند، `temperature` را نمیپذیرند و
|
||
پارامتر `reasoning_effort` را قبول دارند.
|
||
|
||
tool_choice: برای نوبت اول وقتی سوال قطعاً به ابزار نیاز دارد
|
||
("required" یا object تابع خاص مثل create_session_plan) تا مدل بدون
|
||
tool_call متن ننویسد. فقط وقتی tools موجود باشد اعمال میشود.
|
||
"""
|
||
if max_tokens > _MAX_SAFE_CHAT_OUTPUT_TOKENS:
|
||
max_tokens = _MAX_SAFE_CHAT_OUTPUT_TOKENS
|
||
|
||
from app.services.ai.chat_message_builder import repair_llm_tool_messages
|
||
|
||
api_messages = repair_llm_tool_messages(
|
||
[
|
||
{k: v for k, v in msg.items() if not str(k).startswith("_")}
|
||
for msg in messages
|
||
]
|
||
)
|
||
cache_policy = extract_prompt_cache_policy(provider_extra)
|
||
if cache_policy:
|
||
api_messages = split_system_messages_for_provider(api_messages, cache_policy)
|
||
|
||
kwargs: Dict[str, Any] = {
|
||
"model": model,
|
||
"messages": api_messages,
|
||
"tools": tools if tools else None,
|
||
}
|
||
if stream:
|
||
kwargs["stream"] = True
|
||
if tool_choice and tools:
|
||
kwargs["tool_choice"] = tool_choice
|
||
|
||
if (
|
||
cache_policy
|
||
and cache_policy.cache_key
|
||
and openai_supports_prompt_cache(self.api_base_url)
|
||
):
|
||
kwargs["prompt_cache_key"] = cache_policy.cache_key
|
||
retention = (cache_policy.openai_retention or "").strip()
|
||
if retention and retention not in ("in_memory", "default"):
|
||
kwargs["prompt_cache_retention"] = retention
|
||
|
||
if reasoning_effort:
|
||
kwargs["reasoning_effort"] = reasoning_effort
|
||
kwargs["max_completion_tokens"] = max_tokens
|
||
else:
|
||
kwargs["max_tokens"] = max_tokens
|
||
kwargs["temperature"] = temperature
|
||
return kwargs
|
||
|
||
def chat_completion(
|
||
self,
|
||
messages: List[Dict[str, Any]],
|
||
model: str,
|
||
max_tokens: int,
|
||
temperature: float,
|
||
tools: Optional[List[Dict[str, Any]]] = None,
|
||
reasoning_effort: Optional[str] = None,
|
||
provider_extra: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""ارسال درخواست به OpenAI"""
|
||
try:
|
||
from app.services.ai.ai_retry_policy import sync_retry_llm
|
||
|
||
request_kwargs = self._build_request_kwargs(
|
||
messages,
|
||
model,
|
||
max_tokens,
|
||
temperature,
|
||
tools,
|
||
reasoning_effort,
|
||
provider_extra,
|
||
)
|
||
|
||
def _call():
|
||
return self.client.chat.completions.create(**request_kwargs)
|
||
|
||
response = sync_retry_llm(_call)
|
||
|
||
message = response.choices[0].message
|
||
usage = normalize_openai_usage(response.usage)
|
||
|
||
valid_tool_calls = [
|
||
fc for fc in (message.tool_calls or []) if (fc.function.name or "").strip()
|
||
]
|
||
result = {
|
||
"message": {
|
||
"role": message.role,
|
||
"content": message.content or "",
|
||
"reasoning_content": getattr(message, "reasoning_content", None) or "",
|
||
"function_calls": [
|
||
{
|
||
"name": fc.function.name,
|
||
"arguments": json.loads(fc.function.arguments)
|
||
}
|
||
for fc in valid_tool_calls
|
||
] if valid_tool_calls else None
|
||
},
|
||
"usage": usage.to_usage_dict(),
|
||
}
|
||
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"OpenAI API error: {e}", exc_info=True)
|
||
self._raise_mapped_api_error(e, model=model)
|
||
|
||
def estimate_tokens(self, text: str) -> int:
|
||
"""تخمین تعداد توکن (تقریبی: 4 کاراکتر = 1 توکن)"""
|
||
return len(text) // 4
|
||
|
||
async def chat_completion_stream(
|
||
self,
|
||
messages: List[Dict[str, Any]],
|
||
model: str,
|
||
max_tokens: int,
|
||
temperature: float,
|
||
tools: Optional[List[Dict[str, Any]]] = None,
|
||
reasoning_effort: Optional[str] = None,
|
||
provider_extra: Optional[Dict[str, Any]] = None,
|
||
tool_choice: Optional[Any] = None,
|
||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||
"""ارسال درخواست به OpenAI به صورت streaming با async client"""
|
||
try:
|
||
from app.services.ai.ai_retry_policy import async_retry_llm
|
||
|
||
request_kwargs = self._build_request_kwargs(
|
||
messages,
|
||
model,
|
||
max_tokens,
|
||
temperature,
|
||
tools,
|
||
reasoning_effort,
|
||
provider_extra,
|
||
stream=True,
|
||
tool_choice=tool_choice,
|
||
)
|
||
|
||
async def _open_stream():
|
||
return await self.async_client.chat.completions.create(
|
||
**request_kwargs
|
||
)
|
||
|
||
stream = await async_retry_llm(_open_stream)
|
||
|
||
accumulated_content = ""
|
||
accumulated_reasoning = ""
|
||
final_usage = None
|
||
tool_calls_accumulator = {} # برای جمعآوری tool_calls از chunks مختلف
|
||
tool_planning_sent = False
|
||
|
||
async for chunk in stream:
|
||
# بررسی usage (معمولاً در chunk آخر میآید)
|
||
if chunk.usage:
|
||
final_usage = normalize_openai_usage(chunk.usage).to_usage_dict()
|
||
|
||
# بررسی content chunks
|
||
if chunk.choices and len(chunk.choices) > 0:
|
||
delta = chunk.choices[0].delta
|
||
|
||
# محتوای جدید
|
||
if delta.content:
|
||
accumulated_content += delta.content
|
||
yield {
|
||
"delta": {
|
||
"content": delta.content
|
||
},
|
||
"usage": None,
|
||
"done": False
|
||
}
|
||
|
||
reasoning_piece = getattr(delta, "reasoning_content", None) or ""
|
||
if reasoning_piece:
|
||
accumulated_reasoning += reasoning_piece
|
||
yield {
|
||
"delta": {
|
||
"reasoning_content": reasoning_piece
|
||
},
|
||
"usage": None,
|
||
"done": False
|
||
}
|
||
|
||
# بررسی tool_calls - جمعآوری از chunks مختلف
|
||
if delta.tool_calls:
|
||
if not tool_planning_sent:
|
||
tool_planning_sent = True
|
||
yield {
|
||
"event": "tool_planning",
|
||
"usage": None,
|
||
"done": False,
|
||
}
|
||
for tool_call_delta in delta.tool_calls:
|
||
index = tool_call_delta.index
|
||
if index not in tool_calls_accumulator:
|
||
tool_calls_accumulator[index] = {
|
||
"id": tool_call_delta.id or f"call_{index}",
|
||
"type": tool_call_delta.type or "function",
|
||
"function": {
|
||
"name": "",
|
||
"arguments": ""
|
||
}
|
||
}
|
||
|
||
# جمعآوری نام function (replace نه append)
|
||
if tool_call_delta.function and tool_call_delta.function.name:
|
||
tool_calls_accumulator[index]["function"]["name"] = tool_call_delta.function.name
|
||
|
||
# جمعآوری arguments (append)
|
||
if tool_call_delta.function and tool_call_delta.function.arguments:
|
||
tool_calls_accumulator[index]["function"]["arguments"] += tool_call_delta.function.arguments
|
||
|
||
# اگر usage موجود نبود، از accumulated_content تخمین بزن
|
||
if not final_usage:
|
||
# تخمین tokens (تقریبی)
|
||
input_tokens_estimate = self.estimate_tokens("\n".join([msg.get("content", "") for msg in messages]))
|
||
output_tokens_estimate = self.estimate_tokens(accumulated_content)
|
||
final_usage = {
|
||
"input_tokens": input_tokens_estimate,
|
||
"output_tokens": output_tokens_estimate,
|
||
"total_tokens": input_tokens_estimate + output_tokens_estimate
|
||
}
|
||
|
||
# تبدیل tool_calls به فرمت مورد نیاز (با حفظ id برای tool_call_id)
|
||
function_calls = None
|
||
tool_call_id_map = {}
|
||
if tool_calls_accumulator:
|
||
function_calls = []
|
||
for index in sorted(tool_calls_accumulator.keys()):
|
||
tc = tool_calls_accumulator[index]
|
||
function_name = (tc["function"]["name"] or "").strip()
|
||
if not function_name:
|
||
# tool_call بدون نام معتبر — احتمالاً stream ناقص/خراب؛
|
||
# اضافه کردنش باعث خطای provider در نوبت بعد میشود.
|
||
continue
|
||
try:
|
||
arguments = json.loads(tc["function"]["arguments"]) if tc["function"]["arguments"] else {}
|
||
except json.JSONDecodeError:
|
||
arguments = {}
|
||
|
||
tool_call_id = tc.get("id", f"call_{index}")
|
||
tool_call_id_map[function_name] = tool_call_id
|
||
|
||
function_calls.append({
|
||
"id": tool_call_id,
|
||
"name": function_name,
|
||
"arguments": arguments
|
||
})
|
||
if not function_calls:
|
||
function_calls = None
|
||
|
||
# ارسال chunk نهایی با usage و function_calls
|
||
yield {
|
||
"delta": {
|
||
"content": "",
|
||
"reasoning_content": "",
|
||
},
|
||
"usage": final_usage,
|
||
"function_calls": function_calls,
|
||
"tool_call_id_map": tool_call_id_map, # برای استفاده در ai_service
|
||
"reasoning_content_full": accumulated_reasoning or None,
|
||
"done": True
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"OpenAI streaming API error: {e}", exc_info=True)
|
||
self._raise_mapped_api_error(e, model=model)
|
||
|
||
|
||
def map_tool_choice_for_anthropic(
|
||
tool_choice: Any, *, has_tools: bool
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""تبدیل tool_choice سبک OpenAI به Messages API آنتروپیک."""
|
||
if not has_tools or not tool_choice:
|
||
return None
|
||
if tool_choice == "required":
|
||
return {"type": "any"}
|
||
if tool_choice == "auto":
|
||
return {"type": "auto"}
|
||
if tool_choice == "none":
|
||
return {"type": "none"}
|
||
if isinstance(tool_choice, dict):
|
||
kind = (tool_choice.get("type") or "").strip().lower()
|
||
if kind == "function":
|
||
name = ((tool_choice.get("function") or {}).get("name") or "").strip()
|
||
if name:
|
||
return {"type": "tool", "name": name}
|
||
if kind in ("auto", "any", "tool", "none"):
|
||
mapped = {"type": kind}
|
||
name = (tool_choice.get("name") or "").strip()
|
||
if kind == "tool" and name:
|
||
mapped["name"] = name
|
||
return mapped
|
||
return None
|
||
|
||
|
||
def _openai_tools_to_anthropic(tools: Optional[List[Dict[str, Any]]]) -> Optional[List[Dict[str, Any]]]:
|
||
if not tools:
|
||
return None
|
||
out: List[Dict[str, Any]] = []
|
||
for tool in tools:
|
||
fn = tool.get("function") if isinstance(tool, dict) else None
|
||
if not fn:
|
||
continue
|
||
out.append(
|
||
{
|
||
"name": fn["name"],
|
||
"description": fn.get("description") or "",
|
||
"input_schema": fn.get("parameters") or {"type": "object", "properties": {}},
|
||
}
|
||
)
|
||
return out or None
|
||
|
||
|
||
def _parse_tool_arguments(raw: Any) -> Dict[str, Any]:
|
||
if isinstance(raw, dict):
|
||
return raw
|
||
if isinstance(raw, str):
|
||
try:
|
||
parsed = json.loads(raw)
|
||
return parsed if isinstance(parsed, dict) else {}
|
||
except json.JSONDecodeError:
|
||
return {}
|
||
return {}
|
||
|
||
|
||
def _openai_messages_to_anthropic(
|
||
messages: List[Dict[str, Any]],
|
||
) -> tuple[Optional[str], List[Dict[str, Any]]]:
|
||
system_message: Optional[str] = None
|
||
body: List[Dict[str, Any]] = []
|
||
non_system = [m for m in messages if m.get("role") != "system"]
|
||
for m in messages:
|
||
if m.get("role") == "system":
|
||
system_message = m.get("content") or system_message
|
||
|
||
i = 0
|
||
while i < len(non_system):
|
||
msg = non_system[i]
|
||
role = msg.get("role")
|
||
if role == "user":
|
||
body.append({"role": "user", "content": msg.get("content") or ""})
|
||
i += 1
|
||
elif role == "assistant":
|
||
if msg.get("tool_calls"):
|
||
blocks: List[Dict[str, Any]] = []
|
||
if msg.get("content"):
|
||
blocks.append({"type": "text", "text": msg["content"]})
|
||
for tc in msg["tool_calls"]:
|
||
fn = tc.get("function") or {}
|
||
blocks.append(
|
||
{
|
||
"type": "tool_use",
|
||
"id": tc.get("id") or f"call_{fn.get('name', 'tool')}",
|
||
"name": fn.get("name", "unknown"),
|
||
"input": _parse_tool_arguments(fn.get("arguments")),
|
||
}
|
||
)
|
||
body.append({"role": "assistant", "content": blocks})
|
||
else:
|
||
body.append({"role": "assistant", "content": msg.get("content") or ""})
|
||
i += 1
|
||
elif role == "tool":
|
||
tool_blocks: List[Dict[str, Any]] = []
|
||
while i < len(non_system) and non_system[i].get("role") == "tool":
|
||
tm = non_system[i]
|
||
tool_blocks.append(
|
||
{
|
||
"type": "tool_result",
|
||
"tool_use_id": tm.get("tool_call_id") or "unknown",
|
||
"content": tm.get("content") or "{}",
|
||
}
|
||
)
|
||
i += 1
|
||
body.append({"role": "user", "content": tool_blocks})
|
||
else:
|
||
i += 1
|
||
return system_message, body
|
||
|
||
|
||
def _anthropic_blocks_to_openai_result(content_blocks: Any) -> tuple[str, Optional[List[Dict[str, Any]]]]:
|
||
text_parts: List[str] = []
|
||
function_calls: List[Dict[str, Any]] = []
|
||
for block in content_blocks or []:
|
||
btype = getattr(block, "type", None) or (block.get("type") if isinstance(block, dict) else None)
|
||
if btype == "text":
|
||
text_parts.append(getattr(block, "text", None) or (block.get("text") if isinstance(block, dict) else ""))
|
||
elif btype == "tool_use":
|
||
bid = getattr(block, "id", None) or (block.get("id") if isinstance(block, dict) else None)
|
||
name = getattr(block, "name", None) or (block.get("name") if isinstance(block, dict) else None)
|
||
name = (name or "").strip()
|
||
if not name:
|
||
# tool_use بدون نام معتبر — نادیده گرفته میشود تا در نوبت
|
||
# بعدی بهعنوان فراخوانی ناقص به provider ارسال نشود.
|
||
continue
|
||
inp = getattr(block, "input", None) if hasattr(block, "input") else block.get("input")
|
||
function_calls.append(
|
||
{
|
||
"id": bid or f"call_{name}",
|
||
"name": name,
|
||
"arguments": inp if isinstance(inp, dict) else {},
|
||
}
|
||
)
|
||
content = "".join(text_parts)
|
||
return content, function_calls or None
|
||
|
||
|
||
ANTHROPIC_SKILLS_BETAS = (
|
||
"code-execution-2025-08-25",
|
||
"skills-2025-10-02",
|
||
"files-api-2025-04-14",
|
||
)
|
||
|
||
|
||
class AnthropicProvider(AIProviderBase):
|
||
"""Provider برای Anthropic (Claude) با پشتیبانی tool calling و Agent Skills."""
|
||
|
||
def __init__(self, api_key: str, api_base_url: Optional[str] = None):
|
||
super().__init__(api_key, api_base_url or "https://api.anthropic.com")
|
||
try:
|
||
import anthropic
|
||
|
||
base = (api_base_url or "https://api.anthropic.com").rstrip("/")
|
||
self.client = anthropic.Anthropic(api_key=api_key, base_url=base)
|
||
self.async_client = anthropic.AsyncAnthropic(api_key=api_key, base_url=base)
|
||
except ImportError:
|
||
raise ImportError(
|
||
"anthropic package is required. Install it with: pip install anthropic"
|
||
)
|
||
|
||
def _apply_skills_extra(
|
||
self,
|
||
kwargs: Dict[str, Any],
|
||
provider_extra: Optional[Dict[str, Any]],
|
||
) -> Dict[str, Any]:
|
||
if not provider_extra:
|
||
return kwargs
|
||
skill_ids = provider_extra.get("anthropic_skills") or []
|
||
clean = [str(s).strip() for s in skill_ids if s and str(s).strip()]
|
||
if not clean:
|
||
return kwargs
|
||
out = dict(kwargs)
|
||
out["betas"] = list(ANTHROPIC_SKILLS_BETAS)
|
||
out["container"] = {
|
||
"skills": [
|
||
{"type": "anthropic", "skill_id": sid, "version": "latest"}
|
||
for sid in clean
|
||
]
|
||
}
|
||
return out
|
||
|
||
def _build_request_kwargs(
|
||
self,
|
||
messages: List[Dict[str, Any]],
|
||
model: str,
|
||
max_tokens: int,
|
||
temperature: float,
|
||
tools: Optional[List[Dict[str, Any]]],
|
||
provider_extra: Optional[Dict[str, Any]] = None,
|
||
reasoning_effort: Optional[str] = None,
|
||
*,
|
||
tool_choice: Optional[Any] = None,
|
||
) -> Dict[str, Any]:
|
||
if max_tokens > _MAX_SAFE_CHAT_OUTPUT_TOKENS:
|
||
max_tokens = _MAX_SAFE_CHAT_OUTPUT_TOKENS
|
||
cache_policy = extract_prompt_cache_policy(provider_extra)
|
||
system_message, anthropic_messages = _openai_messages_to_anthropic(messages)
|
||
from app.services.ai.ai_provider_context import (
|
||
apply_anthropic_tool_cache,
|
||
extract_provider_context_policy,
|
||
)
|
||
|
||
anthropic_tools = apply_anthropic_tool_cache(
|
||
_openai_tools_to_anthropic(tools),
|
||
extract_provider_context_policy(provider_extra),
|
||
)
|
||
kwargs: Dict[str, Any] = {
|
||
"model": model,
|
||
"max_tokens": max_tokens,
|
||
"temperature": temperature,
|
||
"messages": anthropic_messages,
|
||
}
|
||
if reasoning_effort:
|
||
from app.services.ai.ai_constants import (
|
||
ANTHROPIC_THINKING_BUDGET_TOKENS,
|
||
)
|
||
|
||
budget = ANTHROPIC_THINKING_BUDGET_TOKENS.get(reasoning_effort)
|
||
if budget:
|
||
# extended thinking: max_tokens باید بزرگتر از budget باشد و
|
||
# temperature سفارشی پشتیبانی نمیشود (باید 1 باشد).
|
||
kwargs["max_tokens"] = min(
|
||
_MAX_SAFE_CHAT_OUTPUT_TOKENS, max(max_tokens, budget + 1024)
|
||
)
|
||
kwargs["temperature"] = 1.0
|
||
kwargs["thinking"] = {
|
||
"type": "enabled",
|
||
"budget_tokens": budget,
|
||
}
|
||
system_blocks = (
|
||
build_anthropic_system_blocks(cache_policy)
|
||
if cache_policy
|
||
else None
|
||
)
|
||
if system_blocks:
|
||
kwargs["system"] = system_blocks
|
||
elif system_message:
|
||
kwargs["system"] = system_message
|
||
if anthropic_tools:
|
||
kwargs["tools"] = anthropic_tools
|
||
mapped_choice = map_tool_choice_for_anthropic(
|
||
tool_choice, has_tools=True
|
||
)
|
||
if mapped_choice:
|
||
kwargs["tool_choice"] = mapped_choice
|
||
auto_ctrl = anthropic_request_cache_control(
|
||
cache_policy,
|
||
has_conversation=bool(anthropic_messages),
|
||
) if cache_policy else None
|
||
if auto_ctrl:
|
||
kwargs["cache_control"] = auto_ctrl
|
||
return self._apply_skills_extra(kwargs, provider_extra)
|
||
|
||
def chat_completion(
|
||
self,
|
||
messages: List[Dict[str, Any]],
|
||
model: str,
|
||
max_tokens: int,
|
||
temperature: float,
|
||
tools: Optional[List[Dict[str, Any]]] = None,
|
||
provider_extra: Optional[Dict[str, Any]] = None,
|
||
reasoning_effort: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
try:
|
||
response = self.client.messages.create(
|
||
**self._build_request_kwargs(
|
||
messages,
|
||
model,
|
||
max_tokens,
|
||
temperature,
|
||
tools,
|
||
provider_extra,
|
||
reasoning_effort,
|
||
)
|
||
)
|
||
content, function_calls = _anthropic_blocks_to_openai_result(response.content)
|
||
usage = normalize_anthropic_usage(response.usage)
|
||
return {
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": content,
|
||
"function_calls": function_calls,
|
||
},
|
||
"usage": usage.to_usage_dict(),
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"Anthropic API error: {e}", exc_info=True)
|
||
raise
|
||
|
||
def estimate_tokens(self, text: str) -> int:
|
||
return len(text) // 4
|
||
|
||
async def chat_completion_stream(
|
||
self,
|
||
messages: List[Dict[str, Any]],
|
||
model: str,
|
||
max_tokens: int,
|
||
temperature: float,
|
||
tools: Optional[List[Dict[str, Any]]] = None,
|
||
provider_extra: Optional[Dict[str, Any]] = None,
|
||
reasoning_effort: Optional[str] = None,
|
||
tool_choice: Optional[Any] = None,
|
||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||
kwargs = self._build_request_kwargs(
|
||
messages,
|
||
model,
|
||
max_tokens,
|
||
temperature,
|
||
tools,
|
||
provider_extra,
|
||
reasoning_effort,
|
||
tool_choice=tool_choice,
|
||
)
|
||
try:
|
||
accumulated_text = ""
|
||
tool_blocks: Dict[int, Dict[str, Any]] = {}
|
||
final_usage = None
|
||
tool_planning_sent = False
|
||
|
||
async with self.async_client.messages.stream(**kwargs) as stream:
|
||
async for event in stream:
|
||
etype = getattr(event, "type", None)
|
||
if etype == "content_block_start":
|
||
block = getattr(event, "content_block", None)
|
||
if block and getattr(block, "type", None) == "tool_use":
|
||
if not tool_planning_sent:
|
||
tool_planning_sent = True
|
||
yield {
|
||
"event": "tool_planning",
|
||
"usage": None,
|
||
"done": False,
|
||
}
|
||
idx = getattr(event, "index", 0)
|
||
tool_blocks[idx] = {
|
||
"id": getattr(block, "id", f"call_{idx}"),
|
||
"name": getattr(block, "name", "unknown"),
|
||
"arguments_json": "",
|
||
}
|
||
elif etype == "content_block_delta":
|
||
delta = getattr(event, "delta", None)
|
||
if not delta:
|
||
continue
|
||
dtype = getattr(delta, "type", None)
|
||
if dtype == "text_delta":
|
||
piece = getattr(delta, "text", "") or ""
|
||
if piece:
|
||
accumulated_text += piece
|
||
yield {"delta": {"content": piece}, "usage": None, "done": False}
|
||
elif dtype == "input_json_delta":
|
||
idx = getattr(event, "index", 0)
|
||
if idx in tool_blocks:
|
||
tool_blocks[idx]["arguments_json"] += getattr(delta, "partial_json", "") or ""
|
||
elif etype == "message_delta":
|
||
usage = getattr(event, "usage", None)
|
||
if usage:
|
||
final_usage = normalize_anthropic_usage(usage).to_usage_dict()
|
||
|
||
final_message = await stream.get_final_message()
|
||
if final_usage is None and final_message.usage:
|
||
final_usage = normalize_anthropic_usage(
|
||
final_message.usage
|
||
).to_usage_dict()
|
||
|
||
function_calls = None
|
||
if tool_blocks:
|
||
function_calls = []
|
||
for idx in sorted(tool_blocks.keys()):
|
||
tb = tool_blocks[idx]
|
||
function_calls.append(
|
||
{
|
||
"id": tb["id"],
|
||
"name": tb["name"],
|
||
"arguments": _parse_tool_arguments(tb.get("arguments_json")),
|
||
}
|
||
)
|
||
elif final_message.content:
|
||
_, parsed_calls = _anthropic_blocks_to_openai_result(final_message.content)
|
||
function_calls = parsed_calls
|
||
|
||
if not final_usage:
|
||
final_usage = {
|
||
"input_tokens": self.estimate_tokens(str(messages)),
|
||
"output_tokens": self.estimate_tokens(accumulated_text),
|
||
"total_tokens": 0,
|
||
}
|
||
final_usage["total_tokens"] = (
|
||
final_usage["input_tokens"] + final_usage["output_tokens"]
|
||
)
|
||
|
||
yield {
|
||
"delta": {"content": ""},
|
||
"usage": final_usage,
|
||
"function_calls": function_calls,
|
||
"done": True,
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"Anthropic streaming API error: {e}", exc_info=True)
|
||
raise
|
||
|
||
|
||
class LocalProvider(AIProviderBase):
|
||
"""Provider برای مدلهای محلی (مثل Ollama)"""
|
||
|
||
def __init__(self, api_key: str, api_base_url: str):
|
||
super().__init__(api_key, api_base_url)
|
||
import httpx
|
||
self.client = httpx.Client(base_url=api_base_url, timeout=60.0)
|
||
|
||
def chat_completion(
|
||
self,
|
||
messages: List[Dict[str, Any]],
|
||
model: str,
|
||
max_tokens: int,
|
||
temperature: float,
|
||
tools: Optional[List[Dict[str, Any]]] = None,
|
||
reasoning_effort: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""ارسال درخواست به مدل محلی"""
|
||
try:
|
||
# فرمت Ollama
|
||
response = self.client.post(
|
||
"/api/chat",
|
||
json={
|
||
"model": model,
|
||
"messages": messages,
|
||
"options": {
|
||
"temperature": temperature,
|
||
"num_predict": max_tokens
|
||
}
|
||
}
|
||
)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
|
||
return {
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": data.get("message", {}).get("content", "")
|
||
},
|
||
"usage": {
|
||
"input_tokens": data.get("prompt_eval_count", 0),
|
||
"output_tokens": data.get("eval_count", 0),
|
||
"total_tokens": data.get("prompt_eval_count", 0) + data.get("eval_count", 0)
|
||
}
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"Local provider error: {e}", exc_info=True)
|
||
raise
|
||
|
||
def estimate_tokens(self, text: str) -> int:
|
||
"""تخمین تعداد توکن"""
|
||
return len(text) // 4
|
||
|
||
async def chat_completion_stream(
|
||
self,
|
||
messages: List[Dict[str, Any]],
|
||
model: str,
|
||
max_tokens: int,
|
||
temperature: float,
|
||
tools: Optional[List[Dict[str, Any]]] = None,
|
||
reasoning_effort: Optional[str] = None,
|
||
tool_choice: Optional[Any] = None,
|
||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||
"""ارسال درخواست به مدل محلی به صورت streaming (tool_choice پشتیبانی نمیشود)"""
|
||
import httpx
|
||
import asyncio
|
||
|
||
try:
|
||
# استفاده از AsyncClient برای streaming
|
||
async with httpx.AsyncClient(base_url=self.api_base_url, timeout=120.0) as client:
|
||
async with client.stream(
|
||
"POST",
|
||
"/api/chat",
|
||
json={
|
||
"model": model,
|
||
"messages": messages,
|
||
"stream": True,
|
||
"options": {
|
||
"temperature": temperature,
|
||
"num_predict": max_tokens
|
||
}
|
||
}
|
||
) as response:
|
||
response.raise_for_status()
|
||
final_usage = None
|
||
|
||
async for line in response.aiter_lines():
|
||
if not line:
|
||
continue
|
||
|
||
try:
|
||
# Parse JSON از هر خط
|
||
data = json.loads(line)
|
||
|
||
# بررسی message chunk
|
||
if "message" in data:
|
||
message = data["message"]
|
||
content = message.get("content", "")
|
||
if content:
|
||
yield {
|
||
"delta": {
|
||
"content": content
|
||
},
|
||
"usage": None,
|
||
"done": False
|
||
}
|
||
|
||
# بررسی done و usage
|
||
if data.get("done", False):
|
||
final_usage = {
|
||
"input_tokens": data.get("prompt_eval_count", 0),
|
||
"output_tokens": data.get("eval_count", 0),
|
||
"total_tokens": data.get("prompt_eval_count", 0) + data.get("eval_count", 0)
|
||
}
|
||
|
||
yield {
|
||
"delta": {
|
||
"content": ""
|
||
},
|
||
"usage": final_usage,
|
||
"done": True
|
||
}
|
||
break
|
||
|
||
except json.JSONDecodeError:
|
||
continue
|
||
except Exception as e:
|
||
logger.warning(f"Error parsing Ollama stream chunk: {e}")
|
||
continue
|
||
|
||
except Exception as e:
|
||
logger.error(f"Local provider streaming error: {e}", exc_info=True)
|
||
raise
|
||
|
||
|
||
def create_provider(
|
||
provider_type: str,
|
||
api_key: str,
|
||
api_base_url: Optional[str] = None
|
||
) -> AIProviderBase:
|
||
"""ایجاد provider بر اساس نوع"""
|
||
if provider_type == "openai":
|
||
return OpenAIProvider(api_key, api_base_url)
|
||
elif provider_type == "anthropic":
|
||
return AnthropicProvider(api_key, api_base_url)
|
||
elif provider_type == "local":
|
||
if not api_base_url:
|
||
raise ValueError("api_base_url is required for local provider")
|
||
return LocalProvider(api_key, api_base_url)
|
||
else:
|
||
raise ValueError(f"Unknown provider type: {provider_type}")
|
||
|