refactor(providers): define typed usage contract

This commit is contained in:
chengyongru
2026-08-25 01:04:25 +08:00
committed by chengyongru
parent 89c94d8744
commit 9895c23cb5
84 changed files with 1643 additions and 726 deletions
+3
View File
@@ -23,6 +23,7 @@ if TYPE_CHECKING:
STREAM_EVENT_TOOL_FAILED,
STREAM_EVENT_TOOL_STARTED,
STREAM_EVENT_TYPES,
LLMUsage,
Nanobot,
RunResult,
RunStream,
@@ -56,6 +57,7 @@ __logo__ = "🐈"
_LAZY_EXPORTS = {
"Nanobot": ".nanobot",
"LLMUsage": ".nanobot",
"RunStream": ".nanobot",
"RunResult": ".nanobot",
"RequestContext": ".agent.tools.context",
@@ -93,6 +95,7 @@ def __getattr__(name: str) -> Any:
__all__ = [
"Nanobot",
"LLMUsage",
"RunResult",
"RequestContext",
"RuntimeContextBlock",
+6 -6
View File
@@ -9,7 +9,7 @@ from typing import Any
from loguru import logger
from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.providers.base import LLMResponse, LLMUsage, ToolCallRequest
@dataclass(slots=True)
@@ -19,7 +19,7 @@ class AgentHookContext:
iteration: int
messages: list[dict[str, Any]]
response: LLMResponse | None = None
usage: dict[str, int] = field(default_factory=dict)
usage: LLMUsage | None = None
tool_calls: list[ToolCallRequest] = field(default_factory=list)
tool_results: list[Any] = field(default_factory=list)
tool_events: list[dict[str, str]] = field(default_factory=list)
@@ -39,7 +39,7 @@ class AgentRunHookContext:
messages: list[dict[str, Any]]
final_content: str | None = None
tools_used: list[str] = field(default_factory=list)
usage: dict[str, int] = field(default_factory=dict)
usage: LLMUsage | None = None
stop_reason: str | None = None
error: str | None = None
tool_events: list[dict[str, str]] = field(default_factory=list)
@@ -284,7 +284,7 @@ class SDKCaptureHook(AgentHook):
super().__init__()
self.tools_used: list[str] = []
self.messages: list[dict[str, Any]] = []
self.usage: dict[str, int] = {}
self.usage: LLMUsage | None = None
self.stop_reason: str | None = None
self.error: str | None = None
self.tool_events: list[dict[str, str]] = []
@@ -294,7 +294,7 @@ class SDKCaptureHook(AgentHook):
for call in context.tool_calls:
self.tools_used.append(call.name)
self.messages = list(context.messages)
self.usage = dict(context.usage)
self.usage = context.usage
self.stop_reason = context.stop_reason
self.error = context.error
self.tool_events = list(context.tool_events)
@@ -302,7 +302,7 @@ class SDKCaptureHook(AgentHook):
async def after_run(self, context: AgentRunHookContext) -> None:
self.tools_used = list(context.tools_used)
self.messages = list(context.messages)
self.usage = dict(context.usage)
self.usage = context.usage
self.stop_reason = context.stop_reason
self.error = context.error
self.tool_events = list(context.tool_events)
+7 -7
View File
@@ -49,7 +49,7 @@ from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider, ProviderConversationState
from nanobot.providers.base import LLMProvider, LLMUsage, ProviderConversationState
from nanobot.providers.factory import ProviderSnapshot
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
@@ -167,7 +167,7 @@ class TurnContext:
turn_wall_started_at: float = field(default_factory=time.time)
visible_run_started_at: float | None = None
turn_latency_ms: int | None = None
usage: dict[str, int] = field(default_factory=dict)
usage: LLMUsage | None = None
def require_runtime(self) -> LLMRuntime:
"""Return the runtime established by the BUILD stage."""
@@ -203,7 +203,7 @@ class AgentLoop:
return self.tools.tool_names
@property
def last_usage(self) -> Mapping[str, int]:
def last_usage(self) -> LLMUsage | None:
"""Latest aggregate usage exposed through the runtime-control snapshot."""
return self._last_usage
@@ -378,7 +378,7 @@ class AgentLoop:
default_restrict_to_workspace=restrict_to_workspace,
)
self._start_time = time.time()
self._last_usage: dict[str, int] = {}
self._last_usage: LLMUsage | None = None
self._extra_hooks: list[AgentHook] = hooks or []
self._hook_factories: list[AgentTurnHookFactory] = hook_factories or []
@@ -2031,7 +2031,7 @@ class AgentLoop:
ctx.all_messages = all_msgs
ctx.stop_reason = stop_reason
ctx.had_injections = had_injections
ctx.usage = dict(self._last_usage)
ctx.usage = self._last_usage
ctx.delivery.record_usage(ctx.usage)
if ctx.kind is TurnKind.USER:
await turn_continuation.maybe_continue_turn(ctx)
@@ -2058,8 +2058,8 @@ class AgentLoop:
else ctx.turn_wall_started_at
)
ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000))
if ctx.usage and not ctx.ephemeral:
session.metadata["_last_usage"] = dict(ctx.usage)
if ctx.usage is not None and not ctx.ephemeral:
session.metadata["_last_usage"] = ctx.usage.to_dict()
self._save_turn(
session, ctx.all_messages, ctx.save_skip,
turn_latency_ms=ctx.turn_latency_ms,
+7 -5
View File
@@ -210,12 +210,14 @@ class AgentProgressHook(AgentHook):
tool_hint=False,
tool_events=tool_events,
)
u = context.usage or {}
u = context.usage
logger.debug(
"LLM usage: prompt={} completion={} cached={}",
u.get("prompt_tokens", 0),
u.get("completion_tokens", 0),
u.get("cached_tokens", 0),
"LLM usage: input={} output={} cache_read={} cache_write={} source={}",
u.input_tokens if u else 0,
u.output_tokens if u else 0,
u.cache_read_tokens if u else None,
u.cache_write_tokens if u else None,
u.source if u else "missing",
)
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
+41 -78
View File
@@ -23,6 +23,7 @@ from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
LLMUsage,
ProviderCallContext,
ProviderConversationState,
ToolCallRequest,
@@ -126,7 +127,7 @@ class AgentRunResult:
final_content: str | None
messages: list[dict[str, Any]]
tools_used: list[str] = field(default_factory=list)
usage: dict[str, int] = field(default_factory=dict)
usage: LLMUsage | None = None
stop_reason: str = "completed"
error: str | None = None
tool_events: list[dict[str, str]] = field(default_factory=list)
@@ -412,7 +413,7 @@ class AgentRunner:
context.messages = deepcopy(result.messages)
context.final_content = result.final_content
context.tools_used = list(result.tools_used)
context.usage = dict(result.usage)
context.usage = result.usage
context.stop_reason = result.stop_reason
context.error = result.error
context.tool_events = deepcopy(result.tool_events)
@@ -443,7 +444,7 @@ class AgentRunner:
) -> AgentRunResult:
final_content: str | None = None
tools_used: list[str] = []
usage = {"prompt_tokens": 0, "completion_tokens": 0}
usage: LLMUsage | None = None
error: str | None = None
stop_reason = "completed"
tool_events: list[dict[str, str]] = []
@@ -519,8 +520,8 @@ class AgentRunner:
)
response.content = cleaned_content
raw_usage = self._usage_or_estimate(spec, messages_for_model, response)
context.usage = dict(raw_usage)
self._accumulate_usage(usage, raw_usage)
context.usage = raw_usage
usage = self._merge_usage(usage, raw_usage)
if reasoning_text and not context.streamed_reasoning:
await hook.emit_reasoning(reasoning_text)
await hook.emit_reasoning_end()
@@ -683,10 +684,10 @@ class AgentRunner:
conversation_state=conversation_state,
)
retry_usage = self._usage_or_estimate(spec, retry_messages, response)
self._accumulate_usage(usage, retry_usage)
usage = self._merge_usage(usage, retry_usage)
raw_usage = self._merge_usage(raw_usage, retry_usage)
context.response = response
context.usage = dict(raw_usage)
context.usage = raw_usage
context.tool_calls = list(response.tool_calls)
original_content = response.content
clean = hook.finalize_content(context, response.content)
@@ -859,7 +860,7 @@ class AgentRunner:
had_injections = True
terminal_content = None
if spec.finalize_on_max_iterations:
terminal_content = await self._try_finalize_after_max_iterations(
terminal_content, usage = await self._try_finalize_after_max_iterations(
spec,
hook,
messages,
@@ -1236,9 +1237,9 @@ class AgentRunner:
spec: AgentRunSpec,
hook: AgentHook,
messages: list[dict[str, Any]],
usage: dict[str, int],
usage: LLMUsage | None,
conversation_state: ProviderConversationStateController,
) -> str | None:
) -> tuple[str | None, LLMUsage | None]:
retry_messages = self._budget_exhausted_finalization_messages(messages)
try:
response = await self._request_no_tools(
@@ -1253,10 +1254,10 @@ class AgentRunner:
"Budget-exhausted finalization failed for {}; using fallback",
spec.session_key or "default",
)
return None
return None, usage
raw_usage = self._usage_or_estimate(spec, retry_messages, response)
self._accumulate_usage(usage, raw_usage)
usage = self._merge_usage(usage, raw_usage)
if response.finish_reason == "error" or response.has_tool_calls:
logger.warning(
"Budget-exhausted finalization returned finish_reason='{}' "
@@ -1265,19 +1266,19 @@ class AgentRunner:
len(response.tool_calls),
spec.session_key or "default",
)
return None
return None, usage
context = AgentHookContext(
iteration=spec.max_iterations,
messages=messages,
response=response,
usage=dict(raw_usage),
usage=raw_usage,
session_key=spec.session_key,
)
clean = hook.finalize_content(context, response.content)
if is_blank_text(clean):
return None
return clean
return None, usage
return clean, usage
async def _request_no_tools(
self,
@@ -1349,31 +1350,24 @@ class AgentRunner:
spec: AgentRunSpec,
messages: list[dict[str, Any]],
response: LLMResponse,
) -> dict[str, int]:
usage = self._usage_dict(response.usage)
total = self._usage_total(usage)
if total > 0:
usage["total_tokens"] = total
usage.setdefault("provider_tokens", total)
elif response.finish_reason == "error":
return {}
else:
) -> LLMUsage | None:
usage = response.usage
if response.finish_reason == "error":
if usage is None or usage.total_tokens == 0:
usage = LLMUsage.empty_request()
elif usage is None or usage.total_tokens == 0:
usage = self._estimate_response_usage(spec, messages, response)
completion = usage.get("completion_tokens", 0)
if response.generation_ms is not None and completion > 0:
usage["generation_ms"] = response.generation_ms
usage["measured_completion_tokens"] = completion
if response.ttft_ms is not None:
usage["ttft_ms"] = response.ttft_ms
usage["timed_requests"] = 1
return usage
return usage.with_timing(
generation_ms=response.generation_ms,
ttft_ms=response.ttft_ms,
)
def _estimate_response_usage(
self,
spec: AgentRunSpec,
messages: list[dict[str, Any]],
response: LLMResponse,
) -> dict[str, int]:
) -> LLMUsage:
try:
tools = spec.tools.get_definitions()
except Exception:
@@ -1391,52 +1385,21 @@ class AgentRunner:
thinking_blocks=response.thinking_blocks,
)
completion_tokens = estimate_message_tokens(assistant_message)
total_tokens = max(0, prompt_tokens) + max(0, completion_tokens)
if total_tokens <= 0:
return {}
return {
"prompt_tokens": max(0, prompt_tokens),
"completion_tokens": max(0, completion_tokens),
"total_tokens": total_tokens,
"estimated_tokens": total_tokens,
}
return LLMUsage.estimated(
input_tokens=max(0, prompt_tokens),
output_tokens=max(0, completion_tokens),
)
@staticmethod
def _usage_dict(usage: dict[str, Any] | None) -> dict[str, int]:
if not usage:
return {}
result: dict[str, int] = {}
for key, value in usage.items():
try:
result[key] = int(value or 0)
except (TypeError, ValueError):
continue
return result
@staticmethod
def _usage_total(usage: dict[str, int]) -> int:
return max(0, usage.get("total_tokens", 0) or (
usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
))
@staticmethod
def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]:
merged = dict(left)
for key, value in right.items():
merged[key] = merged.get(key, 0) + value
return merged
@staticmethod
def _accumulate_usage(total: dict[str, int], request: dict[str, int]) -> None:
"""Fold one model request into the current turn's usage."""
total["request_count"] = total.get("request_count", 0) + 1
prompt_tokens = request.get("prompt_tokens")
if prompt_tokens is not None and prompt_tokens >= 0:
total["context_tokens"] = prompt_tokens
for key, value in request.items():
if key in {"context_tokens", "request_count"} or value < 0:
continue
total[key] = total.get(key, 0) + value
def _merge_usage(
left: LLMUsage | None,
right: LLMUsage | None,
) -> LLMUsage | None:
if left is None:
return right
if right is None:
return left
return left + right
async def _execute_tools(
self,
+3 -3
View File
@@ -28,7 +28,7 @@ from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import AgentDefaults, ToolsConfig
from nanobot.providers.base import LLMProvider
from nanobot.providers.base import LLMProvider, LLMUsage
from nanobot.security.workspace_access import (
WorkspaceScope,
bind_workspace_scope,
@@ -56,7 +56,7 @@ class SubagentStatus:
phase: str = "initializing" # initializing | awaiting_tools | tools_completed | final_response | done | error
iteration: int = 0
tool_events: list[dict[str, str]] = field(default_factory=list)
usage: dict[str, int] = field(default_factory=dict)
usage: LLMUsage | None = None
stop_reason: str | None = None
error: str | None = None
@@ -82,7 +82,7 @@ class _SubagentHook(AgentHook):
return
self._status.iteration = context.iteration
self._status.tool_events = list(context.tool_events)
self._status.usage = dict(context.usage)
self._status.usage = context.usage
if context.error:
self._status.error = str(context.error)
+5 -4
View File
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
from nanobot.agent.tools.shell import ExecToolConfig
from nanobot.agent.tools.web import WebToolsConfig
from nanobot.config.schema import ModelPresetConfig
from nanobot.providers.base import LLMUsage
from nanobot.utils.llm_runtime import LLMRuntime
@@ -65,7 +66,7 @@ class RuntimeSnapshot:
web_config: dict[str, object]
exec_config: dict[str, object]
subagent_statuses: dict[str, dict[str, object]]
last_usage: dict[str, int]
last_usage: Mapping[str, JsonScalar]
scratchpad: dict[str, JsonValue]
def as_mapping(self) -> Mapping[str, object]:
@@ -151,7 +152,7 @@ class _RuntimeControlTarget(Protocol):
def tool_names(self) -> list[str]: ...
@property
def last_usage(self) -> Mapping[str, int]: ...
def last_usage(self) -> LLMUsage | None: ...
def set_runtime_model(self, model: str) -> LLMRuntime: ...
@@ -190,7 +191,7 @@ class AgentRuntimeControl:
web_config=_snapshot_web_config(target.web_config),
exec_config=_snapshot_exec_config(target.exec_config),
subagent_statuses=_snapshot_subagent_statuses(target.subagents),
last_usage=dict(target.last_usage),
last_usage=target.last_usage.to_dict() if target.last_usage is not None else {},
scratchpad=_snapshot_json_mapping(self.__scratchpad),
)
@@ -297,7 +298,7 @@ def _snapshot_subagent_status(status: SubagentStatus) -> dict[str, object]:
"phase": status.phase,
"iteration": status.iteration,
"tool_events": [dict(event) for event in status.tool_events],
"usage": dict(status.usage),
"usage": status.usage.to_dict() if status.usage is not None else None,
"stop_reason": status.stop_reason,
"error": status.error,
}
+1 -1
View File
@@ -150,7 +150,7 @@ class MyTool(Tool):
"Actions: check, set.\n"
"- check (no key): full config overview — start here.\n"
"- check (key): drill into a value. Dot-paths allowed "
"(e.g. '_last_usage.prompt_tokens', 'web_config.enable').\n"
"(e.g. '_last_usage.input_tokens', 'web_config.enable').\n"
"- set (key, value): change config or store notes in your scratchpad. "
"Scratchpad keys persist across turns but not restarts.\n"
"Key values: _current_iteration (current progress), "
+3 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import dataclasses
import time
from collections.abc import Awaitable, Callable, Mapping
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, cast
@@ -19,6 +19,7 @@ from nanobot.bus.outbound_events import (
from nanobot.bus.progress import build_bus_progress_callback
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus, RuntimeEventPublisher
from nanobot.providers.base import LLMUsage
if TYPE_CHECKING:
from nanobot.utils.llm_runtime import LLMRuntime
@@ -203,7 +204,7 @@ class TurnDelivery:
def record_latency(self, latency_ms: int | None) -> None:
self.runtime_event_publisher.record_turn_latency(self.session_key, latency_ms)
def record_usage(self, usage: Mapping[str, int]) -> None:
def record_usage(self, usage: LLMUsage | None) -> None:
self.runtime_event_publisher.record_turn_usage(self.session_key, usage)
def background_response(
+5 -4
View File
@@ -18,6 +18,7 @@ from aiohttp import web
from loguru import logger
from nanobot.config.paths import get_media_dir
from nanobot.providers.base import LLMUsage
from nanobot.utils.helpers import safe_filename
from nanobot.utils.media_decode import (
MAX_FILE_SIZE,
@@ -93,11 +94,11 @@ def _error_json(status: int, message: str, err_type: str = "invalid_request_erro
def _chat_completion_response(
content: str,
model: str,
usage: dict[str, int] | None = None,
usage: LLMUsage | None = None,
) -> dict[str, Any]:
prompt = (usage or {}).get("prompt_tokens", 0)
completion = (usage or {}).get("completion_tokens", 0)
total = (usage or {}).get("total_tokens", 0) or prompt + completion
prompt = usage.input_tokens if usage else 0
completion = usage.output_tokens if usage else 0
total = usage.total_tokens if usage else 0
return {
"id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
"object": "chat.completion",
+2 -6
View File
@@ -12,6 +12,7 @@ from dataclasses import dataclass, replace
from typing import Any, cast
from nanobot.bus.events import OutboundMessage
from nanobot.providers.base import LLMUsage
class OutboundEvent:
@@ -58,7 +59,7 @@ class StreamedResponseEvent(OutboundEvent):
class TurnEndEvent(OutboundEvent):
latency_ms: int | None = None
goal_state: dict[str, Any] | None = None
usage: dict[str, int] | None = None
usage: LLMUsage | None = None
context_window_tokens: int | None = None
@@ -197,11 +198,6 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
return TurnEndEvent(
latency_ms=_metadata_int(meta, "latency_ms"),
goal_state=cast(dict[str, Any], goal_state) if isinstance(goal_state, dict) else None,
usage=(
cast(dict[str, int], meta.get("usage"))
if isinstance(meta.get("usage"), dict)
else None
),
context_window_tokens=_metadata_int(meta, "context_window_tokens"),
)
if meta.get("_session_updated"):
+8 -10
View File
@@ -10,13 +10,14 @@ from __future__ import annotations
import asyncio
import contextlib
import inspect
from collections.abc import Awaitable, Callable, Mapping
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from loguru import logger
from nanobot.bus.events import InboundMessage
from nanobot.providers.base import LLMUsage
if TYPE_CHECKING:
from nanobot.utils.llm_runtime import LLMRuntime
@@ -72,7 +73,7 @@ class TurnCompleted:
context: RuntimeEventContext
latency_ms: int | None = None
runtime: LLMRuntime | None = None
usage: dict[str, int] = field(default_factory=dict)
usage: LLMUsage | None = None
@dataclass(frozen=True)
@@ -180,7 +181,7 @@ class RuntimeEventPublisher:
self.bus = bus or RuntimeEventBus()
self._turn_latency_ms: dict[str, int] = {}
self._turn_runtime: dict[str, LLMRuntime] = {}
self._turn_usage: dict[str, dict[str, int]] = {}
self._turn_usage: dict[str, LLMUsage] = {}
@staticmethod
def _context(
@@ -206,12 +207,9 @@ class RuntimeEventPublisher:
if latency_ms is not None:
self._turn_latency_ms[session_key] = int(latency_ms)
def record_turn_usage(self, session_key: str, usage: Mapping[str, int]) -> None:
self._turn_usage[session_key] = {
key: int(value)
for key, value in usage.items()
if type(value) is int and value >= 0
}
def record_turn_usage(self, session_key: str, usage: LLMUsage | None) -> None:
if usage is not None:
self._turn_usage[session_key] = usage
def clear_turn(self, session_key: str) -> None:
self._turn_latency_ms.pop(session_key, None)
@@ -332,7 +330,7 @@ class RuntimeEventPublisher:
),
latency_ms=self._turn_latency_ms.pop(session_key, None),
runtime=self._turn_runtime.pop(session_key, None),
usage=self._turn_usage.pop(session_key, {}),
usage=self._turn_usage.pop(session_key, None),
)
)
+7 -15
View File
@@ -44,6 +44,7 @@ from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import USER_SHELL_COMMAND, builtin_command_starts_agent_turn
from nanobot.config.schema import Base
from nanobot.providers.base import LLMUsage
from nanobot.runtime_context import (
RUNTIME_CONTEXT_INPUT_META,
WEBUI_QUOTE_METADATA,
@@ -458,18 +459,9 @@ class WebSocketChannel(BaseChannel):
recovery_state = recovery_state_from_metadata(metadata)
if recovery_state is not None:
fields["recovery_state"] = recovery_state
usage = metadata.get("_last_usage")
if isinstance(usage, dict):
sanitized_usage: dict[str, int | float] = {}
for key, value in cast(dict[object, object], usage).items():
if (
isinstance(key, str)
and isinstance(value, (int, float))
and not isinstance(value, bool)
and value >= 0
):
sanitized_usage[key] = value
fields["usage"] = sanitized_usage
usage = LLMUsage.from_dict(metadata.get("_last_usage"))
if usage is not None:
fields["usage"] = usage.to_turn_dict()
return fields
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
@@ -2019,7 +2011,7 @@ class WebSocketChannel(BaseChannel):
latency_ms: int | None = None,
*,
goal_state: dict[str, Any] | None = None,
usage: dict[str, int] | None = None,
usage: LLMUsage | None = None,
context_window_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
turn_owner: str | None = None,
@@ -2034,8 +2026,8 @@ class WebSocketChannel(BaseChannel):
body["latency_ms"] = int(latency_ms)
if goal_state is not None:
body["goal_state"] = goal_state
if usage:
body["usage"] = usage
if usage is not None:
body["usage"] = usage.to_turn_dict()
if context_window_tokens is not None:
body["context_window_tokens"] = int(context_window_tokens)
canonical_webui_turn = (metadata or {}).get("webui") is True
@@ -44,6 +44,7 @@ from nanobot.channels.websocket.runtime import (
)
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.base import LLMUsage
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META, WEBUI_QUOTE_SOURCE
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
from nanobot.session import webui_turns as wth
@@ -2190,16 +2191,12 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
def test_attach_fields_restore_the_session_model_and_latest_usage() -> None:
usage = LLMUsage.reported(input_tokens=120, output_tokens=8, total_tokens=175)
manager = MagicMock()
manager.read_session_metadata.return_value = {
"metadata": {
SESSION_MODEL_PRESET_METADATA_KEY: "Deep Research",
"_last_usage": {
"prompt_tokens": 120,
"completion_tokens": 8,
"negative": -1,
"boolean": True,
},
"_last_usage": usage.to_dict(),
}
}
bus = MagicMock()
@@ -2211,7 +2208,7 @@ def test_attach_fields_restore_the_session_model_and_latest_usage() -> None:
assert channel._attached_model_fields("chat-1") == {
"model_preset": "Deep Research",
"usage": {"prompt_tokens": 120, "completion_tokens": 8},
"usage": usage.to_turn_dict(),
}
@@ -3329,7 +3326,7 @@ async def test_send_turn_end_includes_latency_ms_when_present() -> None:
content="",
event=TurnEndEvent(
latency_ms=1500,
usage={"prompt_tokens": 80, "completion_tokens": 20, "cached_tokens": 40},
usage=LLMUsage.reported(input_tokens=80, output_tokens=20, cache_read_tokens=40),
context_window_tokens=128_000,
),
))
@@ -3339,7 +3336,11 @@ async def test_send_turn_end_includes_latency_ms_when_present() -> None:
"event": "turn_end",
"chat_id": "chat-1",
"latency_ms": 1500,
"usage": {"prompt_tokens": 80, "completion_tokens": 20, "cached_tokens": 40},
"usage": LLMUsage.reported(
input_tokens=80,
output_tokens=20,
cache_read_tokens=40,
).to_turn_dict(),
"context_window_tokens": 128_000,
},
{"event": "session_updated", "chat_id": "chat-1", "scope": "thread"},
@@ -5306,10 +5307,11 @@ async def test_handle_session_context_get_reads_detached_session() -> None:
from nanobot.session import Session
usage = LLMUsage.reported(input_tokens=12, output_tokens=3, total_tokens=175)
session = Session(
key="websocket:context-route",
messages=[{"role": "user", "content": "hello"}],
metadata={"_last_usage": {"prompt_tokens": 12, "completion_tokens": 3}},
metadata={"_last_usage": usage.to_dict()},
)
manager = MagicMock()
manager.read_session_snapshot.return_value = session
@@ -5326,7 +5328,7 @@ async def test_handle_session_context_get_reads_detached_session() -> None:
assert response.status_code == 200
body = json.loads(response.body.decode())
assert body["replay_messages"] == 1
assert body["last_usage"] == {"prompt_tokens": 12, "completion_tokens": 3}
assert body["last_usage"] == usage.to_dict()
manager.read_session_snapshot.assert_called_once_with(session.key)
+2 -1
View File
@@ -266,7 +266,8 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
runtime=runtime,
)
if ctx_est <= 0:
ctx_est = loop._last_usage.get("prompt_tokens", 0) # pyright: ignore[reportPrivateUsage]
last_usage = loop._last_usage # pyright: ignore[reportPrivateUsage]
ctx_est = last_usage.input_tokens if last_usage is not None else 0
# Fetch web search provider usage (best-effort, never blocks the response)
search_usage_text: str | None = None
+3 -1
View File
@@ -13,6 +13,7 @@ from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.mcp import MCPProvider
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import Config
from nanobot.providers.base import LLMUsage
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient
from nanobot.sdk.runtime import (
@@ -43,6 +44,7 @@ from nanobot.utils.llm_runtime import LLMRuntime
__all__ = [
"Nanobot",
"LLMUsage",
"RunResult",
"RunStream",
"SessionInfo",
@@ -287,7 +289,7 @@ class Nanobot:
type=STREAM_EVENT_RUN_COMPLETED,
content=result.content,
result=result,
usage=dict(result.usage),
usage=result.usage,
metadata=dict(result.metadata),
))
return result
+2 -1
View File
@@ -5,11 +5,12 @@ from __future__ import annotations
from importlib import import_module
from typing import TYPE_CHECKING
from nanobot.providers.base import LLMProvider, LLMResponse
from nanobot.providers.base import LLMProvider, LLMResponse, LLMUsage
__all__ = [
"LLMProvider",
"LLMResponse",
"LLMUsage",
"AnthropicProvider",
"OpenAICompatProvider",
"OpenAICodexProvider",
+22 -18
View File
@@ -17,6 +17,7 @@ from loguru import logger
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
LLMUsage,
ToolCallRequest,
resolve_stream_idle_timeout_s,
tool_arguments_object_for_replay,
@@ -90,8 +91,10 @@ class AnthropicProvider(LLMProvider):
api_base: str | None = None,
default_model: str = "claude-sonnet-4-6",
extra_headers: dict[str, str] | None = None,
*,
provider_name: str = "anthropic",
):
super().__init__(api_key, api_base)
super().__init__(api_key, api_base, provider_name=provider_name)
self.default_model = default_model
self.extra_headers = extra_headers or {}
@@ -689,24 +692,25 @@ class AnthropicProvider(LLMProvider):
stop_map = {"tool_use": "tool_calls", "end_turn": "stop", "max_tokens": "length"}
finish_reason = stop_map.get(response.stop_reason or "", response.stop_reason or "stop")
usage: dict[str, int] = {}
usage: LLMUsage | None = None
if response.usage:
input_tokens = response.usage.input_tokens
cache_creation = getattr(response.usage, "cache_creation_input_tokens", 0) or 0
cache_read = getattr(response.usage, "cache_read_input_tokens", 0) or 0
total_prompt_tokens = input_tokens + cache_creation + cache_read
usage = {
"prompt_tokens": total_prompt_tokens,
"completion_tokens": response.usage.output_tokens,
"total_tokens": total_prompt_tokens + response.usage.output_tokens,
}
for attr in ("cache_creation_input_tokens", "cache_read_input_tokens"):
val = getattr(response.usage, attr, 0)
if val:
usage[attr] = val
# Normalize to cached_tokens for downstream consistency.
if cache_read:
usage["cached_tokens"] = cache_read
cache_write_raw = getattr(
response.usage,
"cache_creation_input_tokens",
None,
)
cache_read_raw = getattr(response.usage, "cache_read_input_tokens", None)
cache_write = int(cache_write_raw) if cache_write_raw is not None else None
cache_read = int(cache_read_raw) if cache_read_raw is not None else None
logical_input = int(response.usage.input_tokens) + (cache_write or 0) + (
cache_read or 0
)
usage = LLMUsage.reported(
input_tokens=logical_input,
output_tokens=int(response.usage.output_tokens),
cache_read_tokens=cache_read,
cache_write_tokens=cache_write,
)
return LLMResponse(
content="".join(content_parts) or None,
+3 -1
View File
@@ -106,8 +106,10 @@ class AzureOpenAIProvider(LLMProvider):
api_key: str = "",
api_base: str = "",
default_model: str = "gpt-5.2-chat",
*,
provider_name: str = "azure_openai",
):
super().__init__(api_key, api_base)
super().__init__(api_key, api_base, provider_name=provider_name)
self.default_model = default_model
self._native_compaction_available = True
+292 -3
View File
@@ -13,7 +13,7 @@ from copy import deepcopy
from dataclasses import dataclass, field
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import Any, cast
from typing import Any, Literal, cast
import json_repair
from loguru import logger
@@ -253,13 +253,292 @@ class ProviderCallContext:
context_window_tokens: int | None = None
@dataclass(frozen=True, slots=True)
class LLMUsage:
"""Canonical token usage reported by, or estimated for, one or more LLM calls.
``input_tokens`` is the logical input total and therefore includes cache reads
and writes. ``None`` cache counts mean the wire protocol did not report that
metric, while zero means it explicitly reported no cache activity.
``total_tokens`` preserves a provider-reported total when it exceeds the
visible input plus output (for example, hidden reasoning or tool usage). It
must be at least ``input_tokens + output_tokens``. The reported and estimated
totals partition it exactly, including after multi-call aggregation.
"""
input_tokens: int
output_tokens: int
total_tokens: int
cache_read_tokens: int | None = None
cache_write_tokens: int | None = None
reported_tokens: int = 0
estimated_tokens: int = 0
generation_ms: int = 0
measured_output_tokens: int = 0
ttft_ms: int = 0
timed_requests: int = 0
context_tokens: int | None = None
request_count: int = 0
def __post_init__(self) -> None:
token_fields = {
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"total_tokens": self.total_tokens,
"reported_tokens": self.reported_tokens,
"estimated_tokens": self.estimated_tokens,
"generation_ms": self.generation_ms,
"measured_output_tokens": self.measured_output_tokens,
"ttft_ms": self.ttft_ms,
"timed_requests": self.timed_requests,
"request_count": self.request_count,
}
for name, value in token_fields.items():
runtime_value = cast(object, value)
if (
not isinstance(runtime_value, int)
or isinstance(runtime_value, bool)
or runtime_value < 0
):
raise ValueError(f"{name} must be a non-negative integer")
for name, value in (
("cache_read_tokens", self.cache_read_tokens),
("cache_write_tokens", self.cache_write_tokens),
("context_tokens", self.context_tokens),
):
runtime_value = cast(object, value)
if runtime_value is not None and (
not isinstance(runtime_value, int)
or isinstance(runtime_value, bool)
or runtime_value < 0
):
raise ValueError(f"{name} must be None or a non-negative integer")
visible_total = self.input_tokens + self.output_tokens
if self.total_tokens < visible_total:
raise ValueError("total_tokens must be at least input_tokens + output_tokens")
if self.reported_tokens + self.estimated_tokens != self.total_tokens:
raise ValueError("reported_tokens + estimated_tokens must equal total_tokens")
cache_total = (self.cache_read_tokens or 0) + (self.cache_write_tokens or 0)
if cache_total > self.input_tokens:
raise ValueError("cache token counts cannot exceed logical input_tokens")
@classmethod
def reported(
cls,
*,
input_tokens: int,
output_tokens: int,
total_tokens: int | None = None,
cache_read_tokens: int | None = None,
cache_write_tokens: int | None = None,
) -> LLMUsage:
"""Build usage normalized from a provider response."""
visible_total = input_tokens + output_tokens
normalized_total = (
visible_total if total_tokens is None else max(visible_total, total_tokens)
)
return cls(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=normalized_total,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
reported_tokens=normalized_total,
context_tokens=input_tokens,
request_count=1,
)
@classmethod
def estimated(cls, *, input_tokens: int, output_tokens: int) -> LLMUsage:
"""Build usage estimated locally because the provider omitted it."""
return cls(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=input_tokens + output_tokens,
estimated_tokens=input_tokens + output_tokens,
context_tokens=input_tokens,
request_count=1,
)
@classmethod
def empty_request(cls) -> LLMUsage:
"""Represent a completed model request with no measurable token usage."""
return cls(
input_tokens=0,
output_tokens=0,
total_tokens=0,
request_count=1,
)
@property
def source(self) -> Literal["reported", "estimated", "mixed"]:
if self.estimated_tokens == 0:
return "reported"
if self.reported_tokens == 0:
return "estimated"
return "mixed"
def with_timing(
self,
*,
generation_ms: int | None,
ttft_ms: int | None,
) -> LLMUsage:
"""Attach locally measured streaming telemetry to this usage value."""
return LLMUsage(
input_tokens=self.input_tokens,
output_tokens=self.output_tokens,
total_tokens=self.total_tokens,
cache_read_tokens=self.cache_read_tokens,
cache_write_tokens=self.cache_write_tokens,
reported_tokens=self.reported_tokens,
estimated_tokens=self.estimated_tokens,
generation_ms=max(0, generation_ms or 0),
measured_output_tokens=self.output_tokens if generation_ms is not None else 0,
ttft_ms=max(0, ttft_ms or 0),
timed_requests=1 if ttft_ms is not None else 0,
context_tokens=self.context_tokens,
request_count=self.request_count,
)
def __add__(self, other: LLMUsage) -> LLMUsage:
"""Aggregate calls without turning partially reported cache data into a count."""
def _sum_cache(left: int | None, right: int | None) -> int | None:
return left + right if left is not None and right is not None else None
return LLMUsage(
input_tokens=self.input_tokens + other.input_tokens,
output_tokens=self.output_tokens + other.output_tokens,
total_tokens=self.total_tokens + other.total_tokens,
cache_read_tokens=_sum_cache(self.cache_read_tokens, other.cache_read_tokens),
cache_write_tokens=_sum_cache(self.cache_write_tokens, other.cache_write_tokens),
reported_tokens=self.reported_tokens + other.reported_tokens,
estimated_tokens=self.estimated_tokens + other.estimated_tokens,
generation_ms=self.generation_ms + other.generation_ms,
measured_output_tokens=(
self.measured_output_tokens + other.measured_output_tokens
),
ttft_ms=self.ttft_ms + other.ttft_ms,
timed_requests=self.timed_requests + other.timed_requests,
context_tokens=(
other.context_tokens
if other.context_tokens is not None
else self.context_tokens
),
request_count=self.request_count + other.request_count,
)
def to_dict(self) -> dict[str, int | str | None]:
"""Serialize the canonical contract at JSON/persistence boundaries."""
return {
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"total_tokens": self.total_tokens,
"cache_read_tokens": self.cache_read_tokens,
"cache_write_tokens": self.cache_write_tokens,
"reported_tokens": self.reported_tokens,
"estimated_tokens": self.estimated_tokens,
"source": self.source,
"generation_ms": self.generation_ms,
"measured_output_tokens": self.measured_output_tokens,
"ttft_ms": self.ttft_ms,
"timed_requests": self.timed_requests,
"context_tokens": self.context_tokens,
"request_count": self.request_count,
}
def to_turn_dict(self) -> dict[str, int]:
"""Project canonical usage into the WebUI's compact per-turn shape."""
result: dict[str, int] = {
"prompt_tokens": self.input_tokens,
"completion_tokens": self.output_tokens,
"total_tokens": self.total_tokens,
"request_count": self.request_count,
"estimated_tokens": self.estimated_tokens,
}
if self.context_tokens is not None:
result["context_tokens"] = self.context_tokens
if self.cache_read_tokens is not None:
result["cached_tokens"] = self.cache_read_tokens
if self.cache_write_tokens is not None:
result["cache_write_tokens"] = self.cache_write_tokens
return result
@classmethod
def from_dict(cls, value: object) -> LLMUsage | None:
"""Validate the exact first-party serialized contract."""
if not isinstance(value, dict):
return None
data = cast(dict[object, object], value)
integer_fields = (
"input_tokens",
"output_tokens",
"reported_tokens",
"estimated_tokens",
"generation_ms",
"measured_output_tokens",
"ttft_ms",
"timed_requests",
"request_count",
)
serialized_fields = {
*integer_fields,
"total_tokens",
"cache_read_tokens",
"cache_write_tokens",
"context_tokens",
"source",
}
if set(data) != serialized_fields:
return None
if any(
not isinstance(item := data.get(name), int) or isinstance(item, bool)
for name in integer_fields
):
return None
cache_read = data.get("cache_read_tokens")
cache_write = data.get("cache_write_tokens")
context_tokens = data.get("context_tokens")
total = data.get("total_tokens")
source = data.get("source")
if any(
item is not None and (not isinstance(item, int) or isinstance(item, bool))
for item in (cache_read, cache_write, context_tokens)
) or not isinstance(total, int) or isinstance(total, bool):
return None
try:
usage = cls(
input_tokens=cast(int, data["input_tokens"]),
output_tokens=cast(int, data["output_tokens"]),
total_tokens=total,
cache_read_tokens=cast(int | None, cache_read),
cache_write_tokens=cast(int | None, cache_write),
reported_tokens=cast(int, data["reported_tokens"]),
estimated_tokens=cast(int, data["estimated_tokens"]),
generation_ms=cast(int, data["generation_ms"]),
measured_output_tokens=cast(int, data["measured_output_tokens"]),
ttft_ms=cast(int, data["ttft_ms"]),
timed_requests=cast(int, data["timed_requests"]),
context_tokens=cast(int | None, context_tokens),
request_count=cast(int, data["request_count"]),
)
except (KeyError, ValueError):
return None
if source != usage.source:
return None
return usage
@dataclass
class LLMResponse:
"""Response from an LLM provider."""
content: str | None
tool_calls: list[ToolCallRequest] = field(default_factory=list)
finish_reason: str = "stop"
usage: dict[str, int] = field(default_factory=dict)
usage: LLMUsage | None = None
# Locally measured streaming telemetry. ``generation_ms`` excludes time to
# first token and provider retry gaps; ``ttft_ms`` measures the first
# streamed reasoning/content delta from request start. They stay separate
@@ -383,9 +662,19 @@ class LLMProvider(ABC):
_SENTINEL = object()
def __init__(self, api_key: str | None = None, api_base: str | None = None):
def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
*,
provider_name: str,
):
runtime_provider_name = cast(object, provider_name)
if not isinstance(runtime_provider_name, str) or not runtime_provider_name.strip():
raise ValueError("provider_name must be a non-empty configured identity")
self.api_key = api_key
self.api_base = api_base
self.provider_name = provider_name
self.generation: GenerationSettings = GenerationSettings()
def can_resume_conversation_state(
+21 -19
View File
@@ -14,6 +14,7 @@ from typing import Any, cast
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
LLMUsage,
ToolCallRequest,
parse_tool_arguments,
resolve_stream_idle_timeout_s,
@@ -60,8 +61,9 @@ class BedrockProvider(LLMProvider):
profile: str | None = None,
extra_body: dict[str, Any] | None = None,
client: Any | None = None,
provider_name: str = "bedrock",
):
super().__init__(api_key, api_base)
super().__init__(api_key, api_base, provider_name=provider_name)
self.default_model = default_model
self.region = region or os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
self.profile = profile
@@ -453,25 +455,25 @@ class BedrockProvider(LLMProvider):
}.get(stop_reason or "", stop_reason or "stop")
@staticmethod
def _usage(usage: dict[str, Any] | None) -> dict[str, int]:
def _usage(usage: dict[str, Any] | None) -> LLMUsage | None:
if not usage:
return {}
prompt = int(usage.get("inputTokens") or 0)
completion = int(usage.get("outputTokens") or 0)
total = int(usage.get("totalTokens") or prompt + completion)
result = {
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": total,
}
cache_read = int(usage.get("cacheReadInputTokens") or 0)
cache_write = int(usage.get("cacheWriteInputTokens") or 0)
if cache_read:
result["cached_tokens"] = cache_read
result["cache_read_input_tokens"] = cache_read
if cache_write:
result["cache_creation_input_tokens"] = cache_write
return result
return None
def _optional_count(key: str) -> int | None:
raw = usage.get(key)
return int(raw) if raw is not None else None
cache_read = _optional_count("cacheReadInputTokens")
cache_write = _optional_count("cacheWriteInputTokens")
logical_input = int(usage.get("inputTokens") or 0) + (cache_read or 0) + (
cache_write or 0
)
return LLMUsage.reported(
input_tokens=logical_input,
output_tokens=int(usage.get("outputTokens") or 0),
cache_read_tokens=cache_read,
cache_write_tokens=cache_write,
)
@staticmethod
def _parse_reasoning(block: dict[str, Any]) -> tuple[str | None, dict[str, Any] | None]:
+7 -1
View File
@@ -172,6 +172,7 @@ def _make_provider_core(
default_model=model,
proxy=getattr(p, "proxy", None) if p else None,
extra_body=p.extra_body if p else None,
provider_name=provider_name,
)
elif backend == "xai_grok":
from nanobot.providers.xai_grok_provider import XAIGrokProvider
@@ -180,6 +181,7 @@ def _make_provider_core(
default_model=model,
proxy=getattr(p, "proxy", None) if p else None,
extra_body=p.extra_body if p else None,
provider_name=provider_name,
)
elif backend == "azure_openai":
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
@@ -190,11 +192,12 @@ def _make_provider_core(
api_key=p.api_key or "",
api_base=p.api_base,
default_model=model,
provider_name=provider_name,
)
elif backend == "github_copilot":
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
provider = GitHubCopilotProvider(default_model=model)
provider = GitHubCopilotProvider(default_model=model, provider_name=provider_name)
elif backend == "anthropic":
from nanobot.providers.anthropic_provider import AnthropicProvider
@@ -203,6 +206,7 @@ def _make_provider_core(
api_base=config.get_api_base(model, preset=preset),
default_model=model,
extra_headers=_provider_extra_headers(spec, p),
provider_name=provider_name,
)
elif backend == "bedrock":
from nanobot.providers.bedrock_provider import BedrockProvider
@@ -214,6 +218,7 @@ def _make_provider_core(
region=getattr(p, "region", None) if p else None,
profile=getattr(p, "profile", None) if p else None,
extra_body=p.extra_body if p else None,
provider_name=provider_name,
)
else:
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
@@ -228,6 +233,7 @@ def _make_provider_core(
api_type=p.api_type if p and provider_name == "openai" else "auto",
extra_query=p.extra_query if p else None,
proxy=p.proxy if p else None,
provider_name=provider_name,
)
provider.generation = preset.to_generation_settings()
+3
View File
@@ -124,7 +124,10 @@ class FallbackProvider(LLMProvider):
fallback_model_observer: FallbackModelObserver | None = None,
primary_context_window_tokens: int | None = None,
):
primary_generation = primary.generation
self._primary = primary
super().__init__(provider_name=primary.provider_name)
self._primary.generation = primary_generation
self._fallback_presets = list(fallback_presets)
self._provider_factory = provider_factory
self._fallback_model_observer = fallback_model_observer
+7 -1
View File
@@ -174,7 +174,12 @@ def login_github_copilot(
class GitHubCopilotProvider(OpenAICompatProvider):
"""Provider that exchanges a stored GitHub OAuth token for Copilot access tokens."""
def __init__(self, default_model: str = "github-copilot/gpt-4.1"):
def __init__(
self,
default_model: str = "github-copilot/gpt-4.1",
*,
provider_name: str = "github_copilot",
):
from nanobot.providers.registry import find_by_name
self._copilot_access_token: str | None = None
@@ -190,6 +195,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
"User-Agent": USER_AGENT,
},
spec=find_by_name("github_copilot"),
provider_name=provider_name,
)
async def _get_copilot_access_token(self) -> str:
+3 -1
View File
@@ -51,8 +51,10 @@ class OpenAICodexProvider(LLMProvider):
default_model: str = "openai-codex/gpt-5.6-sol",
proxy: str | None = None,
extra_body: dict[str, Any] | None = None,
*,
provider_name: str = "openai_codex",
):
super().__init__(api_key=None, api_base=None)
super().__init__(api_key=None, api_base=None, provider_name=provider_name)
self.default_model = default_model
self.proxy = proxy or None
self._extra_body = dict(extra_body or {})
+40 -25
View File
@@ -26,6 +26,7 @@ from pydantic.alias_generators import to_snake
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
LLMUsage,
ProviderCallContext,
ProviderConversationState,
ToolCallRequest,
@@ -517,8 +518,9 @@ class OpenAICompatProvider(LLMProvider):
api_type: str = "auto",
extra_query: dict[str, str] | None = None,
proxy: str | None = None,
provider_name: str = "openai",
):
super().__init__(api_key, api_base)
super().__init__(api_key, api_base, provider_name=provider_name)
self.default_model = default_model
self.extra_headers = extra_headers or {}
self._spec = spec
@@ -1428,12 +1430,12 @@ class OpenAICompatProvider(LLMProvider):
return "".join(parts) or None
@classmethod
def _extract_usage(cls, response: Any) -> dict[str, int]:
def _extract_usage(cls, response: Any) -> LLMUsage | None:
"""Extract token usage from an OpenAI-compatible response.
Handles both dict-based (raw JSON) and object-based (SDK Pydantic)
responses. Provider-specific ``cached_tokens`` fields are normalised
under a single key; see the priority chain inside for details.
responses. Provider-specific cache fields are normalized once at
this Chat Completions wire boundary.
"""
# --- resolve usage object ---
usage_obj = None
@@ -1445,21 +1447,18 @@ class OpenAICompatProvider(LLMProvider):
usage_map = cls._maybe_mapping(usage_obj)
if usage_map is not None:
result = {
"prompt_tokens": int(usage_map.get("prompt_tokens") or 0),
"completion_tokens": int(usage_map.get("completion_tokens") or 0),
"total_tokens": int(usage_map.get("total_tokens") or 0),
}
input_tokens = int(usage_map.get("prompt_tokens") or 0)
output_tokens = int(usage_map.get("completion_tokens") or 0)
elif usage_obj:
result = {
"prompt_tokens": getattr(usage_obj, "prompt_tokens", 0) or 0,
"completion_tokens": getattr(usage_obj, "completion_tokens", 0) or 0,
"total_tokens": getattr(usage_obj, "total_tokens", 0) or 0,
}
input_tokens = int(getattr(usage_obj, "prompt_tokens", 0) or 0)
output_tokens = int(getattr(usage_obj, "completion_tokens", 0) or 0)
else:
return {}
return None
# --- cached_tokens (normalised across providers) ---
wire_total = cls._get_nested_int(usage_obj, ("total_tokens",))
cache_read: int | None = None
# --- cached_tokens (normalised across Chat-compatible providers) ---
# Try nested paths first (dict), fall back to attribute (SDK object).
# Priority order ensures the most specific field wins.
for path in (
@@ -1468,17 +1467,28 @@ class OpenAICompatProvider(LLMProvider):
("prompt_cache_hit_tokens",), # DeepSeek/SiliconFlow
):
cached = cls._get_nested_int(usage_map, path)
if not cached and usage_obj:
if cached is None and usage_obj:
cached = cls._get_nested_int(usage_obj, path)
if cached:
result["cached_tokens"] = cached
if cached is not None:
cache_read = cached
break
return result
cache_write = cls._get_nested_int(
usage_obj,
("prompt_tokens_details", "cache_write_tokens"),
)
return LLMUsage.reported(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=wire_total,
cache_read_tokens=cache_read,
cache_write_tokens=cache_write,
)
@staticmethod
def _get_nested_int(obj: object, path: tuple[str, ...]) -> int:
"""Drill into *obj* by *path* segments and return an ``int`` value.
def _get_nested_int(obj: object, path: tuple[str, ...]) -> int | None:
"""Return a present usage count while preserving explicit zero.
Supports both dict-key access and attribute access so it works
uniformly with raw JSON dicts **and** SDK Pydantic models.
@@ -1486,12 +1496,17 @@ class OpenAICompatProvider(LLMProvider):
current: object = obj
for segment in path:
if current is None:
return 0
return None
if isinstance(current, dict):
current = cast(dict[str, Any], current).get(segment)
else:
current = getattr(current, segment, None)
return int(cast(Any, current) or 0) if current is not None else 0
if current is None or isinstance(current, bool):
return None
try:
return int(cast(Any, current))
except (TypeError, ValueError):
return None
def _parse(self, response: Any) -> LLMResponse:
if isinstance(response, str):
@@ -1645,7 +1660,7 @@ class OpenAICompatProvider(LLMProvider):
reasoning_parts: list[str] = []
tc_bufs: dict[int, dict[str, Any]] = {}
finish_reason = "stop"
usage: dict[str, int] = {}
usage: LLMUsage | None = None
def _accum_tc(tc: Any, idx_hint: int) -> None:
"""Accumulate one streaming tool-call delta into *tc_bufs*."""
+31 -37
View File
@@ -10,7 +10,7 @@ from typing import Any, AsyncGenerator, cast
import httpx
from loguru import logger
from nanobot.providers.base import LLMResponse, ToolCallRequest, parse_tool_arguments
from nanobot.providers.base import LLMResponse, LLMUsage, ToolCallRequest, parse_tool_arguments
from nanobot.providers.openai_responses.state import build_responses_state
FINISH_REASON_MAP = {
@@ -186,33 +186,40 @@ def _response_finish_reason(
return map_finish_reason(terminal_status)
def _usage_from_response_obj(response: object) -> dict[str, int]:
def _usage_from_response_obj(response: object) -> LLMUsage | None:
response_object = _response_object(response)
usage_raw: object = (
response_object.get("usage")
if response_object is not None
else getattr(response, "usage", None)
)
if not usage_raw:
return {}
if usage_raw is None:
return None
usage = _response_object(usage_raw)
if usage is None:
return {}
prompt_tokens = int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0)
completion_tokens = int(
usage.get("output_tokens") or usage.get("completion_tokens") or 0
)
total_tokens = int(usage.get("total_tokens") or prompt_tokens + completion_tokens)
result = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
return None
def _usage_int(container: dict[str, Any] | None, key: str) -> int | None:
if container is None:
return None
raw = container.get(key)
if raw is None or isinstance(raw, bool):
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
input_tokens = _usage_int(usage, "input_tokens") or 0
output_tokens = _usage_int(usage, "output_tokens") or 0
input_details = _response_object(usage.get("input_tokens_details"))
cached_tokens = int(input_details.get("cached_tokens") or 0) if input_details else 0
if cached_tokens > 0:
result["cached_tokens"] = cached_tokens
return result
return LLMUsage.reported(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=_usage_int(usage, "total_tokens"),
cache_read_tokens=_usage_int(input_details, "cached_tokens"),
cache_write_tokens=_usage_int(input_details, "cache_write_tokens"),
)
def _parse_tool_call_arguments(args_raw: Any, name: str | None) -> Any:
@@ -352,14 +359,14 @@ async def consume_sse_with_reasoning(
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
on_response_event: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
capture: ResponsesStreamCapture | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
) -> tuple[str, list[ToolCallRequest], str, LLMUsage | None, str | None]:
"""Consume a Responses API SSE stream, including visible reasoning summaries."""
content = ""
tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {}
tool_call_args_emitted: set[str] = set()
finish_reason = "stop"
usage: dict[str, int] = {}
usage: LLMUsage | None = None
reasoning_content: str | None = None
streamed_reasoning = False
reasoning_summary_key: tuple[str | None, int] | None = None
@@ -657,14 +664,14 @@ async def consume_sdk_stream(
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
capture: ResponsesStreamCapture | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
) -> tuple[str, list[ToolCallRequest], str, LLMUsage | None, str | None]:
"""Consume an SDK async stream from ``client.responses.create(stream=True)``."""
content = ""
tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {}
tool_call_args_emitted: set[str] = set()
finish_reason = "stop"
usage: dict[str, int] = {}
usage: LLMUsage | None = None
reasoning_content: str | None = None
streamed_reasoning = False
refusal_seen = False
@@ -823,20 +830,7 @@ async def consume_sdk_stream(
if on_content_delta and remaining_text:
await on_content_delta(remaining_text)
if resp:
usage_obj = getattr(resp, "usage", None)
if usage_obj:
usage = {
"prompt_tokens": int(getattr(usage_obj, "input_tokens", 0) or 0),
"completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0),
"total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0),
}
usage_data = _response_object(usage_obj) or {}
input_details = _response_object(usage_data.get("input_tokens_details"))
cached_tokens = (
int(input_details.get("cached_tokens") or 0) if input_details else 0
)
if cached_tokens > 0:
usage["cached_tokens"] = cached_tokens
usage = _usage_from_response_obj(resp) or usage
if not reasoning_content:
reasoning_content = _extract_reasoning_summary_from_output(
getattr(resp, "output", None)
+4 -12
View File
@@ -7,7 +7,7 @@ from typing import Any, cast
from loguru import logger
from nanobot.providers.base import ProviderConversationState
from nanobot.providers.base import LLMUsage, ProviderConversationState
from nanobot.providers.openai_responses.converters import convert_messages
RESPONSES_STATE_KIND = "openai_responses"
@@ -84,7 +84,7 @@ def build_responses_state(
model: str,
input_items: list[dict[str, Any]],
output_items: list[dict[str, Any]],
usage: dict[str, int] | None = None,
usage: LLMUsage | None = None,
) -> ProviderConversationState:
"""Create the canonical next state from request input and every output item."""
unpruned_items = [*input_items, *output_items]
@@ -178,16 +178,8 @@ def _prune_before_latest_output_compaction(
return output_items[latest:]
def _context_tokens_from_usage(usage: dict[str, int] | None) -> int:
if not usage:
return 0
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
total_tokens = usage.get("total_tokens", 0)
values = (prompt_tokens, completion_tokens, total_tokens)
if any(isinstance(value, bool) for value in values):
return 0
return max(0, total_tokens or prompt_tokens + completion_tokens)
def _context_tokens_from_usage(usage: LLMUsage | None) -> int:
return usage.total_tokens if usage is not None else 0
def _state_items(
+1 -1
View File
@@ -11,7 +11,7 @@ class UnconfiguredProvider(LLMProvider):
"""Keep the gateway available for settings before a model is configured."""
def __init__(self, default_model: str) -> None:
super().__init__()
super().__init__(provider_name="unconfigured")
self._default_model = default_model
async def chat(
+5 -2
View File
@@ -18,6 +18,7 @@ from nanobot import __version__
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
LLMUsage,
ToolCallRequest,
resolve_stream_idle_timeout_s,
)
@@ -69,8 +70,10 @@ class XAIGrokProvider(LLMProvider):
default_model: str = DEFAULT_XAI_GROK_MODEL,
proxy: str | None = None,
extra_body: dict[str, Any] | None = None,
*,
provider_name: str = "xai_grok",
):
super().__init__(api_key=None, api_base=None)
super().__init__(api_key=None, api_base=None, provider_name=provider_name)
self.default_model = default_model
self.proxy = proxy or None
self._extra_body = dict(extra_body or {})
@@ -436,7 +439,7 @@ async def _request_xai(
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
) -> tuple[str, list[ToolCallRequest], str, LLMUsage | None, str | None]:
async def _on_response_event(event: dict[str, Any]) -> None:
hosted_event = _xai_hosted_tool_event(event)
if hosted_event is not None and on_tool_call_delta is not None:
+3 -2
View File
@@ -6,6 +6,7 @@ from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, Literal, Mapping, TypeAlias, cast
from nanobot.providers.base import LLMUsage
from nanobot.runtime_context import public_history_messages
StreamEventType: TypeAlias = Literal[
@@ -53,7 +54,7 @@ class RunResult:
content: str
tools_used: list[str] = field(default_factory=list)
messages: list[dict[str, Any]] = field(default_factory=list)
usage: dict[str, int] = field(default_factory=dict)
usage: LLMUsage | None = None
stop_reason: str | None = None
error: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@@ -72,7 +73,7 @@ class StreamEvent:
arguments: dict[str, Any] | None = None
iteration: int | None = None
resuming: bool | None = None
usage: dict[str, int] = field(default_factory=dict)
usage: LLMUsage | None = None
error: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
+3 -3
View File
@@ -37,7 +37,7 @@ from nanobot.bus.runtime_events import (
TurnRuntimeAdmitted,
UserInputAccepted,
)
from nanobot.providers.base import LLMProvider
from nanobot.providers.base import LLMProvider, LLMUsage
from nanobot.providers.fallback_provider import FallbackModelObserver
from nanobot.runtime_context import public_history_message
from nanobot.session.goal_state import goal_state_ws_blob
@@ -695,7 +695,7 @@ class WebuiTurnCoordinator:
*,
session_key: str,
latency_ms: int | None,
usage: dict[str, int] | None = None,
usage: LLMUsage | None = None,
context_window_tokens: int | None = None,
) -> None:
if msg.channel != "websocket":
@@ -709,7 +709,7 @@ class WebuiTurnCoordinator:
event=TurnEndEvent(
latency_ms=latency_ms,
goal_state=goal_state_ws_blob(session.metadata),
usage=usage or None,
usage=usage,
context_window_tokens=context_window_tokens,
),
metadata=msg.metadata,
+2 -2
View File
@@ -16,7 +16,7 @@ Concrete scenarios showing when and how to use the my tool effectively.
→ my(action="check", key="max_iterations")
→ 40
→ my(action="check", key="_last_usage")
→ {"prompt_tokens": 62000, "completion_tokens": 3000}
→ {"input_tokens": 62000, "output_tokens": 3000}
→ "I hit the iteration limit (40). The task was complex. I can ask the user if they want to increase it."
```
@@ -72,6 +72,6 @@ Concrete scenarios showing when and how to use the my tool effectively.
### Token-conscious behavior
```
→ my(action="check", key="_last_usage")
→ {"prompt_tokens": 58000, "completion_tokens": 12000}
→ {"input_tokens": 58000, "output_tokens": 12000}
→ "I've consumed ~70k tokens. I'll keep my remaining responses focused."
```
+10 -5
View File
@@ -1,5 +1,7 @@
"""Utility functions for nanobot."""
from __future__ import annotations
import base64
import json
import os
@@ -12,11 +14,14 @@ from contextlib import suppress
from datetime import datetime
from functools import lru_cache
from pathlib import Path
from typing import Any, TypeVar, cast, overload
from typing import TYPE_CHECKING, Any, TypeVar, cast, overload
import tiktoken
from loguru import logger
if TYPE_CHECKING:
from nanobot.providers.base import LLMUsage
_TOOLS_TOKEN_CACHE_MAX_ENTRIES = 64
_TOOLS_TOKEN_CACHE: dict[int, tuple[tuple[int, ...], dict[bool, int]]] = {}
_T = TypeVar("_T")
@@ -793,7 +798,7 @@ def build_status_content(
version: str,
model: str,
start_time: float,
last_usage: dict[str, int],
last_usage: LLMUsage | None,
context_window_tokens: int,
session_msg_count: int,
context_tokens_estimate: int,
@@ -814,9 +819,9 @@ def build_status_content(
if uptime_s >= 3600
else f"{uptime_s // 60}m {uptime_s % 60}s"
)
last_in = last_usage.get("prompt_tokens", 0)
last_out = last_usage.get("completion_tokens", 0)
cached = last_usage.get("cached_tokens", 0)
last_in = last_usage.input_tokens if last_usage else 0
last_out = last_usage.output_tokens if last_usage else 0
cached = last_usage.cache_read_tokens if last_usage else None
ctx_total = max(context_window_tokens, 0)
# Budget mirrors Consolidator formula: ctx_window - max_completion - _SAFETY_BUFFER
ctx_budget = max(ctx_total - int(max_completion_tokens) - 1024, 1)
+3 -12
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Any, cast
from nanobot.providers.base import LLMUsage
from nanobot.session.manager import Session
from nanobot.utils.helpers import estimate_message_tokens, truncate_text
@@ -36,18 +37,8 @@ def session_context_payload(session: Session) -> dict[str, Any]:
summary_tokens = (
estimate_message_tokens({"role": "system", "content": summary}) if summary else 0
)
raw_usage = session.metadata.get("_last_usage")
last_usage = (
{
key: value
for key, value in cast(dict[object, object], raw_usage).items()
if isinstance(key, str)
and type(value) is int
and value >= 0
}
if isinstance(raw_usage, dict)
else None
)
stored_usage = LLMUsage.from_dict(session.metadata.get("_last_usage"))
last_usage = stored_usage.to_dict() if stored_usage is not None else None
return {
"schema_version": 1,
+59 -37
View File
@@ -15,19 +15,23 @@ from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.config.paths import get_webui_dir
from nanobot.providers.base import LLMUsage
TOKEN_USAGE_SCHEMA_VERSION = 1
TOKEN_USAGE_SCHEMA_VERSION = 2
_MAX_STATE_FILE_BYTES = 512 * 1024
_MAX_DAYS_RETAINED = 400
_USAGE_KEYS = (
"prompt_tokens",
"completion_tokens",
"cached_tokens",
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
"cache_read_observed_input_tokens",
"cache_write_observed_input_tokens",
"total_tokens",
"provider_tokens",
"reported_tokens",
"estimated_tokens",
)
_REQUEST_KEYS = ("requests", "provider_requests", "estimated_requests")
_REQUEST_KEYS = ("requests", "reported_requests", "estimated_requests")
_SOURCE_KEYS = ("user", "api", "cron", "dream", "system")
_WRITE_LOCK = threading.Lock()
@@ -88,38 +92,43 @@ def _source_from_session_key(session_key: str | None) -> str:
return "user"
def _normalize_usage(raw: dict[str, Any] | None) -> dict[str, int]:
if not isinstance(raw, dict):
def _normalize_usage(raw: LLMUsage | None) -> dict[str, int]:
if raw is None:
return {}
usage = {key: _clean_int(raw.get(key)) for key in _USAGE_KEYS}
fallback_total = usage["prompt_tokens"] + usage["completion_tokens"]
if usage["total_tokens"] <= 0:
usage["total_tokens"] = fallback_total
if usage["estimated_tokens"] <= 0 and usage["provider_tokens"] <= 0:
usage["provider_tokens"] = usage["total_tokens"]
elif usage["estimated_tokens"] > 0 and usage["provider_tokens"] <= 0:
usage["estimated_tokens"] = min(usage["estimated_tokens"], usage["total_tokens"])
elif usage["provider_tokens"] > 0 and usage["estimated_tokens"] <= 0:
usage["provider_tokens"] = min(usage["provider_tokens"], usage["total_tokens"])
usage = {
"input_tokens": raw.input_tokens,
"output_tokens": raw.output_tokens,
"cache_read_tokens": raw.cache_read_tokens or 0,
"cache_write_tokens": raw.cache_write_tokens or 0,
"cache_read_observed_input_tokens": (
raw.input_tokens if raw.cache_read_tokens is not None else 0
),
"cache_write_observed_input_tokens": (
raw.input_tokens if raw.cache_write_tokens is not None else 0
),
"total_tokens": raw.total_tokens,
"reported_tokens": raw.reported_tokens,
"estimated_tokens": raw.estimated_tokens,
}
return usage if usage["total_tokens"] > 0 else {}
def _normalize_usage_row(row: dict[str, Any]) -> dict[str, int]:
cleaned = {key: _clean_int(row.get(key)) for key in _USAGE_KEYS}
if cleaned["total_tokens"] <= 0:
cleaned["total_tokens"] = cleaned["prompt_tokens"] + cleaned["completion_tokens"]
if cleaned["provider_tokens"] <= 0 and cleaned["estimated_tokens"] <= 0:
cleaned["provider_tokens"] = cleaned["total_tokens"]
cleaned["total_tokens"] = cleaned["input_tokens"] + cleaned["output_tokens"]
if cleaned["reported_tokens"] <= 0 and cleaned["estimated_tokens"] <= 0:
cleaned["reported_tokens"] = cleaned["total_tokens"]
requests = {key: _clean_int(row.get(key)) for key in _REQUEST_KEYS}
if (
requests["requests"] > 0
and requests["provider_requests"] <= 0
and requests["reported_requests"] <= 0
and requests["estimated_requests"] <= 0
):
if cleaned["estimated_tokens"] > 0 and cleaned["provider_tokens"] <= 0:
if cleaned["estimated_tokens"] > 0 and cleaned["reported_tokens"] <= 0:
requests["estimated_requests"] = requests["requests"]
else:
requests["provider_requests"] = requests["requests"]
requests["reported_requests"] = requests["requests"]
return {**cleaned, **requests}
@@ -150,6 +159,8 @@ def normalize_token_usage_state(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
return state
raw = cast(dict[str, Any], raw)
if raw.get("schema_version") != TOKEN_USAGE_SCHEMA_VERSION:
return state
days_raw = raw.get("days")
if not isinstance(days_raw, dict):
return state
@@ -197,24 +208,35 @@ def read_token_usage_state() -> dict[str, Any]:
return normalize_token_usage_state(raw)
def write_token_usage_state(raw: dict[str, Any]) -> dict[str, Any]:
state = normalize_token_usage_state(raw)
state["updated_at"] = _utc_now_iso()
encoded = json.dumps(
def _encode_token_usage_state(state: dict[str, Any]) -> bytes:
"""Encode the persisted state compactly, including its trailing newline."""
payload = json.dumps(
state,
ensure_ascii=False,
indent=2,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
)
return f"{payload}\n".encode("utf-8")
def write_token_usage_state(raw: dict[str, Any]) -> dict[str, Any]:
# Day-count retention is applied by normalization first. The byte budget
# then trims only the oldest remaining days, preserving a contiguous suffix.
state = normalize_token_usage_state(raw)
state["updated_at"] = _utc_now_iso()
days = cast(dict[str, dict[str, Any]], state["days"])
encoded = _encode_token_usage_state(state)
while len(encoded) > _MAX_STATE_FILE_BYTES and len(days) > 1:
del days[min(days)]
encoded = _encode_token_usage_state(state)
if len(encoded) > _MAX_STATE_FILE_BYTES:
raise ValueError("token usage state is too large")
raise ValueError("latest token usage day exceeds the state byte limit")
path = token_usage_state_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
with open(tmp, "wb") as f:
f.write(encoded)
f.write(b"\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
@@ -230,7 +252,7 @@ def write_token_usage_state(raw: dict[str, Any]) -> dict[str, Any]:
def record_token_usage(
usage: dict[str, Any] | None,
usage: LLMUsage | None,
*,
source: str = "user",
timezone_name: str | None = None,
@@ -248,10 +270,10 @@ def record_token_usage(
for key in _USAGE_KEYS:
row[key] = _clean_int(row.get(key)) + normalized.get(key, 0)
row["requests"] = _clean_int(row.get("requests")) + 1
if normalized.get("estimated_tokens", 0) > 0 and normalized.get("provider_tokens", 0) <= 0:
if normalized.get("estimated_tokens", 0) > 0 and normalized.get("reported_tokens", 0) <= 0:
row["estimated_requests"] = _clean_int(row.get("estimated_requests")) + 1
else:
row["provider_requests"] = _clean_int(row.get("provider_requests")) + 1
row["reported_requests"] = _clean_int(row.get("reported_requests")) + 1
source_key = _clean_source(source)
sources: dict[str, dict[str, Any]] = dict(
@@ -261,10 +283,10 @@ def record_token_usage(
for key in _USAGE_KEYS:
source_row[key] = _clean_int(source_row.get(key)) + normalized.get(key, 0)
source_row["requests"] = _clean_int(source_row.get("requests")) + 1
if normalized.get("estimated_tokens", 0) > 0 and normalized.get("provider_tokens", 0) <= 0:
if normalized.get("estimated_tokens", 0) > 0 and normalized.get("reported_tokens", 0) <= 0:
source_row["estimated_requests"] = _clean_int(source_row.get("estimated_requests")) + 1
else:
source_row["provider_requests"] = _clean_int(source_row.get("provider_requests")) + 1
source_row["reported_requests"] = _clean_int(source_row.get("reported_requests")) + 1
sources[source_key] = source_row
row["sources"] = sources