Metadata-Version: 2.4
Name: keld
Version: 1.0.0
Summary: Drop-in OTLP telemetry for apps calling openai / anthropic / bedrock directly — zero call-site changes, reports usage to Keld Atlas.
Author-email: Keld <support@keld.co>
Maintainer-email: Keld <support@keld.co>
License: MIT
Project-URL: Homepage, https://keld.co
Project-URL: Repository, https://github.com/ncx-ai/atlas-telemetry-python
Project-URL: Issues, https://github.com/ncx-ai/atlas-telemetry-python/issues
Project-URL: Changelog, https://github.com/ncx-ai/atlas-telemetry-python/blob/main/CHANGELOG.md
Keywords: observability,otlp,opentelemetry,openai,anthropic,bedrock,telemetry,llm,finops
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Monitoring
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.24
Provides-Extra: openai
Requires-Dist: opentelemetry-sdk>=1.44; extra == "openai"
Requires-Dist: opentelemetry-instrumentation-genai-openai==1.1b0; extra == "openai"
Provides-Extra: test
Requires-Dist: pytest>=7.4; extra == "test"
Requires-Dist: pytest-asyncio>=0.21; extra == "test"
Requires-Dist: respx>=0.20; extra == "test"
Requires-Dist: openai>=1.30; extra == "test"
Requires-Dist: anthropic>=0.25; extra == "test"
Requires-Dist: boto3>=1.34; extra == "test"
Requires-Dist: botocore>=1.34; extra == "test"
Requires-Dist: opentelemetry-sdk>=1.44; extra == "test"
Requires-Dist: opentelemetry-instrumentation-genai-openai==1.1b0; extra == "test"
Dynamic: license-file

# keld

Drop-in OTLP telemetry for applications that call the `openai` or `anthropic` Python
clients directly, boto3's Bedrock runtime client, or any OpenAI-*compatible* endpoint
(OpenRouter, vLLM, Together, Groq, Ollama, ...) via a custom `base_url`.

Call `keld.init()` once at startup and every inference call your app already
makes is reported to a [Keld Atlas](https://keld.co) backend as usage telemetry — **zero
call-site changes**. openai is observed via the official OpenTelemetry instrumentation
(class-level, covering every client instance); anthropic and bedrock clients are patched
in place. Requests still go straight to the real provider, untouched. Keld is never in
the request path: no added latency of consequence, and an Atlas outage can never break
your app.

## Install

```bash
pip install "keld[openai]"
```

The `[openai]` extra installs the OpenTelemetry instrumentation that openai reporting
requires. **Without it, openai calls are not reported** (the SDK logs one warning and
your app runs normally). Apps that only use anthropic/bedrock can install the bare
package:

```bash
pip install keld
```

Python 3.10 or newer.

The only runtime dependency of the bare package is `httpx`. Installing this SDK never
pulls in `openai`, `anthropic`, or `boto3`: it patches whichever of them your app
already has.

To install an unreleased commit straight from the repo:

```bash
pip install "keld[openai] @ git+https://github.com/ncx-ai/atlas-telemetry-python@main"
```

## Usage

```python
import keld

keld.init(
    ingest_token="kagt_...",              # or ATLAS_INGEST_TOKEN env var
    endpoint="https://atlas.example.com", # or ATLAS_ENDPOINT env var
    environment="production",             # optional
    repo="acme/checkout-service",         # optional
)
```

Call this before (or after — see "Clients constructed before `init()`" below) you
construct your `openai.OpenAI()` / `anthropic.Anthropic()` / `boto3.client("bedrock-
runtime")` client. From then on, every `chat.completions.create`, `messages.create`,
`invoke_model`, and `converse` call — streaming or not — is reported.

### Clients constructed before `init()`

If an anthropic or bedrock client already exists by the time `init()` runs (common in
scripts and notebooks, or when a client is built inside a third-party library you don't
control), pass it explicitly:

```python
keld.init(
    ingest_token="kagt_...",
    endpoint="https://atlas.example.com",
    clients=[my_anthropic_client, my_bedrock_client],
)
```

openai clients never need this: the OpenTelemetry instrumentation wraps the openai
resource *classes*, so clients constructed before or after `init()` are both covered
(passing one in `clients` is harmless and does nothing).

### Custom / self-hosted OpenAI-compatible endpoints

OpenRouter, vLLM, Together, Groq, Ollama and similar gateways are attributed
automatically from the endpoint's host (via the span's `server.address`; Ollama is also
recognized by its registered port `11434`). An unrecognized host is reported as
`hosted_vllm` with a one-time warning — a confidently-wrong provider label would price
at $0 while looking like real data. For such endpoints pass an explicit override:

```python
keld.init(..., provider="ollama")
```

## Configuration

All of the following can be set as `init()` keyword arguments or as environment
variables (the kwarg wins if both are set):

| Env var | `init()` kwarg | Required | What it is |
|---|---|---|---|
| `ATLAS_INGEST_TOKEN` | `ingest_token` | Yes | A Keld Atlas **agent key** (`kagt_...`), obtained from the Atlas **Integrations** page. **This is not an inference API key** — see below. |
| `ATLAS_ENDPOINT` | `endpoint` | Yes | Your Keld Atlas deployment's base URL, e.g. `https://atlas.example.com`. The SDK appends `/v1/logs` itself. |
| `ATLAS_ENVIRONMENT` | `environment` | No | Freeform deploy environment label (`production`, `staging`, ...); feeds Atlas's CapEx/OpEx classification. |
| `ATLAS_REPO` | `repo` | No | Freeform string identifying the calling application/repo, e.g. `acme/checkout-service`. |
| — | `gzip` | No | Default `True`. Gzips the OTLP export body and sends `Content-Encoding: gzip`; pass `gzip=False` to send plain JSON. |

There is no env var for `clients=` or `provider=` — those only make sense as explicit
`init()` arguments.

### `ATLAS_INGEST_TOKEN` is *not* your provider API key

This is the single most common point of confusion, so to be unmissable:

- `ATLAS_INGEST_TOKEN` (`kagt_...`) is a **Keld Atlas** credential. It authenticates
  *telemetry reports* to Atlas. Get it from your Atlas admin's **Integrations** page.
- Your **OpenAI API key**, **Anthropic API key**, and **AWS credentials** (for Bedrock)
  are configured completely separately, the normal way each provider's own client/SDK
  already expects (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, AWS credential chain, etc).
- **This SDK never sees, reads, or transmits your inference provider credentials.** It
  only reads the *response* of calls your own client makes with your own credentials —
  token counts, model id, request id, timing — never request/response content, and
  never your keys.

## What gets sent (and what doesn't)

Every call is reported as one OTLP log record: `provider`, `model`, `input_tokens`,
`output_tokens`, `cache_read_tokens`, `cache_creation_tokens`, `duration_ms`,
`request_id`, and `stream` (anthropic/bedrock streaming calls only — openai records on
this branch carry no stream marker) — plus a `session.id` /
`event.sequence` / `prompt.id` the SDK mints itself. Token counts always come from the
provider's own response; the SDK never estimates them.

**Never sent:** prompt text, response/completion text, message bodies, or `cost_usd`.
Atlas recomputes cost server-side from a price table — sending a cost figure here would
be redundant at best and wrong (stale/incomplete) at worst.

## Failure modes — the SDK is designed to disappear

- `init()` never raises. A client it can't find or patch is skipped with one warning
  log; the rest of your app runs exactly as if unpatched.
- `init()` is idempotent — call it twice and the second call is a no-op.
- A bug in *our* extraction/export code is caught and logged; it can never surface to
  or break your call. A real error from the provider always propagates to you
  unchanged, exactly as if the SDK weren't installed — it's also recorded as a
  zero-token event with an `error` attribute, so error rates stay visible in Atlas.
- Export runs on a background thread over a bounded queue that **drops the oldest
  entries** under backpressure. It never blocks your request thread and never blocks on
  network I/O.
- A slow or unreachable Atlas endpoint costs you nothing beyond a few dropped telemetry
  rows — never added request latency, never a failed provider call.

## Streaming

OpenAI only includes token usage on a streamed response when the request carries
`stream_options={"include_usage": True}`. The SDK injects that by default — the **one**
place it modifies an outgoing request (telemetry capture itself never touches requests;
it's OpenTelemetry-based). Every chunk, including the resulting usage-only chunk with
empty `choices`, still passes through to your code unchanged. A caller's own
`stream_options` is merged with, never replaced. Opt out with:

```python
keld.init(..., inject_stream_usage=False)
```

The consequence of opting out: streamed openai rows report zero tokens. Opt out if your
OpenAI-compatible gateway rejects `stream_options`.

Anthropic and Bedrock streaming carry usage in their own event/metadata shapes and need
no request changes — usage is accumulated from the stream as it passes through.

## Bedrock specifics

- `request_id` (the AWS request id) is always sent for Bedrock calls — Atlas uses it to
  de-duplicate against a separately-deployed AWS-side CloudWatch collector, if your org
  runs one.
- `model` is sent exactly as passed to `invoke_model`/`converse` (including inference
  profile ids/ARNs) — Atlas normalizes these server-side, this SDK never transforms them.
- `invoke_model`'s response body is a one-shot stream; the SDK reads it once to extract
  usage and replaces it with an equivalent fresh stream over the same bytes, so your own
  `response["body"].read()` still works exactly as if unpatched.

## Development

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[test]"
pytest -v
```

The contract test (`tests/test_contract.py`) asserts the SDK's OTLP export payload is
structurally identical to `tests/fixtures/atlas-sdk-otlp-export.golden.json`, a copy of
the normative fixture published by the `keld-atlas` backend
(`services/api/tests/fixtures/atlas-sdk-otlp-export.golden.json`). If you touch the wire
format, that fixture — and its counterpart in `keld-atlas` — is the thing to keep in
sync; a diff there is the signal the contract moved, not something to "fix" locally.

### Releasing to PyPI

Releases are published by `.github/workflows/release.yml` using
[PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/), so no API token is
stored in this repo. One-time setup registers the publisher at
<https://pypi.org/manage/project/keld/settings/publishing/> (Your projects → `keld` →
Manage → Publishing): owner `ncx-ai`, repository `atlas-telemetry-python`, workflow
`release.yml`, environment `pypi`. Add a second entry on TestPyPI with environment
`testpypi` for dry runs.

Use that project page, **not** the account-level *pending publisher* form. Pending
publishers only accept names that do not exist on PyPI yet; `keld` does, so that form
fails with "project already exists". PyPI returns the same message whether you own the
project or not, so a failure there is about which form you used, or about your role.

That step needs the PyPI **Owner** role on the project. A Maintainer can upload releases
and nothing else, so a Maintainer can neither add a trusted publisher nor yank a release.

`keld` on PyPI previously held the Keld CLI at `0.1.0`. This SDK starts at `1.0.0`: PyPI
versions are immutable, so the CLI's release can be yanked but never replaced.

To cut a release:

1. Bump `__version__` in `src/keld/_version.py`. It is the single source of
   truth; `pyproject.toml` reads it.
2. Move the `Unreleased` notes into a new version section in `CHANGELOG.md`.
3. Merge to `main`, then tag and push:

   ```bash
   git tag v0.1.0 && git push origin v0.1.0
   ```

The workflow verifies the tag matches `__version__`, builds an sdist and a wheel, runs
the suite against the built wheel on Python 3.10 through 3.13, and only then publishes.
Running the workflow manually (Actions → Release → Run workflow) does the same build and
publishes to TestPyPI instead.

## License

MIT
