Files
meida_backend/packages/llm/recording.py
T
xsl c97f8ae3e1
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
feat(cost): persist reasoning tokens and the peak/off-peak tier
ob_ai_call is the only input to V2 pricing, and it was dropping the two
numbers needed to interpret its own cost column.

tokens_reasoning was captured from the provider response but never written to
the table, so the cost of thinking mode was invisible once it left the
process. Folding it into tokens_out was not an option either -- it is billed
at the output rate, and mixing the two hides exactly the difference we now
have to watch.

rate_tier records which price list applied. Without it a row's cost_cents
cannot be rechecked: DeepSeek's peak rate is double off-peak from 2026-08-17,
so recomputing an old row at "the current rate" silently produces a different
answer than what was actually billed.

Migration 0002 adds both columns. tokens_reasoning defaults to 0 rather than
NULL so cost aggregates do not collapse to NULL on historical rows;
rate_tier stays nullable because rows written before this change genuinely
have an unknown tier, and NULL says that more honestly than a guessed 'flat'.

The smoke script now asserts both: reasoning tokens must be 0 (a non-zero
value means a provider default re-enabled thinking mode behind our backs) and
rate_tier must be present. Verified against the real API -- tokens_reasoning=0,
rate_tier=off_peak, 3.9s round trip.
2026-08-14 01:02:13 +08:00

66 lines
2.9 KiB
Python
Executable File

"""Adapters that persist :class:`packages.llm.cost.AICallRecord`.
The SQLAlchemy recorder deliberately opens its **own** session instead of
joining the caller's transaction. If a request later rolls back — quota refund,
moderation rejection, an unhandled error — the cost still happened and must
still be attributable. Losing those rows would silently under-report spend,
which is exactly the blind spot 架构文档 §13 forbids.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from packages.llm.cost import AICallRecord
from packages.shared.models import AiCall
from packages.shared.observability import LLM_CALLS, LLM_COST_CENTS, LLM_LATENCY, LLM_TOKENS
class SqlAlchemyAICallRecorder:
"""Writes one ``ob_ai_call`` row per provider attempt."""
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._session_factory = session_factory
async def record(self, record: AICallRecord) -> None:
async with self._session_factory() as session:
session.add(
AiCall(
call_id=record.call_id,
user_id=record.user_id,
scene=record.scene,
provider=record.provider,
model=record.model,
prompt_id=record.prompt_id,
prompt_version=record.prompt_version,
tokens_in=record.usage.tokens_in,
tokens_out=record.usage.tokens_out,
tokens_cached=record.usage.tokens_cached,
tokens_reasoning=record.usage.tokens_reasoning,
latency_ms=record.latency_ms,
cost_cents=record.cost_cents,
rate_tier=record.rate_tier,
success=record.success,
error_code=record.error_code,
error_message=record.error_message,
ref_type=record.ref_type,
ref_id=record.ref_id,
)
)
await session.commit()
class PrometheusAICallRecorder:
"""Feeds the AI 成本看板 (架构文档 §15) straight from the same records."""
async def record(self, record: AICallRecord) -> None:
labels = (record.scene, record.provider, record.model)
LLM_CALLS.labels(*labels, "true" if record.success else "false").inc()
LLM_LATENCY.labels(*labels).observe(record.latency_ms / 1000)
LLM_COST_CENTS.labels(*labels).inc(float(record.cost_cents))
LLM_TOKENS.labels(*labels, "in").inc(record.usage.tokens_in)
LLM_TOKENS.labels(*labels, "out").inc(record.usage.tokens_out)
LLM_TOKENS.labels(*labels, "cached").inc(record.usage.tokens_cached)
# 思考模式 token 单独出一条曲线:它按输出价计费,是最容易悄悄涨上去的一项。
LLM_TOKENS.labels(*labels, "reasoning").inc(record.usage.tokens_reasoning)