Metadata-Version: 2.4
Name: lilac-sdk
Version: 0.1.0
Summary: Python SDK for Lilac — agent observability. Capture LLM exchanges with one line, no wrapping of your provider call.
Project-URL: Homepage, https://trylilac.ai
Project-URL: Documentation, https://trylilac.ai/docs
Project-URL: Repository, https://github.com/lilac-intelligence/lilac-python
Author: Lilac
License: MIT
License-File: LICENSE
Keywords: agents,anthropic,langchain,lilac,llm,observability,openai
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
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 :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Requires-Dist: httpx>=0.24
Provides-Extra: dev
Requires-Dist: build>=1.0; extra == 'dev'
Requires-Dist: openai>=1.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: respx>=0.20; extra == 'dev'
Provides-Extra: langchain
Requires-Dist: langchain-core>=0.1; extra == 'langchain'
Description-Content-Type: text/markdown

# lilac-sdk

Python SDK for [Lilac](https://trylilac.ai) — agent observability. Capture LLM
exchanges with one line, no wrapping of your provider call, no added latency.

## Install

```bash
pip install lilac-sdk
```

## Quickstart

```python
import lilac

lilac.init(api_key="sk_live_...", agent_id="agt_yourAgentId")
# Self-hosted: set LILAC_ENDPOINT (and LILAC_DEPLOYMENT_MODE=self_hosted) in
# your environment instead of passing endpoint= here. See "Endpoint
# resolution" below.
```

```python
import lilac
from openai import OpenAI, APIError, RateLimitError

client = OpenAI()
lilac.init(api_key="sk_live_...", agent_id="agt_yourAgentId")

def handle_user_message(conversation_id: str, user_text: str) -> str:
    call_input = [{"role": "user", "content": user_text}]
    try:
        response = client.responses.create(model="gpt-5.5", input=call_input)
    except (APIError, RateLimitError) as e:
        # The call still happened and still belongs to this conversation —
        # record it as a failed exchange rather than letting it vanish.
        lilac.capture_failure(input=call_input, conversation_id=conversation_id, error=str(e))
        raise

    lilac.capture(response, input=call_input, conversation_id=conversation_id)
    return response.output_text
```

That's the whole integration for a single exchange. `capture()` reads
`model`/token usage off the real OpenAI/Anthropic response object
automatically — you never set those by hand.

## Endpoint resolution

`lilac.init()` resolves where to send data by checking these in order:

1. Explicit `endpoint=` passed to `init()`
2. `LILAC_ENDPOINT` environment variable — **the normal path**, set once per
   deployment in your own application's environment
3. Default `https://api.trylilac.ai/v1` — used automatically only for the
   Online/hosted product (or when deployment mode is unset)

**Self-hosted deployments fail closed, not open.** Set
`LILAC_DEPLOYMENT_MODE=self_hosted` alongside `LILAC_ENDPOINT` (or pass
`deployment="self_hosted"` to `init()`). With this set, if `LILAC_ENDPOINT`
is missing or empty for any reason, `init()` raises immediately at startup
instead of silently falling back to the public `api.trylilac.ai` endpoint.
This matters most for government/compliant-channel deployments, where
telemetry reaching the public endpoint even once is a real compliance
failure, not just a misconfiguration.

```bash
# Self-hosted
export LILAC_DEPLOYMENT_MODE=self_hosted
export LILAC_ENDPOINT=https://your-lilac-host/v1

# Online — omit both; the SDK defaults to the hosted endpoint
```

## capture()

```python
lilac.capture(
    response,
    input=call_input,
    conversation_id=conversation_id,
    user_id=current_user.id,              # hashed at ingest, never stored in the clear
    system_prompt=SYSTEM_PROMPT,          # feeds judge-tier grounding when set
    output_type="answer",                 # answer | handoff | action | refusal | clarification
    correlation_ref=order_id,             # machine-to-machine join key
    tags={"plan": "enterprise", "region": "us-east"},
)
```

| Parameter | Required | Notes |
|---|---|---|
| `input` | **required** | The same `input`/`messages` value you passed to the provider call — this is the only source of the user's turn; the response object doesn't contain it. |
| `conversation_id` | **required** (or via context manager, below) | Groups this event with the rest of its conversation. |
| `user_id` | optional | Enables cross-session/return-rate analysis; hashed at ingest. |
| `system_prompt` | optional | Feeds judge-tier grounding when set. |
| `output_type` | optional | `answer`, `handoff`, `action`, `refusal`, or `clarification`. |
| `correlation_ref` | optional | Machine-to-machine join key for linking this session to another. |
| `tags` | optional | Any flat dict of string keys/values — segmentation (plan tier, region, experiment arm, etc). |

## capture_failure()

The `except`-block counterpart to `capture()` — records a failed exchange so
provider-side outages/rate limits don't silently vanish from your session
data. Optional, but recommended for any agent running at meaningful volume.

```python
try:
    response = client.responses.create(model="gpt-5.5", input=call_input)
except (APIError, RateLimitError) as e:
    lilac.capture_failure(input=call_input, conversation_id=conversation_id, error=str(e))
    raise
```

## Streaming

```python
call_input = [{"role": "user", "content": user_text}]
stream = client.responses.create(model="gpt-5.5", input=call_input, stream=True)
for chunk in lilac.capture_stream(stream, input=call_input, conversation_id=conversation_id):
    yield chunk   # your consumption of the stream is unchanged
```

## Multi-turn conversations — the context manager

If a single conversation spans several separate `capture()` calls (a
multi-turn chat, or a call several function calls deep inside a tool
dispatcher), `with lilac.conversation(id):` sets `conversation_id` once and
every nested `capture()` call underneath inherits it automatically — a real
[`contextvars`](https://docs.python.org/3/library/contextvars.html) value,
propagating through `async`/`await` and nested calls with no explicit
threading.

```python
with lilac.conversation(conversation_id):
    previous_id = None
    for user_text in incoming_messages():
        response = client.responses.create(
            model="gpt-5.5", previous_response_id=previous_id, input=user_text,
        )
        lilac.capture(response, input=user_text)   # conversation_id inherited
        previous_id = response.id
```

`lilac.set_conversation(id)` is the bare-setter equivalent for places a
context manager doesn't fit cleanly (middleware, framework hooks, background
workers).

**Precedence, never silent:** explicit `conversation_id=` argument > context
variable > raise. If neither is set, Lilac never guesses — it raises
`LilacUsageError` so the gap is visible during development.

## Drop-in wrapper

For bespoke/custom agent loops with call sites scattered across a large
codebase — swap your provider import, every call through that client is
captured automatically:

```python
# Before
from openai import OpenAI
client = OpenAI()

# After
from lilac.openai import OpenAI
client = OpenAI(lilac_api_key="sk_live_...", agent_id="agt_yourAgentId")

# Every call below is captured automatically — no lilac.capture() needed
with lilac.conversation(conversation_id):
    response = client.responses.create(model="gpt-5.5", input=[...])
```

Trade-off worth knowing: this wrapper owns the client instance, so it's the
right fit for a single-vendor integration but doesn't compose as cleanly if
another tool is also wrapping the same client. Use `capture()` instead if
you're layering Lilac alongside another observability tool on the same call
sites.

## Callback function

```python
from lilac.integrations.langchain import LilacCallbackHandler

agent_executor.invoke(
    {"input": user_text},
    config={"callbacks": [LilacCallbackHandler(conversation_id=conversation_id)]},
)
```

Requires the `langchain` extra: `pip install lilac-sdk[langchain]`.

The same pattern applies to any framework exposing a callback/hook interface
around its LLM calls (LlamaIndex, CrewAI, Semantic Kernel, and similar) — the
handler implementation differs per framework's callback interface, but this
SDK currently ships only the LangChain handler; others are on the roadmap.

## Raw OTLP (non-Python stacks)

Send OpenTelemetry spans directly to the same endpoint every integration
method above uses under the hood: `POST {endpoint}/ingest/otlp`. See the
[full API reference](https://trylilac.ai/docs) for the span attribute
contract.

## License

MIT
