Files
AgentLab/backend/tests/test_openai_stream_usage.py

247 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OpenAI 兼容接入的流式用量回传(stream_options.include_usage)。
真实运行里:模型走 LiteLLM 的 OpenAI 兼容接口,上百次模型调用,运行用量却是 0 token、0 美元。ChatOpenAI
只在没填 base_url 时默认开流式用量回传;填了 base_url(所有兼容接口都填)就不开,流里没有 usage 那一块,
llm.end 记下的全是 0,运行的用量是 llm.end 逐次加总的,于是也是 0。
这组守的是:
- 兼容接入构造出来的模型开着流式用量回传,请求里带 stream_options.include_usage
- 流里最后那块 usage 合进响应,llm.end 和运行的用量里 token、金额都对
- 不认 stream_options 的服务:同一次调用去掉它重发一次,照常流式输出,不让调用失败;同一个接口之后不再带它
- 和用量回传无关的报错不重发、原样抛出
假的是 HTTP:openai SDK 发出去的请求在它的 HTTP 库的传输层被接住,按请求体回 SSE。模型名、内容都是虚构的。
"""
from __future__ import annotations
import asyncio
import json
import uuid
import pytest
from langchain_core.messages import HumanMessage
from sqlalchemy import delete, select
from app.db.base import SessionLocal
from app.db.models import Provider, Run, RunEvent
from app.engine.runner import run_manager
from app.providers import catalog, factory
from app.providers.factory import ModelSpec, build_chat_model
try: # openai SDK 3 起换成了 httpx2(和 httpx 同一套接口):假服务要装在 SDK 真正用的那个库上
from langchain_openai._compat import httpx
except ImportError: # pragma: no cover - 老版本的 langchain_openai
import httpx
PRICED = "gpt-5.2-mini"
USAGE = {"prompt_tokens": 1200, "completion_tokens": 300, "total_tokens": 1500}
def _chunk(delta: dict | None = None, *, finish: str | None = None, usage: dict | None = None) -> str:
body = {"id": "c-1", "object": "chat.completion.chunk", "created": 1, "model": PRICED,
"choices": [] if delta is None else [{"index": 0, "delta": delta, "finish_reason": finish}]}
if usage is not None:
body["usage"] = usage
return f"data: {json.dumps(body, ensure_ascii=False)}\n\n"
class FakeServer:
"""OpenAI 兼容服务:带了 stream_options.include_usage 才在流尾补 usage 那一块。
rejects:不认 stream_options 的服务,带了就回 status(400 / 422)和 message;
fail:每个请求都回这个错误(和用量回传无关的报错)。"""
def __init__(self, *, rejects: tuple[int, str] | None = None, fail: tuple[int, str] | None = None) -> None:
self.rejects, self.fail = rejects, fail
self.bodies: list[dict] = []
async def handle(self, request: httpx.Request) -> httpx.Response:
body = json.loads(request.content or b"{}")
self.bodies.append(body)
wants = bool((body.get("stream_options") or {}).get("include_usage"))
if self.fail or (self.rejects and "stream_options" in body):
status, message = self.fail or self.rejects
return httpx.Response(status, json={"error": {"message": message, "type": "invalid_request_error"}},
request=request)
if not body.get("stream"):
reply = {"id": "c-1", "object": "chat.completion", "created": 1, "model": PRICED,
"choices": [{"index": 0, "message": {"role": "assistant", "content": "上周到店 120 人。"},
"finish_reason": "stop"}], "usage": USAGE}
return httpx.Response(200, json=reply, request=request)
sse = (_chunk({"role": "assistant", "content": "上周"}) + _chunk({"content": "到店 120 人。"})
+ _chunk({}, finish="stop") + (_chunk(usage=USAGE) if wants else "") + "data: [DONE]\n\n")
return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=sse.encode(),
request=request)
@pytest.fixture
def server(monkeypatch):
def install(**kwargs) -> FakeServer:
fake = FakeServer(**kwargs)
monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", fake.handle)
return fake
# 记住「这个接口不认 stream_options」的表是进程级的:每个用例从空表开始
monkeypatch.setattr(factory, "_NO_STREAM_USAGE", set(), raising=False)
return install
def _provider(base_url: str | None = None, *, kind: str = "openai_compatible") -> Provider:
return Provider(name=f"compat-{uuid.uuid4().hex[:8]}", kind=kind,
base_url=base_url or f"http://llm-{uuid.uuid4().hex[:6]}.example.test/v1",
api_key=None, models=[{"id": PRICED}], default_model=PRICED, enabled=True, extra={})
async def _stream(model) -> tuple[str, dict | None, int]:
"""流式调用一次:(正文, 合并后的 usage_metadata, 块数)。"""
merged, count = None, 0
async for chunk in model.astream([HumanMessage(content="上周到店多少人")]):
merged = chunk if merged is None else merged + chunk
count += 1
return str(merged.content), merged.usage_metadata, count
# ---------------------------------------------------------------- 构造
@pytest.mark.parametrize("kind", ["openai_compatible", "openai"])
def test_compatible_providers_turn_on_streaming_usage(kind):
model = build_chat_model(_provider(kind=kind), ModelSpec())
assert model.stream_usage is True
# ---------------------------------------------------------------- 流式调用
async def test_usage_chunk_is_requested_and_merged(server):
fake = server()
model = build_chat_model(_provider(), ModelSpec())
text, usage, _ = await _stream(model)
assert text == "上周到店 120 人。"
assert fake.bodies[0]["stream_options"] == {"include_usage": True}
assert usage and usage["input_tokens"] == 1200 and usage["output_tokens"] == 300
@pytest.mark.parametrize("status,message", [
(400, "Unrecognized request argument supplied: stream_options"),
(422, "body -> stream_options: extra fields not permitted"),
(400, "Bad Request"),
], ids=["named-400", "named-422", "generic-400"])
async def test_service_rejecting_stream_options_falls_back_and_is_remembered(server, status, message):
fake = server(rejects=(status, message))
provider = _provider()
model = build_chat_model(provider, ModelSpec())
text, usage, count = await _stream(model)
# 照常流式输出:去掉 stream_options 重发了一次,正文分块到达
assert text == "上周到店 120 人。" and count >= 2
assert ["stream_options" in b for b in fake.bodies] == [True, False]
assert usage is None or not usage.get("input_tokens"), "这个服务给不了流式用量,不编"
# 同一个接口之后不再带 stream_options(新构造的模型也一样),不再每次先失败一次
again = build_chat_model(provider, ModelSpec())
await _stream(again)
assert ["stream_options" in b for b in fake.bodies] == [True, False, False]
async def test_other_errors_are_not_retried(server):
fake = server(fail=(401, "invalid api key"))
model = build_chat_model(_provider(), ModelSpec())
with pytest.raises(Exception, match="invalid api key"):
await _stream(model)
assert len(fake.bodies) == 1
async def test_a_failing_retry_is_not_remembered(server):
"""去掉 stream_options 重发也失败:说明不是它的事,报重发的那个错,也不记成这个接口不认它。"""
fake = server(fail=(400, "max_tokens is too large"))
provider = _provider()
model = build_chat_model(provider, ModelSpec())
with pytest.raises(Exception, match="max_tokens is too large"):
await _stream(model)
assert ["stream_options" in b for b in fake.bodies] == [True, False]
assert provider.base_url not in factory._NO_STREAM_USAGE
# ---------------------------------------------------------------- 运行的用量
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
async def _run_once(model: str) -> tuple[Run, list[RunEvent]]:
"""兼容接入上用 model 跑一个 llm 节点,返回运行行和 llm.end 事件。"""
provider = _provider()
async with SessionLocal() as session:
session.add(provider)
await session.commit()
await session.refresh(provider)
session.expunge(provider)
try:
graph = {
"nodes": [
{"id": "in", "type": "input", "data": {"config": {}}},
{"id": "ask", "type": "llm", "data": {"label": "问答", "config": {
"prompt": "上周到店多少人", "provider": provider.name, "model": model}}},
{"id": "out", "type": "output", "data": {"config": {
"fields": [{"name": "回答", "value": "{{ nodes.ask.text }}"}]}}},
],
"edges": [{"source": "in", "target": "ask"}, {"source": "ask", "target": "out"}],
}
started = await run_manager.start(graph=graph, input_payload={})
for _ in range(200):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, started.id)
if row.status in ("succeeded", "failed"):
break
assert row.status == "succeeded", row.error
async with SessionLocal() as session:
ends = list((await session.execute(select(RunEvent).where(
RunEvent.run_id == started.id, RunEvent.type == "llm.end"))).scalars())
return row, ends
finally:
async with SessionLocal() as session:
await session.execute(delete(Provider).where(Provider.id == provider.id))
await session.commit()
async def test_run_usage_counts_streamed_tokens(server, engine_up):
"""整条链:兼容接入 → llm 节点流式调用 → llm.end → 运行的用量。修之前这里是 0 token、0 美元。"""
server()
row, ends = await _run_once(PRICED)
assert [(e.data["input_tokens"], e.data["output_tokens"]) for e in ends] == [(1200, 300)]
assert row.usage["input_tokens"] == 1200 and row.usage["output_tokens"] == 300
assert row.usage["total_tokens"] == 1500
expected = round(catalog.estimate_cost(PRICED, 1200, 300), 6)
assert expected > 0 and row.usage["cost_usd"] == pytest.approx(expected)
assert "unpriced" not in ends[0].data and "unpriced_calls" not in row.usage
async def test_a_model_missing_from_the_price_catalog_is_marked_unpriced(server, engine_up):
"""token 记到了,模型却不在价格目录里:金额估不出来,不能让运行页写 $0 像是没花钱。
llm.end 标 unpriced(运行中的实时统计据此数),运行用量记 unpriced_calls(界面据此写「无法估算」)。"""
server()
row, ends = await _run_once("vendor-unlisted-model")
assert [(e.data["input_tokens"], e.data["output_tokens"]) for e in ends] == [(1200, 300)]
assert ends[0].data["unpriced"] is True and ends[0].data["cost_usd"] == 0
assert row.usage["total_tokens"] == 1500 and row.usage["cost_usd"] == 0
assert row.usage["unpriced_calls"] == 1
@pytest.mark.parametrize(("data", "unpriced"), [
({"model": "vendor-unlisted-model", "input_tokens": 10, "output_tokens": 2, "cost_usd": 0}, True),
({"model": PRICED, "input_tokens": 10, "output_tokens": 2, "cost_usd": 0.0001}, False),
# 目录里标 0 价的本地模型是真不花钱,不是估不出来
({"model": "qwen3:8b", "input_tokens": 10, "output_tokens": 2, "cost_usd": 0}, False),
# 没用 token 的调用(失败、被取消)谈不上金额
({"model": "vendor-unlisted-model", "input_tokens": 0, "output_tokens": 0}, False),
# 老事件没有 unpriced 键:按模型名补判;事件自己标了的以它为准
({"model": PRICED, "input_tokens": 10, "unpriced": True}, True),
(None, False),
])
def test_unpriced_call(data, unpriced):
assert catalog.unpriced_call(data) is unpriced