Metadata-Version: 2.4
Name: ucin
Version: 0.1.3
Summary: Native Python SDK for the UCIN Network Factory — import ucin, wrap a function, pick a tier.
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: cloudpickle>=3.0
Provides-Extra: http
Requires-Dist: httpx>=0.27; extra == "http"
Provides-Extra: native
Requires-Dist: aider-chat==0.86.2; extra == "native"

# UCIN SDK — `import ucin`

Native Python integration for the UCIN Network Factory. Wrap a function, pick a
tier, get a deterministic SLA quote, accept the receipt, and the function runs
on the network — its return value comes back inline.

```python
import ucin

@ucin.workload(tier="flex")
def fine_tune_llama(dataset_path):
    import torch
    # ... standard PyTorch training loop ...
    return {"final_loss": 0.21}

result = fine_tune_llama("s3://my-bucket/data")   # quote → [Y/n] → run remotely
print(result["final_loss"])
```

Running `python train.py` does **not** execute the function locally. The SDK
packages the call, asks the control plane for a quote, and pauses with the
interactive receipt:

```
╔════════════════════════════════════════════════════════════╗
║ UCIN SLA QUOTE: Flex Tier                                   ║
╠════════════════════════════════════════════════════════════╣
║ Workload classified        : training (14.00 SCU)           ║
║ Profiler confidence        : 91%                            ║
║ Estimated completion       : 3h 15m                         ║
║ SLA guarantee (≤)          : 4h 00m                         ║
║ Target hardware            : H100                           ║
║ Total guaranteed cost      : ₹540.00                        ║
╚════════════════════════════════════════════════════════════╝

Accept this quote and deploy to Network Factory? [Y/n]:
```

## Why this maps cleanly onto the existing platform

Every number on the receipt is the **control plane's**, not the SDK's. The SDK
is a thin client — all pricing, profiling, SLA and billing logic already exists
server-side:

| Receipt field            | Source in the codebase |
|--------------------------|------------------------|
| Workload + SCU           | `auto_profiler.WorkloadProfiler.profile_workload` |
| Confidence               | `auto_profiler._reconcile` |
| Estimated completion     | `customer_api._build_response_fields` (`etc_hrs`) |
| SLA guarantee (≤)        | `customer_api._apply_churn` (`etc_max_hrs`) |
| Total guaranteed cost    | `EstimateResponse.quoted_price_inr` — a hard wallet pre-auth |
| Budget hard-stop         | `gateway_api.py` NF-7: billing clamps at `ceiling_scu`, `complete_at_ceiling` |
| "never billed over quote"| `main.py` heartbeat loop: force-fail removed; UCIN absorbs overruns |

The flow uses three real endpoints, all Bearer-authenticated under `/customer`:

1. `POST /jobs/audit` — the pre-acceptance gauntlet (security scan, profile,
   capacity, balance). Returns the quote **and** the `audit_token` deploy
   requires in production.
2. `POST /jobs/deploy` — pre-authorises `quoted_price_inr` against the wallet
   and queues the `Job`. The SOR (`sor.dispatch_loop`) places it.
3. `GET /jobs/{id}` / `GET /jobs/{id}/results` — poll to completion, then pull
   the result artifact.

## The one real architectural gap (and how this bridges it)

The platform runs **container images**, and `auto_profiler` classifies a job by
substring-matching the image URL and `run_command` (`"torchrun"`, `"vllm"`,
`":latest"`) — there is **no AST analysis today**, despite the original vision.

So `@ucin.workload` cannot "just send the function." The bridge (`packaging.py`
+ `runner.py`):

- **Client side:** `cloudpickle(func, args, kwargs)` + a captured `pip freeze`
  → uploaded via `POST /storage/presign`. A synthetic `run_command` carries
  both the payload key *and* keywords (`torchrun train.py …`) so the existing
  heuristic profiler classifies the workload with no server change.
- **Container side:** a generic **runner image** (`ucin/pyrunner`) whose CMD is
  `ucin-run`. It fetches the payload, tops up deps, calls the function, and
  writes the return value to `UCIN_CHECKPOINT_DIR/ucin-result.pkl` — which
  `entrypoint_wrapper.py`'s existing checkpoint upload pipeline already ships to
  object storage (**AWS S3 Mumbai or E2E** — S3-compatible, India-resident).

This reuses the preemption shield, checkpoint/resume, cooperative ceiling, and
billing untouched.

## Budget & time guarantee — tasks can never overrun

Set a hard ceiling and the SDK guarantees you never pay above it:

```python
@ucin.workload(tier="flex", max_budget_inr=500)      # ₹ cap
def train(path): ...

@ucin.workload(tier="flex", max_runtime_hrs=4)       # wall-clock cap
def train(path): ...
```

How it's enforced, end to end:

1. **SDK** converts `max_budget_inr` → the exact runtime ceiling using the
   tier rates from `GET /customer/config`, audits, and **re-audits until the
   server's own `quoted_price_inr` ≤ your budget** (the quote is linear in
   runtime, so one Newton step lands it). It refuses to deploy otherwise.
2. **Control plane** pins that as `estimated_scu` at 0.95 confidence
   (`_apply_runtime_ceiling`) → `quoted_price_inr` is exact, and the wallet is
   pre-authorised for exactly that.
3. **Gateway** computes `ceiling_scu = (quoted − base_fee) / rate` and, on every
   billing tick, **clamps `scu_consumed` at the ceiling and returns
   `complete_at_ceiling: true`** — the job is finalised at that boundary
   (`gateway_api.py` NF-7). `actual_cost ≤ quoted_price` is an invariant.
4. **Container** receives `UCIN_MAX_SCU`; `entrypoint_wrapper.py`'s ceiling
   watcher SIGTERMs the workload for a graceful checkpoint at 90%, before the
   hard clamp — so the stop happens at a checkpoint, not mid-step.

**Completion vs. truncation.** The cap guarantees spend, not that the work
finishes. The SDK fetches the platform's *uncapped* estimate too; if your cap is
below it, the receipt warns the job will stop at the ceiling unfinished, and in
non-interactive mode `run()` raises `BudgetError` unless you pass
`allow_truncation=True`. To *guarantee completion within budget*, set a cap at
or above the estimate (the receipt shows both numbers).

## Install & authenticate

```bash
pip install ucin
ucin login                 # prompts for email + password; token saved to ~/.ucin
ucin whoami                # confirm the signed-in account
```

After `ucin login`, every `import ucin` script picks up the token automatically —
no env vars to manage. Programmatic alternative:

```python
import ucin
ucin.login("dev@startup.ai", "hunter2")   # or totp_code=... if MFA is on
```

Token precedence: `configure()` / `UCIN_API_TOKEN` → persisted `ucin login`
(`~/.ucin/credentials.json`, keyed by control-plane URL, written 0600).

## Configuration

```python
ucin.configure(base_url="https://api.ucin.in", api_token="ey...")
```
or via env: `UCIN_BASE_URL`, `UCIN_API_TOKEN` (from `POST /auth/login`),
`UCIN_AUTO_YES=true` (skip the prompt in CI), `UCIN_RUNNER_IMAGE`,
`UCIN_CONFIG_DIR` (override the `~/.ucin` credential dir).

## Wallet & funding

Deploy places a hard wallet pre-authorisation equal to the quoted price, so you
need a funded wallet. Check and top up from the SDK:

```python
ucin.wallet()                 # {"user_id": ..., "wallet_balance_inr": 250.0}
ucin.topup(500)               # opens a Cashfree top-up order (sandbox/production)
```
or the CLI:
```bash
ucin wallet                   # show balance
ucin topup 500                # create a Cashfree order; complete it in the web app
```
`topup()` returns a Cashfree `payment_session_id`; the card/UPI payment is
completed via the hosted checkout in the UCIN web app, after which the wallet is
credited (webhook / payment-success). A deploy with insufficient balance raises
`InsufficientBalance` pointing you to `ucin topup`. On completion, the unused
portion of the hold (`quote − actual_cost`) is credited back automatically.

## Escape hatches

```python
fine_tune_llama.local(path)      # run in-process, bypass UCIN (dev/debug)
fine_tune_llama.estimate(path)   # quote only — no deploy, no prompt
fine_tune_llama.submit(path)     # deploy after receipt, return job handle (non-blocking)
```

## Decorator options

```python
@ucin.workload(
    tier="flex",                 # priority | core | flex
    workload_hint="training",    # training | inference | batch | auto (profiler signal)
    state_lock="Maharashtra",    # required for core tier (data residency)
    express=True,                # CPTV express routing → queue_priority=0
    gpu_count=2,                  # parallelism width (e.g. DDP), NOT a GPU model
                                 # — the Smart Order Router picks the hardware
    max_budget_inr=500,          # hard ₹ ceiling — never billed above this
    max_runtime_hrs=4,           # OR a hard wall-clock ceiling
    allow_truncation=False,      # non-interactive: forbid caps below the estimate
    requirements=["torch==2.3.0"],
)
def job(...): ...
```

## Server-side work still required to make this fully live

The three integration points are now **wired end to end** (control plane + data
plane + image):

1. ✅ **Result artifacts in `/jobs/{id}/results`.** On clean completion the
   wrapper (`entrypoint_wrapper._persist_outputs_on_success`) uploads
   `CHECKPOINT_DIR` and reports the key; the CP status handler persists it to
   `Job.checkpoint_id` (`gateway_api.py`, `JobStatusUpdateRequest.checkpoint_r2_key`);
   `/customer/jobs/{id}/results` labels it `ucin-result.pkl` with
   `source_path="checkpoint/ucin-result.pkl"`, and the SDK extracts the return
   value from the tarball. Verified by a round-trip test.
2. ✅ **Publish & pin the `ucin/pyrunner` image.** See `sdk/runner-image/`
   (`Dockerfile` + `build_and_push.sh`). The script prints the immutable
   `@sha256` digest to set as `UCIN_RUNNER_IMAGE` / `_DEFAULT_RUNNER_IMAGE`.
   *(Operator action: run the script against your registry to publish.)*
3. ✅ **Inject `UCIN_PAYLOAD_URL` at dispatch.** The SDK stages the payload via
   `/customer/storage/presign` (user_id derived from the JWT) and carries the
   object key as `UCIN_PAYLOAD_KEY`; `gateway_api._build_job_payload` mints a
   fresh presigned GET into `UCIN_PAYLOAD_URL` at poll time (so it can't expire
   in the queue).

Still optional / future:

4. **Real AST profiling.** To honour the original vision, add an `ast`-based
   classifier path to `auto_profiler` keyed off an SDK-supplied source digest,
   instead of the synthetic `run_command` keywords. The viral loops
   (FinOps-in-CI quote-on-PR, quote sharing) then build on `/jobs/estimate`,
   which is already side-effect-free and safe to call in CI.

## Layout

```
sdk/
  pyproject.toml          # `ucin` dist; `ucin` + `ucin-run` console scripts
  README.md
  runner-image/           # the ucin/pyrunner image (Dockerfile + build_and_push.sh)
  examples/quickstart.py  # copy-paste first run
  tests/test_flow.py      # mocked round-trip regression suite
  ucin/
    __init__.py           # public surface: workload, login, configure, UcinClient
    workload.py           # @ucin.workload decorator + escape hatches
    client.py             # control-plane HTTP client + quote→execute flow
    auth.py               # login / whoami / logout (POST /auth/login)
    credentials.py        # ~/.ucin token store (per base_url)
    cli.py                # `ucin login|whoami|logout|config`
    packaging.py          # function → (cloudpickle payload, profiler run_command)
    receipt.py            # the interactive SLA-quote box + [Y/n] prompt
    config.py             # env / configure() resolution
    errors.py             # QuoteDeclined, InsufficientBalance, JobFailed, ...
    runner.py             # container-side `ucin-run` entrypoint
```
