Metadata-Version: 2.4
Name: mubit-sdk
Version: 1.0.1
Summary: Mubit client for Python: model calls through the Mubit relay, the management API, the memory API and the mubit command
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.31
Requires-Dist: grpcio>=1.62
Requires-Dist: protobuf>=7.35.1
Requires-Dist: httpx>=0.25
Provides-Extra: import
Requires-Dist: pyarrow>=14; extra == "import"

# mubit-sdk

The Mubit client for Python. Version 1.0.1.

Mubit records the model calls your agents make and trains smaller models from them. You send the calls through the Mubit relay with an inference key, using the stock OpenAI or Anthropic client. Mubit groups the calls into tasks, collects and rates training examples, fine-tunes a smaller model for each task and checks it against held-out examples. A model that passes serves its task on a canary or shadow share of the traffic and can be rolled back; Mubit also forms lessons from the traffic.

The console is https://app.mubit.ai. The management API (`/api/v1`) lets code and agents do what the console does. This package gives you the relay clients (`client.calls`), the management API (`client.tasks`, `client.versions` and the rest), the memory API of earlier releases (`client.memory`) and the `mubit` command.

## Install

```bash
pip install mubit-sdk openai        # and anthropic for calls.anthropic()
```

`openai` and `anthropic` are not dependencies of this package: install the one you call. Python 3.10 or newer.

## Keys and base URL

| Key | Prefix | Environment variable | Used for |
| --- | --- | --- | --- |
| Inference key | `mw_` or `mbt_` | `MUBIT_API_KEY` | model calls through the relay, and the memory API |
| Management key | `mwm_` | `MUBIT_MANAGEMENT_KEY` | the management API (`/api/v1`) |

The base URL is `https://api.mubit.ai` (EU, the default) or `https://api-us.mubit.ai` (US); use the one of your organization's region:

```bash
export MUBIT_API_KEY=mw_...
export MUBIT_BASE_URL=https://api-us.mubit.ai     # US organizations only
```

`Mubit(api_key=..., management_key=..., base_url=...)` takes the same values as keyword arguments; an argument wins over the environment, and `MUBIT_ENDPOINT` is still read when `MUBIT_BASE_URL` is unset. An inference key sent to the management API answers 403 `wrong_lane`.

## Model calls through the relay

```python
from mubit import Mubit

client = Mubit()                              # MUBIT_API_KEY and MUBIT_BASE_URL from the environment
openai = client.calls.openai()                # a stock openai.OpenAI for <base URL>/openai/v1 and the inference key

reply = openai.chat.completions.create(
    model="gpt-5-mini",
    messages=[{"role": "user", "content": "Classify this ticket: the invoice total is wrong."}],
)
print(reply.choices[0].message.content)
```

`client.calls.anthropic()` returns a stock `anthropic.Anthropic` for `<base URL>/anthropic` in the same way, and `AsyncMubit().calls` returns `openai.AsyncOpenAI` and `anthropic.AsyncAnthropic`. Both take the provider client's own keyword arguments (`default_headers`, `timeout`, `max_retries` and so on) and pass them through unchanged; `api_key` and `base_url` replace the defaults. To configure a client yourself, give it the base URL `https://api.mubit.ai/openai/v1` (OpenAI) or `https://api.mubit.ai/anthropic` (Anthropic) and the inference key as its API key.

The relay records each call and forwards it to the provider with the provider key stored for your project. The calls, the tasks they form and the models trained from them show in the console and through the management API.

## Management

```python
from mubit import Mubit

client = Mubit()                              # MUBIT_MANAGEMENT_KEY and MUBIT_BASE_URL from the environment

for task in client.tasks.list():                                   # follows next_cursor across pages
    print(task["id"], task["name"], task["stage"], task["active_version"])

task_id = "task_triage12"
calls = client.calls.list(task=task_id, limit=20)                  # one page: calls.data, calls.next_cursor
client.calls.feedback(calls.data[0]["id"], rating="down", comment="Missed the sales tax line.")

train = client.tasks.train(task_id)
print(train["job"]["id"], train["job"]["status"])                  # a queued training job

versions = client.tasks.versions(task_id)
version = versions.data[0]
client.versions.rollout(version["id"], canary_percent=10, shadow_percent=0)
client.versions.promote(version["id"])                             # serve it for all of the task's traffic
back = client.versions.rollback(version["id"])                     # demote it; back["restored_version_id"] serves again
print(back["status"])
```

The namespaces follow the `/api/v1` resources: `client.org.get()`, `client.projects.list()` / `get(id)`, `client.calls.list()` / `get(id)` / `feedback(id, rating=...)`, `client.tasks.list()` / `get(id)` / `examples(id)` / `review_example(id, example_id, action=...)` / `settings(id)` / `train(id)` / `pause(id)` / `resume(id)` / `versions(id)`, `client.versions.get(id)` / `promote(id)` / `rollback(id)` / `rollout(id, canary_percent=..., shadow_percent=...)`, `client.lessons.list()` / `get(id)`, `client.imports.list()` / `create(...)`, `client.datasets.list()`, `client.evals.list()` / `get(id)`, `client.usage.get()`, `client.billing.get()`, `client.activity.list()`, `client.jobs.get(id)` / `wait(id, timeout=...)`, `client.webhooks.list()`, `client.management_keys.list()` / `create(name=..., role=...)` / `rotate(id)` / `revoke(id)` and `client.keys.list()` (the inference keys). Answers are the API's JSON as dicts, with its snake_case field names.

A list call returns its first page (`page.data`, `page.next_cursor`), and iterating the page follows `next_cursor` across every page. Each POST carries an `Idempotency-Key` header (generated unless you pass `idempotency_key=`), so a retried POST is not applied twice. A 429 or 5xx answer is retried up to `max_retries` times (default 2), after `Retry-After` when the server sends one. `AsyncMubit` has the same namespaces for asyncio: `page = await client.tasks.list()`, or `async for task in client.tasks.list():`.

## Availability in 1.0

- Model calls through the relay with an inference key work today.
- The management API is being turned on organization by organization. Until it is on for yours, every `/api/v1` call answers 404 with code `not_found` (the client raises `mubit.NotFoundError`). Management keys (`mwm_`) are not issued yet.
- Where the management API is on, the server answers the read routes (list and get). The write routes (call feedback, example review, train, pause, resume, promote, rollback, rollout, management keys and imports) answer 501 `not_implemented` until they ship; pause and resume come in 1.1.

## Errors

A call that fails raises a subclass of `mubit.APIStatusError` with `status_code`, `code` (the API's error code, for example `not_found`, `wrong_lane`, `rate_limited`, `not_implemented`), `request_id`, `retry_after` and `body`:

```
MubitError
├── ConfigurationError                  no key, no base URL, bad option
├── APIConnectionError                  DNS, refused, TLS
│   └── APITimeoutError
├── APIStatusError(status_code, code, request_id, body, retry_after)
│   ├── BadRequestError (400)  AuthenticationError (401)  PermissionDeniedError (403)  NotFoundError (404)
│   ├── ConflictError (409)    GoneError (410)            PayloadTooLargeError (413)   RateLimitError (429)
│   └── InternalServerError (500 and other 5xx, 501 included)                          UnavailableError (503)
└── UnsupportedFeatureError
```

```python
import mubit

client = mubit.Mubit()
try:
    client.tasks.get("task_triage12")
except mubit.NotFoundError as exc:            # no such task, or the management API is off
    print(exc.code, exc.request_id)           # "not_found"
```

Every class in the tree is on the package root. A failed connection to the management API raises `MubitError` (`Connection error: ...`). Errors of a call through the relay are the provider client's own (`openai.APIError` and its subclasses, for example). The memory API adds `LessonRejected`, `ControlError`, `PolicyError`, `ControlAuthenticationError`, `PolicyAuthenticationError` and `EntitlementError`, also on the root.

## Memory (the API of earlier releases)

The memory API (`/v2`) stays for existing users, on the same inference key.

```python
from mubit import Mubit, Scope

client = Mubit()                                                    # MUBIT_API_KEY and MUBIT_BASE_URL from the environment
support = client.with_options(scope=Scope(project="support", env="dev", run_id="ticket-42"))

support.memory.remember("Customer dates are day-first (DD/MM/YYYY): 03/04/2025 means 2025-04-03.",
                        kind="lesson", visibility="global")
answer = support.memory.recall("How should I read the date 03/04/2025?")
print(answer["final_answer"])
```

`client.memory` also has `context`, `forget`, `checkpoint`, `reflect` and the other memory operations, and the loop helpers of earlier releases (`mubit.init`, `mubit.run`, `run.outcome`, `mubit.wrap_openai`, `mubit.wrap_anthropic` and the rest) are still on the package root. `mubit.Client` is the memory client of earlier releases with its resource namespaces (`client.memory`, `client.runs`, `client.outcomes`, `client.lessons`, `client.policy`, `client.kill`, `client.jobs`, `client.proposals`, `client.snapshots`, `client.agents`, `client.projects`, `client.audit`, `client.admin`, `client.keys`, `client.raw.invoke(op)`); on `Mubit`, `lessons`, `projects`, `jobs` and `keys` are the management namespaces instead. `mubit.Client().keys.delete(key_id)` (alias `revoke`) is `DELETE /v2/core/acl/keys/<key_id>`. The `mubit` command (`mubit doctor`, `mubit runs`, `mubit lessons`, `mubit import` and the rest) works on the memory API. The full memory reference is at https://docs.mubit.ai.

## Step routing (tuned vs frontier)

Route individual workflow steps to a tuned model behind an OpenAI-compatible
endpoint, with automatic escalation back to your own frontier client. Routing
decisions (rollout percentage, escalation triggers, recall) come from the
instance's step manifest (`GET /v2/adapters/step_manifest`) — shifting traffic
is a manifest write, not a code change.

```python
import openai
from mubit import Client
from mubit.steps import StepRouter

router = StepRouter(
    Client(api_key="mbt_..."),
    tuned={"base_url": "https://gw.example.com/v1", "api_key": "gw-key"},
    frontier=openai.OpenAI(),  # or {"base_url": ..., "api_key": ..., "model": ...}
    frontier_model="gpt-4o",
)

result = router.chat("step_extract", messages)
result.routed_to       # "tuned" | "frontier"
result.escalated       # True when a trigger sent the call to the frontier
result.correlation_id  # sent as X-Mubit-Step-Correlation to the tuned endpoint
```

Escalation triggers, per the route's manifest knobs: output-schema failure
(a top-level `required`-key check on JSON output — not full JSON-Schema
validation), `timeout_ms`, tuned-endpoint HTTP and transport errors, and low
confidence. When the tuned endpoint returns no logprobs the configured
`on_missing_logprobs` policy fires (default `escalate`): an absent score is
not a passing score, and treating it as one leaves the gate globally inert.
Escalations buffer locally; `router.flush_events()` records them as step
outcomes.

Pass `user_id=` (or `session_id=`) on every call. It scopes recall to that end
user AND pins the rollout bucket, so one conversation stays on one side of the
split instead of flipping between the adapter and the frontier between steps —
a canary bucketed on the per-call correlation id measures a mixture, not a
cohort. Without a scope, recall is skipped and counted: the router never falls
back to a shared one, which would put one end user's memories in another end
user's prompt.

### LangChain / LangGraph recipe

Wrap **inside the node body** — the place a step actually constructs or
receives its model client — never at graph-construction time. One line per
step:

```python
def summarize_node(state):
    step_llm = router.wrap_openai("step_summarize")
    resp = step_llm.chat.completions.create(
        model="step_summarize",
        messages=state["messages"],
        user_id=state["end_user_id"],  # scopes recall AND pins the rollout bucket
    )
    return {"summary": resp.choices[0].message.content}
```

`router.chat("step_summarize", state["messages"], user_id=...)` is the same
call without the OpenAI-shaped shim, and returns `result.routed_to` /
`result.escalation_reason` for your traces. A step that must keep a
`ChatOpenAI` instance should build it inside the node from the wrapper, so the
model client is chosen per call rather than pinned at import time.

> **Never point a chat client at the tuned endpoint.**
> `ChatOpenAI(base_url=<gateway>)`, `openai.OpenAI(base_url=<gateway>)` or any
> other static pointer sends every call to the adapter directly. That client
> has **no fallback and no kill switch**: no manifest gating (a route disabled
> in the control plane keeps serving), no rollout percentage, no escalation
> back to your frontier model when the adapter times out or returns an
> unusable answer, no `MUBIT_STEPS_DISABLED` and no `router.disable()`, so
> rolling a bad adapter back needs a redeploy. It also emits no step outcomes
> and carries no correlation id, so the escalation rate is uncomputable and
> the savings report stays empty. Route through the router.

## Related

- **Console:** https://app.mubit.ai
- **Documentation:** https://docs.mubit.ai
- **Migration guide (0.x to 1.0):** https://docs.mubit.ai/sdk/migration
- **JavaScript client:** `npm install @mubit-ai/sdk`
