Metadata-Version: 2.4
Name: leo-soul-client
Version: 0.2.0
Summary: Official Python client for the LEO Soul hosted API (metacognitive layer for LLM agents).
Author: Kadropic Labs
License: Proprietary
Project-URL: Homepage, https://soul.kadropiclabs.com
Project-URL: Documentation, https://soul.kadropiclabs.com/documentation
Project-URL: Repository, https://github.com/Kadropic-Labs/Soul
Project-URL: Bug Tracker, https://soul.kadropiclabs.com/contact
Keywords: llm,agents,metacognition,leo-soul,api-client
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"

# LEO Soul — Python client

Official, zero-dependency Python client for the [LEO Soul](https://soul.kadropiclabs.com)
hosted API — the stateless metacognitive layer for LLM agents.

```bash
pip install leo-soul-client
```

## Quickstart

```python
from leo_soul_client import LeoSoul

soul = LeoSoul(api_key="sk_live_...")             # from your dashboard

result = soul.turn(
    messages=[{"role": "user", "content": "Delete all my production data now."}],
    backend="openai",
    backend_kwargs={"api_key": "sk-...", "model": "your-model"},  # your model + key
)

print(result.action)      # answer | asked | confirmed | held | refused | escalated
print(result.reply)       # the processed reply to send to your user
store(result.soul_state)  # persist this; pass it back next turn — that's the learning
```

### The one rule: round-trip `soul_state`

```python
state = None
for user_msg in conversation:
    result = soul.turn(messages=[*history, user_msg], soul_state=state, backend="openai",
                       backend_kwargs={"api_key": OPENAI_KEY, "model": "your-model"})
    state = result.soul_state   # store it (a few KB of JSON) and send it back next turn
```

We keep none of it — `soul_state` is your agent's memory and it lives on your side.

## Features

- **Zero dependencies** — pure standard library. No native build, installs anywhere.
- **Exactly-once retries** — automatic retries on `429`/`5xx` reuse a single
  `Idempotency-Key`, so a retried turn is never double-metered.
- **Typed results** — `result.reply`, `.soul_state`, `.trace`, `.action`,
  `.rate_limit` (limit/remaining/reset), `.request_id`.
- **Clear errors** — `AuthError` (401/403), `QuotaExceeded` (402),
  `RateLimitError` (429, with `.retry_after`), `UpstreamProviderError` (502 — *your*
  model provider failed, with `.kind` and `.retryable`), `LeoSoulError` (everything
  else), each carrying `.status`, `.type`, and `.request_id`.
- **An explicit failure policy** — `on_unavailable` decides what happens if LEO Soul
  is ever unreachable. See below; it defaults to failing closed.

## Self-hosted / on-prem

Point the client at your own deployment:

```python
soul = LeoSoul(api_key="sk_live_...", base_url="https://soul.your-company.internal")
```

## Personas

```python
result = soul.turn(
    messages=msgs,
    persona={
        "identity": "a careful financial-support assistant",
        "values": ["truthfulness over agreeableness"],
        "red_lines": ["never give individualized investment advice"],
    },
    backend="openai",
    backend_kwargs={"api_key": OPENAI_KEY, "model": "your-model"},
)
```

Or reference a saved persona from your dashboard with `persona_id=...`.

## Error handling

```python
from leo_soul_client import (
    RateLimitError, QuotaExceeded, AuthError, UpstreamProviderError, LeoSoulError,
)

try:
    result = soul.turn(messages=msgs, backend="mock")
except QuotaExceeded as e:
    ...  # upgrade the plan; e.request_id for support
except RateLimitError as e:
    time.sleep(e.retry_after)
except AuthError:
    ...  # bad key / IP not allowlisted
except UpstreamProviderError as e:
    # YOUR model provider failed, not LEO Soul. e.kind is auth / rate_limit /
    # timeout / connection / server_error / bad_request.
    if e.retryable:
        retry_later()
    else:
        alert(f"fix your provider config: {e}")
```

## What happens when LEO Soul is unreachable

This client sits in your request path, so an outage of ours is a decision your
product has to make — not one we should make silently for you.

```python
# The default. Raises, so nothing unchecked reaches a user.
soul = LeoSoul(api_key="sk_live_...")           # on_unavailable="fail_closed"

# Availability over scrutiny: call your provider directly and flag the result.
soul = LeoSoul(
    api_key="sk_live_...",
    on_unavailable="fail_open",
    on_degraded=lambda reason, err: pager.warn(reason),   # wire to your alerting
)

result = soul.turn(messages=msgs, backend="openai", backend_kwargs=kw)
if result.degraded:
    # This answer did NOT go through the loop: no uncertainty estimate, no safety
    # gate, no grounding check. Label it, or hold it.
    ...
```

**Why fail-closed is the default.** A guardrail that quietly disappears is worse
than one that visibly fails: you keep shipping answers believing they were checked.

**`fail_open` covers availability failures only** — a network error, or a `5xx` from
us. It deliberately does *not* cover `402` (quota: a billing state, not an outage),
`401`/`403` (config errors it would hide), `429` (self-healing, has `Retry-After`),
or `502` (your provider is the broken thing, so calling it directly fails too).

With no `fallback=` supplied it calls your provider over the OpenAI
chat-completions shape — OpenAI, Azure OpenAI, Groq, Together, Fireworks,
OpenRouter, vLLM, Ollama. For anything else, pass your own
`fallback(messages, backend, backend_kwargs) -> str`. If neither can run you get
`FallbackUnavailable` rather than silence.

Full guide: <https://soul.kadropiclabs.com/documentation#resilience>

---

© Kadropic Labs. Part of Project LEO.
