Metadata-Version: 2.4
Name: superpenguin
Version: 0.8.0
Summary: SuperPenguin Python SDK — AI cost management, attribution, and spend tracking
Project-URL: Homepage, https://superpenguin.ai
Project-URL: Documentation, https://superpenguin.ai/docs
License-Expression: MIT
License-File: LICENSE
Keywords: ai,anthropic,attribution,cost-management,deepgram,elevenlabs,gemini,google,litellm,livekit,llm,monitoring,observability,openai,spend-tracking,stt,tts,voice-ai
Classifier: Development Status :: 3 - Alpha
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.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: anthropic
Requires-Dist: anthropic; extra == 'anthropic'
Provides-Extra: deepgram
Requires-Dist: deepgram-sdk>=3.0.0; extra == 'deepgram'
Provides-Extra: dev
Requires-Dist: anthropic; extra == 'dev'
Requires-Dist: openai; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest>=7.0.0; extra == 'dev'
Provides-Extra: elevenlabs
Requires-Dist: elevenlabs>=1.0.0; extra == 'elevenlabs'
Provides-Extra: google
Requires-Dist: google-genai; extra == 'google'
Provides-Extra: livekit
Requires-Dist: livekit-agents>=1.0.0; extra == 'livekit'
Provides-Extra: openai
Requires-Dist: openai; extra == 'openai'
Provides-Extra: otel
Requires-Dist: opentelemetry-api; extra == 'otel'
Provides-Extra: voice
Requires-Dist: deepgram-sdk>=3.0.0; extra == 'voice'
Requires-Dist: elevenlabs>=1.0.0; extra == 'voice'
Requires-Dist: livekit-agents>=1.0.0; extra == 'voice'
Description-Content-Type: text/markdown

# SuperPenguin Python SDK

Track AI costs automatically across LLMs and voice. Wrap your OpenAI, Anthropic, Google Gemini, or AWS Bedrock client (or patch litellm) and every LLM call is captured with token counts, estimated cost, latency, and attribution metadata. For voice, the same `sp.wrap()` works on **Deepgram** (STT) and **ElevenLabs** (TTS) clients standalone, and a one-line shim captures **LiveKit Agents** per-turn metrics + per-session billing rows so a single voice call's STT + LLM + TTS + agent-session all stitch back together on one `session_id`. No proxy required.

> **How is this different from native provider attribution?** See [`docs/vs-native-attribution.md`](../../docs/vs-native-attribution.md) for the full breakdown of what OpenAI / Anthropic / Deepgram / ElevenLabs offer natively vs. what SuperPenguin adds on top.

## Installation

```bash
pip install superpenguin
```

For OpenTelemetry trace correlation:

```bash
pip install "superpenguin[otel]"
```

Or install from source (in the `sdk/python/` directory):

```bash
pip install -e .
```

## Quick Start

### 1. Wrap your client (one line)

```python
import superpenguin as sp
from openai import OpenAI

sp.init(api_key="sp_...")  # your SuperPenguin API key

client = sp.wrap(OpenAI())

# Use the client exactly as normal — cost events are captured automatically
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Hello!"}],
)
print(response.choices[0].message.content)
```

That's it. Every `create()` call through the wrapped client is captured with provider, model, token counts, estimated cost (USD), and latency.

### 2. Works with Anthropic too

```python
import superpenguin as sp
from anthropic import Anthropic

sp.init(api_key="sp_...")

client = sp.wrap(Anthropic())

response = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Hello!"}],
)
```

### 3. Google Gemini (AI Studio or Vertex AI)

The same `sp.wrap()` works on the unified [`google-genai`](https://github.com/googleapis/python-genai) client, which targets either the Gemini API (AI Studio) or Vertex AI:

```python
import superpenguin as sp
from google import genai

sp.init(api_key="sp_...")

# AI Studio
client = sp.wrap(genai.Client(api_key="..."))

# Or Vertex AI
client = sp.wrap(genai.Client(vertexai=True, project="my-gcp", location="us-central1"))

response = client.models.generate_content(
    model="gemini-2.5-pro",
    contents="Hello!",
)
```

Both `generate_content` and `generate_content_stream` are tracked, on `client.models` and `client.aio.models` (async). Tiered pricing for `gemini-2.5-pro` and `gemini-3.1-pro-preview` is applied automatically based on the input token count.

### 3.5 AWS Bedrock (boto3 / aioboto3)

The same `sp.wrap()` instruments a Bedrock runtime client. Wrap the client and every `converse` / `converse_stream` call auto-submits a row with the raw `modelId`, token counts, latency, and metadata:

```python
import superpenguin as sp
import boto3

sp.init(api_key="sp_...")

client = sp.wrap(boto3.client("bedrock-runtime"), metadata={"customer_id": "acme_corp"})

response = client.converse(
    modelId="anthropic.claude-3-5-sonnet-20241022-v2:0",
    messages=[{"role": "user", "content": [{"text": "Hello!"}]}],
)
```

`async` works too via `aioboto3` (`session.client("bedrock-runtime")`); both `converse` and `converse_stream` are tracked, with streaming usage accumulated across the event stream into a single row.

Bedrock is a reseller: a model's price on Bedrock differs from its origin vendor (Claude on Bedrock vs. Anthropic direct). To keep this accurate and current, the Bedrock wrapper sends **no client-side cost**. It emits token facts plus the raw `modelId`, and the server prices the row with its provider-scoped Bedrock rate card.

v1 scope is the `Converse` API only. `InvokeModel` (vendor-specific bodies, no uniform usage) and per-call metadata overrides are not yet supported; pass defaults via `sp.wrap(..., metadata={...})`.

### 4. Streaming works transparently

```python
client = sp.wrap(OpenAI())

stream = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Tell me a story"}],
    stream=True,
)

for chunk in stream:
    if chunk.choices and chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="", flush=True)

# Cost event is submitted automatically when the stream finishes
```

### 5. LiteLLM support

If you use [litellm](https://github.com/BerriAI/litellm) to call 100+ LLM providers through a single interface, one call patches everything:

```python
import superpenguin as sp
import litellm

sp.init(api_key="sp_...")
sp.patch_litellm()

# Every litellm.completion() / litellm.acompletion() is now tracked
response = litellm.completion(
    model="openai/gpt-4o",
    messages=[{"role": "user", "content": "Hello!"}],
)
```

### 6. Add attribution metadata

Attach metadata to attribute costs to customers, features, teams, or environments:

```python
# Set defaults for all calls from this client
client = sp.wrap(OpenAI(), metadata={
    "customer_id": "cust_acme_123",
    "feature": "doc_summary",
    "team": "product",
    "environment": "production",
})

# Or override per-call via extra_body
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Summarize this document"}],
    extra_body={
        "sp_metadata": {
            "customer_id": "cust_other_456",
            "prompt_key": "summarize_v2",
            "prompt_version": "3",
        }
    },
)
```

### 7. Standalone Deepgram (STT) — `sp.wrap(deepgram_client)`

If you call Deepgram directly from a backend service (no LiveKit Agents in the loop), wrap the client and every `transcribe_url` / `transcribe_file` call auto-submits a row with `audio_seconds`, the canonical model SKU, and the cost in USD micros.

```python
import superpenguin as sp
from deepgram import DeepgramClient, PrerecordedOptions

sp.init(api_key="sp_...")

dg = sp.wrap(
    DeepgramClient(api_key="..."),
    metadata={"customer_id": "cust_acme_123", "feature": "podcasts"},
    tier="growth",  # optional — drop or set "growth" if you're on the Growth plan
)

result = dg.listen.rest.v("1").transcribe_url(
    {"url": "https://example.com/episode.mp3"},
    PrerecordedOptions(model="nova-3", multilingual=True),
)
# A single request_logs row is submitted automatically with
#   provider="deepgram"
#   model="deepgram/nova-3-multilingual-prerecorded[-growth]"
#   audio_seconds = result.metadata.duration
```

#### What's wrapped

| Method | Sync | Async |
|---|---|---|
| `client.listen.rest.v("1").transcribe_url` | ✓ | ✓ (via `client.listen.asyncrest`) |
| `client.listen.rest.v("1").transcribe_file` | ✓ | ✓ (via `client.listen.asyncrest`) |
| Live WebSocket STT (`listen.live.v(...)`) | ⏳ planned (v2) | ⏳ planned (v2) |
| Callback-mode async transcribe (`callback=` URL) | ✗ — response is empty | ✗ |
| Deepgram TTS (`speak.rest.v(...)`) | ⏳ pricing seed pending | ⏳ |
| Voice Agent API (`agent.v(...)`) | ⏳ pricing seed pending | ⏳ |

#### SKU routing

Both methods we wrap hit Deepgram's HTTP REST endpoint, which bills against the **pre-recorded** SKU — ~44% cheaper than the streaming WebSocket SKU on every Nova model. The wrapper composes the right slug from four signals:

* model engine (Nova-3 vs Nova-2, mono- vs multi-lingual — pulled from `response.metadata.model_info`)
* API endpoint (always `-prerecorded` for the methods we wrap today)
* commitment tier (`-growth` when you pass `tier="growth"`; defaults to PAYG)

Example slugs the dashboard will see: `deepgram/nova-3-monolingual-prerecorded`, `deepgram/nova-3-multilingual-prerecorded-growth`, `deepgram/nova-2-monolingual-prerecorded`.

#### Per-call metadata

Deepgram's request models don't expose an `extra_body` hook, so per-call metadata overrides aren't supported in this iteration. Pass defaults via `sp.wrap(..., metadata={...})`. A `sp.context()` context manager that works across all wrappers is planned.

#### Don't double-wrap with LiveKit

If you're already using `LiveKitObservability` (Section 9), do NOT additionally wrap the Deepgram client used inside the LiveKit Agents plugin — both paths would emit a row for the same audio. In practice LiveKit Agents constructs its own internal Deepgram client that you don't hold a reference to, so natural separation is the norm.

### 8. Standalone ElevenLabs (TTS) — `sp.wrap(elevenlabs_client)`

Same `sp.wrap()` pattern for ElevenLabs. Every `text_to_speech.convert` / `convert_as_stream` / `text_to_sound_effects.convert` call auto-submits a row with `characters = len(text)`, the canonical model SKU, and the cost.

```python
import superpenguin as sp
from elevenlabs.client import ElevenLabs

sp.init(api_key="sp_...")

el = sp.wrap(
    ElevenLabs(api_key="..."),
    metadata={"customer_id": "cust_acme_123", "feature": "ivr-greeting"},
)

audio = el.text_to_speech.convert(
    voice_id="21m00Tcm4TlvDq8ikWAM",
    text="Welcome to SuperPenguin",  # 23 characters
    model_id="eleven_flash_v2_5",
)
# A single request_logs row is submitted automatically with
#   provider="elevenlabs"
#   model="elevenlabs/eleven_flash_v2_5"
#   characters=23
```

#### What's wrapped

| Method | Sync | Async |
|---|---|---|
| `client.text_to_speech.convert` | ✓ | ✓ (`AsyncElevenLabs`) |
| `client.text_to_speech.convert_as_stream` | ✓ (stream proxy) | ✓ (async stream proxy) |
| `client.text_to_sound_effects.convert` | ✓ | ✓ |
| `client.text_to_sound_effects.convert_as_stream` | ✓ (stream proxy) | ✓ |
| Real-time WebSocket TTS (`text_to_speech.stream`) | ⏳ planned | ⏳ |
| Speech-to-Speech (`speech_to_speech.convert`) | ⏳ pricing seed pending | ⏳ |
| Voice changer | ⏳ pricing seed pending | ⏳ |

#### Stream wrapping

`convert_as_stream` returns an iterator of audio bytes. The wrapper proxies the iterator and submits the row when you finish consuming it (or call `.close()`/exit a `with` block). The cost is fixed at `len(text)` regardless of how you consume the stream — wrapping the iterator just means `latency_ms` reflects time-to-completion instead of time-to-iterator-construction.

#### Failed requests

ElevenLabs doesn't bill failed synthesis. If a call raises, the wrapper still emits a row (so error-rate dashboards stay accurate) with `status_code != 200` and `cost_usd_micros = 0`.

### 9. Voice agents (LiveKit + Deepgram + ElevenLabs)

If you run a voice agent on top of [LiveKit Agents](https://docs.livekit.io/agents/) — typically Deepgram for STT, an OpenAI/Anthropic LLM for reasoning, and ElevenLabs for TTS — wire the `LiveKitObservability` shim into your `AgentSession` and three of those four billing surfaces are captured per-turn straight from LiveKit's own `MetricsCollectedEvent` stream:

```python
import asyncio
import superpenguin as sp
from superpenguin.voice import LiveKitObservability
from openai import AsyncOpenAI

sp.init(api_key="sp_...")

llm_client = sp.wrap(AsyncOpenAI())  # ← LLM still goes through sp.wrap()

obs = LiveKitObservability(
    session_id=ctx.room.name,  # any stable per-call id; LiveKit room name works
    metadata={"customer_id": "cust_acme_123", "feature": "drive-thru"},
)

@session.on("metrics_collected")
def _on_metrics(ev):
    asyncio.create_task(obs.on_metrics(ev))

# When the call ends, emit the LiveKit session-minute + observability-event rows:
await obs.on_session_end(duration_seconds=elapsed_seconds)
```

#### What gets emitted

| LiveKit event | Provider row | Billing unit |
|---|---|---|
| `STTMetrics` (per turn) | `deepgram` / `nova-3-monolingual` | `audio_seconds` |
| `TTSMetrics` (per turn) | `elevenlabs` / `eleven_flash_v2_5` | `characters` (+ `audio_seconds` for analytics) |
| `LLMMetrics` (per turn) | **skipped** — already captured by `sp.wrap()` | — |
| `on_session_end()` | `livekit` / `agent-session-minute` | `audio_seconds` (= session duration) |
| `on_session_end()` | `livekit` / `observability-event` | `events` (count of MetricsCollectedEvent fan-out) |

Defaults match the pricing entries seeded in `pricing/models.json`. Override per session via the constructor if you're on a different SKU:

```python
obs = LiveKitObservability(
    session_id=ctx.room.name,
    stt_provider="deepgram", stt_model="deepgram/nova-3-multilingual",
    tts_provider="elevenlabs", tts_model="elevenlabs/eleven_v3",
    routing_path="livekit_inference",  # ← if you're using LiveKit's bundled inference
)
```

#### Why no LLM row from the shim?

`LLMMetrics` is intentionally a no-op. The LLM call is already captured by the `sp.wrap()` client wrapper with full token / cache / tool detail; emitting a second row from the voice shim would double the LLM line on the dashboard.

#### Cross-provider correlation

Every row the shim emits carries the same `session_id`, so the dashboard can join Deepgram + LLM + ElevenLabs + LiveKit rows back into a single voice call. Each row also carries a deterministic `idempotency_key = f"{session_id}:{kind}:{request_id}"`, so an SDK-side retry is deduplicated server-side via the `idx_request_logs_idempotency` index.

#### Subscription quotas

Quotas (LiveKit Build/Ship/Scale, ElevenLabs Pro/etc., Deepgram Growth) are **not** applied per-row — per-call costing has no view of monthly aggregation. The dashboard subtracts `subscription_tiers[tier].included_units` from the monthly sum before applying the per-unit overage rate, using the tier auto-detected from your Connect-Provider integration.

#### Voice provider integration model

| Provider | Server-pull (Connect Provider) | SDK standalone wrap | SDK LiveKit shim |
|---|---|---|---|
| Deepgram | ✓ daily usage breakdown | ✓ `sp.wrap(DeepgramClient(...))` | ✓ per-turn STT |
| ElevenLabs | ✓ daily usage analytics | ✓ `sp.wrap(ElevenLabs(...))` | ✓ per-turn TTS |
| LiveKit | credentials only — **no usage API** | ✗ (no standalone surface to wrap) | ✓ per-session billing rows (the only source) |

Three integration paths, three different shapes:

* **Server-pull** is daily aggregates pulled from the provider's billing API on a cron — fast to set up but coarse (no per-call attribution, no latency).
* **Standalone wrap** is per-call rows from the SDK with full latency and metadata; pick this when you call Deepgram or ElevenLabs directly from a backend service.
* **LiveKit shim** is per-turn rows from LiveKit's own `MetricsCollectedEvent` stream; pick this when you run inside `livekit-agents`. Don't combine with standalone wrap on the same client — that double-counts.

LiveKit itself doesn't expose a billing or usage API at all, so the SDK shim is the *only* path that produces LiveKit cost rows. The LiveKit Connect-Provider entry exists purely so the dashboard can show "LiveKit · connected" and store the credential triple for future control-plane operations (force-disconnect a runaway session, list active rooms, etc.).

## `@sp.trace` Decorator

For multi-step pipelines (RAG, agents, chains), use the `@sp.trace` decorator. Any wrapped LLM calls inside the function are automatically linked as children.

```python
import superpenguin as sp
from openai import OpenAI

sp.init(api_key="sp_...")
client = sp.wrap(OpenAI())


@sp.trace
def answer_question(question: str) -> str:
    docs = search_knowledge_base(question)

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": f"Context:\n{docs}"},
            {"role": "user", "content": question},
        ],
    )

    return response.choices[0].message.content


result = answer_question("How do I reset my password?")
```

### Decorator variants

```python
@sp.trace
def my_function(): ...

@sp.trace("my-pipeline")
def my_function(): ...

@sp.trace(name="my-pipeline", tags=["production"], metadata={"customer_id": "acme"})
def my_function(): ...
```

### Async support

Both `wrap()` and `@sp.trace` work with async clients and functions:

```python
from openai import AsyncOpenAI

client = sp.wrap(AsyncOpenAI())


@sp.trace
async def answer_question(question: str) -> str:
    response = await client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": question}],
    )
    return response.choices[0].message.content
```

## Configuration

### `sp.init()`

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `api_key` | `str` | `SP_API_KEY` env var | Your SuperPenguin API key |
| `base_url` | `str` | `https://app.superpenguin.ai` | API endpoint |
| `flush_interval` | `float` | `5.0` | Seconds between background batch flushes |
| `batch_size` | `int` | `50` | Max events per batch POST |

### Environment variables

| Variable | Description |
|----------|-------------|
| `SP_API_KEY` | API key (used if not passed to `init()`) |
| `SP_BASE_URL` | API base URL override |

If `SP_API_KEY` is set, `init()` is called automatically on first use.

### `sp.wrap()`

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `client` | `OpenAI \| Anthropic \| genai.Client \| DeepgramClient \| ElevenLabs` | required | The client to wrap. Provider is auto-detected from `type(client).__module__`. |
| `name` | `str` | `None` | Override the default event name (LLM wrappers only today) |
| `metadata` | `dict` | `None` | Default metadata for every call (customer_id, feature, team, etc.) |
| `tags` | `list[str]` | `None` | Tags added to every event |
| `tier` | `str` | `None` | Deepgram only. `"growth"` routes rows to the discounted Growth-plan SKU; omit / `None` for PAYG. Raises `TypeError` for non-Deepgram providers. |

### `sp.patch_litellm()`

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `name` | `str` | `None` | Override the default event name |
| `metadata` | `dict` | `None` | Default metadata for every litellm call |
| `tags` | `list[str]` | `None` | Tags added to every event |

### `sp.flush()`

Force-flush any pending events. Useful before process exit in short-lived scripts:

```python
sp.flush()
```

An `atexit` handler also flushes automatically on normal interpreter shutdown.

## Metadata Fields

| Field | Type | Purpose |
|-------|------|---------|
| `customer_id` | string | End-customer or account consuming the AI call |
| `feature` | string | Product feature name (e.g., `search`, `support_agent`) |
| `team` | string | Internal team owning the feature |
| `environment` | string | `production`, `staging`, `dev`, etc. |
| `prompt_key` | string | Identifier for the prompt template |
| `prompt_version` | string | Version of the prompt template |
| Any other key | string | Stored as custom tags, queryable in the dashboard |

## What Gets Tracked

Each event includes:

### LLM rows (from `sp.wrap()` / `sp.patch_litellm()`)

| Field | Description |
|-------|-------------|
| `provider` | `"openai"`, `"anthropic"`, `"google"`, or `"litellm"` |
| `model` | Model name used |
| `input_tokens` | Prompt token count |
| `output_tokens` | Completion token count |
| `cached_tokens` | Cached prompt tokens (if applicable) |
| `cost_usd_micros` | Estimated cost in USD micros (1 USD = 1,000,000 micros) |
| `latency_ms` | End-to-end call duration |
| `streaming` | Whether the call was streamed |
| `has_tools` | Whether tool calls were used |
| `has_vision` | Whether image inputs were included |

### Voice rows (from standalone `sp.wrap()` or `LiveKitObservability`)

| Field | Description |
|-------|-------------|
| `provider` | `"deepgram"`, `"elevenlabs"`, or `"livekit"` |
| `model` | e.g. `deepgram/nova-3-monolingual-prerecorded`, `elevenlabs/eleven_flash_v2_5`, `livekit/agent-session-minute`, `livekit/observability-event` |
| `modality` | `"audio_in"` (STT), `"audio_out"` (TTS), `"session"` (LiveKit minute), or `"event"` (LiveKit observability) |
| `audio_seconds` | Billable audio duration for STT and LiveKit-session rows; recorded for analytics on TTS rows |
| `characters` | Synthesized character count on TTS rows (the ElevenLabs billing unit) |
| `events` | Count of MetricsCollectedEvent fan-out on the LiveKit observability-event row |
| `cost_usd_micros` | Estimated cost in USD micros, computed against the bundled `pricing/models.json` |
| `session_id` | Cross-provider correlation key (typically the LiveKit room name). NULL on standalone Deepgram/ElevenLabs rows. |
| `idempotency_key` | `f"{session_id}:{kind}:{request_id_or_seq}"` — server-side dedup via `idx_request_logs_idempotency`. Only set on LiveKit-shim rows; standalone calls are atomic. |
| `routing_path` | `"direct"` (default) or `"livekit_inference"` when using LiveKit's bundled inference. NULL on standalone rows. |
| `latency_ms` | Standalone wrap: full call duration (or stream completion). LiveKit shim: best-effort TTS `ttft` / STT `duration` in ms. |

**Never captured:** Prompt content, response content, images, audio, tool arguments, or function results. The SDK only captures cost-relevant metadata.
