il-audit 0.1.0__tar.gz

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.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: il-audit
3
+ Version: 0.1.0
4
+ Summary: Cross-app audit and analytics logging for Locaria Intelligence Layer
5
+ Author: Locaria Intelligence Layer Team
6
+ License: Proprietary
7
+ Keywords: audit,logging,bigquery,analytics,locaria
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: google-cloud-bigquery>=3.27.0
16
+ Requires-Dist: pydantic>=2.0
17
+ Requires-Dist: fastapi>=0.115.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8.0; extra == "dev"
20
+
21
+ # il-audit — Intelligence Layer audit & analytics
22
+
23
+ Cross-app BigQuery audit logging for **intelligence-layer-ingest** and **intelligence-layer-chat**.
24
+
25
+ ## Install
26
+
27
+ Local dev (sibling repo):
28
+
29
+ ```bash
30
+ pip install -e ../intelligence-layer-audit
31
+ ```
32
+
33
+ Production pin (PyPI, after release):
34
+
35
+ ```toml
36
+ "il-audit>=0.1.0",
37
+ ```
38
+
39
+ Or from Git:
40
+
41
+ ```toml
42
+ "il-audit @ git+https://github.com/Locaria/intelligence-layer-audit@v0.1.0",
43
+ ```
44
+
45
+ See [PUBLISHING.md](PUBLISHING.md) for release → PyPI workflow (same as `locaria-integrated-testing`).
46
+
47
+ ## Configuration
48
+
49
+ All defaults live in bundled JSON — **never hardcode prices or dataset names in application code**.
50
+
51
+ | File | Purpose |
52
+ |------|---------|
53
+ | `il_audit/data/audit_defaults.json` | Dataset, flush tuning, default `cost_rates_source` |
54
+ | `il_audit/data/cost_rates_seed.json` | Interim pricing catalog (until BQ `cost_rates` is scraped) |
55
+ | `il_audit/cost_catalog.py` | Stable service/operation/resource identifier constants |
56
+
57
+ | Variable | Default (from JSON) | Purpose |
58
+ |----------|---------------------|---------|
59
+ | `IL_ENABLE_AUDIT_LOGS` | `true` | Master switch (also respects `IL_ENABLE_BQ_LOGS`) |
60
+ | `IL_BQ_AUDIT_DATASET` | `intelligence_layer_logs` | BQ dataset |
61
+ | `IL_BQ_LOGS_PROJECT` | — | GCP project (`GOOGLE_CLOUD_PROJECT` / `GCP_PROJECT` fallback) |
62
+ | `IL_COST_RATES_SOURCE` | `seed` | `seed` or `bigquery` (authoritative catalog from `cost_rates` table) |
63
+ | `IL_COST_RATES_CACHE_SECONDS` | `3600` | In-memory rate cache TTL |
64
+ | `IL_AUDIT_STORE_QUERY_TEXT` | `true` | Store full chat questions in BQ |
65
+
66
+ When `IL_COST_RATES_SOURCE=bigquery`, rates load from `{project}.{dataset}.cost_rates`. A scheduled-tasks job will scrape GCP pricing docs and maintain that table. Until then, seed JSON is used and rows are tagged `pricing_source=seed_catalog`.
67
+
68
+ ## Usage
69
+
70
+ ```python
71
+ from il_audit import AuditContext, emit
72
+ from il_audit import taxonomy as T
73
+
74
+ emit(
75
+ T.AUTH_LOGIN_SUCCEEDED,
76
+ context=AuditContext(app="ingest", actor_uid=uid, actor_email=email),
77
+ status="succeeded",
78
+ action="login",
79
+ )
80
+ ```
81
+
82
+ ## Chat adoption (phase 2)
83
+
84
+ 1. Add same `il-audit` dep to chat
85
+ 2. Mount `create_beacon_router(app_name="chat", get_context=...)`
86
+ 3. Emit `chat_turns` on `POST /query` with client `conversation_session_id`
87
+ 4. See `documentation/chat-phase2.md`
88
+
89
+ ## SQL
90
+
91
+ Operator-run views: `sql/analytics_views.sql`
@@ -0,0 +1,71 @@
1
+ # il-audit — Intelligence Layer audit & analytics
2
+
3
+ Cross-app BigQuery audit logging for **intelligence-layer-ingest** and **intelligence-layer-chat**.
4
+
5
+ ## Install
6
+
7
+ Local dev (sibling repo):
8
+
9
+ ```bash
10
+ pip install -e ../intelligence-layer-audit
11
+ ```
12
+
13
+ Production pin (PyPI, after release):
14
+
15
+ ```toml
16
+ "il-audit>=0.1.0",
17
+ ```
18
+
19
+ Or from Git:
20
+
21
+ ```toml
22
+ "il-audit @ git+https://github.com/Locaria/intelligence-layer-audit@v0.1.0",
23
+ ```
24
+
25
+ See [PUBLISHING.md](PUBLISHING.md) for release → PyPI workflow (same as `locaria-integrated-testing`).
26
+
27
+ ## Configuration
28
+
29
+ All defaults live in bundled JSON — **never hardcode prices or dataset names in application code**.
30
+
31
+ | File | Purpose |
32
+ |------|---------|
33
+ | `il_audit/data/audit_defaults.json` | Dataset, flush tuning, default `cost_rates_source` |
34
+ | `il_audit/data/cost_rates_seed.json` | Interim pricing catalog (until BQ `cost_rates` is scraped) |
35
+ | `il_audit/cost_catalog.py` | Stable service/operation/resource identifier constants |
36
+
37
+ | Variable | Default (from JSON) | Purpose |
38
+ |----------|---------------------|---------|
39
+ | `IL_ENABLE_AUDIT_LOGS` | `true` | Master switch (also respects `IL_ENABLE_BQ_LOGS`) |
40
+ | `IL_BQ_AUDIT_DATASET` | `intelligence_layer_logs` | BQ dataset |
41
+ | `IL_BQ_LOGS_PROJECT` | — | GCP project (`GOOGLE_CLOUD_PROJECT` / `GCP_PROJECT` fallback) |
42
+ | `IL_COST_RATES_SOURCE` | `seed` | `seed` or `bigquery` (authoritative catalog from `cost_rates` table) |
43
+ | `IL_COST_RATES_CACHE_SECONDS` | `3600` | In-memory rate cache TTL |
44
+ | `IL_AUDIT_STORE_QUERY_TEXT` | `true` | Store full chat questions in BQ |
45
+
46
+ When `IL_COST_RATES_SOURCE=bigquery`, rates load from `{project}.{dataset}.cost_rates`. A scheduled-tasks job will scrape GCP pricing docs and maintain that table. Until then, seed JSON is used and rows are tagged `pricing_source=seed_catalog`.
47
+
48
+ ## Usage
49
+
50
+ ```python
51
+ from il_audit import AuditContext, emit
52
+ from il_audit import taxonomy as T
53
+
54
+ emit(
55
+ T.AUTH_LOGIN_SUCCEEDED,
56
+ context=AuditContext(app="ingest", actor_uid=uid, actor_email=email),
57
+ status="succeeded",
58
+ action="login",
59
+ )
60
+ ```
61
+
62
+ ## Chat adoption (phase 2)
63
+
64
+ 1. Add same `il-audit` dep to chat
65
+ 2. Mount `create_beacon_router(app_name="chat", get_context=...)`
66
+ 3. Emit `chat_turns` on `POST /query` with client `conversation_session_id`
67
+ 4. See `documentation/chat-phase2.md`
68
+
69
+ ## SQL
70
+
71
+ Operator-run views: `sql/analytics_views.sql`
@@ -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)
@@ -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,5 @@
1
+ """Shared row builders for envelope-style tables."""
2
+
3
+ from il_audit.builders.shared import build_envelope_row
4
+
5
+ __all__ = ["build_envelope_row"]
@@ -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
@@ -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()
@@ -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
@@ -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"