Metadata-Version: 2.4
Name: autotune-hook
Version: 0.2.0
Summary: Autotune Hook
Author: Autotune
License-Expression: LicenseRef-Proprietary
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Provides-Extra: httpx
Requires-Dist: httpx>=0.27; extra == "httpx"

# Autotune Hook

Python instrumentation for reporting OpenRouter calls to AutoTune.

## Zero-code: `with_session` is the only line (recommended)

With `AUTOTUNE_ENDPOINT` (and usually `AUTOTUNE_API_KEY`) in the environment,
entering a session auto-enables process-wide httpx interception: every
OpenRouter chat completion made by any httpx-based SDK (openai-python,
pydantic-ai) is preflighted against cloud workflow policy and shadow-reported
or proxied accordingly. No hook construction, no transport wiring, no
decorator — the session context is the only AutoTune line in application
code, and it carries all dynamic attribution:

```python
from autotune_hook import with_session

with with_session(
    workflow="slack-agent",
    session_id=thread_id,
    user_id=user_id,
):
    completion = client.chat.completions.create(...)  # any httpx-based SDK
```

Workflow, project, session, and user are code-only: the hook deliberately
never reads them from env vars, because they vary with code and callers.
The only attribution default that rides the environment is
`AUTOTUNE_ENVIRONMENT` (deployment-static by nature). Requires the `httpx`
extra (`pip install autotune-hook[httpx]`).

Controls:

- `AUTOTUNE_INTERCEPT=0` — kill switch: sessions only attribute, never
  install interception (for apps wired with a decorator or explicit
  transport instead).
- `AUTOTUNE_HOOK_MODE=observe` — report-only tee semantics: no preflight,
  no proxy, AutoTune being down can never block a model call. The default
  (`full`) honors the cloud policy envelope, including proxy mode.
- `enable_interception()` / `disable_interception()` — explicit runtime
  control over the same machinery (mirrors the TypeScript hook's
  `enableInterception()`).

Per-call overrides can also ride request headers wherever the SDK lets you
add them (openai-python `extra_headers`): `x-autotune-workflow`,
`x-autotune-project`, `x-autotune-environment`, `x-autotune-session-id`,
`x-autotune-user-id`. Headers beat the session context and are stripped
before the provider sees the request.

Non-httpx HTTP stacks (requests, aiohttp) are not intercepted — use the
wrapper/decorator below or the drop-in proxy base URL for those.

## Transport integration (explicit wiring)

Same machinery, scoped to one client instead of the process. Wrap the app's
httpx transport and every OpenRouter chat completion through it is visible to
AutoTune. `mode="observe"` (default) is a pure tee; `mode="full"` adds the
preflight/proxy envelope:

```python
import httpx
from autotune_hook import AutotuneAsyncTransport

client = httpx.AsyncClient(transport=AutotuneAsyncTransport(mode="full"))
openrouter = AsyncOpenAI(base_url="https://openrouter.ai/api/v1", http_client=client)
```

Already wrapping transports (retries, sanitizers)? Put the AutoTune transport
outermost so one logical call maps to one recorded run — the final response
the app saw.

## Wrapper / decorator integration

For call sites that hold the request payload directly, and for non-httpx
stacks. These also honor cloud workflow policy: the request is preflighted,
and AutoTune answers with an explicit envelope — `{"mode": "shadow",
"ttl_seconds": N}` (execute the wrapped call, report its output in the
background, and skip further preflights for that workflow until the TTL
expires) or `{"mode": "proxy", "response": {...}}` (return the
AutoTune-executed response). Any response without the envelope falls back to
the app's own call, so an intermediary's bare 200 can never be mistaken for
an LLM response. Streaming requests pass through unobserved.

```python
from autotune_hook import AutotuneHook

hook = AutotuneHook(workflow="email-triage")

def call_openrouter(payload):
    return hook.openrouter_call(
        payload,
        lambda: openrouter_client.chat.completions.create(**payload),
    )
```

Decorator form:

```python
@hook.openrouter()
def call_openrouter(**payload):
    return openrouter_client.chat.completions.create(**payload)
```

Integrations stack safely: a call already observed by a decorator or an
explicit transport is suppressed at the interception layer, so one logical
call is never recorded twice.

## Sessions

```python
from autotune_hook import with_session

with with_session(session_id=run_id, user_id=user_id, workflow="ocr"):
    ...  # every hooked/intercepted call inside is attributed
```

Sessions nest (inner values win), flow through async code and
`asyncio.to_thread`, and carry `workflow`, `project`, `environment`,
`session_id`, and `user_id`.

## Reporting behavior

Reports ride a single background thread with a bounded queue: they never
block the calling thread or event loop, overflow drops reports (with a
warning) rather than growing memory, and `flush_reports()` drains pending
reports at worker shutdown (also registered via `atexit`). Inline `data:`
URLs larger than 256 bytes (base64 images) are replaced with a
size-and-hash placeholder before reporting; disable with
`AUTOTUNE_HOOK_REDACT_DATA_URLS=0`. Reports whose serialized size exceeds
`AUTOTUNE_HOOK_MAX_REPORT_BYTES` (default 4 MB) are dropped with a warning.

## Configuration

Set `AUTOTUNE_ENDPOINT` and, when required, `AUTOTUNE_API_KEY` in the
environment. If `AUTOTUNE_ENDPOINT` is unset, the hook stays fully out of the
way.

Dynamic attribution belongs in code, not static process env. Pass workflow,
session, user, project, and environment values through `with_session(...)`,
`AutotuneHook(...)`, or per-call metadata/headers so each request is
attributed to the right run.
