DeepSeek (OpenAI-compatible) as primary with a 智谱 GLM slot that is a real routing path but raises until the 算法备案 model list is confirmed. Adds scene routing, failover, a per-provider circuit breaker, structured output with retry-on-validation-failure, prompts loaded by id+version from prompts/, and cost attribution written to ob_ai_call for every attempt -- successes, retries and failures alike. Two controls exist because they are cheap now and expensive later: a cache-prefix validator that rejects timestamps, uuids and nicknames in the cached system prefix (a volatile prefix silently multiplies input cost), and a hard platform-wide daily budget that rejects non-critical scenes instead of merely alerting, since a retry storm outruns any human on-call.
62 lines
2.6 KiB
Python
Executable File
62 lines
2.6 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,
|
|
latency_ms=record.latency_ms,
|
|
cost_cents=record.cost_cents,
|
|
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)
|