Metadata-Version: 2.4
Name: kadari
Version: 0.2.0
Summary: Local capture client for Kadari -- record your LLM calls to a local log for cost analysis.
Author-email: Kadari <support.kadari@gmail.com>
Maintainer-email: Kadari <support.kadari@gmail.com>
License-Expression: Apache-2.0
Project-URL: Homepage, https://kadari.ai/
Keywords: llm,openai,anthropic,observability,cost,logging,capture,inference,classification
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
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 :: Logging
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# kadari — the local capture client

`kadari` is the only thing Kadari ships to you. It's a **thin, zero-dependency Python
library** that records the LLM calls you *already make* to a **local** log. Kadari's
engine (which stays on Kadari's side) then analyses that log and shows you, with proof,
where you could safely route to a cheaper in-family model — without ever re-running a
call or crossing a quality bar you set.

This package carries **none of Kadari's analysis engine** — no scoring, no classifier
model, no benchmarks. It is capture glue, by design. Read every line.

## Install

Zero runtime dependencies (stdlib only):

```
pip install kadari
```

Or install from a checkout / a wheel your Kadari contact hands you:

```
pip install ./client
# or:  pip install kadari-0.2.0-py3-none-any.whl
```

## Use

```python
from kadari import LiveRecorder, wrap

rec = LiveRecorder("kadari_capture.jsonl")   # a local path you control

# Option A — record explicitly, right after a call you already make:
resp = client.messages.create(model="claude-opus-4-8", messages=[{"role": "user", "content": text}])
rec.record_anthropic(id=req_id, input=text, response=resp.to_dict())
# OpenAI: rec.record_openai(id=req_id, input=text, response=resp.to_dict())

# Option B — wrap the call once and capture every call automatically:
create = wrap(client.chat.completions.create, provider="openai", recorder=rec)
resp = create(model="gpt-5.4-nano", messages=[{"role": "user", "content": text}])
```

Then hand the resulting `kadari_capture.jsonl` to Kadari (or, later, point the hosted
client at it) to get your savings report.

## Safety contract

- **Fail open on the host, fail closed on the data.** Recording is a side-channel: a bug
  here will **never** raise into your production call path — `record()` warns and returns
  `False` instead of throwing (unless you pass `strict=True`). What *does* get written is
  always valid and loadable.
- **Local-first.** This library never opens a network connection. Your prompts and outputs
  stay in the local file you named; nothing is uploaded by `kadari`.
- **Privacy controls.** `redact=` scrubs each input — and each tool-call argument value —
  before it is written; `sample=` records only a fraction of calls to bound log size.

```python
rec = LiveRecorder("kadari_capture.jsonl", sample=0.1, redact=my_scrubber)
```

## Wire format — the stable SDK contract

The on-disk JSONL log is the **contract** between this client and Kadari's engine. It is
**near-immutable**: backward-compatible within a major version; a breaking change requires a
new major version and a documented migration path (Kadari rule 6). One JSON object per line:

```json
{"id": "req-123", "model": "gpt-5.4-nano", "input": "...", "output": "Electronics",
 "usage": {"input_tokens": 64, "output_tokens": 2}}
```

| field | type | required | meaning |
|-------|------|----------|---------|
| `id` | string | yes | unique per log — a captured log is **rejected wholesale** on a duplicate id. If you don't pass one, a uuid is generated, and a reused id is refused at write time so one retry can't make the whole log unloadable. |
| `model` | string | yes | the premium provider model string you actually called (e.g. `gpt-5.5`, `claude-opus-4-8`) — priced verbatim. |
| `input` | string | yes (may be empty) | the request text that gets scored/re-classified cheaply. |
| `output` | string | yes | the label or prose the premium model already returned. The provider adapters (`record_openai`/`record_anthropic`/`wrap`) unwrap a single-field structured object `{"category": "X"}` to `X`; the raw `record(output=...)` path writes the string **verbatim**, so pass a bare label there. When a response carries a tool call and **no** prose, the adapters write `tool:<name>` (see below). |
| `usage` | object | optional | `{"input_tokens", "output_tokens"}`, both non-negative integers. Attached **only when both are present** — a half block is dropped, since the engine honours usage only if both are set. When omitted, Kadari estimates spend from text length. |
| `tool_calls` | array | optional (since 0.2.0) | the structured decision(s) the model already emitted, in provider order. Never written empty — a log with no tool call is byte-identical to one written before this field existed. |
| `tool_calls[].name` | string | yes (per entry) | the tool/function name — *which* decision was made. |
| `tool_calls[].arguments` | object | optional | the arguments, verbatim, with their original JSON types. Present unless `arguments_omitted` is. |
| `tool_calls[].arguments_omitted` | string | optional | why the payload is absent: `oversize`, `unparsed`, `not_an_object`, `unserializable`. Mutually exclusive with `arguments`. **Readers must tolerate a reason they don't recognise.** |

Comment lines (`//…`) and blank lines are ignored by the reader.

**Compatibility guarantees within a major version:**

- the five fields above keep their names and meaning;
- new **optional** fields may be added; readers ignore unknown fields, so a newer client log
  still loads in an older engine and vice-versa;
- no required field is removed or repurposed, and no field type changes.

### Tool calls — the structured decision

If your premium call already emits a **tool call** (an agentic support agent deciding to
issue a refund, look up an order, escalate), that tool call *is* the decision — and it is
the part worth measuring. The adapters record it automatically; nothing to configure:

```json
{"id": "req-9", "model": "gpt-5.5", "input": "where is my order 118?",
 "output": "I've started your refund — 3–5 business days.",
 "usage": {"input_tokens": 812, "output_tokens": 96},
 "tool_calls": [{"name": "refund_order", "arguments": {"order_id": "A-118", "amount_cents": 4000}}]}
```

- **A reply that explains *and* acts keeps both halves** — prose in `output`, decision in
  `tool_calls`. Nothing about `output` changed for calls that already captured.
- **A reply that only acts** has no prose, so `output` becomes `tool:<first tool name>` —
  `output` is required and non-empty, and the `tool:` prefix keeps an agentic call from
  being mistaken for a classification label.
- **Every tool call is recorded**, in the order the provider returned them — not just the first.
- **We only record a decision the model already emitted.** Kadari never infers one from
  prose: an inferred decision is a guess wearing a proof's clothing.
- Arguments are recorded **verbatim or not at all** — never truncated. If they can't be
  represented faithfully (malformed JSON from the model, a value that isn't JSON, a payload
  too large for the line ceiling), the entry keeps the tool `name` and states the reason in
  `arguments_omitted`. Losing the payload never loses the call.
- `redact=` **applies to argument values** as well as `input` (keys and tool names are
  structure, not content, and are left alone). One consequence worth knowing: if you redact
  a value that also appears in the reply text, Kadari's checks can no longer match the two —
  which is the honest outcome, not a silent one.

This is enforced, not promised: the client never imports engine code, but a contract test in
the engine repo (`tests/test_kadari_client.py`) writes a log with **this** client and asserts
it loads unchanged through the engine's reader — so the two halves can never silently drift.
The engine accepts one additional **input** shape (a nested Anthropic Messages
`{"id","request","response"}` transcript); this client always emits the flattened shape above.
