Metadata-Version: 2.5
Name: marginal-sdk
Version: 1.0.0
Summary: Marginal SDK for Python — send AI cost events to Marginal.
Project-URL: Homepage, https://marginalhq.com/docs
License-Expression: MIT
License-File: LICENSE
Keywords: ai,analytics,anthropic,cost,gemini,llm,marginal,openai,tracking
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# marginal-sdk

[Marginal](https://marginalhq.com) SDK for Python — see what your AI spend
costs per customer, per feature, per model. Wrap your provider client and
every call is tracked and priced server-side; attach `customer` / `feature`
once per request and every event inherits them.

```bash
pip install marginal-sdk
```

Python 3.9+, stdlib only (zero dependencies). Works with `openai`,
`anthropic`, `google-genai`, every OpenAI-compatible host, sync and async.

## Quickstart

Set `MARGINAL_API_KEY` (an `mgl_…` project key), then:

```python
from openai import OpenAI
from marginal import Marginal

marginal = Marginal()                    # reads MARGINAL_API_KEY
client = marginal.wrap(OpenAI())         # same client back, now tracked

with marginal.fields(customer=org.id, feature="chat"):
    client.chat.completions.create(model="gpt-5-mini", messages=messages, stream=True)
```

That's the whole integration. Non-streaming and streaming calls, the
Responses API, embeddings, the `stream()` / `parse()` helpers, async clients
— all captured. Run with `MARGINAL_DEBUG=1` to watch it happen:

```
[marginal] wrapped openai client → provider "openai" (chat.completions.create, chat.completions.parse, responses.create, responses.parse, embeddings.create)
[marginal] tracked openai/gpt-5-mini-2025-08-07 · customer=acme, feature=chat · via openai-wrap
[marginal] ✓ openai/gpt-5-mini-2025-08-07 · customer=acme, feature=chat → $0.000235
```

### Anthropic and Gemini

```python
anthropic = marginal.wrap(Anthropic())          # messages.create, messages.stream(), beta
gemini = marginal.wrap(genai.Client())          # generate_content, generate_content_stream, chats, aio
```

### OpenAI-compatible hosts

The provider is inferred from the client's base URL (`api.openai.com` →
`openai`, `api.groq.com` → `groq`, Azure → `azure`, `api.deepseek.com` →
`deepseek`, …). For a host the SDK doesn't recognize, name it so pricing
resolves the right catalog:

```python
groq = marginal.wrap(OpenAI(base_url="https://my-proxy.internal/v1"), provider="groq")
```

Streaming Chat Completions carry no usage unless the request opts in with
`stream_options={"include_usage": True}`. The wrapper adds it automatically
for api.openai.com and Azure; pass `include_usage=True` for other hosts that
support it (or `False` to never touch request params).

## Attribution fields

Fields ride on every event and are what you slice by in Marginal. Set them
at whichever level fits:

```python
Marginal(fields={"service": "api"})                        # every event
marginal.wrap(client, fields={"feature": "chat"})          # every event from this client
with marginal.fields(customer=org.id): ...                 # per request, any depth
marginal.track(..., fields={"feature": "voice"})           # per event
```

Per-event > `fields()` scope (inner scopes win) > client/wrapper defaults.
The scope is a `contextvars` context: it follows `asyncio` tasks and `await`s,
and works as a decorator too (`@marginal.fields(feature="summarize")`). It
does **not** cross into a `ThreadPoolExecutor` worker on its own — submit
with `contextvars.copy_context().run(fn)` — and a Celery task starts fresh,
so set the scope inside the task. New field keys are registered in your
project the first time they arrive.

## LiteLLM

One logger, registered once, covers every `litellm.completion` /
`acompletion` / `embedding` call — streams included — and, on the LiteLLM
proxy, every request from any language:

```python
import litellm
from marginal.litellm import MarginalLogger

litellm.callbacks = [MarginalLogger()]  # reads MARGINAL_API_KEY

litellm.completion(model="anthropic/claude-sonnet-5", messages=messages,
                   metadata={"customer": "acme", "feature": "chat"})
```

Fields come from the call's `metadata`, the enclosing `marginal.fields()`
scope, or LiteLLM's `user=` (as `customer`). LiteLLM's own `response_cost`
is sent as the event's cost alongside the usage (`cost_source="marginal"`
to let Marginal price instead). Needs `litellm` (Python 3.10+); the rest of
the package doesn't. Proxy setup and details:
[LiteLLM](https://marginalhq.com/docs/litellm).

## Without wrapping

Hand any provider response to `track_response()` — model and usage are read
off the object, the provider inferred from its shape:

```python
completion = client.chat.completions.create(...)
marginal.track_response(completion, fields={"customer": "acme"})

marginal.track_response(stream.get_final_message())              # Anthropic streams
marginal.track_response(converse_output, model=model_id)         # Bedrock (no model echoed)
marginal.track_response(litellm_response)                        # LiteLLM (provider + cost from the router)
marginal.track_response(ai_message)                              # LangChain AIMessage
```

Or send the triple yourself — the usage object goes as-is (pydantic objects
included; no `.model_dump()` needed), never converted:

```python
marginal.track(provider="openai", model=r.model, usage=r.usage, fields={"customer": "acme"})
```

For spend that isn't token-shaped (voice minutes, images, a cost you already
computed), send dollars: `marginal.track(cost=0.05, fields={"feature": "voice"})`.

## Configuration

Everything reads from the environment first:

| Variable | Option | Effect |
|---|---|---|
| `MARGINAL_API_KEY` | `api_key` | Project key (`mgl_…`). Unset → one warning, events dropped, nothing raises. |
| `MARGINAL_BASE_URL` | `base_url` | Ingestion endpoint. Default `https://api.marginalhq.com`. |
| `MARGINAL_DEBUG=1` | `debug` | Log every tracked event and every delivery with its cost (stderr). |
| `MARGINAL_DISABLED=1` | `disabled` | Full no-op (tests, local dev). |

```python
Marginal(
    api_key="mgl_...",            # server-side only
    fields={"service": "api"},    # default fields
    on_error=lambda err: ...,     # delivery/validation reports (never raised); default: logging warning
    transport=my_transport,       # (url, headers, body) -> (status, body); for unit tests
    flush_on_exit=True,           # default; False disables the atexit flush
)
```

## Delivery

`track()`, `track_response()` and the wrappers are synchronous and never
raise: events are buffered and flushed by a daemon worker thread (every 5 s
or at 100 events) with retries on network errors, 429s and 5xx. Problems
reach `on_error` — `ignored-fields`, `unknown-keys`, `unpriced-models`,
`rejected-events`, `missing-usage` (a stream abandoned before its usage
arrived), `request-failed`, `buffer-overflow`, `wrap-failed`.

On a clean interpreter exit the SDK flushes via `atexit` (not on an unhandled
`SIGTERM` — call `marginal.shutdown()` from your signal handler). Serverless
runtimes can freeze before that, so `marginal.flush()` before returning.
Fork-safe: the worker restarts in the child after `os.fork()` (gunicorn,
celery) without re-sending the parent's buffer.

Delivery is best-effort, at-most-once, with an `event_id` idempotency key
stamped on every event (30-day dedupe window), so a retried batch never
double-counts. A hard crash loses what is still buffered.

## Docs

- [Quickstart](https://marginalhq.com/docs)
- [Integrations](https://marginalhq.com/docs/integrations) — [OpenAI](https://marginalhq.com/docs/openai) and compatible hosts, [Anthropic](https://marginalhq.com/docs/anthropic), [Gemini](https://marginalhq.com/docs/gemini), [LiteLLM](https://marginalhq.com/docs/litellm) (SDK or proxy), [any provider](https://marginalhq.com/docs/any-provider) (Bedrock, LangChain, self-hosted)
- [Attribution fields](https://marginalhq.com/docs/fields) · [Verify & debug](https://marginalhq.com/docs/verify) · [SDK reference](https://marginalhq.com/docs/sdk) · [Event shape & API](https://marginalhq.com/docs/events)
- [llms.txt](https://marginalhq.com/llms.txt) — paste into your coding assistant to instrument a codebase; every docs page is also served as Markdown (`.md` suffix)
- [Agent Skill](https://marginalhq.com/docs/verify#from-your-coding-agent) — `npx skills add https://marginalhq.com` installs Marginal's skill (the integration playbook plus per-provider references) into Claude Code, Cursor, Codex, and friends; the same skill ships inside this package — `npx skills add "$(python -c 'import marginal, os; print(os.path.dirname(marginal.__file__))')"` installs the bundled copy (`marginal/skills/`)
