tuner-core
Session coordination, configuration, and API client for the Tuner
observability SDKs. Zero provider dependencies — every role package
(tuner-stt-observer, tuner-tts-observer, tuner-llm-observer,
tuner-langchain) depends on this; this package depends on nothing else in
the SDK.
Installation
pip install tuner-core
tuner-core alone is enough if you're calling session._record_user_turn()
/ _record_agent_turn() directly (e.g. bridging a framework like LiveKit or
Pipecat that owns its own provider connections). Add a role package to get
automatic recording for a specific provider.
TunerConfig
Single configuration object, built from environment variables or explicit kwargs — explicit kwargs always win.
from tuner_core import TunerConfig
config = TunerConfig.from_env(
asr_model="nova-2",
llm_model="gpt-4o-mini",
tts_model="sonic-2",
cost_calculator=my_cost_fn, # optional — receives a CallUsage, returns cents
)
| Env var | Required | Purpose |
|---|---|---|
TUNER_API_KEY |
Yes | Bearer token (tr_api_...) |
TUNER_WORKSPACE_ID |
Yes | Integer workspace ID |
TUNER_AGENT_ID |
Yes | Agent identifier from Agent Settings |
TUNER_BASE_URL |
No | Defaults to https://api.usetuner.ai |
TUNER_DEBUG |
No | "true" to log the full payload on flush |
AGENT_VERSION |
No | Integer agent version tag (note: no TUNER_ prefix) |
Extra metadata
Attach arbitrary key-value data to every call record:
config = TunerConfig.from_env(
extra_metadata={
"env": "production",
"region": "us-east-1",
"deployment": "v2.3.1",
},
)
extra_metadata is explicit-kwarg-only (no env var). Keys are merged into
general_meta_data_raw at flush time — a key named ai_models or usage_token is
reserved for the SDK's own computed values and is dropped (with a logged warning)
rather than overwriting them.
Simulation Correlation using SIP call ID
If your agent runs over SIP telephony, pass the call's SIP Call-ID so Tuner
can link a simulation run to the call record your agent submits. Without it,
simulated calls still show up in Tuner — they just won't be tagged as
simulations.
config = TunerConfig.from_env(sip_call_id=sip_id)
tuner-core doesn't talk to your telephony stack directly, so it can't read
this value for you — you resolve it from your own SIP session or provider
webhook and pass it in explicitly. (If you're on LiveKit or Pipecat, our
dedicated SDKs for those platforms capture this automatically.)
TunerSession
One session per call. Attach handlers as you create provider connections;
call flush() when the call ends.
from tuner_core import TunerSession
session = TunerSession(config=config, call_id=call_id)
handler = session.attach(SomeHandler()) # returns the handler for chaining
await session.flush() # POSTs the assembled call payload; never raises
attach() supports two kinds of handlers — see below. It returns whatever
you pass in, so adapter = session.attach(CartesiaAdapter()) works in one
line.
Two handler contracts
1. Transcript collectors — extend BaseTunerHandler
For handlers that record turns as they happen: STT and TTS adapters.
TunerSession.attach() detects the BaseTunerHandler subclass and injects
itself via _bind(), so the handler can call _record_user_turn() /
_record_agent_turn() / _record_stt_usage() / _record_tts_usage()
directly.
# tuner_stt_observer.DeepgramAdapter does this
class DeepgramAdapter(BaseSTTAdapter): # BaseSTTAdapter extends BaseTunerHandler
def _on_transcript(self, event):
self._record_user_turn(text=..., timestamp_ms=...)
2. LLM trace providers — implement get_segments() (coming)
For LLM framework handlers that accumulate execution data. No inheritance
from tuner-core needed — TunerSession finds them via duck typing.
Useful TunerSession state
session.last_user_end_ms— relative ms when the last user utterance ended; TTS adapters read this to computee2e_latency.session.last_llm_duration_ms— most recent LLM turn's processing time; TTS adapters attach it asllm_node_ttft.session.disconnection_reason— settable; pass aDisconnectReasonvalue beforeflush().
Full example — custom FastAPI + LangGraph + Deepgram + Cartesia
import os
import uuid
from fastapi import FastAPI, WebSocket
from cartesia import AsyncCartesia
from tuner_core import TunerConfig, TunerSession
from tuner_stt_observer import DeepgramAdapter
from tuner_tts_observer import CartesiaAdapter
from tuner_langchain import wrap_graph
app = FastAPI()
_agent = build_agent() # your LangGraph graph
@app.websocket("/call")
async def handle_call(websocket: WebSocket):
await websocket.accept()
call_id = str(uuid.uuid4())
config = TunerConfig.from_env(asr_model="nova-2", llm_model="gpt-4o-mini", tts_model="sonic-2")
session = TunerSession(config=config, call_id=call_id)
# STT — attach before connection.start()
dg_connection = dg_client.listen.asyncwebsocket.v("1")
dg_adapter = DeepgramAdapter(connection=dg_connection)
session.attach(dg_adapter)
await dg_connection.start(options)
# TTS — attach once per call, used per utterance
cartesia_client = AsyncCartesia(api_key=os.environ["CARTESIA_API_KEY"])
cartesia_adapter = session.attach(CartesiaAdapter())
# LLM — wraps the graph, exposes get_segments() for duck typing
instrumented_graph = wrap_graph(_agent)
session.attach(instrumented_graph)
# In your agent turn loop:
# result = await instrumented_graph.ainvoke({"messages": history})
# agent_text = result["messages"][-1].content
#
# async with cartesia_adapter.track_ws(agent_text) as tracked:
# async for chunk in tracked(ctx.receive()):
# if chunk.audio:
# await websocket.send_bytes(chunk.audio)
await session.flush() # at call end
What gets captured automatically
| Signal | Source | Handler |
|---|---|---|
| User transcript | Deepgram Transcript/UtteranceEnd events |
DeepgramAdapter |
| Turn timestamps | Provider event timing + stream open time | DeepgramAdapter / SpeechmaticsAdapter |
| STT latency | User speech end → transcript delta | SpeechmaticsAdapter / DeepgramAdapter |
| Agent transcript | Text passed to CartesiaAdapter.track() / track_ws() |
CartesiaAdapter |
| TTS TTFB | Synthesis request → first audio chunk | CartesiaAdapter |
| E2e latency | User speech end → agent first audio byte | CartesiaAdapter (reads session.last_user_end_ms) |
| LLM latency | Graph/chain invocation duration | wrap_graph / wrap_chain (tuner-langchain) |
| Tool calls + results | Graph/chain callbacks | wrap_graph / wrap_chain (tuner-langchain) |
Submission
submit_call() (in client.py) never raises — failures are logged and
swallowed so a Tuner outage can't crash the voice agent it's observing.
- Retries on
429/5xxand network/timeout errors; abandons immediately on other4xx. - Backoff:
1s, 2s, 4s+ up to 500ms jitter,max_retriesattempts (default 3). 409(duplicate call) is treated as success and logged, not retried.
Package install matrix
| Stack | Install | Status |
|---|---|---|
| Custom stack + LangGraph + Deepgram/Speechmatics + Cartesia | tuner-core tuner-stt-observer tuner-tts-observer tuner-langchain |
✅ Supported |
| Custom stack, OpenAI/Anthropic LLM only | tuner-core tuner-llm-observer |
✅ Supported (OpenAI adapter only — see tuner-llm-observer) |
| LiveKit — dedicated integration package | tuner-livekit-sdk |
✅ Supported |
| Pipecat — dedicated integration package | tuner-pipecat-sdk |
✅ Supported |
Development
uv sync
uv run pytest
uv run ruff check .
uv run mypy
Metadata
Release files for tuner-core 0.1.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| tuner_core-0.1.2.tar.gz | 24.1 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tuner_core-0.1.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 44.4 kB
Release files / tuner_core-0.1.2.tar.gz
| Download URL | tuner_core-0.1.2.tar.gz |
|---|---|
| Size | 24.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
63258b961fa78242a66f08b83ab7d2655d25c5b60566491aac6bae81f7cd6a1e
|
|
BLAKE2b-256 checksum How to use checksums |
8754233f0c779af502be8174885b90fd373785758a5218365730c6aefc543f70
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.7
|
Release files / tuner_core-0.1.2-py3-none-any.whl
| Download URL | tuner_core-0.1.2-py3-none-any.whl |
|---|---|
| Size | 20.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
357e2550ee86e794da0210e8fff838b47c7d46b8e6791f70e45e483e661b8680
|
|
BLAKE2b-256 checksum How to use checksums |
4db154b946ce400261178e04ef4b6612baecabb53d6ba49ac375e77cbd2fd4aa
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.7
|
Security for tuner-core 0.1.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.