Skip to content

Commit 4b2b6ca

Browse files
feat(providers): optional token-usage capture via usage_sink
Add an opt-in `usage_sink` dict to every process_* provider and the shared call_openai_compatible_api / call_http_api_with_retry core. When supplied it is populated in place with {prompt_tokens, completion_tokens, total_tokens} (plus a native USD cost for OpenRouter, which sends usage:{include:true} only when a sink is passed). Default path is unchanged: omit the sink and the request body, network behavior, and list[str] return are byte-identical. Also expose public extractors extract_chat_completions_usage and extract_gemini_usage. Motivation: recover per-call token usage / cost for consensus runs without monkeypatching requests.post / generate_content. - anthropic: bespoke usage normalizer written to the documented Messages-API shape but NOT validated against the live API (flagged in-code; needs live tests and confirmation before it is relied on). - tests: offline behavioral coverage + format-drift resilience (extra/missing/ partial/malformed usage); default-path byte-identical test. - CHANGELOG: note under Unreleased.
1 parent 200e4dc commit 4b2b6ca

15 files changed

Lines changed: 611 additions & 17 deletions

python/CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22

33
All notable changes to the Python implementation of mLLMCelltype will be documented in this file.
44

5+
## [Unreleased]
6+
7+
### Added
8+
- Optional token-usage capture for provider calls. Every `process_*` provider function
9+
(and the shared `call_openai_compatible_api` / `call_http_api_with_retry` core) now
10+
accepts an opt-in `usage_sink` dict that is populated in place with
11+
`{prompt_tokens, completion_tokens, total_tokens}` — plus a native `cost` (USD) for
12+
OpenRouter, which opts in to `usage: {include: true}` only when a sink is supplied.
13+
- Public usage extractors `extract_chat_completions_usage` and `extract_gemini_usage`
14+
(exported from `mllmcelltype.providers`) for callers that parse responses directly.
15+
16+
### Notes
17+
- Default behavior is unchanged: omitting `usage_sink` leaves request shape and return
18+
values byte-identical to prior releases.
19+
520
## [2.0.5] - 2026-05-11
621

722
### Fixed

python/mllmcelltype/providers/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
This package contains modules for interacting with various LLM providers."""
33

44
from .anthropic import process_anthropic
5+
from .common import UsageSink, extract_chat_completions_usage
56
from .deepseek import process_deepseek
6-
from .gemini import process_gemini
7+
from .gemini import extract_gemini_usage, process_gemini
78
from .grok import process_grok
89
from .minimax import process_minimax
910
from .openai import process_openai
@@ -13,6 +14,9 @@
1314
from .zhipu import process_zhipu
1415

1516
__all__ = [
17+
"UsageSink",
18+
"extract_chat_completions_usage",
19+
"extract_gemini_usage",
1620
"process_anthropic",
1721
"process_deepseek",
1822
"process_gemini",

python/mllmcelltype/providers/anthropic.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from ..logger import write_log
1010
from .common import (
11+
UsageSink,
1112
call_http_api_with_retry,
1213
ensure_api_key,
1314
resolve_endpoint_url,
@@ -64,8 +65,43 @@ def _parse_anthropic_response(content: dict[str, Any]) -> list[str]:
6465
return [line.rstrip(",") for line in lines]
6566

6667

68+
def _extract_anthropic_usage(content: dict[str, Any]) -> dict[str, Any] | None:
69+
"""Normalize Anthropic's ``usage`` block to the shared usage schema.
70+
71+
NOTE (honesty): written to Anthropic's documented Messages-API response shape
72+
(``usage.input_tokens`` / ``usage.output_tokens``; ``total_tokens`` is derived
73+
since Anthropic doesn't return it). I did NOT validate this against the live
74+
Anthropic API — I only have OpenRouter + Gemini keys. This path still needs
75+
live testing and confirmation against the real Anthropic API before it should
76+
be relied on; the offline tests only pin the documented shape, not real
77+
payloads. Please scrutinize; happy to split this normalizer into a follow-up
78+
if you'd rather not carry unverified code. ``total_tokens`` stays ``None``
79+
unless both token counts are present ints (no fabrication from partial data).
80+
"""
81+
if not isinstance(content, dict):
82+
return None
83+
usage = content.get("usage")
84+
if not isinstance(usage, dict):
85+
return None
86+
87+
prompt_tokens = usage.get("input_tokens")
88+
completion_tokens = usage.get("output_tokens")
89+
total_tokens = None
90+
if isinstance(prompt_tokens, int) and isinstance(completion_tokens, int):
91+
total_tokens = prompt_tokens + completion_tokens
92+
return {
93+
"prompt_tokens": prompt_tokens,
94+
"completion_tokens": completion_tokens,
95+
"total_tokens": total_tokens,
96+
}
97+
98+
6799
def process_anthropic(
68-
prompt: str, model: str, api_key: str, base_url: str | None = None
100+
prompt: str,
101+
model: str,
102+
api_key: str,
103+
base_url: str | None = None,
104+
usage_sink: UsageSink | None = None,
69105
) -> list[str]:
70106
"""Process request using Anthropic Claude models.
71107
@@ -74,6 +110,7 @@ def process_anthropic(
74110
model: The model name (e.g., 'claude-opus-4-7', 'claude-sonnet-4-6')
75111
api_key: Anthropic API key
76112
base_url: Optional custom base URL
113+
usage_sink: Optional dict populated in place with token usage.
77114
78115
Returns:
79116
List[str]: Processed responses, one per cluster
@@ -111,4 +148,6 @@ def process_anthropic(
111148
retry_delay=2,
112149
timeout=30,
113150
request_json=False,
151+
usage_sink=usage_sink,
152+
usage_parser=_extract_anthropic_usage,
114153
)

python/mllmcelltype/providers/common.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import json
66
import time
7-
from collections.abc import Callable
7+
from collections.abc import Callable, MutableMapping
88
from typing import Any
99

1010
import requests
@@ -14,6 +14,45 @@
1414

1515
ResponseParser = Callable[[dict[str, Any]], list[str]]
1616

17+
# A caller-supplied dict that, when passed, is populated in place with token
18+
# usage for the call: prompt_tokens / completion_tokens / total_tokens, plus an
19+
# optional native `cost` (USD) when the provider returns one. None = no capture.
20+
UsageSink = MutableMapping[str, Any]
21+
22+
# Maps a raw response payload to the normalized usage schema (or None if absent).
23+
UsageParser = Callable[[dict[str, Any]], dict[str, Any] | None]
24+
25+
26+
def extract_chat_completions_usage(content: dict[str, Any]) -> dict[str, Any] | None:
27+
"""Extract token usage from an OpenAI-compatible chat completions payload.
28+
29+
Returns a normalized ``{prompt_tokens, completion_tokens, total_tokens}`` dict
30+
(plus ``cost`` when the provider includes a native USD cost, e.g. OpenRouter
31+
when ``usage: {include: true}`` was requested), or ``None`` when no usage block
32+
is present. Never raises on a malformed/absent ``usage`` block.
33+
34+
Resilient to provider format drift by design: only the three canonical keys
35+
(plus ``cost``) are read; any extra/unknown fields are ignored. Missing keys
36+
are reported as ``None`` rather than fabricated, so callers can tell "not
37+
reported" from a real zero. Validated live against OpenRouter; the other
38+
OpenAI-compatible providers (OpenAI, DeepSeek, Qwen, Grok, StepFun, Zhipu,
39+
MiniMax) share this exact path but were not run against their native endpoints.
40+
"""
41+
if not isinstance(content, dict):
42+
return None
43+
usage = content.get("usage")
44+
if not isinstance(usage, dict):
45+
return None
46+
47+
normalized: dict[str, Any] = {
48+
"prompt_tokens": usage.get("prompt_tokens"),
49+
"completion_tokens": usage.get("completion_tokens"),
50+
"total_tokens": usage.get("total_tokens"),
51+
}
52+
if usage.get("cost") is not None:
53+
normalized["cost"] = usage["cost"]
54+
return normalized
55+
1756

1857
class NonRetryableProviderError(ValueError):
1958
"""Provider error that should fail fast without retry."""
@@ -138,8 +177,16 @@ def call_http_api_with_retry(
138177
timeout: int = 30,
139178
request_json: bool = False,
140179
non_retry_exceptions: tuple[type[Exception], ...] = (),
180+
usage_sink: UsageSink | None = None,
181+
usage_parser: UsageParser = extract_chat_completions_usage,
141182
) -> list[str]:
142-
"""Execute an HTTP API request with retry and unified error handling."""
183+
"""Execute an HTTP API request with retry and unified error handling.
184+
185+
When ``usage_sink`` is provided, it is populated in place with token usage
186+
parsed from the successful response via ``usage_parser`` (default: the
187+
OpenAI-compatible extractor). Leaving it ``None`` is byte-identical to the
188+
prior behavior.
189+
"""
143190
write_log("Sending API request...")
144191

145192
for attempt in range(max_retries):
@@ -190,6 +237,11 @@ def call_http_api_with_retry(
190237
raise NonRetryableProviderError(
191238
f"{provider_name} response parser returned {type(res).__name__}, expected list"
192239
)
240+
if usage_sink is not None:
241+
usage = usage_parser(content)
242+
if usage is not None:
243+
usage_sink.update(usage)
244+
193245
normalized_res = [str(line) for line in res]
194246
write_log(f"Got response with {len(normalized_res)} lines")
195247
write_log(f"Raw response from {provider_name}:\n{normalized_res}", level="debug")
@@ -235,8 +287,14 @@ def call_openai_compatible_api(
235287
timeout: int = 30,
236288
request_json: bool = False,
237289
non_retry_exceptions: tuple[type[Exception], ...] = (),
290+
usage_sink: UsageSink | None = None,
238291
) -> list[str]:
239-
"""Execute a request against an OpenAI-compatible endpoint with retries."""
292+
"""Execute a request against an OpenAI-compatible endpoint with retries.
293+
294+
When ``usage_sink`` is provided, it is populated in place with the call's
295+
token usage (see :func:`extract_chat_completions_usage`). Default ``None``
296+
preserves prior behavior exactly.
297+
"""
240298
headers = {
241299
"Content-Type": "application/json",
242300
"Authorization": f"Bearer {api_key}",
@@ -258,4 +316,5 @@ def call_openai_compatible_api(
258316
timeout=timeout,
259317
request_json=request_json,
260318
non_retry_exceptions=non_retry_exceptions,
319+
usage_sink=usage_sink,
261320
)

python/mllmcelltype/providers/deepseek.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from ..logger import write_log
88
from .common import (
9+
UsageSink,
910
build_chat_completions_body,
1011
call_openai_compatible_api,
1112
ensure_api_key,
@@ -14,7 +15,11 @@
1415

1516

1617
def process_deepseek(
17-
prompt: str, model: str, api_key: str, base_url: str | None = None
18+
prompt: str,
19+
model: str,
20+
api_key: str,
21+
base_url: str | None = None,
22+
usage_sink: UsageSink | None = None,
1823
) -> list[str]:
1924
"""Process request using DeepSeek models.
2025
@@ -23,6 +28,7 @@ def process_deepseek(
2328
model: The model name (e.g., 'deepseek-v4-flash', 'deepseek-v4-pro')
2429
api_key: DeepSeek API key
2530
base_url: Optional custom base URL
31+
usage_sink: Optional dict populated in place with token usage.
2632
2733
Returns:
2834
List[str]: Processed responses, one per cluster
@@ -52,4 +58,5 @@ def process_deepseek(
5258
retry_delay=3,
5359
timeout=90,
5460
request_json=True,
61+
usage_sink=usage_sink,
5562
)

python/mllmcelltype/providers/gemini.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,24 @@
66
from typing import Any
77

88
from ..logger import write_log
9-
from .common import ensure_api_key
9+
from .common import UsageSink, ensure_api_key
10+
11+
12+
def extract_gemini_usage(response: Any) -> dict[str, Any] | None:
13+
"""Extract token usage from a Gemini SDK response's ``usage_metadata``.
14+
15+
Returns the shared ``{prompt_tokens, completion_tokens, total_tokens}`` schema,
16+
or ``None`` when no usage metadata is present. Never raises on absent/odd
17+
metadata.
18+
"""
19+
metadata = getattr(response, "usage_metadata", None)
20+
if metadata is None:
21+
return None
22+
return {
23+
"prompt_tokens": getattr(metadata, "prompt_token_count", None),
24+
"completion_tokens": getattr(metadata, "candidates_token_count", None),
25+
"total_tokens": getattr(metadata, "total_token_count", None),
26+
}
1027

1128

1229
def _parse_gemini_response(response: Any) -> list[str]:
@@ -26,7 +43,11 @@ def _parse_gemini_response(response: Any) -> list[str]:
2643

2744

2845
def process_gemini(
29-
prompt: str, model: str, api_key: str, base_url: str | None = None
46+
prompt: str,
47+
model: str,
48+
api_key: str,
49+
base_url: str | None = None,
50+
usage_sink: UsageSink | None = None,
3051
) -> list[str]:
3152
"""Process request using Google Gemini models.
3253
@@ -35,6 +56,7 @@ def process_gemini(
3556
model: The model name (e.g., 'gemini-3.1-pro-preview', 'gemini-3-flash-preview', 'gemini-3.1-flash-lite')
3657
api_key: Google API key
3758
base_url: Optional custom base URL (Note: Gemini uses SDK, base_url may not be applicable)
59+
usage_sink: Optional dict populated in place with token usage.
3860
3961
Returns:
4062
List[str]: Processed responses, one per cluster
@@ -85,6 +107,10 @@ def process_gemini(
85107
)
86108

87109
result = _parse_gemini_response(response)
110+
if usage_sink is not None:
111+
usage = extract_gemini_usage(response)
112+
if usage is not None:
113+
usage_sink.update(usage)
88114
write_log(f"Got response with {len(result)} lines")
89115
write_log(f"Raw response from Gemini:\n{result}", level="debug")
90116
return result

python/mllmcelltype/providers/grok.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from ..logger import write_log
88
from .common import (
9+
UsageSink,
910
build_chat_completions_body,
1011
call_openai_compatible_api,
1112
ensure_api_key,
@@ -14,7 +15,11 @@
1415

1516

1617
def process_grok(
17-
prompt: str, model: str, api_key: str, base_url: str | None = None
18+
prompt: str,
19+
model: str,
20+
api_key: str,
21+
base_url: str | None = None,
22+
usage_sink: UsageSink | None = None,
1823
) -> list[str]:
1924
"""Process request using Grok models from xAI.
2025
@@ -23,6 +28,7 @@ def process_grok(
2328
model: The model name (e.g., 'grok-4.3', 'grok-4.3-latest')
2429
api_key: xAI API key
2530
base_url: Optional custom base URL
31+
usage_sink: Optional dict populated in place with token usage.
2632
2733
Returns:
2834
List[str]: Processed responses, one per cluster
@@ -42,4 +48,5 @@ def process_grok(
4248
url=url,
4349
body=body,
4450
post_func=requests.post,
51+
usage_sink=usage_sink,
4552
)

python/mllmcelltype/providers/minimax.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ..url_utils import get_working_minimax_endpoint
1212
from .common import (
1313
NonRetryableProviderError,
14+
UsageSink,
1415
build_chat_completions_body,
1516
call_openai_compatible_api,
1617
ensure_api_key,
@@ -43,7 +44,11 @@ def _parse_minimax_response(content: dict[str, Any]) -> list[str]:
4344

4445

4546
def process_minimax(
46-
prompt: str, model: str, api_key: str, base_url: str | None = None
47+
prompt: str,
48+
model: str,
49+
api_key: str,
50+
base_url: str | None = None,
51+
usage_sink: UsageSink | None = None,
4752
) -> list[str]:
4853
"""Process request using MiniMax models.
4954
@@ -52,6 +57,7 @@ def process_minimax(
5257
model: The model name (e.g., 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5')
5358
api_key: MiniMax API key
5459
base_url: Optional custom base URL
60+
usage_sink: Optional dict populated in place with token usage.
5561
5662
Returns:
5763
List[str]: Processed responses, one per cluster
@@ -87,4 +93,5 @@ def process_minimax(
8793
post_func=requests.post,
8894
response_parser=_parse_minimax_response,
8995
non_retry_exceptions=(NonRetryableProviderError,),
96+
usage_sink=usage_sink,
9097
)

0 commit comments

Comments
 (0)