Metadata-Version: 2.4
Name: kalmia
Version: 0.3.1
Summary: Kalmia Tracing SDK — automatic LLM call tracing for OpenAI and Anthropic
Author: Kalmia
License: MIT
Project-URL: Homepage, https://github.com/xuandy05/trace-analyzer/tree/main/sdk/python#readme
Project-URL: Repository, https://github.com/xuandy05/trace-analyzer
Project-URL: Issues, https://github.com/xuandy05/trace-analyzer/issues
Keywords: llm,tracing,observability,openai,anthropic,claude,agents
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.7
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: openai
Requires-Dist: openai>=1.0.0; extra == "openai"
Provides-Extra: anthropic
Requires-Dist: anthropic>=0.10.0; extra == "anthropic"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
Dynamic: license-file

# Kalmia SDK (Python)

Automatic LLM call tracing for OpenAI and Anthropic. Wrap your client once and
every call - prompts, responses, tool calls, errors - is captured and sent to
your Kalmia dashboard. Zero external dependencies.

## Installation

```sh
pip install kalmia
```

## Usage

Get your API key from the Kalmia dashboard, then initialize the logger once at
startup and wrap your LLM client:

```python
from kalmia import init_logger, wrap_anthropic
import anthropic

init_logger(
    project_name="my-agent",
    api_key="kal_live_your_api_key",  # required - or set KALMIA_API_KEY
)

client = wrap_anthropic(anthropic.Anthropic())
# use `client` exactly like the Anthropic SDK - traces are captured automatically
```

OpenAI works the same way:

```python
from kalmia import init_logger, wrap_openai
import openai

init_logger(project_name="my-agent", api_key="kal_live_your_api_key")
client = wrap_openai(openai.OpenAI())
```

## Configuration

| Argument / env var | Purpose |
| --- | --- |
| `project_name` (required) | Groups traces under a project. |
| `api_key` / `KALMIA_API_KEY` | Workspace API key. Without it, traces are rejected and dropped. |
| `base_url` / `KALMIA_BASE_URL` | Where traces are sent. Defaults to the hosted Kalmia backend (`https://www.kalmia.dev`). Set this for local development or self-hosting. |
| `metadata` | Optional dict merged into every trace's metadata (e.g. environment, version). |

## Decorators and spans

`traced` works as a decorator, and also as a context manager. Use `current_span()`
to log inside the active span:

```python
from kalmia import traced, current_span

@traced(name="run")
def run(message):
    current_span().log(input={"message": message})
    ...

# or as a context manager
with traced(name="Read", span_type="tool") as span:
    span.log(input={"path": "f.txt"}, output="contents")
```

Tool spans are priced automatically when the logged output is the raw response
of a recognized paid service (Exa, Tavily, Brave, Serper, and more).
For any other paid call, include `cost_usd` in the logged output and Kalmia
records it as that tool's cost:

```python
with traced(name="scrape_page", span_type="tool") as span:
    result = client.scrape(url)
    span.log(input={"url": url}, output={**result, "cost_usd": 0.002})
```

## Streaming

Streaming responses (`stream=True`) are captured automatically.
The span is emitted when the stream ends or when you break out of it early, including the bare `for` loop that every example here and in the docs uses:

```python
for chunk in client.chat.completions.create(
    stream=True, model="...", messages=[...]
):
    ...
    break  # the partial span is still captured and sent
```

For deterministic finalization - for instance when you hold the stream in a long-lived variable, or want the span emitted the moment you finish - use the `with` form, the explicit and preferred shape:

```python
with client.chat.completions.create(
    stream=True, model="...", messages=[...]
) as stream:
    for chunk in stream:
        ...
```

Either way, exactly one span is emitted per stream.

## Live trace streaming (mid-run batches)

By default a trace is posted exactly once, when its root span ends.
Opt in to live streaming and completed spans are additionally posted in mid-run batches while the trace is still running, so the dashboard can show it live:

```python
init_logger(
    project_name="my-agent",
    api_key="kal_live_your_api_key",
    streaming=True,               # or KALMIA_STREAMING=1
    streaming_batch_size=20,      # optional
    streaming_flush_interval_s=5, # optional
)
```

| Argument / env var | Default | Purpose |
| --- | --- | --- |
| `streaming` / `KALMIA_STREAMING` | off | Opt into mid-run span batches. With the flag off, behavior is identical to previous releases (single post at root end). |
| `streaming_batch_size` / `KALMIA_STREAMING_BATCH_SIZE` | 20 | Completed-but-unsent spans that trigger a mid-run batch. |
| `streaming_flush_interval_s` / `KALMIA_STREAMING_FLUSH_INTERVAL_MS` | 5 s | Time since the last flush that triggers a mid-run batch. The env var is in milliseconds (mirroring the TypeScript SDK) and is converted to seconds. |

Explicit arguments win over env vars, matching the other options.

The delivery contract:

- **Mid-run batches are best-effort.** Each batch is a single delivery attempt with no retry and no buffering, and a failure is silent. Spans are only marked as delivered after the server confirms the batch, so a failed batch simply rides along in a later batch or the final flush.
- **The final flush is the durability net.** When the root span ends, the trace is posted through the normal durable path (retry, buffering, exit-time drain) carrying the completed root plus every span not already confirmed delivered. It first waits briefly for any in-flight mid-run batch to settle, so no span is double-sent in its final state.
- **Batches merge safely in any order.** Mid-run payloads omit unset fields entirely (the running root is sent without an end time, never with a null one), so a batch that arrives late can never overwrite data another post already delivered.

## Customer feedback

Relay your end customer's reaction to a run - a thumbs rating and/or an open-text comment - and Kalmia attaches it to the run's trace.
Capture the trace id during the run with `current_trace_id()` (or `span.trace_id`), store it with your own session record, then submit feedback whenever it arrives:

```python
from kalmia import traced, current_trace_id, submit_feedback

# During the run: capture the trace id and store it with your session.
with traced(name="my-agent-run"):
    trace_id = current_trace_id()
    # ... run your agent, persist trace_id with the session record ...

# Later, when the customer reacts:
result = submit_feedback(trace_id, rating="down", comment="Wrong date")
if not result["ok"]:
    print("feedback not delivered:", result["error"])
```

`rating` is `"up"` or `"down"`; `comment` is open text.
Provided fields overwrite the trace's stored feedback state and omitted fields keep it - one current state per trace, no history.
The server accepts feedback for a trace that has not finished ingesting, so no wait-for-trace logic is needed.

Invalid usage (no trace id, empty feedback, unknown rating) raises `ValueError`.
Delivery problems (network, auth, server errors) never raise: they come back as `{"ok": False, "error": ...}` after bounded retry.
Delivery is a blocking HTTP call bounded by the retry schedule - offload it if that matters on your path.

## Concurrency

Tracing tracks the active span with a `contextvars.ContextVar`, which is
**copied into `asyncio` tasks** (`asyncio.gather`, `create_task`) - so async
concurrency links parent/child spans correctly. Context vars are **not**
inherited by new OS threads, however: if you offload LLM calls to a thread pool
(`ThreadPoolExecutor`, `asyncio.to_thread`, `loop.run_in_executor`), the worker
thread won't see the parent span and each call becomes its own root trace. To
preserve the tree, capture the context and run the child within it:

```python
import contextvars
ctx = contextvars.copy_context()
executor.submit(ctx.run, do_llm_call)
```

## Delivery reliability

Trace delivery is **at-least-once with no silent loss**:

- **Retry with backoff.** Transient failures (transport errors, timeouts, `429`,
  `5xx`) are retried with bounded backoff. Permanent `4xx` responses (`400`,
  `401`, `413`) are not retried.
- **Buffering.** A trace that still fails after its retries is held in a bounded
  in-memory buffer and re-attempted on the next send, then once more at
  interpreter exit (`atexit`).
- **No silent loss.** Every drop path - a non-retryable response, a full buffer
  evicting its oldest entry, a server-side rejection, or a trace still
  undelivered at exit - emits a warning naming the trace `id`, so a lost run is
  always visible in your logs.
- **Safe retries.** The backend de-duplicates by trace `id`, so a retried
  delivery never creates a duplicate.

The buffer lives in memory, so a hard kill (`SIGKILL`, OOM) can still lose
buffered traces - those are warned when the trace is first buffered. Durable
on-disk buffering is planned. Delivery never raises into your application.
