elltri 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.
- elltri-0.1.0/PKG-INFO +42 -0
- elltri-0.1.0/cost_platform/__init__.py +3 -0
- elltri-0.1.0/cost_platform/db/__init__.py +0 -0
- elltri-0.1.0/cost_platform/db/engine.py +40 -0
- elltri-0.1.0/cost_platform/db/models.py +186 -0
- elltri-0.1.0/cost_platform/emitter.py +206 -0
- elltri-0.1.0/cost_platform/event_assembler.py +68 -0
- elltri-0.1.0/cost_platform/extractor.py +148 -0
- elltri-0.1.0/cost_platform/models.py +90 -0
- elltri-0.1.0/cost_platform/rate_card.py +127 -0
- elltri-0.1.0/cost_platform/wrapper.py +238 -0
- elltri-0.1.0/elltri.egg-info/PKG-INFO +42 -0
- elltri-0.1.0/elltri.egg-info/SOURCES.txt +32 -0
- elltri-0.1.0/elltri.egg-info/dependency_links.txt +1 -0
- elltri-0.1.0/elltri.egg-info/requires.txt +26 -0
- elltri-0.1.0/elltri.egg-info/top_level.txt +1 -0
- elltri-0.1.0/pyproject.toml +86 -0
- elltri-0.1.0/setup.cfg +4 -0
- elltri-0.1.0/tests/test_db_models.py +192 -0
- elltri-0.1.0/tests/test_e2e_collection.py +170 -0
- elltri-0.1.0/tests/test_e2e_staging.py +141 -0
- elltri-0.1.0/tests/test_event_assembler.py +146 -0
- elltri-0.1.0/tests/test_extractor.py +160 -0
- elltri-0.1.0/tests/test_iceberg_schema.py +154 -0
- elltri-0.1.0/tests/test_key_validation.py +126 -0
- elltri-0.1.0/tests/test_openai_wrapper.py +213 -0
- elltri-0.1.0/tests/test_placeholder.py +2 -0
- elltri-0.1.0/tests/test_rate_card.py +182 -0
- elltri-0.1.0/tests/test_reconciliation.py +579 -0
- elltri-0.1.0/tests/test_rest_api.py +449 -0
- elltri-0.1.0/tests/test_sql_views.py +265 -0
- elltri-0.1.0/tests/test_transformation_7a.py +612 -0
- elltri-0.1.0/tests/test_transformation_7b.py +499 -0
- elltri-0.1.0/tests/test_wrapper.py +236 -0
elltri-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: elltri
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI Total Cost of Ownership — instrumented agent metering and first-party Claude usage
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Elltri/Cost-Explorer
|
|
7
|
+
Project-URL: Repository, https://github.com/Elltri/Cost-Explorer
|
|
8
|
+
Keywords: llm,cost,observability,opentelemetry,anthropic,openai
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: pyyaml>=6.0
|
|
19
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
|
20
|
+
Requires-Dist: alembic>=1.13
|
|
21
|
+
Requires-Dist: asyncpg>=0.29
|
|
22
|
+
Requires-Dist: fastapi>=0.111
|
|
23
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
24
|
+
Requires-Dist: cachetools>=5.3
|
|
25
|
+
Requires-Dist: opentelemetry-sdk>=1.24
|
|
26
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.24
|
|
27
|
+
Requires-Dist: opentelemetry-proto>=1.24
|
|
28
|
+
Requires-Dist: httpx>=0.27
|
|
29
|
+
Requires-Dist: anthropic>=0.26
|
|
30
|
+
Requires-Dist: pyiceberg[glue,pyiceberg-core,s3fs]>=0.7
|
|
31
|
+
Requires-Dist: pyarrow>=15.0
|
|
32
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
33
|
+
Requires-Dist: psycopg2-binary>=2.9
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
36
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
37
|
+
Requires-Dist: black>=24.0; extra == "dev"
|
|
38
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
39
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
40
|
+
Requires-Dist: httpx2>=2.0; extra == "dev"
|
|
41
|
+
Requires-Dist: boto3>=1.34; extra == "dev"
|
|
42
|
+
Requires-Dist: botocore>=1.34; extra == "dev"
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import Engine, create_engine
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
6
|
+
|
|
7
|
+
_DEFAULT_URL = "postgresql://cost_platform:changeme@localhost:5433/cost_platform"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _database_url() -> str:
|
|
11
|
+
if url := os.environ.get("DATABASE_URL"):
|
|
12
|
+
return url
|
|
13
|
+
# ECS injects the Secrets Manager value as DB_SECRET_JSON containing
|
|
14
|
+
# {username, password, host, port, dbname}.
|
|
15
|
+
if secret := os.environ.get("DB_SECRET_JSON"):
|
|
16
|
+
s = json.loads(secret)
|
|
17
|
+
return (
|
|
18
|
+
f"postgresql://{s['username']}:{s['password']}"
|
|
19
|
+
f"@{s['host']}:{s.get('port', 5432)}/{s['dbname']}"
|
|
20
|
+
)
|
|
21
|
+
return _DEFAULT_URL
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _async_url(url: str) -> str:
|
|
25
|
+
# asyncpg requires the postgresql+asyncpg:// scheme
|
|
26
|
+
if url.startswith("postgresql://"):
|
|
27
|
+
return url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
28
|
+
if url.startswith("postgres://"):
|
|
29
|
+
return url.replace("postgres://", "postgresql+asyncpg://", 1)
|
|
30
|
+
return url
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_sync_engine() -> Engine:
|
|
34
|
+
"""Return a synchronous engine (psycopg2). Used by Alembic and batch jobs."""
|
|
35
|
+
return create_engine(_database_url())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_async_engine() -> AsyncEngine:
|
|
39
|
+
"""Return an async engine (asyncpg). Used by FastAPI services."""
|
|
40
|
+
return create_async_engine(_async_url(_database_url()))
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import uuid
|
|
3
|
+
from datetime import UTC, date, datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import (
|
|
7
|
+
Date,
|
|
8
|
+
DateTime,
|
|
9
|
+
ForeignKey,
|
|
10
|
+
Index,
|
|
11
|
+
Integer,
|
|
12
|
+
Numeric,
|
|
13
|
+
String,
|
|
14
|
+
UniqueConstraint,
|
|
15
|
+
func,
|
|
16
|
+
)
|
|
17
|
+
from sqlalchemy import (
|
|
18
|
+
Enum as SAEnum,
|
|
19
|
+
)
|
|
20
|
+
from sqlalchemy.dialects.postgresql import UUID
|
|
21
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OrgStatus(enum.StrEnum):
|
|
25
|
+
active = "active"
|
|
26
|
+
suspended = "suspended"
|
|
27
|
+
churned = "churned"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ApiKeyRole(enum.StrEnum):
|
|
31
|
+
admin = "admin"
|
|
32
|
+
ingest = "ingest"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Base(DeclarativeBase):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Organisation(Base):
|
|
40
|
+
__tablename__ = "organisations"
|
|
41
|
+
|
|
42
|
+
org_id: Mapped[uuid.UUID] = mapped_column(
|
|
43
|
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
44
|
+
)
|
|
45
|
+
org_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
46
|
+
org_slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
|
|
47
|
+
status: Mapped[OrgStatus] = mapped_column(
|
|
48
|
+
SAEnum(OrgStatus, name="org_status"),
|
|
49
|
+
nullable=False,
|
|
50
|
+
default=OrgStatus.active,
|
|
51
|
+
)
|
|
52
|
+
plan_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
53
|
+
provisioned_at: Mapped[datetime] = mapped_column(
|
|
54
|
+
DateTime(timezone=True),
|
|
55
|
+
nullable=False,
|
|
56
|
+
default=lambda: datetime.now(UTC),
|
|
57
|
+
server_default=func.now(),
|
|
58
|
+
)
|
|
59
|
+
anthropic_workspace_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
60
|
+
anthropic_admin_api_key_secret_arn: Mapped[str | None] = mapped_column(
|
|
61
|
+
String(2048), nullable=True
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ApiKey(Base):
|
|
66
|
+
__tablename__ = "api_keys"
|
|
67
|
+
|
|
68
|
+
key_id: Mapped[uuid.UUID] = mapped_column(
|
|
69
|
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
70
|
+
)
|
|
71
|
+
org_id: Mapped[uuid.UUID] = mapped_column(
|
|
72
|
+
UUID(as_uuid=True),
|
|
73
|
+
ForeignKey("organisations.org_id", ondelete="RESTRICT"),
|
|
74
|
+
nullable=False,
|
|
75
|
+
)
|
|
76
|
+
key_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
77
|
+
role: Mapped[ApiKeyRole] = mapped_column(
|
|
78
|
+
SAEnum(ApiKeyRole, name="api_key_role"), nullable=False
|
|
79
|
+
)
|
|
80
|
+
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
81
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
82
|
+
DateTime(timezone=True),
|
|
83
|
+
nullable=False,
|
|
84
|
+
default=lambda: datetime.now(UTC),
|
|
85
|
+
server_default=func.now(),
|
|
86
|
+
)
|
|
87
|
+
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
88
|
+
|
|
89
|
+
__table_args__ = (Index("ix_api_keys_key_hash", "key_hash", unique=True),)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class OrgRateOverride(Base):
|
|
93
|
+
__tablename__ = "org_rate_overrides"
|
|
94
|
+
|
|
95
|
+
override_id: Mapped[uuid.UUID] = mapped_column(
|
|
96
|
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
97
|
+
)
|
|
98
|
+
org_id: Mapped[uuid.UUID] = mapped_column(
|
|
99
|
+
UUID(as_uuid=True),
|
|
100
|
+
ForeignKey("organisations.org_id", ondelete="RESTRICT"),
|
|
101
|
+
nullable=False,
|
|
102
|
+
)
|
|
103
|
+
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
104
|
+
model_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
105
|
+
effective_from: Mapped[date] = mapped_column(Date(), nullable=False)
|
|
106
|
+
discount_pct: Mapped[Decimal] = mapped_column(Numeric(5, 4), nullable=False)
|
|
107
|
+
|
|
108
|
+
__table_args__ = (
|
|
109
|
+
UniqueConstraint(
|
|
110
|
+
"org_id", "provider", "model_id", "effective_from",
|
|
111
|
+
name="uq_org_rate_overrides_key",
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ReviewerRate(Base):
|
|
117
|
+
__tablename__ = "reviewer_rates"
|
|
118
|
+
|
|
119
|
+
rate_id: Mapped[uuid.UUID] = mapped_column(
|
|
120
|
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
121
|
+
)
|
|
122
|
+
org_id: Mapped[uuid.UUID] = mapped_column(
|
|
123
|
+
UUID(as_uuid=True),
|
|
124
|
+
ForeignKey("organisations.org_id", ondelete="RESTRICT"),
|
|
125
|
+
nullable=False,
|
|
126
|
+
)
|
|
127
|
+
owner_team: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
128
|
+
hourly_rate: Mapped[Decimal] = mapped_column(Numeric(10, 4), nullable=False)
|
|
129
|
+
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="USD")
|
|
130
|
+
effective_from: Mapped[date] = mapped_column(Date(), nullable=False)
|
|
131
|
+
|
|
132
|
+
__table_args__ = (
|
|
133
|
+
UniqueConstraint(
|
|
134
|
+
"org_id", "owner_team", "effective_from",
|
|
135
|
+
name="uq_reviewer_rates_key",
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class AgentStatus(enum.StrEnum):
|
|
141
|
+
active = "active"
|
|
142
|
+
deprecated = "deprecated"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class AgentRegistry(Base):
|
|
146
|
+
__tablename__ = "agent_registry"
|
|
147
|
+
|
|
148
|
+
org_id: Mapped[uuid.UUID] = mapped_column(
|
|
149
|
+
UUID(as_uuid=True),
|
|
150
|
+
ForeignKey("organisations.org_id", ondelete="RESTRICT"),
|
|
151
|
+
primary_key=True,
|
|
152
|
+
)
|
|
153
|
+
agent_id: Mapped[str] = mapped_column(String(255), primary_key=True)
|
|
154
|
+
agent_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
155
|
+
description: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
156
|
+
owner_team: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
157
|
+
department: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
158
|
+
cost_centre: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
159
|
+
product: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
160
|
+
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
161
|
+
status: Mapped[AgentStatus] = mapped_column(
|
|
162
|
+
SAEnum(AgentStatus, name="agent_status"),
|
|
163
|
+
nullable=False,
|
|
164
|
+
default=AgentStatus.active,
|
|
165
|
+
)
|
|
166
|
+
onboarded_at: Mapped[datetime] = mapped_column(
|
|
167
|
+
DateTime(timezone=True),
|
|
168
|
+
nullable=False,
|
|
169
|
+
default=lambda: datetime.now(UTC),
|
|
170
|
+
server_default=func.now(),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class TransformationCheckpoint(Base):
|
|
175
|
+
__tablename__ = "transformation_checkpoints"
|
|
176
|
+
|
|
177
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
178
|
+
job_name: Mapped[str] = mapped_column(String, nullable=False, unique=True)
|
|
179
|
+
last_processed_prefix: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
180
|
+
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
181
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
182
|
+
DateTime(timezone=True),
|
|
183
|
+
nullable=False,
|
|
184
|
+
server_default=func.now(),
|
|
185
|
+
onupdate=func.now(),
|
|
186
|
+
)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OTLPEmitter — serialises TelemetryEvent and OutcomeEvent as OTel spans and exports
|
|
3
|
+
them to the collector via OTLP HTTP.
|
|
4
|
+
|
|
5
|
+
Collector endpoint: OTEL_EXPORTER_OTLP_ENDPOINT env var (default http://localhost:4318).
|
|
6
|
+
Auth: COST_PLATFORM_API_KEY is sent as x-api-key in the OTLP export headers.
|
|
7
|
+
This is how the collector's key-validation sidecar resolves org_id.
|
|
8
|
+
|
|
9
|
+
If COST_PLATFORM_API_KEY is not set at init time, the emitter operates in no-op mode:
|
|
10
|
+
emit_event and emit_outcome do nothing.
|
|
11
|
+
|
|
12
|
+
Both emit methods are async and must never raise. Catch all exceptions, log to stderr.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from opentelemetry import trace
|
|
23
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
24
|
+
from opentelemetry.sdk.resources import Resource
|
|
25
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
26
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from cost_platform.models import OutcomeEvent, TelemetryEvent
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OTLPEmitter:
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
api_key = os.environ.get("COST_PLATFORM_API_KEY", "")
|
|
35
|
+
if not api_key:
|
|
36
|
+
print(
|
|
37
|
+
"cost_platform: COST_PLATFORM_API_KEY not set — running in no-op mode",
|
|
38
|
+
file=sys.stderr,
|
|
39
|
+
)
|
|
40
|
+
self._tracer: trace.Tracer | None = None
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
base = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318").rstrip("/")
|
|
44
|
+
exporter = OTLPSpanExporter(
|
|
45
|
+
endpoint=f"{base}/v1/traces",
|
|
46
|
+
headers={"x-api-key": api_key},
|
|
47
|
+
)
|
|
48
|
+
resource = Resource.create({"service.name": "cost-platform-wrapper"})
|
|
49
|
+
provider = TracerProvider(resource=resource)
|
|
50
|
+
provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
51
|
+
self._tracer = provider.get_tracer("cost_platform", schema_url="")
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
# Public async interface (fire-and-forget; never raises)
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
async def emit_event(self, event: TelemetryEvent) -> None:
|
|
58
|
+
try:
|
|
59
|
+
self._emit_telemetry_sync(event)
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
print(f"cost_platform: telemetry emit failed: {exc}", file=sys.stderr)
|
|
62
|
+
|
|
63
|
+
async def emit_outcome(self, event: OutcomeEvent) -> None:
|
|
64
|
+
try:
|
|
65
|
+
self._emit_outcome_sync(event)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
print(f"cost_platform: outcome emit failed: {exc}", file=sys.stderr)
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Internal sync helpers (called by async methods and by wrapper sync path)
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def emit_event_sync(self, event: TelemetryEvent) -> None:
|
|
74
|
+
"""Synchronous entry-point for the wrapper's sync messages.create() path."""
|
|
75
|
+
try:
|
|
76
|
+
self._emit_telemetry_sync(event)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
print(f"cost_platform: telemetry emit failed: {exc}", file=sys.stderr)
|
|
79
|
+
|
|
80
|
+
def emit_outcome_sync(self, event: OutcomeEvent) -> None:
|
|
81
|
+
"""Synchronous entry-point for the wrapper's sync complete() path."""
|
|
82
|
+
try:
|
|
83
|
+
self._emit_outcome_sync(event)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
print(f"cost_platform: outcome emit failed: {exc}", file=sys.stderr)
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Private helpers
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _emit_telemetry_sync(self, event: TelemetryEvent) -> None:
|
|
92
|
+
if self._tracer is None:
|
|
93
|
+
return
|
|
94
|
+
with self._tracer.start_as_current_span("cost_platform.action") as span:
|
|
95
|
+
span.set_attribute("event_type", "telemetry")
|
|
96
|
+
# Group 1 — Trace Identity
|
|
97
|
+
span.set_attribute("cp.org_id", event.org_id)
|
|
98
|
+
span.set_attribute("cp.workflow_id", event.workflow_id)
|
|
99
|
+
span.set_attribute("cp.agent_id", event.agent_id)
|
|
100
|
+
span.set_attribute("cp.agent_version", event.agent_version)
|
|
101
|
+
span.set_attribute("cp.environment", event.environment)
|
|
102
|
+
if event.parent_span_id is not None:
|
|
103
|
+
span.set_attribute("cp.parent_span_id", event.parent_span_id)
|
|
104
|
+
if event.step_name is not None:
|
|
105
|
+
span.set_attribute("cp.step_name", event.step_name)
|
|
106
|
+
if event.step_index is not None:
|
|
107
|
+
span.set_attribute("cp.step_index", event.step_index)
|
|
108
|
+
|
|
109
|
+
# Group 2 — Call Metadata
|
|
110
|
+
span.set_attribute("cp.provider", event.provider)
|
|
111
|
+
span.set_attribute("gen_ai.system", event.provider)
|
|
112
|
+
span.set_attribute("gen_ai.request.model", event.model_id)
|
|
113
|
+
span.set_attribute("cp.model_id", event.model_id)
|
|
114
|
+
span.set_attribute("cp.request_id", event.request_id)
|
|
115
|
+
span.set_attribute("cp.latency_ms_total", event.latency_ms_total)
|
|
116
|
+
span.set_attribute("cp.stop_reason", event.stop_reason)
|
|
117
|
+
span.set_attribute("cp.service_tier", event.service_tier)
|
|
118
|
+
span.set_attribute("cp.batch_mode", event.batch_mode)
|
|
119
|
+
span.set_attribute("cp.retry_attempt", event.retry_attempt)
|
|
120
|
+
if event.latency_ms_ttfb is not None:
|
|
121
|
+
span.set_attribute("cp.latency_ms_ttfb", event.latency_ms_ttfb)
|
|
122
|
+
if event.error_code is not None:
|
|
123
|
+
span.set_attribute("cp.error_code", event.error_code)
|
|
124
|
+
|
|
125
|
+
# Group 3 — Token Counts
|
|
126
|
+
span.set_attribute("gen_ai.usage.input_tokens", event.tokens_input_fresh)
|
|
127
|
+
span.set_attribute("gen_ai.usage.output_tokens", event.tokens_output_total)
|
|
128
|
+
span.set_attribute("cp.tokens_input_fresh", event.tokens_input_fresh)
|
|
129
|
+
span.set_attribute("cp.tokens_input_total", event.tokens_input_total)
|
|
130
|
+
span.set_attribute("cp.tokens_output_total", event.tokens_output_total)
|
|
131
|
+
if event.tokens_input_cache_write_5m is not None:
|
|
132
|
+
span.set_attribute(
|
|
133
|
+
"cp.tokens_input_cache_write_5m", event.tokens_input_cache_write_5m
|
|
134
|
+
)
|
|
135
|
+
if event.tokens_input_cache_write_1h is not None:
|
|
136
|
+
span.set_attribute(
|
|
137
|
+
"cp.tokens_input_cache_write_1h", event.tokens_input_cache_write_1h
|
|
138
|
+
)
|
|
139
|
+
if event.tokens_input_cache_read is not None:
|
|
140
|
+
span.set_attribute("cp.tokens_input_cache_read", event.tokens_input_cache_read)
|
|
141
|
+
if event.tokens_output_thinking_est is not None:
|
|
142
|
+
span.set_attribute(
|
|
143
|
+
"cp.tokens_output_thinking_est", event.tokens_output_thinking_est
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Group 4 — Cost Fields (None when emitted from wrapper; set by transformation job)
|
|
147
|
+
if event.cost_usd_input_fresh is not None:
|
|
148
|
+
span.set_attribute("cp.cost_usd_input_fresh", event.cost_usd_input_fresh)
|
|
149
|
+
if event.cost_usd_output is not None:
|
|
150
|
+
span.set_attribute("cp.cost_usd_output", event.cost_usd_output)
|
|
151
|
+
if event.cost_usd_tools is not None:
|
|
152
|
+
span.set_attribute("cp.cost_usd_tools", event.cost_usd_tools)
|
|
153
|
+
if event.cost_usd_total is not None:
|
|
154
|
+
span.set_attribute("cp.cost_usd_total", event.cost_usd_total)
|
|
155
|
+
if event.rate_card_version is not None:
|
|
156
|
+
span.set_attribute("cp.rate_card_version", event.rate_card_version)
|
|
157
|
+
if event.cost_usd_input_cache_write is not None:
|
|
158
|
+
span.set_attribute(
|
|
159
|
+
"cp.cost_usd_input_cache_write", event.cost_usd_input_cache_write
|
|
160
|
+
)
|
|
161
|
+
if event.cost_usd_input_cache_read is not None:
|
|
162
|
+
span.set_attribute(
|
|
163
|
+
"cp.cost_usd_input_cache_read", event.cost_usd_input_cache_read
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Group 5 — SDK Metadata
|
|
167
|
+
if event.sdk_language is not None:
|
|
168
|
+
span.set_attribute("cp.sdk_language", event.sdk_language)
|
|
169
|
+
if event.sdk_version is not None:
|
|
170
|
+
span.set_attribute("cp.sdk_version", event.sdk_version)
|
|
171
|
+
|
|
172
|
+
# Group 6 — Tool Usage
|
|
173
|
+
span.set_attribute("cp.tool_calls_total", event.tool_calls_total)
|
|
174
|
+
span.set_attribute("cp.tool_calls_web_search", event.tool_calls_web_search)
|
|
175
|
+
span.set_attribute("cp.tool_calls_code_exec", event.tool_calls_code_exec)
|
|
176
|
+
span.set_attribute("cp.tool_calls_detail", json.dumps(event.tool_calls_detail))
|
|
177
|
+
|
|
178
|
+
span.set_attribute("cp.event_timestamp", event.event_timestamp.isoformat())
|
|
179
|
+
|
|
180
|
+
def _emit_outcome_sync(self, event: OutcomeEvent) -> None:
|
|
181
|
+
if self._tracer is None:
|
|
182
|
+
return
|
|
183
|
+
with self._tracer.start_as_current_span("cost_platform.outcome") as span:
|
|
184
|
+
span.set_attribute("event_type", "outcome")
|
|
185
|
+
span.set_attribute("cp.org_id", event.org_id)
|
|
186
|
+
span.set_attribute("cp.workflow_id", event.workflow_id)
|
|
187
|
+
span.set_attribute("cp.agent_id", event.agent_id)
|
|
188
|
+
span.set_attribute("cp.outcome_status", event.outcome_status)
|
|
189
|
+
span.set_attribute("cp.started_at", event.started_at.isoformat())
|
|
190
|
+
span.set_attribute("cp.completed_at", event.completed_at.isoformat())
|
|
191
|
+
span.set_attribute("cp.duration_ms", event.duration_ms)
|
|
192
|
+
span.set_attribute("cp.human_review_required", event.human_review_required)
|
|
193
|
+
if event.error_code is not None:
|
|
194
|
+
span.set_attribute("cp.error_code", event.error_code)
|
|
195
|
+
if event.human_review_duration_mins is not None:
|
|
196
|
+
span.set_attribute(
|
|
197
|
+
"cp.human_review_duration_mins", event.human_review_duration_mins
|
|
198
|
+
)
|
|
199
|
+
if event.session_id is not None:
|
|
200
|
+
span.set_attribute("cp.session_id", event.session_id)
|
|
201
|
+
if event.external_reference_id is not None:
|
|
202
|
+
span.set_attribute("cp.external_reference_id", event.external_reference_id)
|
|
203
|
+
if event.sdk_language is not None:
|
|
204
|
+
span.set_attribute("cp.sdk_language", event.sdk_language)
|
|
205
|
+
if event.sdk_version is not None:
|
|
206
|
+
span.set_attribute("cp.sdk_version", event.sdk_version)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Assemble a TelemetryEvent from extracted token counts, trace context, and call metadata.
|
|
3
|
+
|
|
4
|
+
Pure field assembly — no rate card, no cost computation. All cost_usd_* fields and
|
|
5
|
+
rate_card_version are set to None; the transformation job is the sole owner of those fields.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from cost_platform.models import TelemetryEvent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EventAssembler:
|
|
18
|
+
def assemble(
|
|
19
|
+
self,
|
|
20
|
+
token_counts: dict[str, Any],
|
|
21
|
+
trace_fields: dict[str, Any],
|
|
22
|
+
call_metadata: dict[str, Any],
|
|
23
|
+
) -> TelemetryEvent:
|
|
24
|
+
event_timestamp: datetime = call_metadata.get("event_timestamp") or datetime.now(UTC)
|
|
25
|
+
|
|
26
|
+
return TelemetryEvent(
|
|
27
|
+
trace_id=call_metadata.get("trace_id") or str(uuid.uuid4()),
|
|
28
|
+
span_id=call_metadata.get("span_id") or str(uuid.uuid4()),
|
|
29
|
+
workflow_id=trace_fields["workflow_id"],
|
|
30
|
+
agent_id=trace_fields["agent_id"],
|
|
31
|
+
agent_version=trace_fields["agent_version"],
|
|
32
|
+
environment=trace_fields["environment"],
|
|
33
|
+
provider=call_metadata["provider"],
|
|
34
|
+
model_id=call_metadata["model_id"],
|
|
35
|
+
request_id=call_metadata["request_id"],
|
|
36
|
+
latency_ms_total=int(call_metadata.get("latency_ms_total", 0)),
|
|
37
|
+
stop_reason=call_metadata.get("stop_reason", "end_turn"),
|
|
38
|
+
service_tier=call_metadata.get("service_tier", "standard"),
|
|
39
|
+
batch_mode=bool(call_metadata.get("batch_mode", False)),
|
|
40
|
+
retry_attempt=int(call_metadata.get("retry_attempt", 0)),
|
|
41
|
+
tokens_input_fresh=token_counts["tokens_input_fresh"],
|
|
42
|
+
tokens_input_total=token_counts["tokens_input_total"],
|
|
43
|
+
tokens_output_total=token_counts["tokens_output_total"],
|
|
44
|
+
cost_usd_input_fresh=None,
|
|
45
|
+
cost_usd_output=None,
|
|
46
|
+
cost_usd_tools=None,
|
|
47
|
+
cost_usd_total=None,
|
|
48
|
+
cost_usd_input_cache_write=None,
|
|
49
|
+
cost_usd_input_cache_read=None,
|
|
50
|
+
rate_card_version=None,
|
|
51
|
+
tool_calls_total=int(call_metadata.get("tool_calls_total", 0)),
|
|
52
|
+
tool_calls_web_search=int(call_metadata.get("tool_calls_web_search", 0)),
|
|
53
|
+
tool_calls_code_exec=int(call_metadata.get("tool_calls_code_exec", 0)),
|
|
54
|
+
tool_calls_detail=call_metadata.get("tool_calls_detail", []),
|
|
55
|
+
event_timestamp=event_timestamp,
|
|
56
|
+
parent_span_id=call_metadata.get("parent_span_id"),
|
|
57
|
+
step_name=trace_fields.get("step_name"),
|
|
58
|
+
step_index=trace_fields.get("step_index"),
|
|
59
|
+
latency_ms_ttfb=call_metadata.get("latency_ms_ttfb"),
|
|
60
|
+
error_code=call_metadata.get("error_code"),
|
|
61
|
+
tokens_input_cache_write_5m=token_counts.get("tokens_input_cache_write_5m"),
|
|
62
|
+
tokens_input_cache_write_1h=token_counts.get("tokens_input_cache_write_1h"),
|
|
63
|
+
tokens_input_cache_read=token_counts.get("tokens_input_cache_read"),
|
|
64
|
+
tokens_output_thinking_est=token_counts.get("tokens_output_thinking_est"),
|
|
65
|
+
cost_usd_runtime=None,
|
|
66
|
+
sdk_language=call_metadata.get("sdk_language"),
|
|
67
|
+
sdk_version=call_metadata.get("sdk_version"),
|
|
68
|
+
)
|