Skip to content
Open
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
115 changes: 115 additions & 0 deletions src/xagent/core/model/asr/usage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Media-usage recording for ASR.

ASR is reached from several entry points that do not go through ``audio_tool``
— the Telegram channel and the ``/speech/transcribe`` API both call
``transcribe`` directly — so this module is the single place that knows how to
turn a transcription result into a usage record. Every ASR caller routes here,
including ``audio_tool``: keeping one implementation is what stops the copies
from drifting (an earlier pair differed in whether they logged at all).
"""

from __future__ import annotations

import logging
from typing import Any, Optional, Sequence, Union

from ...tools.core.media_usage import coerce_duration, record_media_seconds
from ..chat.token_context import MediaCallType
from .base import ASRResult

logger = logging.getLogger(__name__)

# Keys providers use for the total length of the submitted audio.
_DURATION_KEYS = ("duration", "audio_duration", "duration_seconds")


def duration_from_raw_response(raw_response: Any) -> Optional[float]:
"""Provider-reported total audio duration, if it exposed one.

Preferred over segment timings: the end of the last spoken segment
undercounts a recording with trailing silence, which the provider still
processed and billed for.
"""
if not isinstance(raw_response, dict):
return None
for key in _DURATION_KEYS:
seconds = coerce_duration(raw_response.get(key))
if seconds is not None:
return seconds
return None


def duration_from_segments(segments: Optional[Sequence[Any]]) -> Optional[float]:
"""Transcribed duration inferred from the end of the last timed segment.

Accepts both ``ASRSegment`` objects and the plain dicts ``audio_tool``
builds from them.
"""
if not segments:
return None
last_end = 0.0
for segment in segments:
end = (
segment.get("end")
if isinstance(segment, dict)
else getattr(segment, "end", None)
)
seconds = coerce_duration(end)
if seconds is not None:
last_end = max(last_end, seconds)
return last_end if last_end > 0 else None


def resolve_asr_seconds(
raw_response: Any = None,
segments: Optional[Sequence[Any]] = None,
) -> Optional[float]:
"""Best available transcribed-audio duration, provider field first."""
return duration_from_raw_response(raw_response) or duration_from_segments(segments)


def record_asr_seconds(
seconds: Optional[float],
*,
model_name: str = "",
model_id: str = "",
) -> None:
"""Record one transcription from an already-resolved duration.

Thin wrapper over the shared :func:`record_media_seconds`: ASR is one of
several duration-billed modalities, and keeping a second implementation is
how the two drifted before. The shared helper owns the invariants — unit
always seconds (a call whose duration is unknown records 0 rather than
switching units, which would make a (model, unit) price table unusable),
the unmeasured-call warning, and swallowing errors so accounting never
breaks a transcription.
"""
record_media_seconds(
seconds,
model=model_name,
model_id=model_id,
call_type=MediaCallType.ASR,
)


def record_asr_usage(
result: Union[str, ASRResult],
*,
model_name: str = "",
model_id: str = "",
) -> None:
"""Record one transcription from a provider result.

A non-verbose call returns a bare string with no timing information, so it
is recorded as unmeasured rather than silently skipped.
"""
raw_response = None
segments = None
if isinstance(result, ASRResult):
raw_response = result.raw_response
segments = result.segments
record_asr_seconds(
resolve_asr_seconds(raw_response, segments),
model_name=model_name,
model_id=model_id,
)
93 changes: 89 additions & 4 deletions src/xagent/core/tools/core/audio_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

from ...file_ref import build_workspace_file_ref
from ...model.asr.base import ASRResult, BaseASR
from ...model.asr.usage import record_asr_seconds, resolve_asr_seconds
from ...model.chat.token_context import MediaCallType
from ...model.tts.base import BaseTTS, TTSResult
from ...workspace import TaskWorkspace
from .audio_tool_descriptions import (
Expand All @@ -27,6 +29,7 @@
SYNTHESIZE_SPEECH_JSON_DESCRIPTION,
TRANSCRIBE_AUDIO_DESCRIPTION,
)
from .media_usage import record_media_usage, resolve_billing_model

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -572,6 +575,33 @@ def _get_tts_model_id(self, tts_model: BaseTTS) -> str:
return model_id
return "default"

@staticmethod
def _resolve_billing_model(
models: Dict[str, Any], model: Any, model_id: Optional[str]
) -> str:
"""Identify the model that actually served a call, for usage records.

Resolves by configured key, then by object identity (the batch path
hands back a different instance than the registry dict holds), then
delegates to the shared :func:`resolve_billing_model` for the
provider-attribute fallback and the placeholder filter — so the
Xinference default model, which exposes no ``model_name``, is never
billed under the literal string ``"default"``.
"""
if model_id and model_id in models:
return model_id
if model is not None:
for configured_id, configured_model in models.items():
if configured_model is model:
return configured_id
# The provider class name is the last identity that still attributes
# cost to something real. The shared helper's own fallback is the
# literal "default", which the metering invariants forbid as a billing
# identity — the Xinference default model reaches exactly that case,
# exposing no model_name and never matching by identity above.
fallback = type(model).__name__ if model is not None else "default"
return resolve_billing_model(None, model, fallback=fallback)

def _get_provider_tts_model(
self,
*,
Expand Down Expand Up @@ -713,14 +743,15 @@ async def transcribe_audio(
)

# Determine the actual model used
actual_model_id = (
model_id if model_id and model_id in self._asr_models else "default"
actual_model_id = self._resolve_billing_model(
self._asr_models, asr_model, model_id
)

# Handle different result types
text = None
raw_segments = None
language_detected = None
raw_response: dict[str, Any] = {}

if isinstance(result, str):
text = result
Expand All @@ -741,6 +772,35 @@ async def transcribe_audio(
else None
)
language_detected = result.language
if isinstance(result.raw_response, dict):
raw_response = result.raw_response

# Shared with /speech/transcribe and the Telegram channel so the
# duration rule (provider's own total first, segment timings only
# as a fallback) has exactly one implementation.
audio_seconds = resolve_asr_seconds(raw_response, raw_segments)

# Recorded here, before _aggregate_segments and the rest of the
# post-processing below. Those steps can raise (a segment missing
# start/end raises ValueError), and the broad handler at the bottom
# turns any raise into success:False -- so recording afterwards
# meant a provider call that succeeded and was billed went
# unmetered. That is the bug class this metering exists to remove,
# and TTS in this same file already records before unpacking.
# Routed through the shared ASR recorder so every entry point
# meters identically.
record_asr_seconds(
audio_seconds,
# model_id is deliberately left unset. This tool is handed
# name-keyed registries (model_service keys _asr_models by
# model_name), so the only identity in scope here is a name —
# writing a name into model_id would persist it under an id
# field. The aggregator groups on `model_id or model`, so
# leaving it empty lets these rows key on the name
# consistently instead of inventing a third identity that can
# never be reconciled.
model_name=str(actual_model_id),
)

segment_view = "raw" if verbose else "processed"
segments = raw_segments
Expand Down Expand Up @@ -916,8 +976,19 @@ async def synthesize_speech(
)

# Determine the actual model used
actual_model_id = (
model_id if model_id and model_id in self._tts_models else "default"
actual_model_id = self._resolve_billing_model(
self._tts_models, tts_model, model_id
)

# Meter TTS by input characters (how providers like ElevenLabs
# bill). Recorded before the result is unpacked below: the provider
# call already happened and is billable regardless of what fails
# afterwards. model_id stays unset — see synthesize/ASR above, the
# registries here are name-keyed so only a name is ever in scope.
record_media_usage(
MediaCallType.TTS,
len(text or ""),
model=str(actual_model_id),
)

audio_data: Optional[bytes] = None
Expand Down Expand Up @@ -1873,6 +1944,20 @@ async def _synthesize_single_segment(
**kwargs,
)

# Meter TTS by input characters, matching synthesize_speech so batch
# synthesis is metered the same way as single-shot synthesis.
# _resolve_billing_model rather than _get_tts_model_id: the latter
# bottoms out at the literal "default" with no model_name fallback,
# so an implicitly-resolved model would bill a phantom name here.
batch_model_id = self._resolve_billing_model(
self._tts_models, tts_model, None
)
record_media_usage(
MediaCallType.TTS,
len(text or ""),
model=batch_model_id,
)

# Handle result
if isinstance(audio_data, bytes):
audio_binary = audio_data
Expand Down
41 changes: 41 additions & 0 deletions src/xagent/core/tools/core/music_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
from typing import Any, Optional

from ...file_ref import build_workspace_file_ref
from ...model.chat.token_context import MediaCallType
from ...model.music import BaseMusicModel, MusicResult
from ...workspace import TaskWorkspace
from .media_usage import coerce_duration, record_media_seconds, resolve_billing_model

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -137,6 +139,45 @@ async def generate_music(
force_instrumental=force_instrumental,
output_format=output_format,
)
# Billing policy for the media tools: a provider call that has
# already happened is billable regardless of what fails afterwards,
# so usage is recorded before the response is validated. Metering
# after the checks below would silently drop every call that
# succeeded at the HTTP level but came back empty or malformed —
# which the provider still charges for.
#
# Music is duration-billed: always meter in seconds, even when the
# length was auto-selected and no duration came back. `result` is
# not yet known to be a MusicResult at this point, so read through
# getattr rather than attribute access.
raw_response = getattr(result, "raw_response", None)
reported_length = (
raw_response.get("music_length_seconds")
if isinstance(raw_response, dict)
else None
)
seconds = coerce_duration(reported_length) or coerce_duration(
music_length_seconds
)
record_media_seconds(
seconds,
# `model` carries the provider's name, `model_id` the
# configured id. Passing configured_model_id as the first
# argument here would return it unchanged, writing the same id
# into both fields and losing the provider name entirely.
# None is passed instead so the resolver falls through to the
# provider's model_name. The fallback keeps the configured id
# ahead of the class name -- a provider exposing no model_name
# (Xinference's default) would otherwise be billed under a
# Python class name while its real id sat unused in scope --
# and never the placeholder "default".
model=resolve_billing_model(
None, model, fallback=configured_model_id or type(model).__name__
),
model_id=configured_model_id or "",
call_type=MediaCallType.MUSIC,
)

if not isinstance(result, MusicResult):
raise RuntimeError(f"Unexpected music response: {type(result)}")
if not result.audio:
Expand Down
35 changes: 35 additions & 0 deletions src/xagent/core/tools/core/sound_effect_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
from typing import Any, Optional

from ...file_ref import build_workspace_file_ref
from ...model.chat.token_context import MediaCallType
from ...model.sound_effect import BaseSoundEffectModel, SoundEffectResult
from ...workspace import TaskWorkspace
from .media_usage import coerce_duration, record_media_seconds, resolve_billing_model

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -151,6 +153,39 @@ async def generate_sound_effect(
loop=loop,
output_format=output_format,
)
# Billing policy for the media tools: a provider call that has
# already happened is billable regardless of what fails afterwards,
# so usage is recorded before the response is validated — a call
# that returned empty or malformed audio was still charged for.
#
# ElevenLabs prices sound effects by duration, so seconds is the
# only meaningful unit here — an auto-length call with no reported
# duration records 0 seconds rather than switching to characters,
# which would be wrong in kind, not just in magnitude. `result` is
# not yet known to be a SoundEffectResult, so read via getattr.
raw_response = getattr(result, "raw_response", None)
reported_duration = (
raw_response.get("duration_seconds")
if isinstance(raw_response, dict)
else None
)
seconds = coerce_duration(reported_duration) or coerce_duration(
duration_seconds
)
record_media_seconds(
seconds,
# `model` is the provider name, `model_id` the configured id —
# see music_tool: passing configured_model_id as the first
# argument would put the same id in both fields and drop the
# provider name, while the fallback keeps it ahead of the
# class name for providers exposing no model_name.
model=resolve_billing_model(
None, model, fallback=configured_model_id or type(model).__name__
),
model_id=configured_model_id or "",
call_type=MediaCallType.SOUND_EFFECT,
)

if not isinstance(result, SoundEffectResult):
raise RuntimeError(f"Unexpected sound effect response: {type(result)}")
if not result.audio:
Expand Down
Loading
Loading