* feat: add billing context to LLM telemetry traces Add billing metadata (plan type, cost source, customer ID) to LLM traces in ClickHouse for cost analytics and attribution. **Data Flow:** - Cloud-API: Extract billing info from subscription in rate limiting, set x-billing-* headers - Core: Parse headers into BillingContext object via dependencies - Adapters: Flow billing_context through all LLM adapters (blocking & streaming) - Agent: Pass billing_context to step() and stream() methods - ClickHouse: Store in billing_plan_type, billing_cost_source, billing_customer_id columns **Changes:** - Add BillingContext schema to provider_trace.py - Add billing columns to llm_traces ClickHouse table DDL - Update getCustomerSubscription to fetch stripeCustomerId from organization_billing_details - Propagate billing_context through agent step flow, adapters, and streaming service - Update ProviderTrace and LLMTrace to include billing metadata - Regenerate SDK with autogen **Production Deployment:** Requires env vars: LETTA_PROVIDER_TRACE_BACKEND=clickhouse, LETTA_STORE_LLM_TRACES=true, CLICKHOUSE_* 🐾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: add billing_context parameter to agent step methods - Add billing_context to BaseAgent and BaseAgentV2 abstract methods - Update LettaAgent, LettaAgentV2, LettaAgentV3 step methods - Update multi-agent groups: SleeptimeMultiAgentV2, V3, V4 - Fix test_utils.py to include billing header parameters - Import BillingContext in all affected files * fix: add billing_context to stream methods - Add billing_context parameter to BaseAgentV2.stream() - Add billing_context parameter to LettaAgentV2.stream() - LettaAgentV3.stream() already has it from previous commit * fix: exclude billing headers from OpenAPI spec Mark billing headers as internal (include_in_schema=False) so they don't appear in the public API. These are internal headers between cloud-api and core, not part of the public SDK. Regenerated SDK with stage-api - removes 10,650 lines of bloat that was causing OOM during Next.js build. * refactor: return billing context from handleUnifiedRateLimiting instead of mutating req Instead of passing req into handleUnifiedRateLimiting and mutating headers inside it: - Return billing context fields (billingPlanType, billingCostSource, billingCustomerId) from handleUnifiedRateLimiting - Set headers in handleMessageRateLimiting (middleware layer) after getting the result - This fixes step-orchestrator compatibility since it doesn't have a real Express req object * chore: remove extra gencode * p --------- Co-authored-by: Letta <noreply@letta.com>
105 lines
4.3 KiB
Python
105 lines
4.3 KiB
Python
"""PostgreSQL provider trace backend."""
|
|
|
|
from letta.helpers.json_helpers import json_dumps, json_loads
|
|
from letta.orm.provider_trace import ProviderTrace as ProviderTraceModel
|
|
from letta.orm.provider_trace_metadata import ProviderTraceMetadata as ProviderTraceMetadataModel
|
|
from letta.schemas.provider_trace import ProviderTrace, ProviderTraceMetadata
|
|
from letta.schemas.user import User
|
|
from letta.server.db import db_registry
|
|
from letta.services.provider_trace_backends.base import ProviderTraceBackendClient
|
|
from letta.settings import telemetry_settings
|
|
|
|
|
|
class PostgresProviderTraceBackend(ProviderTraceBackendClient):
|
|
"""Store provider traces in PostgreSQL."""
|
|
|
|
async def create_async(
|
|
self,
|
|
actor: User,
|
|
provider_trace: ProviderTrace,
|
|
) -> ProviderTrace | ProviderTraceMetadata:
|
|
if telemetry_settings.provider_trace_pg_metadata_only:
|
|
return await self._create_metadata_only_async(actor, provider_trace)
|
|
return await self._create_full_async(actor, provider_trace)
|
|
|
|
async def _create_full_async(
|
|
self,
|
|
actor: User,
|
|
provider_trace: ProviderTrace,
|
|
) -> ProviderTrace:
|
|
"""Write full provider trace to provider_traces table."""
|
|
async with db_registry.async_session() as session:
|
|
provider_trace_model = ProviderTraceModel(**provider_trace.model_dump(exclude={"billing_context"}))
|
|
provider_trace_model.organization_id = actor.organization_id
|
|
|
|
if provider_trace.request_json:
|
|
request_json_str = json_dumps(provider_trace.request_json)
|
|
provider_trace_model.request_json = json_loads(request_json_str)
|
|
|
|
if provider_trace.response_json:
|
|
response_json_str = json_dumps(provider_trace.response_json)
|
|
provider_trace_model.response_json = json_loads(response_json_str)
|
|
|
|
await provider_trace_model.create_async(session, actor=actor, no_commit=True, no_refresh=True)
|
|
return provider_trace_model.to_pydantic()
|
|
|
|
async def _create_metadata_only_async(
|
|
self,
|
|
actor: User,
|
|
provider_trace: ProviderTrace,
|
|
) -> ProviderTraceMetadata:
|
|
"""Write metadata-only trace to provider_trace_metadata table."""
|
|
metadata = ProviderTraceMetadata(
|
|
id=provider_trace.id,
|
|
step_id=provider_trace.step_id,
|
|
agent_id=provider_trace.agent_id,
|
|
agent_tags=provider_trace.agent_tags,
|
|
call_type=provider_trace.call_type,
|
|
run_id=provider_trace.run_id,
|
|
source=provider_trace.source,
|
|
org_id=provider_trace.org_id,
|
|
user_id=provider_trace.user_id,
|
|
)
|
|
metadata_model = ProviderTraceMetadataModel(**metadata.model_dump())
|
|
metadata_model.organization_id = actor.organization_id
|
|
|
|
async with db_registry.async_session() as session:
|
|
await metadata_model.create_async(session, actor=actor, no_commit=True, no_refresh=True)
|
|
return metadata_model.to_pydantic()
|
|
|
|
async def get_by_step_id_async(
|
|
self,
|
|
step_id: str,
|
|
actor: User,
|
|
) -> ProviderTrace | None:
|
|
"""Read from provider_traces table. Always reads from full table regardless of write flag."""
|
|
return await self._get_full_by_step_id_async(step_id, actor)
|
|
|
|
async def _get_full_by_step_id_async(
|
|
self,
|
|
step_id: str,
|
|
actor: User,
|
|
) -> ProviderTrace | None:
|
|
"""Read from provider_traces table."""
|
|
async with db_registry.async_session() as session:
|
|
provider_trace_model = await ProviderTraceModel.read_async(
|
|
db_session=session,
|
|
step_id=step_id,
|
|
actor=actor,
|
|
)
|
|
return provider_trace_model.to_pydantic() if provider_trace_model else None
|
|
|
|
async def _get_metadata_by_step_id_async(
|
|
self,
|
|
step_id: str,
|
|
actor: User,
|
|
) -> ProviderTraceMetadata | None:
|
|
"""Read from provider_trace_metadata table."""
|
|
async with db_registry.async_session() as session:
|
|
metadata_model = await ProviderTraceMetadataModel.read_async(
|
|
db_session=session,
|
|
step_id=step_id,
|
|
actor=actor,
|
|
)
|
|
return metadata_model.to_pydantic() if metadata_model else None
|