feat: capture streaming token usage in QwenClient and QwenVLClient

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
wangwei
2026-07-23 13:19:23 +08:00
co-authored by Copilot
parent 81a6d54fff
commit f2bd0deeb3
2 changed files with 61 additions and 6 deletions
+30 -6
View File
@@ -140,8 +140,14 @@ class QwenClient(BaseLLMClient):
max_tokens: Optional[int] = None, max_tokens: Optional[int] = None,
temperature: Optional[float] = None, temperature: Optional[float] = None,
**kwargs **kwargs
) -> Generator[str, None, None]: ) -> Generator[str, None, Optional[Dict[str, int]]]:
"""Stream chat for the Qwen Client instance.""" """Stream chat for the Qwen Client instance.
Returns the trailing token-usage dict as the generator's return value
(read via StopIteration.value when manually driven with next()) when
the gateway sends one via stream_options.include_usage, else None.
"""
usage: Optional[Dict[str, int]] = None
try: try:
# Keep provider-specific behavior explicit so debugging stays straightforward. # Keep provider-specific behavior explicit so debugging stays straightforward.
payload = { payload = {
@@ -150,7 +156,8 @@ class QwenClient(BaseLLMClient):
"max_tokens": max_tokens or self.config.max_tokens, "max_tokens": max_tokens or self.config.max_tokens,
"temperature": temperature or self.config.temperature, "temperature": temperature or self.config.temperature,
"top_p": kwargs.get("top_p", self.config.top_p), "top_p": kwargs.get("top_p", self.config.top_p),
"stream": True # Keep provider-specific behavior explicit so debugging stays straightforward. "stream": True, # Keep provider-specific behavior explicit so debugging stays straightforward.
"stream_options": {"include_usage": True}
} }
# Keep provider-specific behavior explicit so debugging stays straightforward. # Keep provider-specific behavior explicit so debugging stays straightforward.
@@ -167,6 +174,9 @@ class QwenClient(BaseLLMClient):
data = json.loads(data_str) data = json.loads(data_str)
choices = data.get("choices", []) choices = data.get("choices", [])
if not choices: if not choices:
if data.get("usage"):
# Trailing usage-only chunk — capture it, nothing to yield.
usage = data["usage"]
continue # Keep provider-specific behavior explicit so debugging stays straightforward. continue # Keep provider-specific behavior explicit so debugging stays straightforward.
delta = choices[0].get("delta", {}) delta = choices[0].get("delta", {})
content = delta.get("content", "") content = delta.get("content", "")
@@ -183,6 +193,8 @@ class QwenClient(BaseLLMClient):
logger.error(f"Qwen流式调用失败: {e}") logger.error(f"Qwen流式调用失败: {e}")
yield f"[ERROR: {str(e)}]" yield f"[ERROR: {str(e)}]"
return usage
async def async_stream_chat( async def async_stream_chat(
self, self,
messages: List[Dict[str, str]], messages: List[Dict[str, str]],
@@ -299,8 +311,14 @@ class QwenVLClient(BaseLLMClient):
max_tokens: Optional[int] = None, max_tokens: Optional[int] = None,
temperature: Optional[float] = None, temperature: Optional[float] = None,
**kwargs **kwargs
) -> Generator[str, None, None]: ) -> Generator[str, None, Optional[Dict[str, int]]]:
"""Stream chat for the Qwen V L Client instance.""" """Stream chat for the Qwen V L Client instance.
Returns the trailing token-usage dict as the generator's return value
(read via StopIteration.value when manually driven with next()) when
the gateway sends one via stream_options.include_usage, else None.
"""
usage: Optional[Dict[str, int]] = None
try: try:
payload = { payload = {
"model": self.config.model, "model": self.config.model,
@@ -308,7 +326,8 @@ class QwenVLClient(BaseLLMClient):
"max_tokens": max_tokens or self.config.max_tokens, "max_tokens": max_tokens or self.config.max_tokens,
"temperature": temperature or self.config.temperature, "temperature": temperature or self.config.temperature,
"top_p": kwargs.get("top_p", self.config.top_p), "top_p": kwargs.get("top_p", self.config.top_p),
"stream": True "stream": True,
"stream_options": {"include_usage": True}
} }
with self._client.stream("POST", "/chat/completions", json=payload) as response: with self._client.stream("POST", "/chat/completions", json=payload) as response:
@@ -323,6 +342,9 @@ class QwenVLClient(BaseLLMClient):
data = json.loads(data_str) data = json.loads(data_str)
choices = data.get("choices", []) choices = data.get("choices", [])
if not choices: if not choices:
if data.get("usage"):
# Trailing usage-only chunk — capture it, nothing to yield.
usage = data["usage"]
continue # Keep provider-specific behavior explicit so debugging stays straightforward. continue # Keep provider-specific behavior explicit so debugging stays straightforward.
delta = choices[0].get("delta", {}) delta = choices[0].get("delta", {})
content = delta.get("content", "") content = delta.get("content", "")
@@ -335,6 +357,8 @@ class QwenVLClient(BaseLLMClient):
logger.error(f"QwenVL流式调用失败: {e}") logger.error(f"QwenVL流式调用失败: {e}")
yield f"[ERROR: {str(e)}]" yield f"[ERROR: {str(e)}]"
return usage
def get_available_models(self) -> List[str]: def get_available_models(self) -> List[str]:
"""Return available models for the Qwen V L Client instance.""" """Return available models for the Qwen V L Client instance."""
return self.SUPPORTED_MODELS return self.SUPPORTED_MODELS
@@ -81,3 +81,34 @@ def test_deepseek_stream_chat_without_usage_chunk_returns_none():
assert chunks == ["Hi"] assert chunks == ["Hi"]
assert returned_usage is None assert returned_usage is None
from app.services.llm.qwen_client import QwenClient, QwenVLClient
def test_qwen_stream_chat_returns_usage_from_trailing_chunk():
"""QwenClient.stream_chat() must return the trailing usage dict."""
config = LLMConfig(provider=LLMProvider.QWEN, model="qwen3.5-flash", api_key="k", base_url="http://x/v1")
client = QwenClient(config)
usage = {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14}
client._client = _mock_streaming_client(_sse_lines("Bonjour", usage=usage))
chunks, returned_usage = _drain(client.stream_chat([{"role": "user", "content": "hi"}]))
assert chunks == ["Bonjour"]
assert returned_usage == usage
sent_payload = client._client.stream.call_args.kwargs["json"]
assert sent_payload["stream_options"] == {"include_usage": True}
def test_qwen_vl_stream_chat_returns_usage_from_trailing_chunk():
"""QwenVLClient.stream_chat() must return the trailing usage dict."""
config = LLMConfig(provider=LLMProvider.QWEN_VL, model="qwen3-vl-plus", api_key="k", base_url="http://x/v1")
client = QwenVLClient(config)
usage = {"prompt_tokens": 20, "completion_tokens": 6, "total_tokens": 26}
client._client = _mock_streaming_client(_sse_lines("Describing image", usage=usage))
chunks, returned_usage = _drain(client.stream_chat([{"role": "user", "content": "describe"}]))
assert chunks == ["Describing image"]
assert returned_usage == usage