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.
Files changed (34) hide show
  1. elltri-0.1.0/PKG-INFO +42 -0
  2. elltri-0.1.0/cost_platform/__init__.py +3 -0
  3. elltri-0.1.0/cost_platform/db/__init__.py +0 -0
  4. elltri-0.1.0/cost_platform/db/engine.py +40 -0
  5. elltri-0.1.0/cost_platform/db/models.py +186 -0
  6. elltri-0.1.0/cost_platform/emitter.py +206 -0
  7. elltri-0.1.0/cost_platform/event_assembler.py +68 -0
  8. elltri-0.1.0/cost_platform/extractor.py +148 -0
  9. elltri-0.1.0/cost_platform/models.py +90 -0
  10. elltri-0.1.0/cost_platform/rate_card.py +127 -0
  11. elltri-0.1.0/cost_platform/wrapper.py +238 -0
  12. elltri-0.1.0/elltri.egg-info/PKG-INFO +42 -0
  13. elltri-0.1.0/elltri.egg-info/SOURCES.txt +32 -0
  14. elltri-0.1.0/elltri.egg-info/dependency_links.txt +1 -0
  15. elltri-0.1.0/elltri.egg-info/requires.txt +26 -0
  16. elltri-0.1.0/elltri.egg-info/top_level.txt +1 -0
  17. elltri-0.1.0/pyproject.toml +86 -0
  18. elltri-0.1.0/setup.cfg +4 -0
  19. elltri-0.1.0/tests/test_db_models.py +192 -0
  20. elltri-0.1.0/tests/test_e2e_collection.py +170 -0
  21. elltri-0.1.0/tests/test_e2e_staging.py +141 -0
  22. elltri-0.1.0/tests/test_event_assembler.py +146 -0
  23. elltri-0.1.0/tests/test_extractor.py +160 -0
  24. elltri-0.1.0/tests/test_iceberg_schema.py +154 -0
  25. elltri-0.1.0/tests/test_key_validation.py +126 -0
  26. elltri-0.1.0/tests/test_openai_wrapper.py +213 -0
  27. elltri-0.1.0/tests/test_placeholder.py +2 -0
  28. elltri-0.1.0/tests/test_rate_card.py +182 -0
  29. elltri-0.1.0/tests/test_reconciliation.py +579 -0
  30. elltri-0.1.0/tests/test_rest_api.py +449 -0
  31. elltri-0.1.0/tests/test_sql_views.py +265 -0
  32. elltri-0.1.0/tests/test_transformation_7a.py +612 -0
  33. elltri-0.1.0/tests/test_transformation_7b.py +499 -0
  34. 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"
@@ -0,0 +1,3 @@
1
+ from cost_platform.wrapper import AgentTelemetry
2
+
3
+ __all__ = ["AgentTelemetry"]
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
+ )