| 1 | """Token accounting policy: estimates, billing direction, cost weights.
|
| 2 |
|
| 3 | Billing direction: text the model EMITS (commands it types, prose) is priced
|
| 4 | as output tokens; text it INGESTS (tool results it reads back) is input; cache
|
| 5 | reads are heavily discounted. The historical estimated_tokens_input/output
|
| 6 | field names are CLI-perspective and kept only for backward compatibility.
|
| 7 |
|
| 8 | char/4 is a tokenizer-neutral proxy, biased for hash-heavy output; quantify
|
| 9 | the bias with scripts/token_calibration.py before cross-subject token claims.
|
| 10 | """
|
| 11 |
|
| 12 | from __future__ import annotations
|
| 13 |
|
| 14 | from typing import Any
|
| 15 |
|
| 16 | EMITTED_TOKEN_WEIGHT = 5.0
|
| 17 | INGESTED_TOKEN_WEIGHT = 1.0
|
| 18 | CACHE_READ_TOKEN_WEIGHT = 0.1
|
| 19 | COST_WEIGHTS_NOTE = (
|
| 20 | f"emitted={EMITTED_TOKEN_WEIGHT},ingested={INGESTED_TOKEN_WEIGHT},cache_read={CACHE_READ_TOKEN_WEIGHT}"
|
| 21 | )
|
| 22 |
|
| 23 | # A tool call is never just the command text. The model emits a structured
|
| 24 | # tool-use block (tool name, JSON argument framing, often a description
|
| 25 | # field), and the harness feeds the result back inside a tool-result block.
|
| 26 | # These constants are the midpoint of Anthropic/OpenAI tool-call framing as
|
| 27 | # measured by `scripts/token_calibration.py --envelope` (which compares them
|
| 28 | # against representative templates and flags drift); re-run it per provider
|
| 29 | # before publishing absolute costs. They are additive fields: the historical
|
| 30 | # estimated_tokens_* fields exclude envelopes and stay byte-identical
|
| 31 | # (row parity, ADR-0005).
|
| 32 | TOOL_CALL_ENVELOPE_EMITTED_TOKENS = 58
|
| 33 | TOOL_RESULT_ENVELOPE_INGESTED_TOKENS = 44
|
| 34 | ENVELOPE_NOTE = (
|
| 35 | f"per_call_emitted={TOOL_CALL_ENVELOPE_EMITTED_TOKENS},"
|
| 36 | f"per_call_ingested={TOOL_RESULT_ENVELOPE_INGESTED_TOKENS}; "
|
| 37 | "midpoint of exact o200k_base template measurements 2026-06-11 "
|
| 38 | "(anthropic tool_use/result 61/45, openai function call/result 56/43; "
|
| 39 | "validate with token_calibration.py --envelope)"
|
| 40 | )
|
| 41 | DIRECTION_NOTE = (
|
| 42 | "input/output are CLI-perspective (deprecated naming). For model billing, the "
|
| 43 | "command is model OUTPUT (agent_emitted) and stdout/stderr is model INPUT (agent_ingested)."
|
| 44 | )
|
| 45 |
|
| 46 |
|
| 47 | def estimate_tokens(text: str) -> int:
|
| 48 | if not text:
|
| 49 | return 0
|
| 50 | return max(1, (len(text) + 3) // 4)
|
| 51 |
|
| 52 |
|
| 53 | def cost_weighted_total(emitted_tokens: float, ingested_tokens: float, cache_read_tokens: float = 0.0) -> float:
|
| 54 | return round(
|
| 55 | emitted_tokens * EMITTED_TOKEN_WEIGHT
|
| 56 | + ingested_tokens * INGESTED_TOKEN_WEIGHT
|
| 57 | + cache_read_tokens * CACHE_READ_TOKEN_WEIGHT,
|
| 58 | 1,
|
| 59 | )
|
| 60 |
|
| 61 |
|
| 62 | def int_or_zero(value: Any) -> int:
|
| 63 | """Best-effort non-negative integer coercion for additive counters.
|
| 64 |
|
| 65 | Rows may carry ``null`` for unmeasured fields (ADR-0002). Summary rows
|
| 66 | aggregate the measured children they have, so absent/null/non-numeric
|
| 67 | additive counters contribute zero instead of crashing the lane.
|
| 68 | """
|
| 69 | if value is None or isinstance(value, bool):
|
| 70 | return 0
|
| 71 | try:
|
| 72 | parsed = int(value)
|
| 73 | except (TypeError, ValueError):
|
| 74 | return 0
|
| 75 | return parsed if parsed >= 0 else 0
|
| 76 |
|
| 77 |
|
| 78 | def envelope_token_fields(tool_call_count: int, emitted_tokens: int, ingested_tokens: int) -> dict[str, Any]:
|
| 79 | """Additive envelope accounting for rows that represent tool calls.
|
| 80 |
|
| 81 | tool_call_count == 0 (diagnostic probes) yields zero envelope overhead but
|
| 82 | the fields still appear so dashboards can sum without key checks.
|
| 83 | """
|
| 84 | envelope_emitted = tool_call_count * TOOL_CALL_ENVELOPE_EMITTED_TOKENS
|
| 85 | envelope_ingested = tool_call_count * TOOL_RESULT_ENVELOPE_INGESTED_TOKENS
|
| 86 | return {
|
| 87 | "estimated_tokens_envelope_emitted": envelope_emitted,
|
| 88 | "estimated_tokens_envelope_ingested": envelope_ingested,
|
| 89 | "estimated_tokens_total_with_envelope": emitted_tokens + ingested_tokens + envelope_emitted + envelope_ingested,
|
| 90 | "estimated_cost_weighted_tokens_with_envelope": cost_weighted_total(
|
| 91 | emitted_tokens + envelope_emitted, ingested_tokens + envelope_ingested
|
| 92 | ),
|
| 93 | }
|
| 94 |
|
| 95 |
|
| 96 | def interaction_token_fields(
|
| 97 | command_text: str,
|
| 98 | stdout_text: str,
|
| 99 | stderr_text: str,
|
| 100 | stdout_bytes: int,
|
| 101 | stderr_bytes: int,
|
| 102 | stdout_truncated: bool,
|
| 103 | stderr_truncated: bool,
|
| 104 | source: str = "command_plus_admitted_output_chars_div_4",
|
| 105 | tool_call_count: int = 1,
|
| 106 | ) -> dict[str, Any]:
|
| 107 | """The flat token fields plus nested token_estimate every CLI-lane row carries."""
|
| 108 | command_tokens = estimate_tokens(command_text)
|
| 109 | output_tokens = estimate_tokens(stdout_text) + estimate_tokens(stderr_text)
|
| 110 | total_tokens = command_tokens + output_tokens
|
| 111 | cost_weighted = cost_weighted_total(command_tokens, output_tokens)
|
| 112 | envelope_fields = envelope_token_fields(tool_call_count, command_tokens, output_tokens)
|
| 113 | return {
|
| 114 | **envelope_fields,
|
| 115 | "estimated_tokens_total": total_tokens,
|
| 116 | "estimated_tokens_input": command_tokens,
|
| 117 | "estimated_tokens_output": output_tokens,
|
| 118 | "estimated_tokens_agent_emitted": command_tokens,
|
| 119 | "estimated_tokens_agent_ingested": output_tokens,
|
| 120 | "estimated_cost_weighted_tokens": cost_weighted,
|
| 121 | "raw_output_bytes": stdout_bytes + stderr_bytes,
|
| 122 | "stdout_bytes": stdout_bytes,
|
| 123 | "stderr_bytes": stderr_bytes,
|
| 124 | "stdout_truncated": stdout_truncated,
|
| 125 | "stderr_truncated": stderr_truncated,
|
| 126 | "output_truncated": stdout_truncated or stderr_truncated,
|
| 127 | "token_estimate": {
|
| 128 | "source": source,
|
| 129 | "total": total_tokens,
|
| 130 | "input": command_tokens,
|
| 131 | "output": output_tokens,
|
| 132 | "envelope_note": ENVELOPE_NOTE,
|
| 133 | "direction_note": DIRECTION_NOTE,
|
| 134 | "agent_emitted": command_tokens,
|
| 135 | "agent_ingested": output_tokens,
|
| 136 | "cost_weighted_total": cost_weighted,
|
| 137 | "cost_weights": COST_WEIGHTS_NOTE,
|
| 138 | "command_chars": len(command_text),
|
| 139 | "stdout_admitted_chars": len(stdout_text),
|
| 140 | "stderr_admitted_chars": len(stderr_text),
|
| 141 | "stdout_bytes": stdout_bytes,
|
| 142 | "stderr_bytes": stderr_bytes,
|
| 143 | "stdout_truncated": stdout_truncated,
|
| 144 | "stderr_truncated": stderr_truncated,
|
| 145 | },
|
| 146 | }
|
| 147 |
|
| 148 |
|
| 149 | def summed_token_fields(rows: list[dict[str, Any]], source: str, command_chars: int | None = None) -> dict[str, Any]:
|
| 150 | """Aggregate the flat token fields of sub-rows into one summary row's fields."""
|
| 151 | input_tokens = sum(int_or_zero(row.get("estimated_tokens_input")) for row in rows)
|
| 152 | output_tokens = sum(int_or_zero(row.get("estimated_tokens_output")) for row in rows)
|
| 153 | total_tokens = input_tokens + output_tokens
|
| 154 | cost_weighted = cost_weighted_total(input_tokens, output_tokens)
|
| 155 | stdout_bytes = sum(int_or_zero(row.get("stdout_bytes")) for row in rows)
|
| 156 | stderr_bytes = sum(int_or_zero(row.get("stderr_bytes")) for row in rows)
|
| 157 | envelope_emitted = sum(int_or_zero(row.get("estimated_tokens_envelope_emitted")) for row in rows)
|
| 158 | envelope_ingested = sum(int_or_zero(row.get("estimated_tokens_envelope_ingested")) for row in rows)
|
| 159 | estimate: dict[str, Any] = {
|
| 160 | "source": source,
|
| 161 | "total": total_tokens,
|
| 162 | "input": input_tokens,
|
| 163 | "output": output_tokens,
|
| 164 | "agent_emitted": input_tokens,
|
| 165 | "agent_ingested": output_tokens,
|
| 166 | "cost_weighted_total": cost_weighted,
|
| 167 | "cost_weights": COST_WEIGHTS_NOTE,
|
| 168 | }
|
| 169 | if command_chars is not None:
|
| 170 | estimate["command_chars"] = command_chars
|
| 171 | estimate["stdout_admitted_chars"] = sum(
|
| 172 | int_or_zero((row.get("token_estimate") or {}).get("stdout_admitted_chars")) for row in rows
|
| 173 | )
|
| 174 | estimate["stderr_admitted_chars"] = sum(
|
| 175 | int_or_zero((row.get("token_estimate") or {}).get("stderr_admitted_chars")) for row in rows
|
| 176 | )
|
| 177 | estimate["stdout_bytes"] = stdout_bytes
|
| 178 | estimate["stderr_bytes"] = stderr_bytes
|
| 179 | estimate["stdout_truncated"] = any(bool((row.get("token_estimate") or {}).get("stdout_truncated")) for row in rows)
|
| 180 | estimate["stderr_truncated"] = any(bool((row.get("token_estimate") or {}).get("stderr_truncated")) for row in rows)
|
| 181 | return {
|
| 182 | "estimated_tokens_total": total_tokens,
|
| 183 | "estimated_tokens_input": input_tokens,
|
| 184 | "estimated_tokens_output": output_tokens,
|
| 185 | "estimated_tokens_agent_emitted": input_tokens,
|
| 186 | "estimated_tokens_agent_ingested": output_tokens,
|
| 187 | "estimated_cost_weighted_tokens": cost_weighted,
|
| 188 | "estimated_tokens_envelope_emitted": envelope_emitted,
|
| 189 | "estimated_tokens_envelope_ingested": envelope_ingested,
|
| 190 | "estimated_tokens_total_with_envelope": total_tokens + envelope_emitted + envelope_ingested,
|
| 191 | "estimated_cost_weighted_tokens_with_envelope": cost_weighted_total(
|
| 192 | input_tokens + envelope_emitted, output_tokens + envelope_ingested
|
| 193 | ),
|
| 194 | "raw_output_bytes": stdout_bytes + stderr_bytes,
|
| 195 | "stdout_bytes": stdout_bytes,
|
| 196 | "stderr_bytes": stderr_bytes,
|
| 197 | "stdout_truncated": any(bool(row.get("stdout_truncated")) for row in rows),
|
| 198 | "stderr_truncated": any(bool(row.get("stderr_truncated")) for row in rows),
|
| 199 | "output_truncated": any(bool(row.get("output_truncated")) for row in rows),
|
| 200 | "token_estimate": estimate,
|
| 201 | }
|