# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/why-does-an-instance-of-the-opentelemetry-genai-semantic-conventions-silently-under-report-token-usage-when-an-agent-span-contains-nested-tool-call-sp.md
# Remnant Warrant:    https://corpusfetch.com/c/why-does-an-instance-of-the-opentelemetry-genai-semantic-conventions-silently-under-report-token-usage-when-an-agent-span-contains-nested-tool-call-sp.warrant.json
# Markdown SHA-256:   a585996e82e15c1a5e420dea7081f2176ede020e874a283a740fccb8e54d8946
# Code AST SHA-256:   91e1e847d62bb5a20d1eeb35f3fda7778b498877a98d0b96781fd50bf3e68dca
# Verified Classes:   GenAIUsageAggregationError
# Defensive Guards:   2 fail-closed invariants (zero external deps)

class GenAIUsageAggregationError(RuntimeError):
    """Raised when token usage is aggregated across incompatible GenAI span roles."""


MODEL_CALL_OPS = frozenset({"chat", "text_completion", "embeddings"})


def aggregate_usage(spans: list[dict], *, assume_agent_rollup: bool = False) -> dict:
    """
    Aggregate gen_ai.usage.* tokens across a trace.

    spans: list of dicts with keys:
        span_id, parent_span_id, gen_ai.operation.name,
        gen_ai.usage.input_tokens, gen_ai.usage.output_tokens

    Raises GenAIUsageAggregationError if the trace mixes a rollup agent span
    with per-call chat spans (double-count hazard), or if usage is requested
    from a span role that does not own tokens.
    """
    input_total = 0
    output_total = 0
    agent_usage_present = False
    chat_usage_present = False

    for span in spans:
        op = span.get("gen_ai.operation.name")
        it = span.get("gen_ai.usage.input_tokens")
        ot = span.get("gen_ai.usage.output_tokens")

        if op == "invoke_agent" and (it is not None or ot is not None):
            agent_usage_present = True
            if not assume_agent_rollup:
                # Structural span: do not count toward token totals.
                continue
            input_total += it or 0
            output_total += ot or 0
        elif op in MODEL_CALL_OPS:
            if it is not None or ot is not None:
                chat_usage_present = True
            input_total += it or 0
            output_total += ot or 0
        elif op == "execute_tool":
            if it is not None or ot is not None:
                raise GenAIUsageAggregationError(
                    "execute_tool span carries gen_ai.usage.*; tool calls are not "
                    "model calls and must not report token usage under the GenAI "
                    "semantic conventions."
                )
        # other ops: ignore for token accounting

    if assume_agent_rollup and agent_usage_present and chat_usage_present:
        raise GenAIUsageAggregationError(
            "Trace contains both agent-level usage and per-call chat usage; "
            "summing both double-counts. Choose one token source: either the "
            "agent rollup (assume_agent_rollup=True, drop chat spans) or the "
            "chat spans (assume_agent_rollup=False)."
        )

    return {
        "input_tokens": input_total,
        "output_tokens": output_total,
        "source": "agent_rollup" if assume_agent_rollup else "model_calls",
    }
