il-audit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- il_audit/__init__.py +20 -0
- il_audit/beacon_router.py +60 -0
- il_audit/beacon_schemas.py +22 -0
- il_audit/bq_client.py +119 -0
- il_audit/builders/__init__.py +5 -0
- il_audit/builders/shared.py +45 -0
- il_audit/config.py +117 -0
- il_audit/context.py +17 -0
- il_audit/cost_calculator.py +50 -0
- il_audit/cost_catalog.py +48 -0
- il_audit/cost_rates_loader.py +179 -0
- il_audit/data/audit_defaults.json +9 -0
- il_audit/data/cost_rates_seed.json +66 -0
- il_audit/emit.py +169 -0
- il_audit/envelope.py +51 -0
- il_audit/registry.py +92 -0
- il_audit/schemas/__init__.py +278 -0
- il_audit/static/audit_beacon.js +80 -0
- il_audit/taxonomy.py +54 -0
- il_audit/writer.py +161 -0
- il_audit-0.1.0.dist-info/METADATA +91 -0
- il_audit-0.1.0.dist-info/RECORD +24 -0
- il_audit-0.1.0.dist-info/WHEEL +5 -0
- il_audit-0.1.0.dist-info/top_level.txt +1 -0
il_audit/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Cross-app audit and analytics logging for Locaria Intelligence Layer."""
|
|
2
|
+
|
|
3
|
+
from il_audit.config import AuditSettings, get_audit_settings
|
|
4
|
+
from il_audit.context import AuditContext
|
|
5
|
+
from il_audit.emit import emit, emit_api_usage, emit_batch, emit_resource_usage, schedule_beacon_rows
|
|
6
|
+
from il_audit.registry import register_builder
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AuditContext",
|
|
10
|
+
"AuditSettings",
|
|
11
|
+
"emit",
|
|
12
|
+
"emit_api_usage",
|
|
13
|
+
"emit_batch",
|
|
14
|
+
"emit_resource_usage",
|
|
15
|
+
"get_audit_settings",
|
|
16
|
+
"register_builder",
|
|
17
|
+
"schedule_beacon_rows",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""FastAPI router for client UI beacons."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
10
|
+
|
|
11
|
+
from il_audit.beacon_schemas import BeaconRequest
|
|
12
|
+
from il_audit.context import AuditContext
|
|
13
|
+
from il_audit.emit import schedule_beacon_rows
|
|
14
|
+
|
|
15
|
+
_beacon_last_flush: dict[str, float] = defaultdict(float)
|
|
16
|
+
_BEACON_MIN_INTERVAL_SECONDS = 5.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_beacon_router(
|
|
20
|
+
*,
|
|
21
|
+
app_name: str,
|
|
22
|
+
get_context: Callable[[Request], AuditContext],
|
|
23
|
+
) -> APIRouter:
|
|
24
|
+
router = APIRouter()
|
|
25
|
+
|
|
26
|
+
@router.post("/api/audit/beacon")
|
|
27
|
+
def audit_beacon(request: Request, body: BeaconRequest) -> dict[str, str | int]:
|
|
28
|
+
session_key = request.client.host if request.client else "unknown"
|
|
29
|
+
now = time.monotonic()
|
|
30
|
+
if now - _beacon_last_flush[session_key] < _BEACON_MIN_INTERVAL_SECONDS:
|
|
31
|
+
raise HTTPException(status_code=429, detail="Beacon rate limit exceeded")
|
|
32
|
+
_beacon_last_flush[session_key] = now
|
|
33
|
+
if not body.events:
|
|
34
|
+
return {"status": "ok", "accepted": 0}
|
|
35
|
+
context = get_context(request)
|
|
36
|
+
if context.app != app_name:
|
|
37
|
+
context = AuditContext(
|
|
38
|
+
app=app_name,
|
|
39
|
+
actor_uid=context.actor_uid,
|
|
40
|
+
actor_email=context.actor_email,
|
|
41
|
+
trace_id=context.trace_id,
|
|
42
|
+
request_id=context.request_id,
|
|
43
|
+
source="beacon",
|
|
44
|
+
)
|
|
45
|
+
else:
|
|
46
|
+
context = AuditContext(
|
|
47
|
+
app=context.app,
|
|
48
|
+
actor_uid=context.actor_uid,
|
|
49
|
+
actor_email=context.actor_email,
|
|
50
|
+
trace_id=context.trace_id,
|
|
51
|
+
request_id=context.request_id,
|
|
52
|
+
source="beacon",
|
|
53
|
+
)
|
|
54
|
+
schedule_beacon_rows(
|
|
55
|
+
context=context,
|
|
56
|
+
events=[event.model_dump() for event in body.events],
|
|
57
|
+
)
|
|
58
|
+
return {"status": "ok", "accepted": len(body.events)}
|
|
59
|
+
|
|
60
|
+
return router
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Pydantic models for POST /api/audit/beacon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BeaconEventItem(BaseModel):
|
|
11
|
+
event_type: str
|
|
12
|
+
surface: str | None = None
|
|
13
|
+
element_id: str | None = None
|
|
14
|
+
interaction: str | None = None
|
|
15
|
+
duration_ms: int | None = None
|
|
16
|
+
result_count: int | None = None
|
|
17
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
18
|
+
client_ts: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BeaconRequest(BaseModel):
|
|
22
|
+
events: list[BeaconEventItem] = Field(default_factory=list, max_length=50)
|
il_audit/bq_client.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""BigQuery client helpers for audit log writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from google.api_core.exceptions import NotFound
|
|
11
|
+
|
|
12
|
+
_logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_shared_clients: dict[str, Any] = {}
|
|
15
|
+
_client_lock = threading.Lock()
|
|
16
|
+
|
|
17
|
+
WRITE_APPEND = "WRITE_APPEND"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _import_bigquery() -> Any:
|
|
21
|
+
try:
|
|
22
|
+
from google.cloud import bigquery
|
|
23
|
+
except ImportError as exc:
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
"google-cloud-bigquery is required — pip install google-cloud-bigquery",
|
|
26
|
+
) from exc
|
|
27
|
+
return bigquery
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_bigquery_client(*, project: str) -> Any:
|
|
31
|
+
project_id = project.strip()
|
|
32
|
+
if not project_id:
|
|
33
|
+
raise ValueError("BigQuery project id is required")
|
|
34
|
+
with _client_lock:
|
|
35
|
+
cached = _shared_clients.get(project_id)
|
|
36
|
+
if cached is not None:
|
|
37
|
+
return cached
|
|
38
|
+
bigquery = _import_bigquery()
|
|
39
|
+
client = bigquery.Client(project=project_id)
|
|
40
|
+
_shared_clients[project_id] = client
|
|
41
|
+
return client
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_bigquery_query(*, sql: str, project: str) -> list[dict[str, Any]]:
|
|
45
|
+
bigquery = _import_bigquery()
|
|
46
|
+
client = get_bigquery_client(project=project)
|
|
47
|
+
rows = client.query(sql).result()
|
|
48
|
+
return [dict(row.items()) for row in rows]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_bigquery_rows(
|
|
52
|
+
*,
|
|
53
|
+
project: str,
|
|
54
|
+
dataset: str,
|
|
55
|
+
table: str,
|
|
56
|
+
rows: Sequence[Mapping[str, Any]],
|
|
57
|
+
schema: Sequence[Any] | None = None,
|
|
58
|
+
) -> int:
|
|
59
|
+
if not rows:
|
|
60
|
+
return 0
|
|
61
|
+
bigquery = _import_bigquery()
|
|
62
|
+
client = get_bigquery_client(project=project)
|
|
63
|
+
table_ref = f"{project.strip()}.{dataset.strip()}.{table.strip()}"
|
|
64
|
+
payload = [dict(row) for row in rows]
|
|
65
|
+
job_config = bigquery.LoadJobConfig(write_disposition=WRITE_APPEND)
|
|
66
|
+
if schema:
|
|
67
|
+
job_config.schema = list(schema)
|
|
68
|
+
job = client.load_table_from_json(payload, table_ref, job_config=job_config)
|
|
69
|
+
job.result()
|
|
70
|
+
return len(payload)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class BigQueryTableManager:
|
|
74
|
+
def __init__(self, *, project_id: str) -> None:
|
|
75
|
+
project = project_id.strip()
|
|
76
|
+
if not project:
|
|
77
|
+
raise ValueError("BigQuery project id is required")
|
|
78
|
+
self.project_id = project
|
|
79
|
+
self._client: Any | None = None
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def client(self) -> Any:
|
|
83
|
+
if self._client is None:
|
|
84
|
+
self._client = get_bigquery_client(project=self.project_id)
|
|
85
|
+
return self._client
|
|
86
|
+
|
|
87
|
+
def ensure_dataset_exists(self, dataset_id: str) -> None:
|
|
88
|
+
bigquery = _import_bigquery()
|
|
89
|
+
dataset_ref = f"{self.project_id}.{dataset_id.strip()}"
|
|
90
|
+
try:
|
|
91
|
+
self.client.get_dataset(dataset_ref)
|
|
92
|
+
except NotFound:
|
|
93
|
+
_logger.info("Creating BigQuery dataset %s", dataset_ref)
|
|
94
|
+
self.client.create_dataset(bigquery.Dataset(dataset_ref))
|
|
95
|
+
|
|
96
|
+
def ensure_table_exists(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
dataset_id: str,
|
|
100
|
+
table_id: str,
|
|
101
|
+
schema: Sequence[Any],
|
|
102
|
+
) -> bool:
|
|
103
|
+
bigquery = _import_bigquery()
|
|
104
|
+
table_ref = f"{self.project_id}.{dataset_id.strip()}.{table_id.strip()}"
|
|
105
|
+
schema_list = list(schema)
|
|
106
|
+
try:
|
|
107
|
+
table = self.client.get_table(table_ref)
|
|
108
|
+
existing_names = {field.name for field in table.schema}
|
|
109
|
+
missing = [field for field in schema_list if field.name not in existing_names]
|
|
110
|
+
if missing:
|
|
111
|
+
table.schema = list(table.schema) + missing
|
|
112
|
+
self.client.update_table(table, ["schema"])
|
|
113
|
+
return False
|
|
114
|
+
except NotFound:
|
|
115
|
+
self.ensure_dataset_exists(dataset_id)
|
|
116
|
+
_logger.info("Creating BigQuery table %s", table_ref)
|
|
117
|
+
table = bigquery.Table(table_ref, schema=schema_list)
|
|
118
|
+
self.client.create_table(table)
|
|
119
|
+
return True
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Shared envelope row builder for domain audit tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from il_audit.context import AuditContext
|
|
8
|
+
from il_audit.envelope import build_envelope
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _module_for_event(event_type: str) -> str:
|
|
12
|
+
prefix = event_type.split(".", 1)[0]
|
|
13
|
+
return prefix
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_envelope_row(
|
|
17
|
+
*,
|
|
18
|
+
context: AuditContext,
|
|
19
|
+
event_type: str,
|
|
20
|
+
metadata: dict[str, Any] | None = None,
|
|
21
|
+
module: str | None = None,
|
|
22
|
+
action: str | None = None,
|
|
23
|
+
status: str | None = None,
|
|
24
|
+
resource_type: str | None = None,
|
|
25
|
+
resource_id: str | None = None,
|
|
26
|
+
batch_id: str | None = None,
|
|
27
|
+
before: dict[str, Any] | None = None,
|
|
28
|
+
after: dict[str, Any] | None = None,
|
|
29
|
+
**extra: Any,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
row = build_envelope(
|
|
32
|
+
context=context,
|
|
33
|
+
module=module or _module_for_event(event_type),
|
|
34
|
+
event_type=event_type,
|
|
35
|
+
action=action,
|
|
36
|
+
status=status,
|
|
37
|
+
resource_type=resource_type,
|
|
38
|
+
resource_id=resource_id,
|
|
39
|
+
batch_id=batch_id,
|
|
40
|
+
before=before,
|
|
41
|
+
after=after,
|
|
42
|
+
metadata=metadata,
|
|
43
|
+
)
|
|
44
|
+
row.update({key: value for key, value in extra.items() if value is not None})
|
|
45
|
+
return row
|
il_audit/config.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Runtime configuration for il_audit — defaults from bundled JSON, overrides via env."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from il_audit.cost_catalog import COST_RATES_SOURCE_SEED
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
14
|
+
raw = os.getenv(name, "").strip().lower()
|
|
15
|
+
if not raw:
|
|
16
|
+
return default
|
|
17
|
+
return raw in {"1", "true", "yes", "on"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _env_int(name: str, default: int) -> int:
|
|
21
|
+
raw = os.getenv(name, "").strip()
|
|
22
|
+
if not raw:
|
|
23
|
+
return default
|
|
24
|
+
try:
|
|
25
|
+
return int(raw)
|
|
26
|
+
except ValueError:
|
|
27
|
+
return default
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _env_float(name: str, default: float) -> float:
|
|
31
|
+
raw = os.getenv(name, "").strip()
|
|
32
|
+
if not raw:
|
|
33
|
+
return default
|
|
34
|
+
try:
|
|
35
|
+
return float(raw)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return default
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_bq_project() -> str:
|
|
41
|
+
for key in ("IL_BQ_LOGS_PROJECT", "GOOGLE_CLOUD_PROJECT", "GCP_PROJECT"):
|
|
42
|
+
value = os.getenv(key, "").strip()
|
|
43
|
+
if value:
|
|
44
|
+
return value
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@lru_cache(maxsize=1)
|
|
49
|
+
def _bundled_defaults() -> dict[str, Any]:
|
|
50
|
+
from il_audit.cost_rates_loader import load_audit_defaults
|
|
51
|
+
|
|
52
|
+
return load_audit_defaults()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class AuditSettings:
|
|
57
|
+
"""BigQuery audit logging settings."""
|
|
58
|
+
|
|
59
|
+
bq_project: str
|
|
60
|
+
bq_dataset: str
|
|
61
|
+
enable_audit_logs: bool
|
|
62
|
+
store_query_text: bool
|
|
63
|
+
flush_batch_size: int
|
|
64
|
+
flush_interval_seconds: float
|
|
65
|
+
cost_rates_source: str
|
|
66
|
+
cost_rates_cache_seconds: int
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_env(cls) -> AuditSettings:
|
|
70
|
+
defaults = _bundled_defaults()
|
|
71
|
+
bq_project = _resolve_bq_project()
|
|
72
|
+
bq_dataset = (
|
|
73
|
+
os.getenv("IL_BQ_AUDIT_DATASET", "").strip()
|
|
74
|
+
or str(defaults.get("bq_audit_dataset") or "")
|
|
75
|
+
)
|
|
76
|
+
enable = _env_bool("IL_ENABLE_AUDIT_LOGS", bool(defaults.get("enable_audit_logs", True)))
|
|
77
|
+
if not _env_bool("IL_ENABLE_BQ_LOGS", True):
|
|
78
|
+
enable = False
|
|
79
|
+
cost_source = (
|
|
80
|
+
os.getenv("IL_COST_RATES_SOURCE", "").strip().lower()
|
|
81
|
+
or str(defaults.get("cost_rates_source") or COST_RATES_SOURCE_SEED)
|
|
82
|
+
)
|
|
83
|
+
return cls(
|
|
84
|
+
bq_project=bq_project,
|
|
85
|
+
bq_dataset=bq_dataset,
|
|
86
|
+
enable_audit_logs=enable,
|
|
87
|
+
store_query_text=_env_bool(
|
|
88
|
+
"IL_AUDIT_STORE_QUERY_TEXT",
|
|
89
|
+
bool(defaults.get("store_query_text", True)),
|
|
90
|
+
),
|
|
91
|
+
flush_batch_size=_env_int(
|
|
92
|
+
"IL_AUDIT_FLUSH_BATCH_SIZE",
|
|
93
|
+
int(defaults.get("flush_batch_size") or 50),
|
|
94
|
+
),
|
|
95
|
+
flush_interval_seconds=_env_float(
|
|
96
|
+
"IL_AUDIT_FLUSH_INTERVAL_SECONDS",
|
|
97
|
+
float(defaults.get("flush_interval_seconds") or 1.0),
|
|
98
|
+
),
|
|
99
|
+
cost_rates_source=cost_source,
|
|
100
|
+
cost_rates_cache_seconds=_env_int(
|
|
101
|
+
"IL_COST_RATES_CACHE_SECONDS",
|
|
102
|
+
int(defaults.get("cost_rates_cache_seconds") or 3600),
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@lru_cache(maxsize=1)
|
|
108
|
+
def get_audit_settings() -> AuditSettings:
|
|
109
|
+
return AuditSettings.from_env()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def reset_audit_settings_cache() -> None:
|
|
113
|
+
get_audit_settings.cache_clear()
|
|
114
|
+
_bundled_defaults.cache_clear()
|
|
115
|
+
from il_audit.cost_rates_loader import reset_cost_rates_cache
|
|
116
|
+
|
|
117
|
+
reset_cost_rates_cache()
|
il_audit/context.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Audit context passed to every emit call."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class AuditContext:
|
|
10
|
+
"""Who performed an action and how to correlate it."""
|
|
11
|
+
|
|
12
|
+
app: str
|
|
13
|
+
actor_uid: str | None = None
|
|
14
|
+
actor_email: str | None = None
|
|
15
|
+
trace_id: str | None = None
|
|
16
|
+
request_id: str | None = None
|
|
17
|
+
source: str = "api"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""USD estimation from the active cost_rates catalog (BQ or seed JSON)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from il_audit.cost_rates_loader import RateEntry, get_active_cost_rates
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _lookup_entry(*, service: str, model_id: str) -> RateEntry | None:
|
|
9
|
+
catalog = get_active_cost_rates()
|
|
10
|
+
return catalog.index().get((service, model_id))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def estimate_api_cost_usd(
|
|
14
|
+
*,
|
|
15
|
+
service: str,
|
|
16
|
+
model_id: str,
|
|
17
|
+
input_tokens: int = 0,
|
|
18
|
+
output_tokens: int = 0,
|
|
19
|
+
) -> tuple[float, str, str]:
|
|
20
|
+
"""
|
|
21
|
+
Estimate API cost from the active catalog.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
(cost_usd, pricing_version, pricing_source)
|
|
25
|
+
"""
|
|
26
|
+
catalog = get_active_cost_rates()
|
|
27
|
+
entry = catalog.index().get((service, model_id))
|
|
28
|
+
if entry is None:
|
|
29
|
+
return 0.0, catalog.rate_version, catalog.pricing_source
|
|
30
|
+
cost = (
|
|
31
|
+
(max(0, input_tokens) * entry.input_price_per_1m_tokens)
|
|
32
|
+
+ (max(0, output_tokens) * entry.output_price_per_1m_tokens)
|
|
33
|
+
) / 1_000_000
|
|
34
|
+
version = entry.rate_version or catalog.rate_version
|
|
35
|
+
return round(cost, 8), version, catalog.pricing_source
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def estimate_unit_cost_usd(
|
|
39
|
+
*,
|
|
40
|
+
service: str,
|
|
41
|
+
model_id: str,
|
|
42
|
+
quantity: float,
|
|
43
|
+
) -> tuple[float, str, str]:
|
|
44
|
+
"""Estimate resource cost from unit-priced catalog entry."""
|
|
45
|
+
catalog = get_active_cost_rates()
|
|
46
|
+
entry = catalog.index().get((service, model_id))
|
|
47
|
+
if entry is None or entry.unit_price is None:
|
|
48
|
+
return 0.0, catalog.rate_version, catalog.pricing_source
|
|
49
|
+
version = entry.rate_version or catalog.rate_version
|
|
50
|
+
return round(max(0.0, quantity) * entry.unit_price, 8), version, catalog.pricing_source
|
il_audit/cost_catalog.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Stable identifiers for cost attribution — single source, no magic strings elsewhere."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Billable API services (maps to cost_rates.service)
|
|
6
|
+
SERVICE_VERTEX_GEMINI = "vertex_gemini"
|
|
7
|
+
SERVICE_VERTEX_EMBEDDING = "vertex_embedding"
|
|
8
|
+
SERVICE_VERTEX_RERANK = "vertex_rerank"
|
|
9
|
+
SERVICE_DRIVE_API = "drive_api"
|
|
10
|
+
SERVICE_CLOUD_RUN = "cloud_run"
|
|
11
|
+
SERVICE_GCS = "gcs"
|
|
12
|
+
|
|
13
|
+
# API operations (maps to api_usage_events.operation)
|
|
14
|
+
OPERATION_GENERATE_CONTENT = "generate_content"
|
|
15
|
+
OPERATION_EMBED_CONTENT = "embed_content"
|
|
16
|
+
OPERATION_SEMANTIC_RANKER = "semantic_ranker"
|
|
17
|
+
OPERATION_FILES_GET = "files.get"
|
|
18
|
+
|
|
19
|
+
# Ingest token categories (maps to api_usage_events.category)
|
|
20
|
+
API_CATEGORY_VISION = "vision"
|
|
21
|
+
API_CATEGORY_TAGGING = "tagging"
|
|
22
|
+
API_CATEGORY_SUMMARY = "summary"
|
|
23
|
+
API_CATEGORY_EMBEDDING = "embedding"
|
|
24
|
+
|
|
25
|
+
# Correlation types (maps to api_usage_events.correlation_type)
|
|
26
|
+
CORRELATION_INGEST_JOB = "ingest_job"
|
|
27
|
+
CORRELATION_INGEST_FILE = "ingest_file"
|
|
28
|
+
CORRELATION_CHAT_TURN = "chat_turn"
|
|
29
|
+
CORRELATION_DRIVE_PIPELINE = "drive_pipeline"
|
|
30
|
+
CORRELATION_CLIENT_RECONCILE = "client_reconcile"
|
|
31
|
+
|
|
32
|
+
# Resource usage types (maps to resource_usage_events.resource_type)
|
|
33
|
+
RESOURCE_CLOUD_RUN_CPU_SECONDS = "cloud_run_cpu_seconds"
|
|
34
|
+
RESOURCE_GCS_STAGING_BYTES = "gcs_staging_bytes"
|
|
35
|
+
RESOURCE_POSTGRES_CHUNK_WRITES = "postgres_chunk_writes"
|
|
36
|
+
RESOURCE_DRIVE_API_CALLS = "drive_api_calls"
|
|
37
|
+
|
|
38
|
+
# Resource rate keys (maps to cost_rates model_id for unit-priced resources)
|
|
39
|
+
RESOURCE_RATE_CPU_SECOND = "cpu_second"
|
|
40
|
+
RESOURCE_RATE_STORAGE_BYTE = "storage_byte"
|
|
41
|
+
|
|
42
|
+
# Pricing source values stored on usage rows until BQ catalog is authoritative
|
|
43
|
+
PRICING_SOURCE_SEED_CATALOG = "seed_catalog"
|
|
44
|
+
PRICING_SOURCE_BIGQUERY_CATALOG = "bigquery_catalog"
|
|
45
|
+
|
|
46
|
+
# cost_rates loader source (AuditSettings.cost_rates_source)
|
|
47
|
+
COST_RATES_SOURCE_SEED = "seed"
|
|
48
|
+
COST_RATES_SOURCE_BIGQUERY = "bigquery"
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Load versioned cost rates from BigQuery catalog or bundled seed JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from il_audit.cost_catalog import (
|
|
15
|
+
COST_RATES_SOURCE_BIGQUERY,
|
|
16
|
+
COST_RATES_SOURCE_SEED,
|
|
17
|
+
PRICING_SOURCE_BIGQUERY_CATALOG,
|
|
18
|
+
PRICING_SOURCE_SEED_CATALOG,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_CACHE_LOCK = threading.Lock()
|
|
24
|
+
_CACHE: "_RatesCache | None" = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class RateEntry:
|
|
29
|
+
service: str
|
|
30
|
+
model_id: str
|
|
31
|
+
input_price_per_1m_tokens: float
|
|
32
|
+
output_price_per_1m_tokens: float
|
|
33
|
+
unit_price: float | None = None
|
|
34
|
+
unit: str = "tokens"
|
|
35
|
+
rate_version: str = ""
|
|
36
|
+
source_url: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class LoadedCostRates:
|
|
41
|
+
rate_version: str
|
|
42
|
+
pricing_source: str
|
|
43
|
+
entries: tuple[RateEntry, ...]
|
|
44
|
+
|
|
45
|
+
def index(self) -> dict[tuple[str, str], RateEntry]:
|
|
46
|
+
return {(entry.service, entry.model_id): entry for entry in self.entries}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class _RatesCache:
|
|
51
|
+
loaded_at: float
|
|
52
|
+
catalog: LoadedCostRates
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_json(name: str) -> dict[str, Any]:
|
|
56
|
+
path = Path(__file__).resolve().parent / "data" / name
|
|
57
|
+
with path.open(encoding="utf-8") as handle:
|
|
58
|
+
return json.load(handle)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_audit_defaults() -> dict[str, Any]:
|
|
62
|
+
"""Bundled defaults — overridden by environment in config.AuditSettings."""
|
|
63
|
+
return _load_json("audit_defaults.json")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_rate_entry(raw: dict[str, Any], *, default_version: str) -> RateEntry:
|
|
67
|
+
return RateEntry(
|
|
68
|
+
service=str(raw["service"]),
|
|
69
|
+
model_id=str(raw["model_id"]),
|
|
70
|
+
input_price_per_1m_tokens=float(raw.get("input_price_per_1m_tokens") or 0.0),
|
|
71
|
+
output_price_per_1m_tokens=float(raw.get("output_price_per_1m_tokens") or 0.0),
|
|
72
|
+
unit_price=(float(raw["unit_price"]) if raw.get("unit_price") is not None else None),
|
|
73
|
+
unit=str(raw.get("unit") or "tokens"),
|
|
74
|
+
rate_version=str(raw.get("rate_version") or default_version),
|
|
75
|
+
source_url=(str(raw["source_url"]) if raw.get("source_url") else None),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_seed_cost_rates() -> LoadedCostRates:
|
|
80
|
+
payload = _load_json("cost_rates_seed.json")
|
|
81
|
+
version = str(payload.get("rate_version") or "")
|
|
82
|
+
entries = tuple(
|
|
83
|
+
_parse_rate_entry(item, default_version=version) for item in payload.get("rates") or []
|
|
84
|
+
)
|
|
85
|
+
return LoadedCostRates(
|
|
86
|
+
rate_version=version,
|
|
87
|
+
pricing_source=PRICING_SOURCE_SEED_CATALOG,
|
|
88
|
+
entries=entries,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_bigquery_cost_rates(*, project: str, dataset: str) -> LoadedCostRates | None:
|
|
93
|
+
if not project.strip() or not dataset.strip():
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
from il_audit.bq_client import run_bigquery_query
|
|
97
|
+
except ImportError:
|
|
98
|
+
return None
|
|
99
|
+
sql = f"""
|
|
100
|
+
SELECT
|
|
101
|
+
rate_version,
|
|
102
|
+
service,
|
|
103
|
+
model_id,
|
|
104
|
+
input_price_per_1m_tokens,
|
|
105
|
+
output_price_per_1m_tokens,
|
|
106
|
+
unit_price,
|
|
107
|
+
unit,
|
|
108
|
+
source_url
|
|
109
|
+
FROM `{project.strip()}.{dataset.strip()}.cost_rates`
|
|
110
|
+
WHERE effective_from <= CURRENT_DATE()
|
|
111
|
+
QUALIFY ROW_NUMBER() OVER (
|
|
112
|
+
PARTITION BY service, model_id
|
|
113
|
+
ORDER BY effective_from DESC, rate_version DESC
|
|
114
|
+
) = 1
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
rows = run_bigquery_query(project=project, sql=sql)
|
|
118
|
+
except Exception:
|
|
119
|
+
_logger.exception("Failed to load cost_rates from BigQuery")
|
|
120
|
+
return None
|
|
121
|
+
if not rows:
|
|
122
|
+
return None
|
|
123
|
+
version = str(rows[0].get("rate_version") or "")
|
|
124
|
+
entries = tuple(
|
|
125
|
+
RateEntry(
|
|
126
|
+
service=str(row["service"]),
|
|
127
|
+
model_id=str(row["model_id"]),
|
|
128
|
+
input_price_per_1m_tokens=float(row.get("input_price_per_1m_tokens") or 0.0),
|
|
129
|
+
output_price_per_1m_tokens=float(row.get("output_price_per_1m_tokens") or 0.0),
|
|
130
|
+
unit_price=(float(row["unit_price"]) if row.get("unit_price") is not None else None),
|
|
131
|
+
unit=str(row.get("unit") or "tokens"),
|
|
132
|
+
rate_version=str(row.get("rate_version") or version),
|
|
133
|
+
source_url=(str(row["source_url"]) if row.get("source_url") else None),
|
|
134
|
+
)
|
|
135
|
+
for row in rows
|
|
136
|
+
)
|
|
137
|
+
return LoadedCostRates(
|
|
138
|
+
rate_version=version,
|
|
139
|
+
pricing_source=PRICING_SOURCE_BIGQUERY_CATALOG,
|
|
140
|
+
entries=entries,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_active_cost_rates(*, force_refresh: bool = False) -> LoadedCostRates:
|
|
145
|
+
"""Return cached cost rates from configured source (BQ when available, else seed)."""
|
|
146
|
+
from il_audit.config import get_audit_settings
|
|
147
|
+
|
|
148
|
+
settings = get_audit_settings()
|
|
149
|
+
now = time.monotonic()
|
|
150
|
+
global _CACHE
|
|
151
|
+
with _CACHE_LOCK:
|
|
152
|
+
if (
|
|
153
|
+
not force_refresh
|
|
154
|
+
and _CACHE is not None
|
|
155
|
+
and (now - _CACHE.loaded_at) < settings.cost_rates_cache_seconds
|
|
156
|
+
):
|
|
157
|
+
return _CACHE.catalog
|
|
158
|
+
|
|
159
|
+
catalog: LoadedCostRates | None = None
|
|
160
|
+
if settings.cost_rates_source == COST_RATES_SOURCE_BIGQUERY:
|
|
161
|
+
catalog = load_bigquery_cost_rates(
|
|
162
|
+
project=settings.bq_project,
|
|
163
|
+
dataset=settings.bq_dataset,
|
|
164
|
+
)
|
|
165
|
+
if catalog is None:
|
|
166
|
+
catalog = load_seed_cost_rates()
|
|
167
|
+
_CACHE = _RatesCache(loaded_at=now, catalog=catalog)
|
|
168
|
+
return catalog
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def reset_cost_rates_cache() -> None:
|
|
172
|
+
global _CACHE
|
|
173
|
+
with _CACHE_LOCK:
|
|
174
|
+
_CACHE = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def package_data_path(relative: str) -> Path:
|
|
178
|
+
"""Return path to bundled data file (for tests/docs)."""
|
|
179
|
+
return Path(__file__).resolve().parent / "data" / relative
|