Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions api/admin/reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ def admin_report(
) -> None:
admin_chat_id = environ.get("ADMIN_CHAT_ID")
instance_name = environ.get("FRIENDLY_INSTANCE_NAME")
formatted_message = f"reporte admin desde {instance_name}: {message}"
formatted_message = f"admin report from {instance_name}: {message}"

if extra_context:
context_details = "\n\ncontexto adicional:"
context_details = "\n\nadditional context:"
for key, value in extra_context.items():
context_details += f"\n{key}: {_format_context_value(key, value)}"
formatted_message += context_details
Expand All @@ -45,12 +45,12 @@ def _format_context_value(key: str, value: Any) -> Any:
units = int(value)
except (TypeError, ValueError):
return value
return f"{format_credit_units(units)} créditos ({units} unidades)"
return f"{format_credit_units(units)} credits ({units} units)"


def _format_error(error: Exception, *, redact: Redactor) -> str:
details = f"\n\ntipo de error: {type(error).__name__}"
details += f"\nmensaje de error: {redact(str(error))}"
details = f"\n\nerror type: {type(error).__name__}"
details += f"\nerror message: {redact(str(error))}"
if error.__traceback__ is not None:
formatted_traceback = "".join(
traceback.format_exception(type(error), error, error.__traceback__)
Expand Down
18 changes: 16 additions & 2 deletions api/ai/pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
CREDIT_UNIT_USD_MICROS = CREDIT_CEIL_DIVISOR_USD_MICROS // 10

CHAT_OUTPUT_TOKEN_LIMIT = 1024
REASONING_CHAT_OUTPUT_TOKEN_LIMIT = 8192
VISION_OUTPUT_TOKEN_LIMIT = 512
IMAGE_CONTEXT_EXTRA_TOKENS_ESTIMATE = 1_200
WEB_SEARCH_USD_MICROS_PER_REQUEST = 1_660
Expand All @@ -36,6 +37,14 @@
}


def chat_output_token_limit(model: str) -> int:
"""Return a larger budget only for chat models that use hidden reasoning."""

if str(model or "").split(":", 1)[0] == "deepseek/deepseek-v4-flash":
return REASONING_CHAT_OUTPUT_TOKEN_LIMIT
return CHAT_OUTPUT_TOKEN_LIMIT


@dataclass
class AIUsageResult:
"""Structured AI response with billing metadata."""
Expand Down Expand Up @@ -126,17 +135,22 @@ def estimate_chat_reserve_credits(
*,
system_message: Optional[Mapping[str, Any]],
messages: Sequence[Mapping[str, Any]],
max_output_tokens: int = CHAT_OUTPUT_TOKEN_LIMIT,
max_output_tokens: Optional[int] = None,
extra_input_tokens: int = 0,
model: str = "deepseek/deepseek-v4-flash",
) -> int:
pricing = MODEL_PRICING_USD_MICROS.get(model, MODEL_PRICING_USD_MICROS["deepseek/deepseek-v4-flash"])
output_token_limit = (
chat_output_token_limit(model)
if max_output_tokens is None
else max_output_tokens
)
input_tokens = estimate_message_tokens(messages) + extra_input_tokens
if system_message:
input_tokens += estimate_message_tokens([system_message])
usd_micros = (
input_tokens * pricing["input_per_million"]
+ max_output_tokens * pricing["output_per_million"]
+ output_token_limit * pricing["output_per_million"]
) // 1_000_000
return credit_units_from_usd_micros(usd_micros)

Expand Down
8 changes: 4 additions & 4 deletions api/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,15 @@
extract_user_id as _billing_extract_user_id,
)
from api.ai.pricing import (
CHAT_OUTPUT_TOKEN_LIMIT,
AIUsageResult,
MODEL_PRICING_USD_MICROS,
VISION_OUTPUT_TOKEN_LIMIT,
calculate_billing_for_segments,
chat_output_token_limit,
ensure_mapping,
estimate_chat_reserve_credits,
estimate_message_tokens,
estimate_vision_reserve_credits,
ensure_mapping,
MODEL_PRICING_USD_MICROS,
)
from api.links.agent_tools import fetch_url_content
from api.core.constants import ADMIN_CONFIG_DENIAL_MESSAGE, PROMPT_NO_MARKDOWN
Expand Down Expand Up @@ -708,7 +708,7 @@ def estimate_ai_base_reserve_credits(
reserve = estimate_chat_reserve_credits(
system_message=system_message,
messages=messages,
max_output_tokens=CHAT_OUTPUT_TOKEN_LIMIT,
max_output_tokens=chat_output_token_limit(PRIMARY_CHAT_MODEL),
extra_input_tokens=extra_input_tokens,
model=PRIMARY_CHAT_MODEL,
)
Expand Down
9 changes: 5 additions & 4 deletions api/providers/openrouter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from typing import Any, Callable, Dict, Iterator, List, Optional

from api.ai.pricing import AIUsageResult, CHAT_OUTPUT_TOKEN_LIMIT
from api.ai.pricing import AIUsageResult, chat_output_token_limit
from api.providers.runtime import ProviderRuntime, ProviderRuntimeDeps
from api.tools.runtime import ToolRuntime
from api.providers.base import StreamingAIProvider
Expand Down Expand Up @@ -89,14 +89,15 @@ def stream(
return

has_tools = bool(extra_tools) or enable_web_search
output_token_limit = chat_output_token_limit(self._primary_model)

try:
if not has_tools:
self._increment_request_count()
request_kwargs: Dict[str, Any] = {
"model": self._primary_model,
"messages": [system_message] + list(messages),
"max_tokens": max_tokens if max_tokens is not None else CHAT_OUTPUT_TOKEN_LIMIT,
"max_tokens": max_tokens if max_tokens is not None else output_token_limit,
"stream": True,
}

Expand All @@ -113,7 +114,7 @@ def stream(
request_kwargs = {
"model": self._primary_model,
"messages": [system_message] + list(messages),
"max_tokens": max_tokens if max_tokens is not None else CHAT_OUTPUT_TOKEN_LIMIT,
"max_tokens": max_tokens if max_tokens is not None else output_token_limit,
"stream": True,
"tools": [self._build_web_search_tool()],
}
Expand Down Expand Up @@ -143,7 +144,7 @@ def stream(
request_kwargs = {
"model": self._primary_model,
"messages": [system_message] + final_messages,
"max_tokens": max_tokens if max_tokens is not None else CHAT_OUTPUT_TOKEN_LIMIT,
"max_tokens": max_tokens if max_tokens is not None else output_token_limit,
"stream": True,
}
for chunk in client.chat.completions.create(**request_kwargs):
Expand Down
141 changes: 137 additions & 4 deletions api/providers/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from openai import APIConnectionError, APIStatusError, APITimeoutError, RateLimitError

from api.ai.pricing import AIUsageResult, CHAT_OUTPUT_TOKEN_LIMIT, ensure_mapping
from api.ai.pricing import AIUsageResult, chat_output_token_limit, ensure_mapping
from api.core.logging import format_log_context, get_logger
from api.providers.types import (
EmptyAssistantMessage,
Expand Down Expand Up @@ -155,7 +155,7 @@ def _run_chat_completion(
request_kwargs: Dict[str, Any] = {
"model": self._deps.primary_model,
"messages": [system_message] + current_messages,
"max_tokens": CHAT_OUTPUT_TOKEN_LIMIT,
"max_tokens": chat_output_token_limit(self._deps.primary_model),
}

tools_list: List[Dict[str, Any]] = []
Expand All @@ -169,7 +169,9 @@ def _run_chat_completion(
try:
for attempt in range(_MAX_RETRIES):
try:
return client.chat.completions.create(**request_kwargs)
if attempt:
self._deps.increment_request_count()
response = client.chat.completions.create(**request_kwargs)
except Exception as error:
if _is_retryable_provider_error(error) and attempt < _MAX_RETRIES - 1:
wait = 2**attempt
Expand All @@ -193,6 +195,38 @@ def _run_chat_completion(
time.sleep(wait)
continue
raise
choices = getattr(response, "choices", None) or []
finish_reason = getattr(choices[0], "finish_reason", None) if choices else None
if (
choices
and self._is_retryable_finish_response(
response,
choices[0],
finish_reason,
)
and attempt < _MAX_RETRIES - 1
):
wait = 2**attempt
retry_context = dict(tool_context or {})
retry_context.update(
{
"model": self._deps.primary_model,
"tool_round": round_idx + 1,
"finish_reason": finish_reason,
**self._response_diagnostics(response, choices[0]),
}
)
logger.warning(
"openrouter: retryable finish_reason=%r retrying in %ss attempt=%d/%d%s",
finish_reason,
wait,
attempt + 1,
_MAX_RETRIES,
format_log_context(retry_context),
)
time.sleep(wait)
continue
return response
except Exception as error:
error_context = dict(tool_context or {})
error_context.update(
Expand All @@ -218,6 +252,95 @@ def _run_chat_completion(
return None
return None

def _is_retryable_finish_response(
self,
response: Any,
choice: Any,
finish_reason: Any,
) -> bool:
diagnostics = self._response_diagnostics(response, choice)
if (
diagnostics["has_content"]
or diagnostics["tool_call_count"]
or self._response_has_usage(response)
):
return False
if finish_reason is None:
return True
if finish_reason != "error":
return False

error = self._response_error(response, choice)
code = error.get("code")
try:
status_code = int(code) if code is not None else 0
except (TypeError, ValueError):
status_code = 0
if status_code in {408, 409, 429} or status_code >= 500:
return True
error_type = str((ensure_mapping(error.get("metadata")) or {}).get("error_type") or "")
return error_type in {
"rate_limit_exceeded",
"provider_overloaded",
"provider_unavailable",
"server",
"timeout",
}

def _response_has_usage(self, response: Any) -> bool:
usage = self._deps.extract_usage_map(response) or {}
for key in (
"cost",
"prompt_tokens",
"completion_tokens",
"total_tokens",
"input_tokens",
"output_tokens",
):
try:
if float(usage.get(key) or 0) > 0:
return True
except (TypeError, ValueError):
continue
server_tool_use = ensure_mapping(usage.get("server_tool_use")) or {}
try:
return int(server_tool_use.get("web_search_requests") or 0) > 0
except (TypeError, ValueError):
return False

@staticmethod
def _response_error(response: Any, choice: Any) -> Dict[str, Any]:
return (
ensure_mapping(getattr(choice, "error", None))
or ensure_mapping(getattr(response, "error", None))
or {}
)

@staticmethod
def _response_diagnostics(response: Any, choice: Any) -> Dict[str, Any]:
diagnostics: Dict[str, Any] = {}
fields = {
"response_id": getattr(response, "id", None),
"request_id": getattr(response, "_request_id", None),
"response_model": getattr(response, "model", None),
"provider": getattr(response, "provider", None),
"native_finish_reason": getattr(choice, "native_finish_reason", None),
}
diagnostics.update({key: value for key, value in fields.items() if value is not None})

error = ProviderRuntime._response_error(response, choice)
if error:
if error.get("code") is not None:
diagnostics["provider_error_code"] = error["code"]
metadata = ensure_mapping(error.get("metadata")) or {}
if metadata.get("error_type") is not None:
diagnostics["provider_error_type"] = metadata["error_type"]

message = getattr(choice, "message", None)
diagnostics["has_content"] = bool(str(getattr(message, "content", "") or "").strip())
diagnostics["tool_call_count"] = len(getattr(message, "tool_calls", None) or [])
return diagnostics

def _filter_known_calls(
self,
tool_calls: List[ToolCallLike],
Expand Down Expand Up @@ -578,6 +701,8 @@ def _handle_stop_response(

def _report_unexpected_finish(
self,
response: Any,
choice: Any,
finish_reason: Any,
round_idx: int,
*,
Expand All @@ -586,7 +711,11 @@ def _report_unexpected_finish(
) -> None:
unexpected_context = dict(tool_context or {})
unexpected_context.update(
{"model": self._deps.primary_model, "tool_round": round_idx + 1}
{
"model": self._deps.primary_model,
"tool_round": round_idx + 1,
**self._response_diagnostics(response, choice),
}
)
logger.warning(
"provider_runtime: unexpected finish_reason=%r%s",
Expand All @@ -598,6 +727,8 @@ def _report_unexpected_finish(
extra_context={
"model": self._deps.primary_model,
"enable_web_search": enable_web_search,
"tool_round": round_idx + 1,
**self._response_diagnostics(response, choice),
},
)

Expand Down Expand Up @@ -670,6 +801,8 @@ def _run_tool_rounds(
)

self._report_unexpected_finish(
response,
choice,
finish_reason,
round_idx,
enable_web_search=enable_web_search,
Expand Down
30 changes: 28 additions & 2 deletions tests/test_admin_reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ def test_admin_report_formats_error_traceback_from_exception():
index.app_runtime.admin.report("failed", captured_error)

message = send_msg.call_args.args[1]
assert "tipo de error: RuntimeError" in message
assert "mensaje de error: boom" in message
assert message.startswith("admin report from test: failed")
assert "error type: RuntimeError" in message
assert "error message: boom" in message
assert "RuntimeError: boom" in message
assert "NoneType: None" not in message

Expand Down Expand Up @@ -55,6 +56,31 @@ def test_admin_report_redacts_telegram_token_from_error_message_and_traceback():
assert "/bot<redacted>/sendMessage" in message


def test_admin_report_formats_additional_context_in_english():
with (
patch.dict(
"api.admin.reporting.environ",
{"ADMIN_CHAT_ID": "1", "FRIENDLY_INSTANCE_NAME": "VPS"},
clear=True,
),
patch.object(index.app_runtime.telegram, "send_message") as send_msg,
):
index.app_runtime.admin.report(
"OpenRouter unexpected finish_reason=None",
extra_context={
"model": "deepseek/deepseek-v4-flash",
"enable_web_search": True,
},
)

assert send_msg.call_args.args[1] == (
"admin report from VPS: OpenRouter unexpected finish_reason=None"
"\n\nadditional context:"
"\nmodel: deepseek/deepseek-v4-flash"
"\nenable_web_search: True"
)


def test_telegram_request_redacts_token_in_error_log(capsys):
fake_bot_auth = "123456" + ":" + "ABC-secret"
error = requests.ConnectionError(
Expand Down
Loading