Metadata-Version: 2.4
Name: traceiq-sdk
Version: 0.2.2
Summary: Thin wrapper around OpenLLMetry/Traceloop for AI agent observability by TraceIQ
Project-URL: Homepage, https://traceiq.com
Project-URL: Documentation, https://traceiq.com
Project-URL: Repository, https://github.com/traceiq-io/traceiq
Project-URL: Issues, https://github.com/traceiq-io/traceiq/issues
Author-email: TraceIQ <hello@traceiq.com>
License-Expression: MIT
License-File: LICENSE
Keywords: agents,cost,llm,observability,opentelemetry,traceiq,traceiq-sdk,traceloop
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Monitoring
Requires-Python: >=3.9
Requires-Dist: opentelemetry-sdk>=1.27.0
Requires-Dist: traceloop-sdk<0.70.0,>=0.61.0
Provides-Extra: all
Requires-Dist: openai; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-anthropic; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-langchain; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-llamaindex; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-openai; extra == 'all'
Provides-Extra: anthropic
Requires-Dist: opentelemetry-instrumentation-anthropic; extra == 'anthropic'
Provides-Extra: dev
Requires-Dist: python-dotenv>=1.0.0; extra == 'dev'
Provides-Extra: langchain
Requires-Dist: opentelemetry-instrumentation-langchain; extra == 'langchain'
Provides-Extra: langgraph
Requires-Dist: langgraph; extra == 'langgraph'
Requires-Dist: opentelemetry-instrumentation-langchain; extra == 'langgraph'
Provides-Extra: llamaindex
Requires-Dist: opentelemetry-instrumentation-llamaindex; extra == 'llamaindex'
Provides-Extra: openai
Requires-Dist: openai; extra == 'openai'
Requires-Dist: opentelemetry-instrumentation-openai; extra == 'openai'
Description-Content-Type: text/markdown

# TraceIQ

Thin Python wrapper around [OpenLLMetry (Traceloop)](https://github.com/traceloop/openllmetry) for capturing AI-agent execution traces with cost observability. Built for waste detection — every trace ships with pre-computed token counts, USD cost, user identity, and a rolled-up summary so your analytics queries never have to map-reduce raw spans.

This is a wrapper, not a fork. It depends on the upstream package and adds field normalization, cost computation, context propagation, and structured local storage on top.

## Install

```bash
pip install traceiq-sdk[openai]       # + OpenAI auto-instrumentation
pip install traceiq-sdk[anthropic]    # + Anthropic
pip install traceiq-sdk[langchain]    # + LangChain + LangGraph
pip install traceiq-sdk[llamaindex]   # + LlamaIndex
pip install traceiq-sdk[all]          # everything
```

Install **`traceiq-sdk`** — not the unrelated [`traceiq`](https://pypi.org/project/traceiq/) package on PyPI (different library). After install, `import traceiq` as shown below.

From source (local development):

```bash
pip install -e ".[openai,dev]"
```

## Quickstart

```python
import traceiq
from traceiq import workflow, task

# 1. Init once at startup
traceiq.init(
    api_key="YOUR_KEY",
    endpoint="https://ingest.example.com/v1/traces",
    environment="production",
    version="v2.1.0",
)

# 2. Set per-request context (user, session)
traceiq.set_context(user_id="u_alice", session_id="sess_001")

# 3. Mark agent boundaries
@workflow(name="research_agent")
def run_agent(query: str):
    return call_llm(query)

@task(name="call_llm")
def call_llm(query: str): ...
```

No `endpoint` → traces are written as structured JSON to `./traces/` (local dev mode, nothing leaves the machine).

## init() options


| Parameter         | Default                   | Description                                                                                                 |
| ----------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `api_key`         | `TRACEIQ_API_KEY` env     | Auth header for the OTLP endpoint                                                                           |
| `endpoint`        | `TRACEIQ_ENDPOINT` env    | OTLP HTTP traces URL. Omit for local JSON mode                                                              |
| `app_name`        | `"traceiq-agent"`         | Service name stamped on every span                                                                          |
| `environment`     | `TRACEIQ_ENVIRONMENT` env | `"production"` / `"staging"` / `"development"` — separates prod cost from dev noise                         |
| `version`         | `TRACEIQ_VERSION` env     | App/agent version — enables before/after cost comparisons across deploys                                    |
| `capture_content` | `True`                    | Capture raw prompt/response text on spans (see privacy note below)                                          |
| `hash_inputs`     | `True`                    | SHA1 fingerprint of input messages written as `traceiq.input.hash` — no raw text stored, safe in production |
| `enforce_budgets` | `False`                   | Poll per-tenant budget rules and block over-budget tenants (see below). Requires an endpoint                |
| `traces_dir`      | `"./traces"`              | Local JSON output directory (no-endpoint mode only)                                                         |
| `disable_batch`   | `False`                   | Flush synchronously — use in short-lived scripts and notebooks                                              |


### Per-request context

Call `set_context()` at the start of each request, before invoking the agent. Every span inside that request is automatically tagged.

```python
traceiq.set_context(
    tenant_id="acme-corp",         # the customer/org — top-level attribution dimension
    user_id="u_abc123",            # the end user within that tenant
    session_id="sess_xyz",         # the conversation/session

    # billing / attribution dimensions
    product_id="prod_pro",         # your product/SKU — the billing unit
    product_name="Pro",            # human-readable tier name
    subscription_id="sub_001",     # active plan instance (chargeback / usage billing)
    task_type="support",           # categorize work for cost-by-task analytics
    agent="triage_agent",          # which agent made the call

    # subscriber / credential (meter spend per issued key)
    subscriber_email="alice@acme-corp.com",
    credential_name="key_live_dashboard",

    # arbitrary tags — flattened to traceiq.meta.<key>
    metadata={"region": "us-east-1", "feature_flag": "v2"},
)
run_agent(user_message)

# Clear between requests on long-running servers
traceiq.clear_context()
```

**Attribution hierarchy:** `tenant_id` → `user_id` → `session_id`. On a multi-tenant platform, `tenant_id` is "which of *your* customers" (the dimension you bill or enforce budgets against), while `user_id` is "which of *their* end-users." Everything is optional — set whichever dimensions your analytics and billing need.

**Billing dimensions** (`product_id`, `subscription_id`, `credential_name`, …) are what turn a cost dashboard into per-customer/per-product metering you can bill or charge back against. `**task_type`** buckets spend by the kind of work (`support` vs `code_review`), and `**metadata**` carries anything else — each key becomes a `traceiq.meta.<key>` span attribute (values must be scalar; non-scalars are stringified; capped at 50 keys/span).

Context is stored in a `ContextVar` so it propagates correctly through asyncio tasks (LangGraph) and threads (Traceloop installs `ThreadingInstrumentor` automatically).

`set_context()` merges into existing context — you can call it at auth time for `tenant_id` / `subscriber_email`, again later for `session_id` / `task_type`, and the `metadata` bag merges key-by-key — without losing earlier values.

### Input hashing vs. content capture

`hash_inputs=True` (default) writes a SHA1 fingerprint of the input messages to `traceiq.input.hash`. No raw text is stored — this is safe in production and enables duplicate/retry detection (#2, #6 in the waste taxonomy) purely from the hash.

`capture_content=True` stores the full prompt and completion text on spans as `gen_ai.input.messages` / `gen_ai.output.messages`. This enables semantic analysis (unused tool output, semantic duplicates) but means **PII and confidential prompt content will appear in exported telemetry**. Set `capture_content=False` in production if prompt text must not leave the execution environment.

The two flags are independent — you can run `hash_inputs=True, capture_content=False` in production to get duplicate detection without any raw text leaving.

## Marking agent boundaries

`@workflow`, `@task`, `@tool`, and `@agent` are re-exported directly from Traceloop — no reimplementation.

```python
from traceiq import workflow, task, tool, agent

@workflow(name="research_agent")   # root of the trace tree
def run_agent(query: str): ...

@task(name="classify_intent")      # named step within the workflow
def classify(query: str): ...

@tool(name="web_search")           # tool call step
def search(query: str): ...
```

Auto-instrumented frameworks (OpenAI, Anthropic, LangChain, LangGraph, LlamaIndex) emit LLM call spans automatically as children of these boundaries — you only need to add decorators for your own business logic steps.

## Span field contract

Every span carries these `traceiq.*` attributes:

### Identity & context


| Field                          | Description                                                                        |
| ------------------------------ | ---------------------------------------------------------------------------------- |
| `traceiq.environment`          | Set at `init()` — e.g. `"production"`                                              |
| `traceiq.version`              | App version set at `init()`                                                        |
| `traceiq.app_name`             | Service name set at `init()`                                                       |
| `traceiq.tenant_id`            | Customer/organization — top-level attribution dimension, set via `set_context()`   |
| `traceiq.user_id`              | End user within the tenant — set via `set_context()`                               |
| `traceiq.session_id`           | Conversation/session — set via `set_context()`                                     |
| `traceiq.product_id`           | Your product/SKU — the billing unit a tenant is subscribed to                      |
| `traceiq.product_name`         | Human-readable product/tier name                                                   |
| `traceiq.subscription_id`      | Active subscription/plan instance — for chargeback and usage-based billing         |
| `traceiq.task_type`            | Category of agent work (`support`, `code_review`, …) for cost-by-task analytics    |
| `traceiq.agent`                | Which agent/service made the call                                                  |
| `traceiq.subscriber.id`        | Stable subscriber/account identifier — the durable billing key (prefer over email) |
| `traceiq.subscriber.email`     | Subscriber contact — per-user billing views without a separate lookup              |
| `traceiq.credential.id`        | Stable credential identifier — meter spend per issued key                          |
| `traceiq.credential.name`      | API credential label — human-readable key name                                     |
| `traceiq.attempt` / `is_retry` | Retry signals from `set_context` — makes retry waste queryable directly            |
| `traceiq.meta.<key>`           | Arbitrary tags from `set_context(metadata=…)` (scalar values, ≤50 keys/span)       |
| `traceiq.workflow.id`          | Stable hex ID derived from workflow name (same ID every run of the same workflow)  |


| `traceiq.input.hash` | SHA1 of input messages — present when `hash_inputs=True` (default). Use for duplicate/retry detection without storing raw content |
| `traceiq.output.hash` | SHA1 of output messages — enables "same prompt, different answer" / semantic-retry analysis |

### Step classification


| Field                            | Description                                                                                                               |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `traceiq.step.type`              | `workflow` / `task` / `llm` / `tool` / `retriever` / `agent`                                                              |
| `traceiq.model`                  | Actual model used (`gen_ai.response.model` → `gen_ai.request.model` fallback)                                             |
| `traceiq.requested_model`        | Model the caller asked for (`gen_ai.request.model`)                                                                       |
| `traceiq.model_routing_mismatch` | `True` when the served model isn't an extension of the requested one — catches routing waste (asked cheap, served pricey) |
| `traceiq.provider`               | Serving provider — `openai` / `anthropic` / … (`gen_ai.provider.name` → `gen_ai.system`)                                  |
| `traceiq.operation.type`         | LLM operation class — `chat` / `text_completion` / `embeddings` …                                                         |
| `traceiq.response.id`            | Provider's own response/request id — reconcile spans against provider invoices                                            |


### Token usage


| Field                                                                                         | Description                                                                                     |
| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `traceiq.usage.input_tokens`                                                                  | Normalized — reads `gen_ai.usage.input_tokens` or legacy `gen_ai.usage.prompt_tokens`           |
| `traceiq.usage.output_tokens`                                                                 | Normalized — reads `gen_ai.usage.output_tokens` or legacy `gen_ai.usage.completion_tokens`      |
| `traceiq.usage.total_tokens`                                                                  | Provider-reported total; derived as input + output when the provider omits it                   |
| `traceiq.usage.cache_read_tokens`                                                             | Reads both OTel dot form and Traceloop underscore form                                          |
| `traceiq.usage.cache_write_tokens`                                                            | Reads both OTel dot form and Traceloop underscore form                                          |
| `traceiq.usage.reasoning_tokens`                                                              | OpenAI o-series internal reasoning tokens (visibility only — already included in output_tokens) |
| `traceiq.usage.cost_usd`                                                                      | Computed total USD cost for this span (absent for unknown models — see pricing table)           |
| `traceiq.usage.image_count` / `pixel_count` / `audio_seconds` / `character_count` / `credits` | Multimodal usage — present when the provider emits it (best-effort)                             |
| `traceiq.usage.cost_input_usd`                                                                | Portion of `cost_usd` attributable to input tokens                                              |
| `traceiq.usage.cost_output_usd`                                                               | Portion of `cost_usd` attributable to output tokens                                             |
| `traceiq.usage.cost_cache_usd`                                                                | Portion of `cost_usd` from cache read + write tokens                                            |
| `traceiq.usage.cost_image_usd` / `cost_audio_usd` / `cost_credits_usd`                        | Modality cost components — present only when nonzero                                            |
| `traceiq.tool_cost_usd`                                                                       | Tool/API call cost, set via `record_metric(tool_cost=…)`                                        |
| `traceiq.escalation_cost_usd`                                                                 | Human review/takeover cost, set via `record_metric(escalation_cost=…)`                          |
| `traceiq.quality_score`                                                                       | Evaluation score, set via `record_metric(quality_score=…)`                                      |
| `traceiq.metric.<key>`                                                                        | Arbitrary numeric units (OCR pages, retrieved rows…), set via `record_metric(**custom)`         |


### Performance


| Field                    | Description                                                                                                                                                          |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `traceiq.streaming`      | `True` when the call was streamed (`gen_ai.is_streaming`)                                                                                                            |
| `traceiq.ttft_ms`        | Time-to-first-token in ms. Derived from the first `gen_ai.content.completion.chunk` span event minus span start. **Streaming calls only** — absent for non-streaming |
| `traceiq.throughput_tps` | Output tokens per second over the span duration. Present for streaming and non-streaming whenever output tokens + duration exist                                     |


> **Streaming chunk events are collapsed.** OpenLLMetry emits one `gen_ai.content.completion.chunk` span event per streamed token. After reading the first one for TTFT, the exporter keeps only that first event and drops the rest, recording the total as `streamed_chunk_count` on the span — so a long stream stays KB-sized instead of ballooning the trace file with thousands of near-empty events.

### Status & errors


| Field                       | Description                                                                     |
| --------------------------- | ------------------------------------------------------------------------------- |
| `traceiq.status`            | `"ok"` or `"error"` (OTel `UNSET` → `"ok"`)                                     |
| `traceiq.finish_reason`     | First stop reason — `stop` / `length` / `tool_calls` …                          |
| `traceiq.truncated`         | `True` when `finish_reason` is `length`/`max_tokens` — a token-cap waste signal |
| `traceiq.exception.type`    | Exception class name, copied from span event to attribute for queryability      |
| `traceiq.exception.message` | Exception message, copied from span event                                       |


All raw upstream attributes (`gen_ai.`*, `traceloop.*`, `trace_id`, `span_id`, `parent_span_id`, start/end timestamps, events) are preserved untouched.

## Trace file format (local mode)

One file per trace: `traces/trace_{trace_id}.json`

```json
{
  "metadata": {
    "trace_id": "d24b6381...",
    "workflow_id": "92f34491...",
    "workflow_name": "research_agent",
    "tenant_id": "acme-corp",
    "user_id": "u_alice",
    "session_id": "sess_001",
    "product_id": "prod_pro",
    "product_name": "Pro",
    "subscription_id": "sub_001",
    "task_type": "support",
    "agent": "triage_agent",
    "subscriber_email": "alice@acme-corp.com",
    "credential_name": "key_live_dashboard",
    "environment": "production",
    "version": "v2.1.0",
    "app_name": "my-agent",
    "metadata": { "region": "us-east-1", "feature_flag": "v2" },
    "start_time": "2026-06-25T12:47:53.566797+00:00",
    "end_time": "2026-06-25T12:47:53.784210+00:00",
    "total_duration_ms": 217.4
  },
  "summary": {
    "status": "ok",
    "span_count": 5,
    "llm_call_count": 2,
    "tool_call_count": 1,
    "truncated_call_count": 0,
    "error_span_count": 0,
    "retried_call_count": 0,
    "duplicate_call_count": 0,
    "routing_mismatch_count": 0,
    "max_tree_depth": 4,
    "max_tool_repeats": 1,
    "models_used": ["gpt-4o-mini-2024-07-18"],
    "providers_used": ["openai"],
    "total_input_tokens": 762,
    "total_output_tokens": 89,
    "total_cache_read_tokens": 100,
    "total_cache_write_tokens": 0,
    "total_cost_usd": 0.00017520,
    "total_input_cost_usd": 0.00011430,
    "total_output_cost_usd": 0.00005340,
    "total_tool_cost_usd": 0.00500000,
    "total_escalation_cost_usd": 0.0,
    "total_spend_usd": 0.00517520,
    "total_cache_savings_usd": 0.00002500,
    "avg_quality_score": 0.91,
    "avg_ttft_ms": 184.2,
    "avg_throughput_tps": 47.6
  },
  "spans": [ ... ]
}
```

A few of the waste rollups have semantics worth knowing:

- `duplicate_call_count` counts repeats of any `traceiq.input.hash` across **all** span types carrying one (in production that's mostly LLM spans, since the hash needs content on the span).
- `max_tree_depth` measures span **nesting** (workflow → task → tool → …), not the number of LLM rounds. For a flat ReAct-style loop, watch `llm_call_count` and `max_tool_repeats` instead — they catch the wide-but-shallow case that depth misses.
- `retried_call_count` counts model spans only — `is_retry` / `attempt` are request-scoped (stamped on every span), so counting all spans would inflate it to the whole workflow.

The `summary` block is pre-aggregated at write time — waste queries read only `metadata` + `summary` without iterating spans:

```python
import json, pathlib

traces = [json.loads(f.read_text()) for f in pathlib.Path("./traces").glob("trace_*.json")]

# Most expensive traces
by_cost = sorted(traces, key=lambda t: t["summary"]["total_cost_usd"], reverse=True)

# Errored traces that still burned tokens
errored_with_spend = [
    t for t in traces
    if t["summary"]["status"] == "error"
    and t["summary"]["total_cost_usd"] > 0
]

# Per-tenant cost rollup (which customer is costing you the most)
from collections import defaultdict
by_tenant = defaultdict(float)
for t in traces:
    by_tenant[t["metadata"]["tenant_id"]] += t["summary"]["total_cost_usd"]

# Per-user cost within a tenant
alice = [t for t in traces if t["metadata"]["user_id"] == "u_alice"]
alice_total = sum(t["summary"]["total_cost_usd"] for t in alice)
```

### Flush behaviour


| Trigger            | When                                                                                            |
| ------------------ | ----------------------------------------------------------------------------------------------- |
| Root span detected | Immediately when the `@workflow` span ends — all children are already buffered                  |
| `shutdown()`       | Process exit — drains all remaining buffers (BatchSpanProcessor safety net)                     |
| Reaper thread      | Every `stale_timeout/3` seconds — flushes traces idle longer than `stale_timeout` (default 30s) |


## Cost engine

USD cost is computed on every LLM span using a built-in pricing table (verified 2026-06-25). Covered models:

**Anthropic** — Claude Fable 5, Mythos 5, Opus 4.x, Sonnet 4.x, Haiku 4.x, Haiku 3.5, Claude 3 Opus/Sonnet/Haiku

**OpenAI** — GPT-5.5, GPT-5.4, GPT-4.1/mini/nano, GPT-4o/mini, o4-mini, o3-mini, o3, o1/mini, GPT-4 Turbo, GPT-4, GPT-3.5 Turbo

All four token types are priced separately where applicable: `input`, `output`, `cache_read`, `cache_write`. Cost is split on each span into `cost_input_usd` / `cost_output_usd` / `cost_cache_usd`.

Unknown models emit a warning and omit `cost_usd` rather than writing a wrong number:

```
WARNING traceiq: unknown model 'my-local-llm' — cost_usd will be absent from span.
Add it to traceiq/_pricing.py to enable cost tracking.
```

Prices are version-prefix matched (`claude-opus-4-8-20260528` → `claude-opus-4` entry) so new minor releases are covered automatically without a code change.

### Beyond text tokens

The engine is modality-aware. A table entry can carry, instead of (or alongside) token rates:


| Modality   | Price key(s)                                                  | Cost formula                                    | Cost bucket |
| ---------- | ------------------------------------------------------------- | ----------------------------------------------- | ----------- |
| Embeddings | `input` (no `output`)                                         | input-only token cost                           | `input`     |
| Image      | `image_by_size{size: usd}` / `image_flat` / `image_per_pixel` | per image by size, flat per image, or per pixel | `image`     |
| Audio STT  | `audio_per_second`                                            | `rate × audio_seconds`                          | `audio`     |
| Audio TTS  | `audio_per_char`                                              | `rate × character_count`                        | `audio`     |
| Credits    | `credit_usd`                                                  | `rate × credits_consumed`                       | `credits`   |


Built-in multimodal entries (DALL·E 2/3, gpt-image-1, Whisper, gpt-4o-transcribe, tts-1/-hd, text-embedding-3) ship as **illustrative rates — verify before billing**. Multimodal usage counts (`image_count`, `audio_seconds`, …) are read best-effort from span attributes; their semconv names aren't standardized yet, so if your instrumentation doesn't emit them, set the attribute yourself (or extend `traceiq/_compat.py`) to drive cost.

## Tool & quality economics

The SDK can't know what a tool call costs you or how good a response was — call `record_metric()` from inside an agent boundary to stamp those onto the active span:

```python
from traceiq import record_metric, tool, workflow

@tool(name="web_search")
def search(q):
    hits = vendor_search(q)               # this API costs you $0.005/call
    record_metric(tool_cost=0.005)
    return hits

@workflow(name="support_agent")
def run(msg):
    answer = agent(msg)
    record_metric(
        quality_score=grade(answer),      # → traceiq.quality_score
        escalation_cost=2.50,             # human takeover → traceiq.escalation_cost_usd
        retrieved_docs=8,                 # arbitrary → traceiq.metric.retrieved_docs
    )
    return answer
```

These roll up into the trace summary as `total_tool_cost_usd`, `total_escalation_cost_usd`, `avg_quality_score`, and — most importantly — `**total_spend_usd**`, the true cost of the trace (`model cost + tool cost + escalation cost`). `record_metric()` is a safe no-op when called with no active span.

### Tool cost registry

To avoid calling `record_metric()` inside every tool, register flat per-call costs once at startup. They're stamped automatically onto matching `@tool` spans (an explicit `record_metric(tool_cost=…)` still wins):

```python
from traceiq import register_tool_cost

register_tool_cost("web_search", 0.005)
register_tool_cost("human_review", 2.50)
```

## Budget enforcement

Enable with `enforce_budgets=True` (requires an endpoint). On `init()`, a daemon
thread polls your server every 30s and caches per-tenant budget rules in memory:

```
GET {origin}/v1/budgets/{app_name}
```

The endpoint returns a list of rules (or `{"budgets": [...]}`):

```json
[
  { "tenant_id": "acme",   "limit_usd": 100.0, "current_usd": 97.3, "action": "block" },
  { "tenant_id": "globex", "limit_usd": 50.0,  "current_usd": 50.0, "action": "block" }
]
```

Your server owns `current_usd` (it sees all traffic across all your customers' processes).
The SDK only reads the rules and enforces them. A rule with `"action": "block"` whose
`current_usd >= limit_usd` causes a `BudgetExceededError`. Any other action (e.g. `"alert"`)
never blocks.

### Blocking before the spend

`enforce_budgets` wires the check into span processing, but to stop a call **before**
it reaches the provider, guard your agent entrypoint with `check_budget()`:

```python
from traceiq import check_budget, BudgetExceededError, set_context

set_context(tenant_id="acme", user_id="u_alice")

try:
    check_budget()          # reads tenant_id from context; raises if over budget
    answer = run_agent(user_message)
except BudgetExceededError as e:
    answer = f"Budget limit reached: {e}"   # short-circuit before any LLM call
```

`check_budget()` is a no-op unless `enforce_budgets=True`, so it's safe to leave in
code that also runs in local/dev mode.

### Fail-open

If the budget server is unreachable or returns malformed data, the SDK keeps the last
known rules and — with no cached rules — allows the call. A metering outage never takes
your customers' agents down ("water-meter" model). All enforcement state lives in memory;
nothing is persisted by the SDK.

## Auto-instrumented providers

Enabled by Traceloop automatically when the corresponding package is installed — no per-provider code in this SDK:

- OpenAI
- Anthropic
- LangChain / LangGraph
- LlamaIndex
- Cohere, Pinecone, Haystack, and others — see [Traceloop integrations](https://www.traceloop.com/docs/openllmetry/integrations/introduction)

## SDK versions targeted

- `traceloop-sdk` ≥ 0.61.0
- `opentelemetry-semantic-conventions-ai` ≥ 0.5.1
- `OTel GenAI semconv spec` 0.5.0

The `_compat` module reads current semconv attribute names first and falls back to deprecated names, so the field contract holds across SDK versions (pre- and post-v0.55).