forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmanaged_spend_ledger.py
More file actions
284 lines (243 loc) · 11.4 KB
/
Copy pathmanaged_spend_ledger.py
File metadata and controls
284 lines (243 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
"""Ledger rows for managed model spend that bypasses the LLM gateway.
The gateway writes one immutable row per provider attempt to the Firestore
collection ``llm_gateway_attempts`` (``database.llm_gateway_accounting``). Two
backend surfaces call providers directly and were invisible to that ledger:
* ``routers/desktop_proxy.py`` — direct Vertex / AI Studio Gemini calls that do
not hop the gateway (BYOK, batch embeddings, ``FEATURE_MODE=off``).
* ``routers/omni_relay.py`` — the legacy realtime WebSocket relay.
This module lets those surfaces record the same event shape into the same
collection, so "managed spend for uid X grouped by feature" is one query
regardless of which door the call went through. It builds the event with the
gateway's own ``build_accounting_event`` and persists it with the gateway's own
DB helper; there is no second ledger.
Rules, matching ``llm_gateway.gateway.accounting_sink``:
* Gated by ``LLM_GATEWAY_ACCOUNTING_ENABLED`` on the serving identity, read at
call time. Off means no write and no error.
* Best-effort and bounded: a write never delays or fails the model call, is
given one timeout, and pending writes are capped so a Firestore outage cannot
grow the process without bound.
* Bounded metadata only: no prompts, provider payloads, headers, or keys.
"""
from __future__ import annotations
import asyncio
import logging
import os
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass
from threading import RLock
from typing import Any
from uuid import uuid4
from database.llm_gateway_accounting import record_llm_gateway_attempt
from llm_gateway.gateway.accounting import (
AccountingContext,
AccountingEvent,
PricedUsage,
ProviderAttempt,
ProviderResponseMetadata,
UsageStatus,
build_accounting_event,
)
logger = logging.getLogger(__name__)
# Same switch and knobs as the gateway sink; one identity, one contract. The
# names are duplicated rather than imported because the sink module pulls the
# gateway's Prometheus registry into whichever process imports it.
ACCOUNTING_ENABLED_ENV_VAR = 'LLM_GATEWAY_ACCOUNTING_ENABLED'
ACCOUNTING_WRITE_TIMEOUT_SECONDS_ENV_VAR = 'LLM_GATEWAY_ACCOUNTING_WRITE_TIMEOUT_SECONDS'
ACCOUNTING_MAX_PENDING_TRACES_ENV_VAR = 'LLM_GATEWAY_ACCOUNTING_MAX_PENDING_TRACES'
DEFAULT_ACCOUNTING_WRITE_TIMEOUT_SECONDS = 1.0
DEFAULT_ACCOUNTING_MAX_PENDING_TRACES = 1_000
# ``caller`` values: which door the attempt went through.
DESKTOP_PROXY_CALLER = 'desktop_proxy'
OMNI_RELAY_CALLER = 'omni_relay'
# ``feature`` for realtime voice on every route (relay today, direct hub via
# ``/v2/realtime/usage`` later). Deliberately the same word as the
# ``llm_usage`` account those turns already debit, so both ledgers agree.
DESKTOP_REALTIME_FEATURE = 'desktop_chat_realtime'
# Ledger writes get their own two threads. The cap below counts the underlying
# Firestore calls until they actually return, so a hung Firestore can occupy at
# most this pool plus the capped queue and never a shared executor that gates
# quota or auth reads.
_LEDGER_WORKERS = 2
_ledger_executor: ThreadPoolExecutor | None = None
_ledger_state_lock = RLock()
_pending_writes: set[Future[bool]] = set()
_ledger_shutdown_in_progress = False
def _get_ledger_executor() -> ThreadPoolExecutor:
global _ledger_executor
with _ledger_state_lock:
if _ledger_executor is None:
_ledger_executor = ThreadPoolExecutor(max_workers=_LEDGER_WORKERS, thread_name_prefix='spend-ledger')
return _ledger_executor
def _discard_pending_write(future: Future[bool]) -> None:
with _ledger_state_lock:
_pending_writes.discard(future)
@dataclass(frozen=True)
class ManagedAttempt:
"""One provider attempt made outside the gateway, in ledger terms."""
request_id: str
caller: str
user_uid: str | None
feature: str
api_surface: str
payer: str
provider: str
configured_model: str
outcome: str
app_platform: str | None = 'desktop'
route_artifact_id: str | None = None
error_class: str = 'none'
retry_ordinal: int = 1
fallback_reason: str | None = None
metadata: ProviderResponseMetadata | None = None
usage_status: UsageStatus | None = None
priced: PricedUsage | None = None
# One invocation per session with an increasing ordinal groups a realtime
# session's turns; a plain request leaves both at their defaults.
invocation_id: str | None = None
ordinal: int = 1
def build_managed_attempt_event(attempt: ManagedAttempt) -> AccountingEvent:
"""The exact ledger event the gateway would write for this attempt."""
metadata = attempt.metadata or ProviderResponseMetadata()
usage_status = attempt.usage_status or (
UsageStatus.CONFIRMED if metadata.usage is not None else UsageStatus.NOT_REPORTED
)
context = AccountingContext(
invocation_id=attempt.invocation_id or str(uuid4()),
request_id=attempt.request_id,
caller=attempt.caller,
user_uid=attempt.user_uid,
feature=attempt.feature,
api_surface=attempt.api_surface,
payer=attempt.payer,
app_platform=attempt.app_platform,
)
provider_attempt = ProviderAttempt(
ordinal=max(attempt.ordinal, 1),
provider=attempt.provider,
configured_model=attempt.configured_model,
route_artifact_id=attempt.route_artifact_id,
fallback_reason=attempt.fallback_reason,
retry_ordinal=max(attempt.retry_ordinal, 1),
outcome=attempt.outcome,
error_class=attempt.error_class,
usage=metadata.usage,
usage_status=usage_status,
provider_response_id=metadata.provider_response_id,
actual_model_version=metadata.actual_model_version,
traffic_type=metadata.traffic_type,
)
return build_accounting_event(context, provider_attempt, priced=attempt.priced)
def record_managed_attempt(attempt: ManagedAttempt, *, firestore_client: Any | None = None) -> bool:
"""Synchronously persist one attempt. Raises on failure; callers decide the policy.
The customer data plane is the target: the ledger snapshots the user's
subscription tier from ``users/{uid}``, and on desktop-backend that document
lives in the customer project, not the compute project.
"""
if firestore_client is None:
from database._client import get_customer_firestore_client
firestore_client = get_customer_firestore_client()
event = build_managed_attempt_event(attempt)
return record_llm_gateway_attempt(event.as_dict(), firestore_client=firestore_client)
def schedule_managed_attempt(attempt: ManagedAttempt) -> bool:
"""Best-effort background persist. Returns whether a write was scheduled.
Never raises and never blocks the caller. Returns ``False`` when accounting
is disabled, when the pending-write cap is reached, or when there is no
running event loop to own the write. The cap counts Firestore calls that
have not returned yet, not asyncio wrappers, so it bounds real work.
"""
if not accounting_enabled():
return False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
logger.warning(
'managed_spend_ledger_dropped caller=%s feature=%s reason=no_event_loop', attempt.caller, attempt.feature
)
return False
# No request context is copied into the write: the attempt already holds
# every field the row needs, and a request's context vars can carry
# validated BYOK credentials that a stalled write must not keep alive.
with _ledger_state_lock:
if _ledger_shutdown_in_progress:
logger.warning(
'managed_spend_ledger_dropped caller=%s feature=%s reason=shutdown', attempt.caller, attempt.feature
)
return False
if len(_pending_writes) >= accounting_max_pending_traces():
logger.warning(
'managed_spend_ledger_dropped caller=%s feature=%s reason=pending_cap', attempt.caller, attempt.feature
)
return False
try:
future = _get_ledger_executor().submit(record_managed_attempt, attempt)
except RuntimeError:
# Interpreter shutdown: the executor no longer accepts work.
return False
_pending_writes.add(future)
future.add_done_callback(_discard_pending_write)
loop.create_task(_observe(attempt, asyncio.wrap_future(future, loop=loop)), name='managed-spend-ledger-persistence')
return True
async def _observe(attempt: ManagedAttempt, future: 'asyncio.Future[bool]') -> None:
"""Log a slow or failed write. The write itself is neither cancelled nor awaited on the request path."""
try:
await asyncio.wait_for(asyncio.shield(future), timeout=accounting_write_timeout_seconds())
except asyncio.TimeoutError:
logger.warning(
'managed_spend_ledger_write_slow caller=%s feature=%s provider=%s',
attempt.caller,
attempt.feature,
attempt.provider,
)
except Exception:
# The attempt already happened and the response is already on its way;
# the only thing left to protect is the process. UIDs stay out of logs.
logger.warning(
'managed_spend_ledger_write_failed caller=%s feature=%s provider=%s',
attempt.caller,
attempt.feature,
attempt.provider,
)
async def drain_pending_writes() -> None:
"""Give scheduled writes one configured timeout during orderly shutdown (and tests)."""
loop = asyncio.get_running_loop()
with _ledger_state_lock:
pending = [asyncio.wrap_future(future, loop=loop) for future in tuple(_pending_writes)]
if pending:
await asyncio.wait(pending, timeout=accounting_write_timeout_seconds())
async def shutdown_managed_spend_ledger() -> None:
"""Drain accepted writes and close the private executor for app shutdown.
The executor is detached before shutdown so a later in-process app/test
lifespan can lazily create a fresh pool. Already-running writes are not
cancelled after the bounded drain; they retain the best-effort ledger
contract while no new work is accepted by the retired pool.
"""
global _ledger_executor, _ledger_shutdown_in_progress
with _ledger_state_lock:
if _ledger_shutdown_in_progress:
return
_ledger_shutdown_in_progress = True
try:
await drain_pending_writes()
finally:
with _ledger_state_lock:
executor = _ledger_executor
_ledger_executor = None
_ledger_shutdown_in_progress = False
if executor is not None:
executor.shutdown(wait=False, cancel_futures=True)
def accounting_enabled() -> bool:
return os.getenv(ACCOUNTING_ENABLED_ENV_VAR, '').strip().lower() in {'1', 'true', 'yes'}
def accounting_write_timeout_seconds() -> float:
raw = os.getenv(ACCOUNTING_WRITE_TIMEOUT_SECONDS_ENV_VAR, '').strip()
try:
value = float(raw) if raw else DEFAULT_ACCOUNTING_WRITE_TIMEOUT_SECONDS
except ValueError:
return DEFAULT_ACCOUNTING_WRITE_TIMEOUT_SECONDS
return value if value > 0 else DEFAULT_ACCOUNTING_WRITE_TIMEOUT_SECONDS
def accounting_max_pending_traces() -> int:
raw = os.getenv(ACCOUNTING_MAX_PENDING_TRACES_ENV_VAR, '').strip()
try:
value = int(raw) if raw else DEFAULT_ACCOUNTING_MAX_PENDING_TRACES
except ValueError:
return DEFAULT_ACCOUNTING_MAX_PENDING_TRACES
return value if value > 0 else DEFAULT_ACCOUNTING_MAX_PENDING_TRACES